Compare commits

39 Commits

Author SHA1 Message Date
e60093c419 Merge pull request 'feature/base-firmware-size-optimization' (#18) from feature/base-firmware-size-optimization into main
Reviewed-on: #18
2025-11-04 12:18:31 +01:00
633957c95c feat: configurable PixelStream 2025-10-28 21:05:02 +01:00
ad879bfe7b feat: remove CpuUsage calculation 2025-10-27 10:38:56 +01:00
4559e13d7d fix: revert RAW message 2025-10-27 07:48:06 +01:00
682849650d Merge pull request 'feat: new cluster protocol, event naming' (#17) from feature/cluster-protocol-update into main
Reviewed-on: #17
2025-10-26 12:51:41 +01:00
0f003335b3 feat: new cluster protocol, event naming 2025-10-26 12:43:22 +01:00
eab10cffa5 Merge pull request 'refactoring/firmware-optimizations' (#16) from refactoring/firmware-optimizations into main
Reviewed-on: #16
2025-10-21 13:51:03 +02:00
7f40626187 feat: improve local node initalization 2025-10-21 11:19:12 +02:00
e796375a9f feat: memberlist optimization 2025-10-21 10:50:46 +02:00
daae29dd3f refactor: update local node 2025-10-20 21:35:08 +02:00
37a68e26d8 refactor: remove unused and obsolet stuff 2025-10-20 21:21:02 +02:00
7bd3e87271 Merge pull request 'feature/improved-cluster-forming' (#15) from feature/improved-cluster-forming into main
Reviewed-on: #15
2025-10-19 17:47:05 +02:00
0d09c5900c docs: update 2025-10-19 16:55:48 +02:00
407b651b82 feat: change event naming schema 2025-10-19 13:48:13 +02:00
23289d9f09 fix: latency calculation 2025-10-19 13:11:36 +02:00
b6ad479352 feat: calculate latency during heartbeat 2025-10-19 12:59:26 +02:00
3ed44cd00f feat: improve cluster forming; just use heartbeat to form the cluster 2025-10-19 12:50:43 +02:00
ce70830678 fix: neopattern example wdt reset 2025-10-18 13:49:39 +02:00
b404852fc7 feat: GET node config endpoint 2025-10-15 22:36:20 +02:00
7063b1ab16 feat: persistent custom labels 2025-10-15 22:23:00 +02:00
1a74d1fe90 Merge pull request 'feat: persistent config' (#14) from feature/persistent-config into main
Reviewed-on: #14
2025-10-15 21:56:34 +02:00
993a431310 feat: persistent config 2025-10-15 21:52:24 +02:00
7a7400422e Merge pull request 'feature/refactoring' (#13) from feature/multimatrix-example into main
Reviewed-on: #13
2025-10-14 18:17:23 +02:00
a45871625e refactor: harmonize method names 2025-10-14 18:01:37 +02:00
52f9098c1b feat: example with multiple various functionallity 2025-10-08 12:25:31 +02:00
0fcebc0459 feat: multimatrix example 2025-10-04 13:54:06 +02:00
d07c1f40de chore: move neopattern tests, add new pixelstream scripts 2025-10-03 21:45:26 +02:00
3077f8685b feat: tetris test 2025-10-03 21:41:05 +02:00
3ff5df5a6f Merge pull request 'feat: udp stream' (#12) from feature/udp-raw into main
Reviewed-on: #12
2025-10-03 21:20:31 +02:00
be1a2f1e21 feat: add snek game pixel stream example 2025-10-03 21:20:10 +02:00
1383f6d32f feat: even more matrix stream examples 2025-10-02 22:08:40 +02:00
d1fb5fc96e feat: more matrix stream examples 2025-10-02 21:46:51 +02:00
f78dd8b843 feat: matrix stream example 2025-10-02 21:36:46 +02:00
f3d99b174f feat: udp stream 2025-10-01 22:34:32 +02:00
99e7ed4809 feat: do not store each infos of each node 2025-10-01 20:45:55 +02:00
dbb3b5bfd3 Merge pull request 'feature/streaming' (#10) from feature/streaming into main
Reviewed-on: #10
2025-09-29 06:29:11 +02:00
2aa887a037 docs(streaming): add 'Things to consider' on high-frequency WS usage and throttling
Notes on heap fragmentation, UDP saturation, throttling/coalescing, buffer reuse, yielding, and payload sizing for reliable streaming/broadcast.
2025-09-28 22:10:18 +02:00
49fe0dabf4 remove node_modules 2025-09-28 22:07:07 +02:00
c0bf16fecb chore(test): remove accidentally committed test/node_modules and ignore it 2025-09-28 21:50:39 +02:00
109 changed files with 8956 additions and 6442 deletions

View File

@@ -93,12 +93,12 @@ void setup() {
spore.setup();
// Create and register custom services
RelayService* relayService = new RelayService(spore.getTaskManager(), 2);
spore.addService(relayService);
RelayService* relayService = new RelayService(spore.getContext(), spore.getTaskManager(), 2);
spore.registerService(relayService);
// Or using smart pointers
auto sensorService = std::make_shared<SensorService>();
spore.addService(sensorService);
auto sensorService = std::make_shared<SensorService>(spore.getContext(), spore.getTaskManager());
spore.registerService(sensorService);
// Start the API server and complete initialization
spore.begin();

417
ctl.sh
View File

@@ -4,6 +4,36 @@ set -e
source .env
## Spore Control Script
## Usage: ./ctl.sh <command> [options]
##
## Commands:
## build [target] - Build firmware for target (base, d1_mini, etc.)
## flash [target] - Flash firmware to device
## uploadfs [target] - Upload filesystem to device
## ota update <ip> <target> - OTA update specific node
## ota all <target> - OTA update all nodes in cluster
## cluster members - List cluster members
## node wifi <ssid> <password> [ip] - Configure WiFi on node
## node label set <key=value> [ip] - Set a label on node
## node label delete <key> [ip] - Delete a label from node
## node config get [ip] - Get node configuration
## node status [ip] - Get node status and information
## monitor - Monitor serial output
##
## Examples:
## ./ctl.sh build base
## ./ctl.sh flash d1_mini
## ./ctl.sh node wifi "MyNetwork" "MyPassword"
## ./ctl.sh node wifi "MyNetwork" "MyPassword" 192.168.1.100
## ./ctl.sh node label set "environment=production"
## ./ctl.sh node label set "location=office" 192.168.1.100
## ./ctl.sh node label delete "environment"
## ./ctl.sh node config get
## ./ctl.sh node config get 192.168.1.100
## ./ctl.sh node status
## ./ctl.sh node status 192.168.1.100
function info {
sed -n 's/^##//p' ctl.sh
}
@@ -69,6 +99,393 @@ function cluster {
${@:-info}
}
function node {
function wifi {
if [ $# -lt 2 ]; then
echo "Usage: $0 node wifi <ssid> <password> [node_ip]"
echo " ssid: WiFi network name"
echo " password: WiFi password"
echo " node_ip: Optional IP address (defaults to API_NODE from .env)"
return 1
fi
local ssid="$1"
local password="$2"
local node_ip="${3:-$API_NODE}"
echo "Configuring WiFi on node $node_ip..."
echo "SSID: $ssid"
# Configure WiFi using the API endpoint
response=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "ssid=$ssid&password=$password" \
"http://$node_ip/api/network/wifi/config" 2>/dev/null || echo -e "\n000")
# Extract HTTP status code and response body
http_code=$(echo "$response" | tail -n1)
response_body=$(echo "$response" | head -n -1)
# Check if curl succeeded
if [ "$http_code" = "000" ] || [ -z "$response_body" ]; then
echo "Error: Failed to connect to node at $node_ip"
echo "Please check:"
echo " - Node is powered on and connected to network"
echo " - IP address is correct"
echo " - Node is running Spore firmware"
return 1
fi
# Check HTTP status code
if [ "$http_code" != "200" ]; then
echo "Error: HTTP $http_code - Server error"
echo "Response: $response_body"
return 1
fi
# Parse and display the response
status=$(echo "$response_body" | jq -r '.status // "unknown"')
message=$(echo "$response_body" | jq -r '.message // "No message"')
config_saved=$(echo "$response_body" | jq -r '.config_saved // false')
restarting=$(echo "$response_body" | jq -r '.restarting // false')
connected=$(echo "$response_body" | jq -r '.connected // false')
ip=$(echo "$response_body" | jq -r '.ip // "N/A"')
echo "Status: $status"
echo "Message: $message"
echo "Config saved: $config_saved"
if [ "$restarting" = "true" ]; then
echo "Restarting: true"
echo "Note: Node will restart to apply new WiFi settings"
fi
echo "Connected: $connected"
if [ "$connected" = "true" ]; then
echo "IP Address: $ip"
fi
# Return appropriate exit code
if [ "$status" = "success" ]; then
echo "WiFi configuration completed successfully!"
return 0
else
echo "WiFi configuration failed!"
return 1
fi
}
function label {
function set {
if [ $# -lt 1 ]; then
echo "Usage: $0 node label set <key=value> [node_ip]"
echo " key=value: Label key and value in format 'key=value'"
echo " node_ip: Optional IP address (defaults to API_NODE from .env)"
return 1
fi
local key_value="$1"
local node_ip="${2:-$API_NODE}"
# Parse key=value format
if [[ ! "$key_value" =~ ^[^=]+=.+$ ]]; then
echo "Error: Label must be in format 'key=value'"
echo "Example: environment=production"
return 1
fi
local key="${key_value%%=*}"
local value="${key_value#*=}"
echo "Setting label '$key=$value' on node $node_ip..."
# First get current labels
current_labels=$(curl -s "http://$node_ip/api/node/status" | jq -r '.labels // {}')
# Add/update the new label
updated_labels=$(echo "$current_labels" | jq --arg key "$key" --arg value "$value" '. + {($key): $value}')
# Send updated labels to the node
response=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "labels=$updated_labels" \
"http://$node_ip/api/node/config" 2>/dev/null || echo -e "\n000")
# Extract HTTP status code and response body
http_code=$(echo "$response" | tail -n1)
response_body=$(echo "$response" | head -n -1)
# Check if curl succeeded
if [ "$http_code" = "000" ] || [ -z "$response_body" ]; then
echo "Error: Failed to connect to node at $node_ip"
echo "Please check:"
echo " - Node is powered on and connected to network"
echo " - IP address is correct"
echo " - Node is running Spore firmware"
return 1
fi
# Check HTTP status code
if [ "$http_code" != "200" ]; then
echo "Error: HTTP $http_code - Server error"
echo "Response: $response_body"
return 1
fi
# Parse and display the response
status=$(echo "$response_body" | jq -r '.status // "unknown"')
message=$(echo "$response_body" | jq -r '.message // "No message"')
echo "Status: $status"
echo "Message: $message"
# Return appropriate exit code
if [ "$status" = "success" ]; then
echo "Label '$key=$value' set successfully!"
return 0
else
echo "Failed to set label!"
return 1
fi
}
function delete {
if [ $# -lt 1 ]; then
echo "Usage: $0 node label delete <key> [node_ip]"
echo " key: Label key to delete"
echo " node_ip: Optional IP address (defaults to API_NODE from .env)"
return 1
fi
local key="$1"
local node_ip="${2:-$API_NODE}"
echo "Deleting label '$key' from node $node_ip..."
# First get current labels
current_labels=$(curl -s "http://$node_ip/api/node/status" | jq -r '.labels // {}')
# Check if key exists
if [ "$(echo "$current_labels" | jq -r --arg key "$key" 'has($key)')" != "true" ]; then
echo "Warning: Label '$key' does not exist on node"
return 0
fi
# Remove the key
updated_labels=$(echo "$current_labels" | jq --arg key "$key" 'del(.[$key])')
# Send updated labels to the node
response=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "labels=$updated_labels" \
"http://$node_ip/api/node/config" 2>/dev/null || echo -e "\n000")
# Extract HTTP status code and response body
http_code=$(echo "$response" | tail -n1)
response_body=$(echo "$response" | head -n -1)
# Check if curl succeeded
if [ "$http_code" = "000" ] || [ -z "$response_body" ]; then
echo "Error: Failed to connect to node at $node_ip"
echo "Please check:"
echo " - Node is powered on and connected to network"
echo " - IP address is correct"
echo " - Node is running Spore firmware"
return 1
fi
# Check HTTP status code
if [ "$http_code" != "200" ]; then
echo "Error: HTTP $http_code - Server error"
echo "Response: $response_body"
return 1
fi
# Parse and display the response
status=$(echo "$response_body" | jq -r '.status // "unknown"')
message=$(echo "$response_body" | jq -r '.message // "No message"')
echo "Status: $status"
echo "Message: $message"
# Return appropriate exit code
if [ "$status" = "success" ]; then
echo "Label '$key' deleted successfully!"
return 0
else
echo "Failed to delete label!"
return 1
fi
}
${@:-info}
}
function config {
function get {
local node_ip="${1:-$API_NODE}"
echo "Getting configuration for node $node_ip..."
# Get node configuration
response=$(curl -s -w "\n%{http_code}" "http://$node_ip/api/node/config" 2>/dev/null || echo -e "\n000")
# Extract HTTP status code and response body
http_code=$(echo "$response" | tail -n1)
response_body=$(echo "$response" | head -n -1)
# Check if curl succeeded
if [ "$http_code" = "000" ] || [ -z "$response_body" ]; then
echo "Error: Failed to connect to node at $node_ip"
echo "Please check:"
echo " - Node is powered on and connected to network"
echo " - IP address is correct"
echo " - Node is running Spore firmware"
return 1
fi
# Check HTTP status code
if [ "$http_code" != "200" ]; then
echo "Error: HTTP $http_code - Server error"
echo "Response: $response_body"
return 1
fi
# Parse and display the response in a nice format
echo ""
echo "=== Node Configuration ==="
echo "Node IP: $node_ip"
echo "Retrieved at: $(date)"
echo ""
# WiFi Configuration
echo "=== WiFi Configuration ==="
echo "SSID: $(echo "$response_body" | jq -r '.wifi.ssid // "N/A"')"
echo "Connect Timeout: $(echo "$response_body" | jq -r '.wifi.connect_timeout_ms // "N/A"') ms"
echo "Retry Delay: $(echo "$response_body" | jq -r '.wifi.retry_delay_ms // "N/A"') ms"
echo "Password: [HIDDEN]"
echo ""
# Network Configuration
echo "=== Network Configuration ==="
echo "UDP Port: $(echo "$response_body" | jq -r '.network.udp_port // "N/A"')"
echo "API Server Port: $(echo "$response_body" | jq -r '.network.api_server_port // "N/A"')"
echo ""
# Cluster Configuration
echo "=== Cluster Configuration ==="
echo "Heartbeat Interval: $(echo "$response_body" | jq -r '.cluster.heartbeat_interval_ms // "N/A"') ms"
echo "Cluster Listen Interval: $(echo "$response_body" | jq -r '.cluster.cluster_listen_interval_ms // "N/A"') ms"
echo "Status Update Interval: $(echo "$response_body" | jq -r '.cluster.status_update_interval_ms // "N/A"') ms"
echo ""
# Node Status Thresholds
echo "=== Node Status Thresholds ==="
echo "Active Threshold: $(echo "$response_body" | jq -r '.thresholds.node_active_threshold_ms // "N/A"') ms"
echo "Inactive Threshold: $(echo "$response_body" | jq -r '.thresholds.node_inactive_threshold_ms // "N/A"') ms"
echo "Dead Threshold: $(echo "$response_body" | jq -r '.thresholds.node_dead_threshold_ms // "N/A"') ms"
echo ""
# System Configuration
echo "=== System Configuration ==="
echo "Restart Delay: $(echo "$response_body" | jq -r '.system.restart_delay_ms // "N/A"') ms"
echo "JSON Doc Size: $(echo "$response_body" | jq -r '.system.json_doc_size // "N/A"') bytes"
echo ""
# Memory Management
echo "=== Memory Management ==="
echo "Low Memory Threshold: $(echo "$response_body" | jq -r '.memory.low_memory_threshold_bytes // "N/A"') bytes"
echo "Critical Memory Threshold: $(echo "$response_body" | jq -r '.memory.critical_memory_threshold_bytes // "N/A"') bytes"
echo "Max Concurrent HTTP Requests: $(echo "$response_body" | jq -r '.memory.max_concurrent_http_requests // "N/A"')"
echo ""
# Custom Labels
labels=$(echo "$response_body" | jq -r '.labels // {}')
if [ "$labels" != "{}" ] && [ "$labels" != "null" ]; then
echo "=== Custom Labels ==="
echo "$labels" | jq -r 'to_entries[] | "\(.key): \(.value)"'
echo ""
else
echo "=== Custom Labels ==="
echo "No custom labels set"
echo ""
fi
# Metadata
echo "=== Metadata ==="
echo "Configuration Version: $(echo "$response_body" | jq -r '.version // "N/A"')"
echo "Retrieved Timestamp: $(echo "$response_body" | jq -r '.retrieved_at // "N/A"')"
echo ""
echo "=== Raw JSON Response ==="
echo "$response_body" | jq '.'
return 0
}
${@:-info}
}
function status {
local node_ip="${1:-$API_NODE}"
echo "Getting status for node $node_ip..."
# Get node status
response=$(curl -s -w "\n%{http_code}" "http://$node_ip/api/node/status" 2>/dev/null || echo -e "\n000")
# Extract HTTP status code and response body
http_code=$(echo "$response" | tail -n1)
response_body=$(echo "$response" | head -n -1)
# Check if curl succeeded
if [ "$http_code" = "000" ] || [ -z "$response_body" ]; then
echo "Error: Failed to connect to node at $node_ip"
echo "Please check:"
echo " - Node is powered on and connected to network"
echo " - IP address is correct"
echo " - Node is running Spore firmware"
return 1
fi
# Check HTTP status code
if [ "$http_code" != "200" ]; then
echo "Error: HTTP $http_code - Server error"
echo "Response: $response_body"
return 1
fi
# Parse and display the response in a nice format
echo ""
echo "=== Node Status ==="
echo "Hostname: $(echo "$response_body" | jq -r '.hostname // "N/A"')"
echo "IP Address: $node_ip"
echo "Free Heap: $(echo "$response_body" | jq -r '.freeHeap // "N/A"') bytes"
echo "Chip ID: $(echo "$response_body" | jq -r '.chipId // "N/A"')"
echo "SDK Version: $(echo "$response_body" | jq -r '.sdkVersion // "N/A"')"
echo "CPU Frequency: $(echo "$response_body" | jq -r '.cpuFreqMHz // "N/A"') MHz"
echo "Flash Size: $(echo "$response_body" | jq -r '.flashChipSize // "N/A"') bytes"
# Display labels if present
labels=$(echo "$response_body" | jq -r '.labels // {}')
if [ "$labels" != "{}" ] && [ "$labels" != "null" ]; then
echo ""
echo "=== Labels ==="
echo "$labels" | jq -r 'to_entries[] | "\(.key): \(.value)"'
else
echo ""
echo "=== Labels ==="
echo "No labels set"
fi
echo ""
echo "=== Raw JSON Response ==="
echo "$response_body" | jq '.'
return 0
}
${@:-info}
}
function monitor {
pio run --target monitor
}

View File

@@ -21,11 +21,13 @@ The system architecture consists of several key components working together:
### API Server
- **HTTP API Server**: RESTful API for cluster management
- **Dynamic Endpoint Registration**: Automatic API endpoint discovery
- **Dynamic Endpoint Registration**: Services register endpoints via `registerEndpoints(ApiServer&)`
- **Service Registry**: Track available services across the cluster
- **Service Lifecycle**: Services register both endpoints and tasks through unified interface
### Task Scheduler
- **Cooperative Multitasking**: Background task management system (`TaskManager`)
- **Service Task Registration**: Services register tasks via `registerTasks(TaskManager&)`
- **Task Lifecycle Management**: Enable/disable tasks and set intervals at runtime
- **Execution Model**: Tasks run in `Spore::loop()` when their interval elapses
@@ -40,65 +42,79 @@ The cluster uses a UDP-based discovery protocol for automatic node detection:
### Discovery Process
1. **Discovery Broadcast**: Nodes periodically send UDP packets on port `udp_port` (default 4210)
2. **Response Handling**: Nodes respond with `CLUSTER_RESPONSE:<hostname>`
3. **Member Management**: Discovered nodes are added/updated in the cluster
4. **Node Info via UDP**: Heartbeat triggers peers to send `CLUSTER_NODE_INFO:<hostname>:<json>`
1. **Discovery Broadcast**: Nodes periodically send heartbeat messages on port `udp_port` (default 4210)
2. **Response Handling**: Nodes respond with node update information containing their current state
3. **Member Management**: Discovered nodes are added/updated in the cluster with current information
4. **Node Synchronization**: Periodic broadcasts ensure all nodes maintain current cluster state
### Protocol Details
- **UDP Port**: 4210 (configurable via `Config.udp_port`)
- **Discovery Message**: `CLUSTER_DISCOVERY`
- **Response Message**: `CLUSTER_RESPONSE`
- **Heartbeat Message**: `CLUSTER_HEARTBEAT`
- **Node Info Message**: `CLUSTER_NODE_INFO:<hostname>:<json>`
- **Heartbeat Message**: `CLUSTER_HEARTBEAT:hostname`
- **Node Update Message**: `NODE_UPDATE:hostname:{json}`
- **Broadcast Address**: 255.255.255.255
- **Discovery Interval**: `Config.discovery_interval_ms` (default 1000 ms)
- **Listen Interval**: `Config.cluster_listen_interval_ms` (default 10 ms)
- **Heartbeat Interval**: `Config.heartbeat_interval_ms` (default 5000 ms)
### Message Formats
- **Discovery**: `CLUSTER_DISCOVERY`
- Sender: any node, broadcast to 255.255.255.255:`udp_port`
- Purpose: announce presence and solicit peer identification
- **Response**: `CLUSTER_RESPONSE:<hostname>`
- Sender: node receiving a discovery; unicast to requester IP
- Purpose: provide hostname so requester can register/update member
- **Heartbeat**: `CLUSTER_HEARTBEAT:<hostname>`
- **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
- Purpose: announce presence, prompt peers for node info, and keep liveness
- **Node Update**: `NODE_UPDATE:hostname:{json}`
- Sender: node responding to heartbeat or broadcasting current state
- JSON fields: hostname, ip, uptime, optional labels
- Purpose: provide current node information for cluster synchronization
### 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
1. **A node broadcasts** `CLUSTER_HEARTBEAT:hostname` to announce its presence
2. **Each receiver responds** with `NODE_UPDATE:hostname:{json}` containing current node state
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)
- Ensures the responding node exists or creates it with current IP and information
- Parses JSON and updates node info, `status = ACTIVE`, `lastSeen = now`
- Calculates `latency = now - lastHeartbeatSentAt` for network performance monitoring
### Node Synchronization
1. **Event-driven broadcasts**: Nodes broadcast `NODE_UPDATE:hostname:{json}` when node information changes
2. **All receivers**: Update their memberlist entry for the broadcasting node
3. **Purpose**: Ensures all nodes maintain current cluster state and configuration
### Sequence Diagram
```mermaid
sequenceDiagram
participant N1 as Node A (esp-node1)
participant N2 as Node B (esp-node2)
Note over N1,N2: Discovery via heartbeat broadcast
N1->>+N2: CLUSTER_HEARTBEAT:esp-node1
Note over N2: Node B responds with its current state
N2->>+N1: NODE_UPDATE:esp-node1:{"hostname":"esp-node2","uptime":12345,"labels":{"role":"sensor"}}
Note over N1: Process NODE_UPDATE response
N1-->>N1: Update memberlist for Node B
N1-->>N1: Set Node B status = ACTIVE
N1-->>N1: Calculate latency for Node B
Note over N1,N2: Event-driven node synchronization
N1->>+N2: NODE_UPDATE:esp-node1:{"hostname":"esp-node1","uptime":12346,"labels":{"role":"controller"}}
Note over N2: Update memberlist with latest information
N2-->>N2: Update Node A info, maintain ACTIVE status
```
### 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
- **Heartbeat** → add/update responding node and send `NODE_UPDATE` response
- **Node Update** → update node information and trigger memberlist logging
### 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)
@@ -118,12 +134,9 @@ The system runs several background tasks at different intervals:
| Task | Interval (default) | Purpose |
|------|--------------------|---------|
| `cluster_discovery` | 1000 ms | Send UDP discovery packets |
| `cluster_listen` | 10 ms | Listen for discovery/heartbeat/node-info |
| `cluster_listen` | 10 ms | Listen for heartbeat/node-info messages |
| `status_update` | 1000 ms | Update node status categories, purge dead |
| `heartbeat` | 5000 ms | Broadcast heartbeat and update local resources |
| `cluster_update_members_info` | 10000 ms | Reserved; no-op (info via UDP) |
| `print_members` | 5000 ms | Log current member list |
### Task Management Features
@@ -140,12 +153,12 @@ The `NodeContext` provides an event-driven architecture for system-wide communic
```cpp
// Subscribe to events
ctx.on("node_discovered", [](void* data) {
ctx.on("node/discovered", [](void* data) {
NodeInfo* node = static_cast<NodeInfo*>(data);
// Handle new node discovery
});
ctx.on("cluster_updated", [](void* data) {
ctx.on("cluster/updated", [](void* data) {
// Handle cluster membership changes
});
```
@@ -154,13 +167,13 @@ ctx.on("cluster_updated", [](void* data) {
```cpp
// Publish events
ctx.fire("node_discovered", &newNode);
ctx.fire("cluster_updated", &clusterData);
ctx.fire("node/discovered", &newNode);
ctx.fire("cluster/updated", &clusterData);
```
### Available Events
- **`node_discovered`**: New node added or local node refreshed
- **`node/discovered`**: New node added or local node refreshed
## Resource Monitoring
@@ -289,6 +302,51 @@ The system includes automatic WiFi fallback for robust operation:
## Configuration Management
SPORE implements a persistent configuration system that manages device settings across reboots and provides runtime reconfiguration capabilities.
### Configuration Architecture
The configuration system consists of several key components:
- **`Config` Class**: Central configuration management with default constants
- **LittleFS Storage**: Persistent file-based storage (`/config.json`)
- **Runtime Updates**: Live configuration changes via HTTP API
- **Automatic Persistence**: Configuration changes are automatically saved
### Configuration Categories
| Category | Description | Examples |
|----------|-------------|----------|
| **WiFi Configuration** | Network connection settings | SSID, password, timeouts |
| **Network Configuration** | Network service settings | UDP port, API server port |
| **Cluster Configuration** | Cluster management settings | Discovery intervals, heartbeat timing |
| **Node Status Thresholds** | Health monitoring thresholds | Active/inactive/dead timeouts |
| **System Configuration** | Core system settings | Restart delay, JSON document size |
| **Memory Management** | Resource management settings | Memory thresholds, HTTP request limits |
### Configuration Lifecycle
1. **Boot Process**: Load configuration from `/config.json` or use defaults
2. **Runtime Updates**: Configuration changes via HTTP API
3. **Persistent Storage**: Changes automatically saved to LittleFS
4. **Service Integration**: Configuration applied to all system services
### Default Value Management
All default values are defined as `constexpr` constants in the `Config` class:
```cpp
static constexpr const char* DEFAULT_WIFI_SSID = "shroud";
static constexpr uint16_t DEFAULT_UDP_PORT = 4210;
static constexpr unsigned long DEFAULT_HEARTBEAT_INTERVAL_MS = 5000;
```
This ensures:
- **Single Source of Truth**: All defaults defined once
- **Type Safety**: Compile-time type checking
- **Maintainability**: Easy to update default values
- **Consistency**: Same defaults used in `setDefaults()` and `loadFromFile()`
### Environment Variables
```bash
@@ -378,6 +436,8 @@ pio device monitor
## Related Documentation
- **[Configuration Management](./ConfigurationManagement.md)** - Persistent configuration system
- **[WiFi Configuration](./WiFiConfiguration.md)** - WiFi setup and reconfiguration process
- **[Task Management](./TaskManagement.md)** - Background task system
- **[API Reference](./API.md)** - REST API documentation
- **[TaskManager API](./TaskManager.md)** - TaskManager class reference

View File

@@ -0,0 +1,337 @@
# SPORE Configuration Management
## Overview
SPORE implements a persistent configuration system that manages device settings across reboots and provides runtime reconfiguration capabilities. The system uses LittleFS for persistent storage and provides both programmatic and HTTP API access to configuration parameters.
## Configuration Architecture
### Core Components
- **`Config` Class**: Central configuration management
- **LittleFS Storage**: Persistent file-based storage (`/config.json`)
- **Default Constants**: Single source of truth for all default values
- **Runtime Updates**: Live configuration changes via HTTP API
- **Automatic Persistence**: Configuration changes are automatically saved
### Configuration Categories
The configuration system manages several categories of settings:
| Category | Description | Examples |
|----------|-------------|----------|
| **WiFi Configuration** | Network connection settings | SSID, password, timeouts |
| **Network Configuration** | Network service settings | UDP port, API server port |
| **Cluster Configuration** | Cluster management settings | Discovery intervals, heartbeat timing |
| **Node Status Thresholds** | Health monitoring thresholds | Active/inactive/dead timeouts |
| **System Configuration** | Core system settings | Restart delay, JSON document size |
| **Memory Management** | Resource management settings | Memory thresholds, HTTP request limits |
## Configuration Lifecycle
### 1. Boot Process
```mermaid
graph TD
A[System Boot] --> B[Initialize LittleFS]
B --> C{Config File Exists?}
C -->|Yes| D[Load from File]
C -->|No| E[Use Defaults]
D --> F[Apply Configuration]
E --> G[Save Defaults to File]
G --> F
F --> H[Start Services]
```
**Boot Sequence:**
1. **LittleFS Initialization**: Mount the filesystem for persistent storage
2. **Configuration Loading**: Attempt to load `/config.json`
3. **Fallback to Defaults**: If no config file exists, use hardcoded defaults
4. **Default Persistence**: Save default configuration to file for future boots
5. **Service Initialization**: Apply configuration to all system services
### 2. Runtime Configuration
```mermaid
graph TD
A[HTTP API Request] --> B[Validate Parameters]
B --> C[Update Config Object]
C --> D[Save to File]
D --> E{Requires Restart?}
E -->|Yes| F[Schedule Restart]
E -->|No| G[Apply Changes]
F --> H[Send Response]
G --> H
```
**Runtime Update Process:**
1. **API Request**: Configuration change via HTTP API
2. **Parameter Validation**: Validate input parameters
3. **Memory Update**: Update configuration object in memory
4. **Persistent Save**: Save changes to `/config.json`
5. **Service Notification**: Notify affected services of changes
6. **Restart if Needed**: Restart system for certain configuration changes
## Configuration File Format
### JSON Structure
The configuration is stored as a JSON file with the following structure:
```json
{
"wifi": {
"ssid": "MyNetwork",
"password": "mypassword",
"connect_timeout_ms": 15000,
"retry_delay_ms": 500
},
"network": {
"udp_port": 4210,
"api_server_port": 80
},
"cluster": {
"heartbeat_interval_ms": 5000,
"cluster_listen_interval_ms": 10,
"status_update_interval_ms": 1000
},
"thresholds": {
"node_active_threshold_ms": 10000,
"node_inactive_threshold_ms": 60000,
"node_dead_threshold_ms": 120000
},
"system": {
"restart_delay_ms": 10,
"json_doc_size": 1024
},
"memory": {
"low_memory_threshold_bytes": 10000,
"critical_memory_threshold_bytes": 5000,
"max_concurrent_http_requests": 3
},
"_meta": {
"version": "1.0",
"saved_at": 1234567890
}
}
```
### Metadata Fields
- **`version`**: Configuration schema version for future compatibility
- **`saved_at`**: Timestamp when configuration was saved (millis())
## Default Configuration Constants
All default values are defined as `constexpr` constants in the `Config` class header:
```cpp
// Default Configuration Constants
static constexpr const char* DEFAULT_WIFI_SSID = "shroud";
static constexpr const char* DEFAULT_WIFI_PASSWORD = "th3r31sn0sp00n";
static constexpr uint16_t DEFAULT_UDP_PORT = 4210;
static constexpr uint16_t DEFAULT_API_SERVER_PORT = 80;
// ... additional constants
```
### Benefits of Constants
- **Single Source of Truth**: All defaults defined once
- **Type Safety**: Compile-time type checking
- **Maintainability**: Easy to update default values
- **Consistency**: Same defaults used in `setDefaults()` and `loadFromFile()`
## Configuration Methods
### Core Methods
| Method | Purpose | Parameters |
|--------|---------|------------|
| `setDefaults()` | Initialize with default values | None |
| `loadFromFile()` | Load configuration from persistent storage | `filename` (optional) |
| `saveToFile()` | Save configuration to persistent storage | `filename` (optional) |
### Loading Process
```cpp
bool Config::loadFromFile(const String& filename) {
// 1. Initialize LittleFS
if (!LittleFS.begin()) {
LOG_ERROR("Config", "LittleFS not initialized");
return false;
}
// 2. Check file existence
if (!LittleFS.exists(filename)) {
LOG_DEBUG("Config", "Config file does not exist");
return false;
}
// 3. Parse JSON with fallback defaults
wifi_ssid = doc["wifi"]["ssid"] | DEFAULT_WIFI_SSID;
wifi_password = doc["wifi"]["password"] | DEFAULT_WIFI_PASSWORD;
// ... additional fields
return true;
}
```
### Saving Process
```cpp
bool Config::saveToFile(const String& filename) {
// 1. Create JSON document
JsonDocument doc;
// 2. Serialize all configuration fields
doc["wifi"]["ssid"] = wifi_ssid;
doc["wifi"]["password"] = wifi_password;
// ... additional fields
// 3. Add metadata
doc["_meta"]["version"] = "1.0";
doc["_meta"]["saved_at"] = millis();
// 4. Write to file
size_t bytesWritten = serializeJson(doc, file);
return bytesWritten > 0;
}
```
## Error Handling
### Common Error Scenarios
| Scenario | Error Handling | Recovery |
|----------|----------------|----------|
| **LittleFS Init Failure** | Log warning, use defaults | Continue with default configuration |
| **File Not Found** | Log debug message, return false | Caller handles fallback to defaults |
| **JSON Parse Error** | Log error, return false | Caller handles fallback to defaults |
| **Write Failure** | Log error, return false | Configuration not persisted |
| **Memory Allocation Failure** | Log error, return false | Operation aborted |
### Logging Levels
- **ERROR**: Critical failures that prevent operation
- **WARN**: Non-critical issues that affect functionality
- **INFO**: Normal operation events
- **DEBUG**: Detailed diagnostic information
## Configuration Validation
### Input Validation
- **Required Fields**: SSID and password are mandatory for WiFi configuration
- **Range Validation**: Numeric values are validated against reasonable ranges
- **Type Validation**: JSON parsing ensures correct data types
- **Length Limits**: String fields have maximum length constraints
### Default Value Fallback
The system uses the `|` operator for safe fallback to defaults:
```cpp
// Safe loading with fallback
wifi_ssid = doc["wifi"]["ssid"] | DEFAULT_WIFI_SSID;
udp_port = doc["network"]["udp_port"] | DEFAULT_UDP_PORT;
```
This ensures that:
- Missing fields use default values
- Invalid values are replaced with defaults
- System remains functional with partial configuration
## Performance Considerations
### Memory Usage
- **Configuration Object**: ~200 bytes in RAM
- **JSON Document**: ~1KB during parsing/saving
- **LittleFS Overhead**: ~2-4KB for filesystem
### Storage Requirements
- **Config File**: ~500-800 bytes on disk
- **LittleFS Minimum**: ~64KB partition size
- **Available Space**: Depends on flash size (1MB+ recommended)
### Processing Overhead
- **Load Time**: ~10-50ms for JSON parsing
- **Save Time**: ~20-100ms for JSON serialization
- **File I/O**: Minimal impact on system performance
## Security Considerations
### Current Implementation
- **Local Storage Only**: Configuration stored on device filesystem
- **No Encryption**: Plain text storage (LAN-only access assumed)
- **Access Control**: No authentication for configuration changes
### Future Enhancements
- **Configuration Encryption**: Encrypt sensitive fields (passwords)
- **Access Control**: Authentication for configuration changes
- **Audit Logging**: Track configuration modifications
- **Backup/Restore**: Configuration backup and restore capabilities
## Troubleshooting
### Common Issues
1. **Configuration Not Persisting**
- Check LittleFS initialization
- Verify file write permissions
- Monitor available flash space
2. **Default Values Not Applied**
- Verify constants are properly defined
- Check JSON parsing errors
- Ensure fallback logic is working
3. **Configuration Corruption**
- Delete `/config.json` to reset to defaults
- Check for JSON syntax errors
- Verify file system integrity
### Debug Commands
```bash
# Check configuration status
curl -s http://192.168.1.100/api/network/status | jq '.'
# View current WiFi settings
curl -s http://192.168.1.100/api/network/status | jq '.wifi'
# Test configuration save
curl -X POST http://192.168.1.100/api/network/wifi/config \
-d "ssid=TestNetwork&password=testpass"
```
## Best Practices
### Configuration Management
1. **Use Constants**: Always define defaults as constants
2. **Validate Input**: Check all configuration parameters
3. **Handle Errors**: Implement proper error handling
4. **Log Changes**: Log configuration modifications
5. **Test Fallbacks**: Ensure default fallbacks work correctly
### Development Guidelines
1. **Single Source**: Define each default value only once
2. **Type Safety**: Use appropriate data types
3. **Documentation**: Document all configuration parameters
4. **Versioning**: Include version metadata in config files
5. **Backward Compatibility**: Handle old configuration formats
## Related Documentation
- **[WiFi Configuration Process](./WiFiConfiguration.md)** - Detailed WiFi setup workflow
- **[API Reference](./API.md)** - HTTP API for configuration management
- **[Architecture Overview](./Architecture.md)** - System architecture and components
- **[OpenAPI Specification](../api/)** - Machine-readable API specification

View File

@@ -29,13 +29,13 @@ spore/
│ │ ├── NetworkManager.cpp # WiFi and network handling
│ │ ├── 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
│ ├── services/ # Built-in services (implement Service interface)
│ │ ├── NodeService.cpp # registerEndpoints() + registerTasks()
│ │ ├── NetworkService.cpp # registerEndpoints() + registerTasks()
│ │ ├── ClusterService.cpp # registerEndpoints() + registerTasks()
│ │ ├── TaskService.cpp # registerEndpoints() + registerTasks()
│ │ ├── StaticFileService.cpp # registerEndpoints() + registerTasks()
│ │ └── MonitoringService.cpp # registerEndpoints() + registerTasks()
│ └── types/ # Shared types
├── include/ # Header files
├── examples/ # Example apps per env (base, relay, neopattern)

View File

@@ -78,3 +78,21 @@ Notes:
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 ≥50100 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.

View File

@@ -11,6 +11,31 @@ The TaskManager system provides:
- **Status Monitoring**: View task status and configuration
- **Automatic Lifecycle**: Tasks are automatically managed and executed
## Service Interface Integration
Services now implement a unified interface for both endpoint and task registration:
```cpp
class MyService : public Service {
public:
void registerEndpoints(ApiServer& api) override {
// Register HTTP endpoints
api.registerEndpoint("/api/my/status", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleStatus(request); });
}
void registerTasks(TaskManager& taskManager) override {
// Register background tasks
taskManager.registerTask("my_heartbeat", 2000,
[this]() { sendHeartbeat(); });
taskManager.registerTask("my_maintenance", 30000,
[this]() { performMaintenance(); });
}
const char* getName() const override { return "MyService"; }
};
```
## Basic Usage
```cpp
@@ -27,6 +52,46 @@ taskManager.registerTask("maintenance", 30000, maintenanceFunction);
taskManager.initialize();
```
## Service Lifecycle
The Spore framework automatically manages service registration and task lifecycle:
### Service Registration Process
1. **Service Creation**: Services are created with required dependencies (NodeContext, TaskManager, etc.)
2. **Service Registration**: Services are registered with the Spore framework via `spore.registerService()`
3. **Endpoint Registration**: When `spore.begin()` is called, `registerEndpoints()` is called for each service
4. **Task Registration**: Simultaneously, `registerTasks()` is called for each service
5. **Task Initialization**: The TaskManager initializes all registered tasks
6. **Execution**: Tasks run in the main loop when their intervals elapse
### Framework Integration
```cpp
void setup() {
spore.setup();
// Create service with dependencies
MyService* service = new MyService(spore.getContext(), spore.getTaskManager());
// Register service (endpoints and tasks will be registered when begin() is called)
spore.registerService(service);
// This triggers registerEndpoints() and registerTasks() for all services
spore.begin();
}
```
### Dynamic Service Addition
Services can be added after the framework has started:
```cpp
// Add service to running framework
MyService* newService = new MyService(spore.getContext(), spore.getTaskManager());
spore.registerService(newService); // Immediately registers endpoints and tasks
```
## Task Registration Methods
### Using std::bind with Member Functions (Recommended)
@@ -153,7 +218,62 @@ taskManager.registerTask("lambda_task", 2000,
## Adding Custom Tasks
### Method 1: Using std::bind (Recommended)
### Method 1: Service Interface (Recommended)
1. **Create your service class implementing the Service interface**:
```cpp
class SensorService : public Service {
public:
SensorService(NodeContext& ctx, TaskManager& taskManager)
: ctx(ctx), taskManager(taskManager) {}
void registerEndpoints(ApiServer& api) override {
api.registerEndpoint("/api/sensor/status", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleStatus(request); });
}
void registerTasks(TaskManager& taskManager) override {
taskManager.registerTask("temp_read", 1000,
[this]() { readTemperature(); });
taskManager.registerTask("calibrate", 60000,
[this]() { calibrateSensors(); });
}
const char* getName() const override { return "SensorService"; }
private:
NodeContext& ctx;
TaskManager& taskManager;
void readTemperature() {
// Read sensor logic
Serial.println("Reading temperature");
}
void calibrateSensors() {
// Calibration logic
Serial.println("Calibrating sensors");
}
void handleStatus(AsyncWebServerRequest* request) {
// Handle status request
}
};
```
2. **Register with Spore framework**:
```cpp
void setup() {
spore.setup();
SensorService* sensorService = new SensorService(spore.getContext(), spore.getTaskManager());
spore.registerService(sensorService);
spore.begin(); // This will call registerTasks() automatically
}
```
### Method 2: Direct TaskManager Registration
1. **Create your service class**:
```cpp
@@ -181,7 +301,7 @@ taskManager.registerTask("lambda_task", 2000,
std::bind(&SensorService::calibrateSensors, &sensors));
```
### Method 2: Traditional Functions
### Method 3: Traditional Functions
1. **Define your task function**:
```cpp
@@ -308,13 +428,55 @@ curl -X POST http://192.168.1.100/api/tasks/control \
## Best Practices
1. **Use std::bind for member functions**: Cleaner than wrapper functions
2. **Group related tasks**: Register multiple related operations in a single task
1. **Use Service Interface**: Implement the Service interface for clean integration with the framework
2. **Group related tasks**: Register multiple related operations in a single service
3. **Monitor task health**: Use the status API to monitor task performance
4. **Plan intervals carefully**: Balance responsiveness with system resources
5. **Use descriptive names**: Make task names clear and meaningful
6. **Separate concerns**: Use registerEndpoints() for HTTP API and registerTasks() for background work
7. **Dependency injection**: Pass required dependencies (NodeContext, TaskManager) to service constructors
## Migration from Wrapper Functions
## Migration to Service Interface
### Before (manual task registration in constructor):
```cpp
class MyService : public Service {
public:
MyService(TaskManager& taskManager) : taskManager(taskManager) {
// Tasks registered in constructor
taskManager.registerTask("heartbeat", 2000, [this]() { sendHeartbeat(); });
}
void registerEndpoints(ApiServer& api) override {
// Only endpoints registered here
}
};
```
### After (using Service interface):
```cpp
class MyService : public Service {
public:
MyService(TaskManager& taskManager) : taskManager(taskManager) {
// No task registration in constructor
}
void registerEndpoints(ApiServer& api) override {
// Register HTTP endpoints
api.registerEndpoint("/api/my/status", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleStatus(request); });
}
void registerTasks(TaskManager& taskManager) override {
// Register background tasks
taskManager.registerTask("heartbeat", 2000, [this]() { sendHeartbeat(); });
}
const char* getName() const override { return "MyService"; }
};
```
### Migration from Wrapper Functions
### Before (with wrapper functions):
```cpp
@@ -335,11 +497,11 @@ taskManager.registerTask("cluster_listen", interval,
## Compatibility
- The new `std::bind` support is fully backward compatible
- Existing code using function pointers will continue to work
- You can mix both approaches in the same project
- The new Service interface is fully backward compatible
- Existing code using direct TaskManager registration will continue to work
- You can mix Service interface and direct registration in the same project
- All existing TaskManager methods remain unchanged
- New status monitoring methods are additive and don't break existing functionality
- The Service interface provides a cleaner, more organized approach for framework integration
## Related Documentation

478
docs/WiFiConfiguration.md Normal file
View File

@@ -0,0 +1,478 @@
# SPORE WiFi Configuration Process
## Overview
SPORE implements a WiFi configuration system that handles initial setup, runtime reconfiguration, and automatic fallback mechanisms. The system supports both Station (STA) and Access Point (AP) modes with seamless switching between them.
## WiFi Configuration Architecture
### Core Components
- **`NetworkManager`**: Handles WiFi operations and configuration
- **`NetworkService`**: Provides HTTP API endpoints for WiFi management
- **`Config`**: Stores WiFi credentials and connection parameters
- **LittleFS**: Persistent storage for WiFi configuration
- **ESP8266 WiFi Library**: Low-level WiFi operations
### Configuration Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `wifi_ssid` | String | "shroud" | Network SSID for connection |
| `wifi_password` | String | "th3r31sn0sp00n" | Network password |
| `wifi_connect_timeout_ms` | uint32_t | 15000 | Connection timeout (15 seconds) |
| `wifi_retry_delay_ms` | uint32_t | 500 | Delay between connection attempts |
## WiFi Configuration Lifecycle
### 1. Boot Process
```mermaid
graph TD
A[System Boot] --> B[Initialize LittleFS]
B --> C[Load Configuration]
C --> D[Initialize WiFi Mode]
D --> E[Set Hostname from MAC]
E --> F[Attempt STA Connection]
F --> G{Connection Successful?}
G -->|Yes| H[STA Mode Active]
G -->|No| I[Switch to AP Mode]
I --> J[Create Access Point]
J --> K[AP Mode Active]
H --> L[Start UDP Services]
K --> L
L --> M[Initialize Node Context]
M --> N[Start Cluster Services]
```
**Detailed Boot Sequence:**
1. **LittleFS Initialization**
```cpp
if (!LittleFS.begin()) {
LOG_WARN("Config", "Failed to initialize LittleFS, using defaults");
setDefaults();
return;
}
```
2. **Configuration Loading**
- Load WiFi credentials from `/config.json`
- Fall back to defaults if file doesn't exist
- Validate configuration parameters
3. **WiFi Mode Initialization**
```cpp
WiFi.mode(WIFI_STA);
WiFi.begin(ctx.config.wifi_ssid.c_str(), ctx.config.wifi_password.c_str());
```
4. **Hostname Generation**
```cpp
void NetworkManager::setHostnameFromMac() {
uint8_t mac[6];
WiFi.macAddress(mac);
char buf[32];
sprintf(buf, "esp-%02X%02X%02X%02X%02X%02X",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
WiFi.hostname(buf);
ctx.hostname = String(buf);
}
```
5. **Connection Attempt**
```cpp
unsigned long startAttemptTime = millis();
while (WiFi.status() != WL_CONNECTED &&
millis() - startAttemptTime < ctx.config.wifi_connect_timeout_ms) {
delay(ctx.config.wifi_retry_delay_ms);
}
```
6. **Fallback to AP Mode**
```cpp
if (WiFi.status() != WL_CONNECTED) {
LOG_WARN("WiFi", "Failed to connect to AP. Creating AP...");
WiFi.mode(WIFI_AP);
WiFi.softAP(ctx.config.wifi_ssid.c_str(), ctx.config.wifi_password.c_str());
}
```
### 2. Runtime Reconfiguration
```mermaid
graph TD
A[HTTP API Request] --> B[Validate Parameters]
B --> C[Update Config Object]
C --> D[Save to Persistent Storage]
D --> E[Send Response to Client]
E --> F[Schedule Node Restart]
F --> G[Node Restarts]
G --> H[Apply New Configuration]
H --> I[Attempt New Connection]
```
**Runtime Reconfiguration Process:**
1. **API Request Validation**
```cpp
if (!request->hasParam("ssid", true) || !request->hasParam("password", true)) {
request->send(400, "application/json", "{\"error\": \"Missing required parameters\"}");
return;
}
```
2. **Configuration Update**
```cpp
void NetworkManager::setWiFiConfig(const String& ssid, const String& password,
uint32_t connect_timeout_ms, uint32_t retry_delay_ms) {
ctx.config.wifi_ssid = ssid;
ctx.config.wifi_password = password;
ctx.config.wifi_connect_timeout_ms = connect_timeout_ms;
ctx.config.wifi_retry_delay_ms = retry_delay_ms;
}
```
3. **Persistent Storage**
```cpp
bool configSaved = networkManager.saveConfig();
if (!configSaved) {
LOG_WARN("NetworkService", "Failed to save WiFi configuration to persistent storage");
}
```
4. **Restart Scheduling**
```cpp
request->onDisconnect([this]() {
LOG_INFO("NetworkService", "Restarting node to apply WiFi configuration...");
delay(100); // Give time for response to be sent
networkManager.restartNode();
});
```
## WiFi Modes
### Station Mode (STA)
**Purpose**: Connect to existing WiFi network as a client
**Configuration**:
- SSID and password required
- Automatic IP assignment via DHCP
- Hostname set from MAC address
- UDP services on configured port
**Connection Process**:
1. Set WiFi mode to STA
2. Begin connection with credentials
3. Wait for connection with timeout
4. Set hostname and start services
### Access Point Mode (AP)
**Purpose**: Create WiFi hotspot when STA connection fails
**Configuration**:
- Uses configured SSID/password for AP
- Fixed IP address (usually 192.168.4.1)
- Allows other devices to connect
- Maintains cluster functionality
**AP Creation Process**:
1. Switch WiFi mode to AP
2. Create soft access point
3. Set AP IP address
4. Start services on AP IP
## HTTP API Endpoints
### Network Status
#### GET `/api/network/status`
Returns comprehensive WiFi and network status information.
**Response Fields**:
```json
{
"wifi": {
"connected": true,
"mode": "STA",
"ssid": "MyNetwork",
"ip": "192.168.1.100",
"mac": "AA:BB:CC:DD:EE:FF",
"hostname": "esp-AABBCCDDEEFF",
"rssi": -45,
"ap_ip": "192.168.4.1",
"ap_mac": "AA:BB:CC:DD:EE:FF",
"stations_connected": 0
}
}
```
### WiFi Scanning
#### GET `/api/network/wifi/scan`
Returns list of available WiFi networks from last scan.
**Response Fields**:
```json
{
"access_points": [
{
"ssid": "MyNetwork",
"rssi": -45,
"channel": 6,
"encryption_type": 4,
"hidden": false,
"bssid": "AA:BB:CC:DD:EE:FF"
}
]
}
```
#### POST `/api/network/wifi/scan`
Initiates a new WiFi network scan.
**Response**:
```json
{
"status": "scanning",
"message": "WiFi scan started"
}
```
### WiFi Configuration
#### POST `/api/network/wifi/config`
Configures WiFi connection with new credentials.
**Parameters**:
- `ssid` (required): Network SSID
- `password` (required): Network password
- `connect_timeout_ms` (optional): Connection timeout (default: 10000)
- `retry_delay_ms` (optional): Retry delay (default: 500)
**Request Example**:
```bash
curl -X POST http://192.168.1.100/api/network/wifi/config \
-d "ssid=MyNewNetwork&password=newpassword&connect_timeout_ms=15000"
```
**Response**:
```json
{
"status": "success",
"message": "WiFi configuration updated and saved",
"config_saved": true,
"restarting": true
}
```
## WiFi Scanning Process
### Scanning Implementation
```cpp
void NetworkManager::scanWifi() {
if (!isScanning) {
isScanning = true;
LOG_INFO("WiFi", "Starting WiFi scan...");
WiFi.scanNetworksAsync([this](int networksFound) {
LOG_INFO("WiFi", "Scan completed, found " + String(networksFound) + " networks");
this->processAccessPoints();
this->isScanning = false;
}, true);
}
}
```
### Access Point Processing
```cpp
void NetworkManager::processAccessPoints() {
int numNetworks = WiFi.scanComplete();
if (numNetworks <= 0) return;
accessPoints.clear();
for (int i = 0; i < numNetworks; i++) {
AccessPoint ap;
ap.ssid = WiFi.SSID(i);
ap.rssi = WiFi.RSSI(i);
ap.encryptionType = WiFi.encryptionType(i);
ap.channel = WiFi.channel(i);
ap.isHidden = ap.ssid.length() == 0;
uint8_t* newBssid = new uint8_t[6];
memcpy(newBssid, WiFi.BSSID(i), 6);
ap.bssid = newBssid;
accessPoints.push_back(ap);
}
WiFi.scanDelete();
}
```
## Error Handling
### Connection Failures
| Error Type | Handling | Recovery |
|------------|----------|----------|
| **Invalid Credentials** | Log error, switch to AP mode | Manual reconfiguration via API |
| **Network Unavailable** | Log warning, switch to AP mode | Automatic retry on next boot |
| **Timeout** | Log timeout, switch to AP mode | Increase timeout or check network |
| **Hardware Failure** | Log error, continue in AP mode | Hardware replacement required |
### API Error Responses
```json
{
"error": "Missing required parameters",
"status": "error"
}
```
```json
{
"error": "Failed to save configuration",
"status": "error",
"config_saved": false
}
```
## Security Considerations
### Current Implementation
- **Plain Text Storage**: WiFi passwords stored unencrypted
- **Local Network Only**: No internet exposure
- **No Authentication**: API access without authentication
- **MAC-based Hostnames**: Predictable hostname generation
### Security Best Practices
1. **Network Isolation**: Keep SPORE devices on isolated network
2. **Strong Passwords**: Use complex WiFi passwords
3. **Regular Updates**: Keep firmware updated
4. **Access Control**: Implement network-level access control
### Future Security Enhancements
- **Password Encryption**: Encrypt stored WiFi credentials
- **API Authentication**: Add authentication to configuration API
- **Certificate-based Security**: Implement TLS/SSL
- **Access Control Lists**: Role-based configuration access
## Troubleshooting
### Common Issues
1. **WiFi Connection Fails**
- Check SSID and password
- Verify network availability
- Check signal strength
- Increase connection timeout
2. **Configuration Not Persisting**
- Check LittleFS initialization
- Verify file write permissions
- Monitor available flash space
3. **AP Mode Not Working**
- Check AP credentials
- Verify IP address assignment
- Check for IP conflicts
4. **Scan Not Finding Networks**
- Wait for scan completion
- Check WiFi hardware
- Verify scan permissions
### Debug Commands
```bash
# Check WiFi status
curl -s http://192.168.1.100/api/network/status | jq '.wifi'
# Scan for networks
curl -X POST http://192.168.1.100/api/network/wifi/scan
# View scan results
curl -s http://192.168.1.100/api/network/wifi/scan | jq '.'
# Test configuration
curl -X POST http://192.168.1.100/api/network/wifi/config \
-d "ssid=TestNetwork&password=testpass"
```
### Log Analysis
**Successful Connection**:
```
[INFO] WiFi: Connected to AP, IP: 192.168.1.100
[INFO] WiFi: Hostname set to: esp-AABBCCDDEEFF
[INFO] WiFi: UDP listening on port 4210
```
**Connection Failure**:
```
[WARN] WiFi: Failed to connect to AP. Creating AP...
[INFO] WiFi: AP created, IP: 192.168.4.1
```
**Configuration Update**:
```
[INFO] NetworkService: Restarting node to apply WiFi configuration...
[INFO] WiFi: Connecting to AP...
```
## Performance Considerations
### Connection Time
- **STA Connection**: 5-15 seconds typical
- **AP Creation**: 1-3 seconds
- **Scan Duration**: 5-10 seconds
- **Configuration Save**: 100-500ms
### Memory Usage
- **WiFi Stack**: ~20-30KB RAM
- **Scan Results**: ~1KB per network
- **Configuration**: ~200 bytes
- **API Buffers**: ~2-4KB
### Network Overhead
- **Scan Packets**: Minimal impact
- **Configuration API**: ~500 bytes per request
- **Status Updates**: ~200 bytes per response
## Best Practices
### Configuration Management
1. **Use Strong Passwords**: Implement password complexity requirements
2. **Set Appropriate Timeouts**: Balance connection speed vs reliability
3. **Monitor Connection Quality**: Track RSSI and connection stability
4. **Implement Retry Logic**: Handle temporary network issues
5. **Log Configuration Changes**: Audit trail for troubleshooting
### Development Guidelines
1. **Handle All Error Cases**: Implement comprehensive error handling
2. **Provide Clear Feedback**: Inform users of connection status
3. **Optimize Scan Frequency**: Balance discovery vs performance
4. **Test Fallback Scenarios**: Ensure AP mode works correctly
5. **Document Configuration Options**: Clear parameter documentation
## Related Documentation
- **[Configuration Management](./ConfigurationManagement.md)** - Persistent configuration system
- **[API Reference](./API.md)** - Complete HTTP API documentation
- **[Architecture Overview](./Architecture.md)** - System architecture and components
- **[OpenAPI Specification](../api/)** - Machine-readable API specification

View File

@@ -0,0 +1,248 @@
#include "MultiMatrixService.h"
#include "spore/core/ApiServer.h"
#include "spore/util/Logging.h"
#include <ArduinoJson.h>
#include <SoftwareSerial.h>
namespace {
constexpr uint8_t DEFAULT_VOLUME = 15;
constexpr char API_STATUS_ENDPOINT[] = "/api/audio/status";
constexpr char API_CONTROL_ENDPOINT[] = "/api/audio";
constexpr char EVENT_TOPIC[] = "audio/player";
}
MultiMatrixService::MultiMatrixService(NodeContext& ctx, TaskManager& taskManager, uint8_t rxPin, uint8_t txPin, uint8_t potentiometerPin)
: m_ctx(ctx),
m_taskManager(taskManager),
m_serial(std::make_unique<SoftwareSerial>(rxPin, txPin)),
m_potentiometerPin(potentiometerPin),
m_volume(DEFAULT_VOLUME),
m_playerReady(false),
m_loopEnabled(false) {
pinMode(m_potentiometerPin, INPUT);
m_serial->begin(9600);
// DFPlayer Mini requires time to initialize after power-on
delay(1000);
if (m_player.begin(*m_serial)) {
m_playerReady = true;
m_player.setTimeOut(500);
m_player.EQ(DFPLAYER_EQ_NORMAL);
m_player.volume(m_volume);
LOG_INFO("MultiMatrixService", "DFPlayer initialized successfully");
publishEvent("ready");
} else {
LOG_ERROR("MultiMatrixService", "Failed to initialize DFPlayer");
}
}
void MultiMatrixService::registerEndpoints(ApiServer& api) {
api.registerEndpoint(API_STATUS_ENDPOINT, HTTP_GET,
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
std::vector<ParamSpec>{});
api.registerEndpoint(API_CONTROL_ENDPOINT, HTTP_POST,
[this](AsyncWebServerRequest* request) { handleControlRequest(request); },
std::vector<ParamSpec>{
ParamSpec{String("action"), true, String("body"), String("string"),
{String("play"), String("stop"), String("pause"), String("resume"), String("next"), String("previous"), String("volume"), String("loop")}},
ParamSpec{String("volume"), false, String("body"), String("numberRange"), {}, String("15")},
ParamSpec{String("loop"), false, String("body"), String("boolean"), {}}
});
}
bool MultiMatrixService::isReady() const {
return m_playerReady;
}
uint8_t MultiMatrixService::getVolume() const {
return m_volume;
}
bool MultiMatrixService::isLoopEnabled() const {
return m_loopEnabled;
}
void MultiMatrixService::play() {
if (!m_playerReady) {
return;
}
m_player.play();
publishEvent("play");
LOG_INFO("MultiMatrixService", "Playback started");
}
void MultiMatrixService::stop() {
if (!m_playerReady) {
return;
}
m_player.stop();
publishEvent("stop");
LOG_INFO("MultiMatrixService", "Playback stopped");
}
void MultiMatrixService::pause() {
if (!m_playerReady) {
return;
}
m_player.pause();
publishEvent("pause");
LOG_INFO("MultiMatrixService", "Playback paused");
}
void MultiMatrixService::resume() {
if (!m_playerReady) {
return;
}
m_player.start();
publishEvent("resume");
LOG_INFO("MultiMatrixService", "Playback resumed");
}
void MultiMatrixService::next() {
if (!m_playerReady) {
return;
}
m_player.next();
publishEvent("next");
LOG_INFO("MultiMatrixService", "Next track");
}
void MultiMatrixService::previous() {
if (!m_playerReady) {
return;
}
m_player.previous();
publishEvent("previous");
LOG_INFO("MultiMatrixService", "Previous track");
}
void MultiMatrixService::setVolume(uint8_t volume) {
if (!m_playerReady) {
return;
}
const uint8_t clampedVolume = std::min<uint8_t>(volume, MAX_VOLUME);
if (clampedVolume == m_volume) {
return;
}
applyVolume(clampedVolume);
}
void MultiMatrixService::setLoop(bool enabled) {
if (!m_playerReady) {
return;
}
m_loopEnabled = enabled;
if (enabled) {
m_player.enableLoop();
} else {
m_player.disableLoop();
}
publishEvent("loop");
LOG_INFO("MultiMatrixService", String("Loop ") + (enabled ? "enabled" : "disabled"));
}
void MultiMatrixService::registerTasks(TaskManager& taskManager) {
taskManager.registerTask("multimatrix_potentiometer", POTENTIOMETER_SAMPLE_INTERVAL_MS,
[this]() { pollPotentiometer(); });
}
void MultiMatrixService::pollPotentiometer() {
if (!m_playerReady) {
return;
}
const uint16_t rawValue = analogRead(static_cast<uint8_t>(m_potentiometerPin));
const uint8_t targetVolume = calculateVolumeFromPotentiometer(rawValue);
if (targetVolume > m_volume + POT_VOLUME_EPSILON || targetVolume + POT_VOLUME_EPSILON < m_volume) {
applyVolume(targetVolume);
}
}
uint8_t MultiMatrixService::calculateVolumeFromPotentiometer(uint16_t rawValue) const {
// Clamp raw value to prevent underflow (analogRead can return 1024)
const uint16_t clampedValue = std::min<uint16_t>(rawValue, 1023U);
// Invert: all down (0) = max volume, all up (1023) = min volume
const uint16_t invertedValue = 1023U - clampedValue;
const uint8_t scaledVolume = static_cast<uint8_t>((static_cast<uint32_t>(invertedValue) * MAX_VOLUME) / 1023U);
return std::min<uint8_t>(scaledVolume, MAX_VOLUME);
}
void MultiMatrixService::applyVolume(uint8_t targetVolume) {
m_volume = targetVolume;
m_player.volume(m_volume);
publishEvent("volume");
LOG_INFO("MultiMatrixService", String("Volume set to ") + String(m_volume));
}
void MultiMatrixService::handleStatusRequest(AsyncWebServerRequest* request) {
StaticJsonDocument<192> doc;
doc["ready"] = m_playerReady;
doc["volume"] = static_cast<int>(m_volume);
doc["loop"] = m_loopEnabled;
String json;
serializeJson(doc, json);
request->send(200, "application/json", json);
}
void MultiMatrixService::handleControlRequest(AsyncWebServerRequest* request) {
String action = request->hasParam("action", true) ? request->getParam("action", true)->value() : "";
bool ok = true;
if (action.equalsIgnoreCase("play")) {
play();
} else if (action.equalsIgnoreCase("stop")) {
stop();
} else if (action.equalsIgnoreCase("pause")) {
pause();
} else if (action.equalsIgnoreCase("resume")) {
resume();
} else if (action.equalsIgnoreCase("next")) {
next();
} else if (action.equalsIgnoreCase("previous")) {
previous();
} else if (action.equalsIgnoreCase("volume")) {
if (request->hasParam("volume", true)) {
int volumeValue = request->getParam("volume", true)->value().toInt();
setVolume(static_cast<uint8_t>(std::max(0, std::min(static_cast<int>(MAX_VOLUME), volumeValue))));
} else {
ok = false;
}
} else if (action.equalsIgnoreCase("loop")) {
if (request->hasParam("loop", true)) {
String loopValue = request->getParam("loop", true)->value();
bool enabled = loopValue.equalsIgnoreCase("true") || loopValue == "1";
setLoop(enabled);
} else {
ok = false;
}
} else {
ok = false;
}
StaticJsonDocument<256> resp;
resp["success"] = ok;
resp["ready"] = m_playerReady;
resp["volume"] = static_cast<int>(m_volume);
resp["loop"] = m_loopEnabled;
if (!ok) {
resp["message"] = "Invalid action";
}
String json;
serializeJson(resp, json);
request->send(ok ? 200 : 400, "application/json", json);
}
void MultiMatrixService::publishEvent(const char* action) {
StaticJsonDocument<192> doc;
doc["action"] = action;
doc["volume"] = static_cast<int>(m_volume);
doc["loop"] = m_loopEnabled;
String payload;
serializeJson(doc, payload);
m_ctx.fire(EVENT_TOPIC, &payload);
}

View File

@@ -0,0 +1,53 @@
#pragma once
#include <Arduino.h>
#include <algorithm>
#include <memory>
#include <SoftwareSerial.h>
#include <DFRobotDFPlayerMini.h>
#include "spore/Service.h"
#include "spore/core/TaskManager.h"
#include "spore/core/NodeContext.h"
class ApiServer;
class AsyncWebServerRequest;
class MultiMatrixService : public Service {
public:
MultiMatrixService(NodeContext& ctx, TaskManager& taskManager, uint8_t rxPin, uint8_t txPin, uint8_t potentiometerPin);
void registerEndpoints(ApiServer& api) override;
void registerTasks(TaskManager& taskManager) override;
const char* getName() const override { return "MultiMatrixAudio"; }
bool isReady() const;
uint8_t getVolume() const;
bool isLoopEnabled() const;
void play();
void stop();
void pause();
void resume();
void next();
void previous();
void setVolume(uint8_t volume);
void setLoop(bool enabled);
private:
static constexpr uint16_t POTENTIOMETER_SAMPLE_INTERVAL_MS = 200;
static constexpr uint8_t MAX_VOLUME = 30;
static constexpr uint8_t POT_VOLUME_EPSILON = 2;
void pollPotentiometer();
void handleStatusRequest(AsyncWebServerRequest* request);
void handleControlRequest(AsyncWebServerRequest* request);
uint8_t calculateVolumeFromPotentiometer(uint16_t rawValue) const;
void applyVolume(uint8_t targetVolume);
void publishEvent(const char* action);
NodeContext& m_ctx;
TaskManager& m_taskManager;
std::unique_ptr<SoftwareSerial> m_serial;
DFRobotDFPlayerMini m_player;
uint8_t m_potentiometerPin;
uint8_t m_volume;
bool m_playerReady;
bool m_loopEnabled;
};

View File

@@ -0,0 +1,19 @@
# Multi-Matrix
This example combines different capabilities:
- Spore base stack
- use PixelStreamController in Matrix mode 16x16
- DFRobotDFPlayerMini audio playback
- analog potentiometer to controll audio volume
- API and web interface to control the audio player (start, stop, pause, next / previous track, volume)
## MCU
- Wemos D1 Mini
## Pin Configuration
```
#define MP3PLAYER_PIN_RX D3
#define MP3PLAYER_PIN_TX D4
#define MATRIX_PIN D2
#define POTI_PIN A0
```

View File

@@ -0,0 +1,508 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multi-Matrix Audio Player</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.player-container {
background: rgba(255, 255, 255, 0.98);
border-radius: 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 3rem 2.5rem;
max-width: 420px;
width: 100%;
backdrop-filter: blur(10px);
}
.player-header {
text-align: center;
margin-bottom: 2rem;
}
.player-title {
font-size: 1.75rem;
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 1rem;
background: rgba(102, 126, 234, 0.1);
border-radius: 20px;
font-size: 0.875rem;
color: #667eea;
font-weight: 500;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #10b981;
animation: pulse 2s infinite;
}
.status-dot.inactive {
background: #ef4444;
animation: none;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.main-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
margin: 2.5rem 0;
}
.control-btn {
width: 52px;
height: 52px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
font-size: 1.2rem;
}
.control-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
}
.control-btn:active {
transform: translateY(0);
}
.play-btn {
width: 72px;
height: 72px;
font-size: 1.5rem;
box-shadow: 0 6px 25px rgba(102, 126, 234, 0.5);
}
.secondary-controls {
display: flex;
gap: 0.75rem;
margin-bottom: 2rem;
justify-content: center;
}
.secondary-btn {
padding: 0.6rem 1.25rem;
border: 2px solid #667eea;
background: white;
color: #667eea;
border-radius: 12px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
transition: all 0.3s ease;
}
.secondary-btn:hover {
background: #667eea;
color: white;
transform: translateY(-1px);
}
.secondary-btn:active {
transform: translateY(0);
}
.volume-section {
margin: 2rem 0;
}
.volume-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
font-size: 0.875rem;
color: #64748b;
font-weight: 600;
}
.volume-value {
color: #667eea;
font-weight: 700;
font-size: 1rem;
}
.volume-slider {
-webkit-appearance: none;
width: 100%;
height: 6px;
border-radius: 3px;
background: linear-gradient(to right, #667eea 0%, #764ba2 100%);
outline: none;
opacity: 0.7;
transition: opacity 0.2s;
}
.volume-slider:hover {
opacity: 1;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: white;
cursor: pointer;
box-shadow: 0 2px 10px rgba(102, 126, 234, 0.5);
border: 2px solid #667eea;
}
.volume-slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: white;
cursor: pointer;
box-shadow: 0 2px 10px rgba(102, 126, 234, 0.5);
border: 2px solid #667eea;
}
.loop-toggle {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
background: rgba(102, 126, 234, 0.05);
border-radius: 12px;
margin-top: 1.5rem;
}
.loop-label {
font-size: 0.95rem;
font-weight: 600;
color: #475569;
display: flex;
align-items: center;
gap: 0.5rem;
}
.toggle-switch {
position: relative;
width: 52px;
height: 28px;
background: #cbd5e1;
border-radius: 14px;
cursor: pointer;
transition: background 0.3s ease;
}
.toggle-switch.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.toggle-slider {
position: absolute;
top: 3px;
left: 3px;
width: 22px;
height: 22px;
background: white;
border-radius: 50%;
transition: transform 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.toggle-switch.active .toggle-slider {
transform: translateX(24px);
}
.icon {
display: inline-block;
}
@media (max-width: 480px) {
.player-container {
padding: 2rem 1.5rem;
}
.player-title {
font-size: 1.5rem;
}
.control-btn {
width: 48px;
height: 48px;
}
.play-btn {
width: 64px;
height: 64px;
}
}
</style>
</head>
<body>
<div class="player-container">
<div class="player-header">
<h1 class="player-title">Multi-Matrix Audio</h1>
<div class="status-badge">
<span class="status-dot" id="statusDot"></span>
<span id="statusText">Connecting...</span>
</div>
</div>
<div class="main-controls">
<button class="control-btn" onclick="sendAction('previous')" title="Previous">
<span class="icon"></span>
</button>
<button class="control-btn play-btn" id="playBtn" onclick="togglePlayPause()" title="Play/Pause">
<span class="icon" id="playIcon"></span>
</button>
<button class="control-btn" onclick="sendAction('next')" title="Next">
<span class="icon"></span>
</button>
</div>
<div class="secondary-controls">
<button class="secondary-btn" onclick="sendAction('stop')">⏹ Stop</button>
<button class="secondary-btn" onclick="sendAction('resume')">▶ Resume</button>
</div>
<div class="volume-section">
<div class="volume-header">
<span>🔊 Volume</span>
<span class="volume-value" id="volumeDisplay">15</span>
</div>
<input type="range"
class="volume-slider"
id="volumeSlider"
min="0"
max="30"
value="15"
oninput="updateVolumeDisplay(this.value)"
onchange="setVolume(this.value)">
</div>
<div class="loop-toggle">
<div class="loop-label">
<span>🔁 Loop Mode</span>
</div>
<div class="toggle-switch" id="loopToggle" onclick="toggleLoop()">
<div class="toggle-slider"></div>
</div>
</div>
</div>
<script>
let isPlaying = false;
let loopEnabled = false;
async function fetchStatus() {
try {
const response = await fetch('/api/audio/status');
if (!response.ok) {
throw new Error('Status request failed');
}
const status = await response.json();
updateUIStatus(status);
} catch (error) {
document.getElementById('statusText').textContent = 'Error';
document.getElementById('statusDot').className = 'status-dot inactive';
console.error(error);
}
}
function updateUIStatus(status) {
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
if (status.ready) {
statusDot.className = 'status-dot';
statusText.textContent = 'Ready';
} else {
statusDot.className = 'status-dot inactive';
statusText.textContent = 'Not Ready';
}
if ('volume' in status) {
document.getElementById('volumeSlider').value = status.volume;
document.getElementById('volumeDisplay').textContent = status.volume;
}
if ('loop' in status) {
loopEnabled = status.loop;
const loopToggle = document.getElementById('loopToggle');
if (loopEnabled) {
loopToggle.classList.add('active');
} else {
loopToggle.classList.remove('active');
}
}
}
async function sendAction(action, params = {}) {
try {
const formData = new URLSearchParams({ action, ...params });
const response = await fetch('/api/audio', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData
});
const result = await response.json();
updateUIStatus(result);
if (action === 'play' || action === 'resume') {
isPlaying = true;
updatePlayButton();
} else if (action === 'pause' || action === 'stop') {
isPlaying = false;
updatePlayButton();
}
if (!result.success) {
alert(result.message || 'Action failed');
}
} catch (error) {
alert('Request failed: ' + error.message);
}
}
function togglePlayPause() {
if (isPlaying) {
sendAction('pause');
} else {
sendAction('play');
}
}
function updatePlayButton() {
const playIcon = document.getElementById('playIcon');
playIcon.textContent = isPlaying ? '⏸' : '▶';
}
function updateVolumeDisplay(value) {
document.getElementById('volumeDisplay').textContent = value;
}
function setVolume(value) {
sendAction('volume', { volume: value });
}
function toggleLoop() {
loopEnabled = !loopEnabled;
const loopToggle = document.getElementById('loopToggle');
if (loopEnabled) {
loopToggle.classList.add('active');
} else {
loopToggle.classList.remove('active');
}
sendAction('loop', { loop: loopEnabled });
}
fetchStatus();
setInterval(fetchStatus, 5000);
function setupWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(protocol + '//' + window.location.host + '/ws');
ws.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload.event === 'audio/player' && payload.payload) {
const data = JSON.parse(payload.payload);
// Skip debug messages
if (data.action === 'pot_debug') {
return;
}
if ('volume' in data) {
document.getElementById('volumeSlider').value = data.volume;
document.getElementById('volumeDisplay').textContent = data.volume;
}
if ('loop' in data) {
loopEnabled = data.loop;
const loopToggle = document.getElementById('loopToggle');
if (loopEnabled) {
loopToggle.classList.add('active');
} else {
loopToggle.classList.remove('active');
}
}
if (data.action) {
const statusText = document.getElementById('statusText');
const statusDot = document.getElementById('statusDot');
if (data.action === 'play' || data.action === 'resume' || data.action === 'next' || data.action === 'previous') {
statusText.textContent = 'Playing';
statusDot.className = 'status-dot';
isPlaying = true;
updatePlayButton();
} else if (data.action === 'pause') {
statusText.textContent = 'Paused';
statusDot.className = 'status-dot';
isPlaying = false;
updatePlayButton();
} else if (data.action === 'stop') {
statusText.textContent = 'Stopped';
statusDot.className = 'status-dot inactive';
isPlaying = false;
updatePlayButton();
} else if (data.action === 'ready') {
statusText.textContent = 'Ready';
statusDot.className = 'status-dot';
}
}
}
} catch (err) {
console.error('WebSocket parse error', err);
}
};
ws.onclose = () => {
setTimeout(setupWebSocket, 3000);
};
}
setupWebSocket();
</script>
</body>
</html>

View File

@@ -0,0 +1,54 @@
#include <Arduino.h>
#include <memory>
#include "spore/Spore.h"
#include "spore/util/Logging.h"
#include "../pixelstream/PixelStreamController.h"
#include "MultiMatrixService.h"
namespace {
constexpr uint8_t MATRIX_PIN = D2;
constexpr uint16_t MATRIX_PIXEL_COUNT = 256;
constexpr uint8_t MATRIX_BRIGHTNESS = 40;
constexpr uint16_t MATRIX_WIDTH = 16;
constexpr bool MATRIX_SERPENTINE = false;
constexpr neoPixelType MATRIX_PIXEL_TYPE = NEO_GRB + NEO_KHZ800;
constexpr uint8_t MP3PLAYER_PIN_RX = D3;
constexpr uint8_t MP3PLAYER_PIN_TX = D4;
constexpr uint8_t POTENTIOMETER_PIN = A0;
}
Spore spore({
{"app", "multimatrix"},
{"role", "media"},
{"matrix", String(MATRIX_PIXEL_COUNT)}
});
std::unique_ptr<PixelStreamController> pixelController;
std::shared_ptr<MultiMatrixService> audioService;
void setup() {
spore.setup();
PixelStreamConfig config{
MATRIX_PIN,
MATRIX_PIXEL_COUNT,
MATRIX_BRIGHTNESS,
MATRIX_WIDTH,
MATRIX_SERPENTINE,
MATRIX_PIXEL_TYPE
};
pixelController = std::make_unique<PixelStreamController>(spore.getContext(), config);
pixelController->begin();
audioService = std::make_shared<MultiMatrixService>(spore.getContext(), spore.getTaskManager(), MP3PLAYER_PIN_RX, MP3PLAYER_PIN_TX, POTENTIOMETER_PIN);
spore.registerService(audioService);
spore.begin();
LOG_INFO("MultiMatrix", "Setup complete");
}
void loop() {
spore.loop();
}

View File

@@ -4,7 +4,6 @@
NeoPattern::NeoPattern(uint16_t pixels, uint8_t pin, uint8_t type, void (*callback)(int))
: Adafruit_NeoPixel(pixels, pin, type)
{
frameBuffer = (uint8_t *)malloc(768);
OnComplete = callback;
TotalSteps = numPixels();
begin();
@@ -13,40 +12,21 @@ NeoPattern::NeoPattern(uint16_t pixels, uint8_t pin, uint8_t type, void (*callba
NeoPattern::NeoPattern(uint16_t pixels, uint8_t pin, uint8_t type)
: Adafruit_NeoPixel(pixels, pin, type)
{
frameBuffer = (uint8_t *)malloc(768);
TotalSteps = numPixels();
begin();
}
NeoPattern::~NeoPattern() {
if (frameBuffer) {
free(frameBuffer);
}
// No frameBuffer to clean up
}
void NeoPattern::handleStream(uint8_t *data, size_t len)
{
//const uint16_t *data16 = (uint16_t *)data;
bufferSize = len;
memcpy(frameBuffer, data, len);
}
void NeoPattern::drawFrameBuffer(int w, uint8_t *frame, int length)
{
for (int i = 0; i < length; i++)
{
uint8_t r = frame[i];
uint8_t g = frame[i + 1];
uint8_t b = frame[i + 2];
setPixelColor(i, r, g, b);
}
}
// Removed unused handleStream and drawFrameBuffer functions
void NeoPattern::onCompleteDefault(int pixels)
{
//Serial.println("onCompleteDefault");
// FIXME no specific code
if (ActivePattern == THEATER_CHASE)
if (ActivePattern == THEATER_CHASE || ActivePattern == RAINBOW_CYCLE)
{
return;
}
@@ -127,7 +107,12 @@ void NeoPattern::RainbowCycleUpdate()
setPixelColor(i, Wheel(((i * 256 / numPixels()) + Index) & 255));
}
show();
Increment();
// RainbowCycle is continuous, just increment Index
Index++;
if (Index >= 255)
{
Index = 0;
}
}
// Initialize for a Theater Chase

View File

@@ -50,19 +50,14 @@ class NeoPattern : public Adafruit_NeoPixel
uint16_t completed = 0;
// Callback on completion of pattern
void (*OnComplete)(int);
uint8_t *frameBuffer;
int bufferSize = 0;
void (*OnComplete)(int);
// Constructor - calls base-class constructor to initialize strip
NeoPattern(uint16_t pixels, uint8_t pin, uint8_t type, void (*callback)(int));
NeoPattern(uint16_t pixels, uint8_t pin, uint8_t type);
~NeoPattern();
// Stream handling
void handleStream(uint8_t *data, size_t len);
void drawFrameBuffer(int w, uint8_t *frame, int length);
// Stream handling functions removed
// Pattern completion
void onCompleteDefault(int pixels);

View File

@@ -34,7 +34,6 @@ NeoPatternService::NeoPatternService(NodeContext& ctx, TaskManager& taskMgr, con
neoPattern->Direction = static_cast<::direction>(direction);
registerPatterns();
registerTasks();
registerEventHandlers();
initialized = true;
@@ -49,17 +48,17 @@ NeoPatternService::~NeoPatternService() {
void NeoPatternService::registerEndpoints(ApiServer& api) {
// Status endpoint
api.addEndpoint("/api/neopattern/status", HTTP_GET,
api.registerEndpoint("/api/neopattern/status", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
std::vector<ParamSpec>{});
// Patterns list endpoint
api.addEndpoint("/api/neopattern/patterns", HTTP_GET,
api.registerEndpoint("/api/neopattern/patterns", HTTP_GET,
[this](AsyncWebServerRequest* request) { handlePatternsRequest(request); },
std::vector<ParamSpec>{});
// Control endpoint
api.addEndpoint("/api/neopattern", HTTP_POST,
api.registerEndpoint("/api/neopattern", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleControlRequest(request); },
std::vector<ParamSpec>{
ParamSpec{String("pattern"), false, String("body"), String("string"), patternNamesVector()},
@@ -73,7 +72,7 @@ void NeoPatternService::registerEndpoints(ApiServer& api) {
});
// State endpoint for complex state updates
api.addEndpoint("/api/neopattern/state", HTTP_POST,
api.registerEndpoint("/api/neopattern/state", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleStateRequest(request); },
std::vector<ParamSpec>{});
}
@@ -196,13 +195,13 @@ void NeoPatternService::registerEventHandlers() {
JsonDocument doc;
DeserializationError err = deserializeJson(doc, *jsonStr);
if (err) {
LOG_WARN("NeoPattern", String("Failed to parse CLUSTER_EVENT data: ") + err.c_str());
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");
LOG_INFO("NeoPattern", "Applied control from cluster/event");
}
});
@@ -340,9 +339,31 @@ void NeoPatternService::setPattern(NeoPatternType pattern) {
currentState.pattern = static_cast<uint>(pattern);
neoPattern->ActivePattern = static_cast<::pattern>(pattern);
resetStateForPattern(pattern);
// Initialize the pattern using the registry
patternRegistry.initializePattern(static_cast<uint8_t>(pattern));
// Set up pattern-specific parameters
switch (pattern) {
case NeoPatternType::RAINBOW_CYCLE:
neoPattern->RainbowCycle(updateIntervalMs, static_cast<::direction>(direction));
break;
case NeoPatternType::THEATER_CHASE:
neoPattern->TheaterChase(currentState.color, currentState.color2, updateIntervalMs, static_cast<::direction>(direction));
break;
case NeoPatternType::COLOR_WIPE:
neoPattern->ColorWipe(currentState.color, updateIntervalMs, static_cast<::direction>(direction));
break;
case NeoPatternType::SCANNER:
neoPattern->Scanner(currentState.color, updateIntervalMs);
break;
case NeoPatternType::FADE:
neoPattern->Fade(currentState.color, currentState.color2, currentState.totalSteps, updateIntervalMs, static_cast<::direction>(direction));
break;
case NeoPatternType::FIRE:
// Fire pattern doesn't need setup
break;
case NeoPatternType::NONE:
// None pattern doesn't need setup
break;
}
}
void NeoPatternService::setPatternByName(const String& name) {
@@ -403,7 +424,7 @@ NeoPatternState NeoPatternService::getState() const {
return currentState;
}
void NeoPatternService::registerTasks() {
void NeoPatternService::registerTasks(TaskManager& taskManager) {
taskManager.registerTask("neopattern_update", updateIntervalMs, [this]() { update(); });
taskManager.registerTask("neopattern_status_print", 10000, [this]() {
LOG_INFO("NeoPattern", "Status update");
@@ -426,7 +447,7 @@ void NeoPatternService::registerPatterns() {
"rainbow_cycle",
static_cast<uint8_t>(NeoPatternType::RAINBOW_CYCLE),
"Rainbow cycle pattern",
[this]() { neoPattern->RainbowCycle(updateIntervalMs, static_cast<::direction>(direction)); },
nullptr, // No initializer needed, state is set up in setPattern
[this]() { updateRainbowCycle(); },
false, // doesn't require color2
true // supports direction
@@ -436,7 +457,7 @@ void NeoPatternService::registerPatterns() {
"theater_chase",
static_cast<uint8_t>(NeoPatternType::THEATER_CHASE),
"Theater chase pattern",
[this]() { neoPattern->TheaterChase(currentState.color, currentState.color2, updateIntervalMs, static_cast<::direction>(direction)); },
nullptr, // No initializer needed, state is set up in setPattern
[this]() { updateTheaterChase(); },
true, // requires color2
true // supports direction
@@ -446,7 +467,7 @@ void NeoPatternService::registerPatterns() {
"color_wipe",
static_cast<uint8_t>(NeoPatternType::COLOR_WIPE),
"Color wipe pattern",
[this]() { neoPattern->ColorWipe(currentState.color, updateIntervalMs, static_cast<::direction>(direction)); },
nullptr, // No initializer needed, state is set up in setPattern
[this]() { updateColorWipe(); },
false, // doesn't require color2
true // supports direction
@@ -456,7 +477,7 @@ void NeoPatternService::registerPatterns() {
"scanner",
static_cast<uint8_t>(NeoPatternType::SCANNER),
"Scanner pattern",
[this]() { neoPattern->Scanner(currentState.color, updateIntervalMs); },
nullptr, // No initializer needed, state is set up in setPattern
[this]() { updateScanner(); },
false, // doesn't require color2
false // doesn't support direction
@@ -466,7 +487,7 @@ void NeoPatternService::registerPatterns() {
"fade",
static_cast<uint8_t>(NeoPatternType::FADE),
"Fade pattern",
[this]() { neoPattern->Fade(currentState.color, currentState.color2, currentState.totalSteps, updateIntervalMs, static_cast<::direction>(direction)); },
nullptr, // No initializer needed, state is set up in setPattern
[this]() { updateFade(); },
true, // requires color2
true // supports direction
@@ -476,7 +497,7 @@ void NeoPatternService::registerPatterns() {
"fire",
static_cast<uint8_t>(NeoPatternType::FIRE),
"Fire effect pattern",
[this]() { neoPattern->Fire(50, 120); },
nullptr, // No initializer needed, state is set up in setPattern
[this]() { updateFire(); },
false, // doesn't require color2
false // doesn't support direction
@@ -500,7 +521,7 @@ void NeoPatternService::resetStateForPattern(NeoPatternType pattern) {
neoPattern->Index = 0;
neoPattern->Direction = static_cast<::direction>(direction);
neoPattern->completed = 0;
lastUpdateMs = 0;
// Don't reset lastUpdateMs to 0, keep the current timing
}
uint32_t NeoPatternService::parseColor(const String& colorStr) const {
@@ -538,10 +559,10 @@ String NeoPatternService::getPatternDescription(const String& name) const {
void NeoPatternService::update() {
if (!initialized) return;
//unsigned long now = millis();
//if (now - lastUpdateMs < updateIntervalMs) return;
//lastUpdateMs = now;
unsigned long now = millis();
if (now - lastUpdateMs < updateIntervalMs) return;
lastUpdateMs = now;
// Use pattern registry to execute the current pattern
patternRegistry.executePattern(static_cast<uint8_t>(activePattern));

View File

@@ -30,6 +30,7 @@ public:
~NeoPatternService();
void registerEndpoints(ApiServer& api) override;
void registerTasks(TaskManager& taskManager) override;
const char* getName() const override { return "NeoPattern"; }
// Pattern control methods
@@ -47,7 +48,6 @@ public:
NeoPatternState getState() const;
private:
void registerTasks();
void registerPatterns();
void update();
void registerEventHandlers();

View File

@@ -10,7 +10,7 @@
#endif
#ifndef NEOPIXEL_LENGTH
#define NEOPIXEL_LENGTH 8
#define NEOPIXEL_LENGTH 16
#endif
#ifndef NEOPIXEL_BRIGHTNESS
@@ -46,7 +46,7 @@ void setup() {
// Create and add custom service
neoPatternService = new NeoPatternService(spore.getContext(), spore.getTaskManager(), config);
spore.addService(neoPatternService);
spore.registerService(neoPatternService);
// Start the API server and complete initialization
spore.begin();

View File

@@ -0,0 +1,127 @@
#include "PixelStreamController.h"
namespace {
constexpr int COMPONENTS_PER_PIXEL = 3;
}
PixelStreamController::PixelStreamController(NodeContext& ctxRef, const PixelStreamConfig& cfg)
: ctx(ctxRef), config(cfg), pixels(cfg.pixelCount, cfg.pin, cfg.pixelType) {
}
void PixelStreamController::begin() {
pixels.begin();
pixels.setBrightness(config.brightness);
// Default all pixels to green so we can verify hardware before streaming frames
for (uint16_t i = 0; i < config.pixelCount; ++i) {
pixels.setPixelColor(i, pixels.Color(0, 255, 0));
}
pixels.show();
ctx.on("udp/raw", [this](void* data) {
this->handleEvent(data);
});
LOG_INFO("PixelStream", String("PixelStreamController ready on pin ") + String(config.pin) + " with " + String(config.pixelCount) + " pixels");
}
void PixelStreamController::handleEvent(void* data) {
if (data == nullptr) {
return;
}
String* payload = static_cast<String*>(data);
if (!payload) {
return;
}
if (!applyFrame(*payload)) {
LOG_WARN("PixelStream", String("Ignoring RAW payload with invalid length (") + String(payload->length()) + ")");
}
}
bool PixelStreamController::applyFrame(const String& payload) {
static constexpr std::size_t frameWidth = COMPONENTS_PER_PIXEL * 2;
const std::size_t payloadLength = static_cast<std::size_t>(payload.length());
if (payloadLength == 0 || (payloadLength % frameWidth) != 0) {
LOG_WARN("PixelStream", String("Payload size ") + String(payloadLength) + " is not a multiple of " + String(frameWidth));
return false;
}
const uint16_t framesProvided = static_cast<uint16_t>(payloadLength / frameWidth);
const uint16_t pixelsToUpdate = std::min(config.pixelCount, framesProvided);
for (uint16_t index = 0; index < pixelsToUpdate; ++index) {
const std::size_t base = static_cast<std::size_t>(index) * frameWidth;
FrameComponents components{};
if (!tryParsePixel(payload, base, components)) {
LOG_WARN("PixelStream", String("Invalid hex data at pixel index ") + String(index));
return false;
}
const uint16_t hardwareIndex = mapPixelIndex(index);
pixels.setPixelColor(hardwareIndex, pixels.Color(components.red, components.green, components.blue));
}
// Clear any remaining pixels so stale data is removed when fewer frames are provided
for (uint16_t index = pixelsToUpdate; index < config.pixelCount; ++index) {
const uint16_t hardwareIndex = mapPixelIndex(index);
pixels.setPixelColor(hardwareIndex, 0);
}
pixels.show();
return true;
}
uint16_t PixelStreamController::mapPixelIndex(uint16_t logicalIndex) const {
if (config.matrixWidth == 0) {
return logicalIndex;
}
const uint16_t row = logicalIndex / config.matrixWidth;
const uint16_t col = logicalIndex % config.matrixWidth;
if (!config.matrixSerpentine || (row % 2 == 0)) {
return row * config.matrixWidth + col;
}
const uint16_t reversedCol = (config.matrixWidth - 1) - col;
return row * config.matrixWidth + reversedCol;
}
int PixelStreamController::hexToNibble(char c) {
if (c >= '0' && c <= '9') {
return c - '0';
}
if (c >= 'a' && c <= 'f') {
return 10 + c - 'a';
}
if (c >= 'A' && c <= 'F') {
return 10 + c - 'A';
}
return -1;
}
bool PixelStreamController::tryParsePixel(const String& payload, std::size_t startIndex, FrameComponents& components) const {
static constexpr std::size_t frameWidth = COMPONENTS_PER_PIXEL * 2;
if (startIndex + frameWidth > static_cast<std::size_t>(payload.length())) {
return false;
}
const int rHi = hexToNibble(payload[startIndex]);
const int rLo = hexToNibble(payload[startIndex + 1]);
const int gHi = hexToNibble(payload[startIndex + 2]);
const int gLo = hexToNibble(payload[startIndex + 3]);
const int bHi = hexToNibble(payload[startIndex + 4]);
const int bLo = hexToNibble(payload[startIndex + 5]);
if (rHi < 0 || rLo < 0 || gHi < 0 || gLo < 0 || bHi < 0 || bLo < 0) {
return false;
}
components.red = static_cast<uint8_t>((rHi << 4) | rLo);
components.green = static_cast<uint8_t>((gHi << 4) | gLo);
components.blue = static_cast<uint8_t>((bHi << 4) | bLo);
return true;
}

View File

@@ -0,0 +1,42 @@
#pragma once
#include <Arduino.h>
#include <Adafruit_NeoPixel.h>
#include <algorithm>
#include <cstddef>
#include "spore/core/NodeContext.h"
#include "spore/util/Logging.h"
struct PixelStreamConfig {
uint8_t pin;
uint16_t pixelCount;
uint8_t brightness;
uint16_t matrixWidth;
bool matrixSerpentine;
neoPixelType pixelType;
};
class PixelStreamController {
public:
PixelStreamController(NodeContext& ctx, const PixelStreamConfig& config);
void begin();
private:
struct FrameComponents {
uint8_t red;
uint8_t green;
uint8_t blue;
};
bool tryParsePixel(const String& payload, std::size_t startIndex, FrameComponents& components) const;
void handleEvent(void* data);
bool applyFrame(const String& payload);
uint16_t mapPixelIndex(uint16_t logicalIndex) const;
static int hexToNibble(char c);
NodeContext& ctx;
PixelStreamConfig config;
Adafruit_NeoPixel pixels;
};

View File

@@ -0,0 +1,202 @@
#include "PixelStreamService.h"
#include "spore/util/Logging.h"
#include <Adafruit_NeoPixel.h>
PixelStreamService::PixelStreamService(NodeContext& ctx, ApiServer& apiServer, PixelStreamController* controller)
: ctx(ctx), apiServer(apiServer), controller(controller) {}
void PixelStreamService::registerEndpoints(ApiServer& api) {
// Config endpoint for setting pixelstream configuration
api.registerEndpoint("/api/pixelstream/config", HTTP_PUT,
[this](AsyncWebServerRequest* request) { handleConfigRequest(request); },
std::vector<ParamSpec>{
ParamSpec{String("pin"), false, String("body"), String("number"), {}, String("")},
ParamSpec{String("pixel_count"), false, String("body"), String("number"), {}, String("")},
ParamSpec{String("brightness"), false, String("body"), String("number"), {}, String("")},
ParamSpec{String("matrix_width"), false, String("body"), String("number"), {}, String("")},
ParamSpec{String("matrix_serpentine"), false, String("body"), String("boolean"), {}, String("")},
ParamSpec{String("pixel_type"), false, String("body"), String("number"), {}, String("")}
});
// Config endpoint for getting pixelstream configuration
api.registerEndpoint("/api/pixelstream/config", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleGetConfigRequest(request); },
std::vector<ParamSpec>{});
}
void PixelStreamService::registerTasks(TaskManager& taskManager) {
// PixelStreamService doesn't register any tasks itself
}
PixelStreamConfig PixelStreamService::loadConfig() {
// Initialize with proper defaults
PixelStreamConfig config;
config.pin = 2;
config.pixelCount = 16;
config.brightness = 80;
config.matrixWidth = 16;
config.matrixSerpentine = false;
config.pixelType = NEO_GRB + NEO_KHZ800;
if (!LittleFS.begin()) {
LOG_WARN("PixelStream", "Failed to initialize LittleFS, using defaults");
return config;
}
if (!LittleFS.exists(CONFIG_FILE())) {
LOG_INFO("PixelStream", "No pixelstream config file found, using defaults");
// Save defaults
saveConfig(config);
return config;
}
File file = LittleFS.open(CONFIG_FILE(), "r");
if (!file) {
LOG_ERROR("PixelStream", "Failed to open config file for reading");
return config;
}
JsonDocument doc;
DeserializationError error = deserializeJson(doc, file);
file.close();
if (error) {
LOG_ERROR("PixelStream", "Failed to parse config file: " + String(error.c_str()));
return config;
}
if (doc["pin"].is<uint8_t>()) config.pin = doc["pin"].as<uint8_t>();
if (doc["pixel_count"].is<uint16_t>()) config.pixelCount = doc["pixel_count"].as<uint16_t>();
if (doc["brightness"].is<uint8_t>()) config.brightness = doc["brightness"].as<uint8_t>();
if (doc["matrix_width"].is<uint16_t>()) config.matrixWidth = doc["matrix_width"].as<uint16_t>();
if (doc["matrix_serpentine"].is<bool>()) config.matrixSerpentine = doc["matrix_serpentine"].as<bool>();
if (doc["pixel_type"].is<uint8_t>()) config.pixelType = static_cast<neoPixelType>(doc["pixel_type"].as<uint8_t>());
LOG_INFO("PixelStream", "Configuration loaded from " + String(CONFIG_FILE()));
return config;
}
bool PixelStreamService::saveConfig(const PixelStreamConfig& config) {
if (!LittleFS.begin()) {
LOG_ERROR("PixelStream", "LittleFS not initialized, cannot save config");
return false;
}
File file = LittleFS.open(CONFIG_FILE(), "w");
if (!file) {
LOG_ERROR("PixelStream", "Failed to open config file for writing");
return false;
}
JsonDocument doc;
doc["pin"] = config.pin;
doc["pixel_count"] = config.pixelCount;
doc["brightness"] = config.brightness;
doc["matrix_width"] = config.matrixWidth;
doc["matrix_serpentine"] = config.matrixSerpentine;
doc["pixel_type"] = static_cast<uint8_t>(config.pixelType);
size_t bytesWritten = serializeJson(doc, file);
file.close();
if (bytesWritten > 0) {
LOG_INFO("PixelStream", "Configuration saved to " + String(CONFIG_FILE()) + " (" + String(bytesWritten) + " bytes)");
return true;
} else {
LOG_ERROR("PixelStream", "Failed to write configuration to file");
return false;
}
}
void PixelStreamService::handleConfigRequest(AsyncWebServerRequest* request) {
// Load current config from file
PixelStreamConfig config = loadConfig();
bool updated = false;
// Handle individual form parameters
if (request->hasParam("pin", true)) {
String pinStr = request->getParam("pin", true)->value();
if (pinStr.length() > 0) {
int pinValue = pinStr.toInt();
if (pinValue >= 0 && pinValue <= 255) {
config.pin = static_cast<uint8_t>(pinValue);
updated = true;
}
}
}
if (request->hasParam("pixel_count", true)) {
String countStr = request->getParam("pixel_count", true)->value();
if (countStr.length() > 0) {
int countValue = countStr.toInt();
if (countValue > 0 && countValue <= 65535) {
config.pixelCount = static_cast<uint16_t>(countValue);
updated = true;
}
}
}
if (request->hasParam("brightness", true)) {
String brightnessStr = request->getParam("brightness", true)->value();
if (brightnessStr.length() > 0) {
int brightnessValue = brightnessStr.toInt();
if (brightnessValue >= 0 && brightnessValue <= 255) {
config.brightness = static_cast<uint8_t>(brightnessValue);
updated = true;
}
}
}
if (request->hasParam("matrix_width", true)) {
String widthStr = request->getParam("matrix_width", true)->value();
if (widthStr.length() > 0) {
int widthValue = widthStr.toInt();
if (widthValue > 0 && widthValue <= 65535) {
config.matrixWidth = static_cast<uint16_t>(widthValue);
updated = true;
}
}
}
if (request->hasParam("matrix_serpentine", true)) {
String serpentineStr = request->getParam("matrix_serpentine", true)->value();
config.matrixSerpentine = (serpentineStr.equalsIgnoreCase("true") || serpentineStr == "1");
updated = true;
}
if (request->hasParam("pixel_type", true)) {
String typeStr = request->getParam("pixel_type", true)->value();
if (typeStr.length() > 0) {
int typeValue = typeStr.toInt();
config.pixelType = static_cast<neoPixelType>(typeValue);
updated = true;
}
}
if (!updated) {
request->send(400, "application/json", "{\"error\":\"No valid configuration fields provided\"}");
return;
}
// Save config to file
if (saveConfig(config)) {
LOG_INFO("PixelStreamService", "Configuration updated and saved to pixelstream.json");
request->send(200, "application/json", "{\"status\":\"success\",\"message\":\"Configuration updated and saved\"}");
} else {
LOG_ERROR("PixelStreamService", "Failed to save configuration to file");
request->send(500, "application/json", "{\"error\":\"Failed to save configuration\"}");
}
}
void PixelStreamService::handleGetConfigRequest(AsyncWebServerRequest* request) {
PixelStreamConfig config = loadConfig();
JsonDocument doc;
doc["pin"] = config.pin;
doc["pixel_count"] = config.pixelCount;
doc["brightness"] = config.brightness;
doc["matrix_width"] = config.matrixWidth;
doc["matrix_serpentine"] = config.matrixSerpentine;
doc["pixel_type"] = static_cast<uint8_t>(config.pixelType);
String json;
serializeJson(doc, json);
request->send(200, "application/json", json);
}

View File

@@ -0,0 +1,33 @@
#pragma once
#include "spore/Service.h"
#include "spore/core/NodeContext.h"
#include "PixelStreamController.h"
#include <ArduinoJson.h>
#include <LittleFS.h>
#include "spore/util/Logging.h"
// PixelStreamConfig is defined in PixelStreamController.h
class PixelStreamService : public Service {
public:
PixelStreamService(NodeContext& ctx, ApiServer& apiServer, PixelStreamController* controller);
void registerEndpoints(ApiServer& api) override;
void registerTasks(TaskManager& taskManager) override;
const char* getName() const override { return "PixelStream"; }
// Config management
PixelStreamConfig loadConfig();
bool saveConfig(const PixelStreamConfig& config);
void setController(PixelStreamController* ctrl) { controller = ctrl; }
private:
NodeContext& ctx;
ApiServer& apiServer;
PixelStreamController* controller;
void handleConfigRequest(AsyncWebServerRequest* request);
void handleGetConfigRequest(AsyncWebServerRequest* request);
static const char* CONFIG_FILE() { return "/pixelstream.json"; }
};

View File

@@ -0,0 +1,33 @@
# PixelStream Example
This example demonstrates how to consume the `udp/raw` cluster event and drive a NeoPixel strip or matrix directly from streamed RGB data. Frames are provided as hex encoded byte triplets (`RRGGBB` per pixel).
## Features
- Subscribes to `udp/raw` via `NodeContext::on`.
- Converts incoming frames into pixel colors for strips or matrices.
- Supports serpentine (zig-zag) matrix wiring.
## Payload Format
Each packet is expected to be `RAW:` followed by `pixelCount * 3 * 2` hexadecimal characters. For example, for 8 pixels:
```
RAW:FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000
```
## Usage
### Strip Mode
Upload the example with `PIXEL_MATRIX_WIDTH` set to 0 (default). Send frames containing `PIXEL_COUNT * 3` bytes as hex.
### Matrix Mode
Set `PIXEL_MATRIX_WIDTH` to the number of columns. The controller remaps even/odd rows to support serpentine wiring.
## Configuration
Adjust `PIXEL_PIN`, `PIXEL_COUNT`, `PIXEL_BRIGHTNESS`, `PIXEL_MATRIX_WIDTH`, `PIXEL_MATRIX_SERPENTINE`, and `PIXEL_TYPE` through build defines or editing `main.cpp`.

View File

@@ -0,0 +1,70 @@
#include <Arduino.h>
#include "spore/Spore.h"
#include "spore/util/Logging.h"
#include "PixelStreamController.h"
#include "PixelStreamService.h"
#include <Adafruit_NeoPixel.h>
// Defaults are now loaded from config.json on LittleFS
// Can still be overridden with preprocessor defines if needed
#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;
PixelStreamService* service = nullptr;
void setup() {
spore.setup();
// Create service first (need it to load config)
service = new PixelStreamService(spore.getContext(), spore.getApiServer(), nullptr);
// Load pixelstream config from LittleFS (pixelstream.json) or use defaults
PixelStreamConfig config = service->loadConfig();
// Create controller with loaded config
controller = new PixelStreamController(spore.getContext(), config);
controller->begin();
// Update service with the actual controller
service->setController(controller);
// Register service
spore.registerService(service);
// Start the API server
spore.begin();
}
void loop() {
spore.loop();
}

View File

@@ -33,7 +33,7 @@ void setup() {
spore.setup();
relayService = new RelayService(spore.getContext(), spore.getTaskManager(), RELAY_PIN);
spore.addService(relayService);
spore.registerService(relayService);
spore.begin();
}

View File

@@ -7,15 +7,14 @@ RelayService::RelayService(NodeContext& ctx, TaskManager& taskMgr, int pin)
pinMode(relayPin, OUTPUT);
// Many relay modules are active LOW. Start in OFF state (relay de-energized).
digitalWrite(relayPin, HIGH);
registerTasks();
}
void RelayService::registerEndpoints(ApiServer& api) {
api.addEndpoint("/api/relay/status", HTTP_GET,
api.registerEndpoint("/api/relay/status", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
std::vector<ParamSpec>{});
api.addEndpoint("/api/relay", HTTP_POST,
api.registerEndpoint("/api/relay", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleControlRequest(request); },
std::vector<ParamSpec>{
ParamSpec{String("state"), true, String("body"), String("string"),
@@ -82,7 +81,7 @@ void RelayService::toggle() {
}
}
void RelayService::registerTasks() {
void RelayService::registerTasks(TaskManager& taskManager) {
taskManager.registerTask("relay_status_print", 5000, [this]() {
LOG_INFO("RelayService", "Status - pin: " + String(relayPin) + ", state: " + (relayOn ? "ON" : "OFF"));
});

View File

@@ -8,6 +8,7 @@ class RelayService : public Service {
public:
RelayService(NodeContext& ctx, TaskManager& taskMgr, int pin);
void registerEndpoints(ApiServer& api) override;
void registerTasks(TaskManager& taskManager) override;
const char* getName() const override { return "Relay"; }
void turnOn();
@@ -15,8 +16,6 @@ public:
void toggle();
private:
void registerTasks();
NodeContext& ctx;
TaskManager& taskManager;
int relayPin;

View File

@@ -23,7 +23,7 @@ void setup() {
// Create and add custom service
relayService = new RelayService(spore.getContext(), spore.getTaskManager(), RELAY_PIN);
spore.addService(relayService);
spore.registerService(relayService);
// Start the API server and complete initialization
spore.begin();

View File

@@ -1,9 +1,12 @@
#pragma once
#include "spore/core/ApiServer.h"
class TaskManager;
class Service {
public:
virtual ~Service() = default;
virtual void registerEndpoints(ApiServer& api) = 0;
virtual void registerTasks(TaskManager& taskManager) = 0;
virtual const char* getName() const = 0;
};

View File

@@ -11,7 +11,6 @@
#include "core/TaskManager.h"
#include "Service.h"
#include "util/Logging.h"
#include "util/CpuUsage.h"
class Spore {
public:
@@ -25,8 +24,8 @@ public:
void loop();
// Service management
void addService(std::shared_ptr<Service> service);
void addService(Service* service);
void registerService(std::shared_ptr<Service> service);
void registerService(Service* service);
// Access to core components
NodeContext& getContext() { return ctx; }
@@ -34,12 +33,6 @@ public:
TaskManager& getTaskManager() { return taskManager; }
ClusterManager& getCluster() { return cluster; }
ApiServer& getApiServer() { return apiServer; }
// CPU usage monitoring
CpuUsage& getCpuUsage() { return cpuUsage; }
float getCurrentCpuUsage() const { return cpuUsage.getCpuUsage(); }
float getAverageCpuUsage() const { return cpuUsage.getAverageCpuUsage(); }
private:
void initializeCore();
@@ -51,7 +44,6 @@ private:
TaskManager taskManager;
ClusterManager cluster;
ApiServer apiServer;
CpuUsage cpuUsage;
std::vector<std::shared_ptr<Service>> services;
bool initialized;

View File

@@ -19,14 +19,14 @@ class ApiServer {
public:
ApiServer(NodeContext& ctx, TaskManager& taskMgr, uint16_t port = 80);
void begin();
void addService(Service& service);
void addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler);
void addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
void registerService(Service& service);
void registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler);
void registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
std::function<void(AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool)> uploadHandler);
void addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
void registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
const std::vector<ParamSpec>& params);
void addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
void registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
std::function<void(AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool)> uploadHandler,
const std::vector<ParamSpec>& params);

View File

@@ -14,17 +14,16 @@ class ClusterManager {
public:
ClusterManager(NodeContext& ctx, TaskManager& taskMgr);
void registerTasks();
void sendDiscovery();
void listen();
void addOrUpdateNode(const String& nodeHost, IPAddress nodeIP);
void updateAllNodeStatuses();
void removeDeadNodes();
void printMemberList();
const std::map<String, NodeInfo>& getMemberList() const { return *ctx.memberList; }
void fetchNodeInfo(const IPAddress& ip);
void updateLocalNodeResources();
size_t getMemberCount() const { return ctx.memberList->getMemberCount(); }
void updateLocalNodeResources(NodeInfo& node);
void heartbeatTaskCallback();
void updateAllMembersInfoTaskCallback();
void broadcastNodeUpdate();
private:
NodeContext& ctx;
TaskManager& taskManager;
@@ -35,16 +34,15 @@ private:
};
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 isNodeUpdateMsg(const char* msg);
static bool isClusterEventMsg(const char* msg);
void onDiscovery(const char* msg);
static bool isRawMsg(const char* msg);
void onHeartbeat(const char* msg);
void onResponse(const char* msg);
void onNodeInfo(const char* msg);
void onNodeUpdate(const char* msg);
void onClusterEvent(const char* msg);
void onRawMessage(const char* msg);
void sendNodeInfo(const String& hostname, const IPAddress& targetIP);
unsigned long lastHeartbeatSentAt = 0;
std::vector<MessageHandler> messageHandlers;
};

View File

@@ -0,0 +1,133 @@
#pragma once
#include <Arduino.h>
#include <map>
#include <string>
#include <optional>
#include <functional>
#include "spore/types/NodeInfo.h"
/**
* @brief Manages the list of cluster members.
*
* The Memberlist class maintains a collection of cluster members, where each member
* is identified by its IP address and associated with a NodeInfo object. It provides
* methods to add, update, and remove members, as well as handle node status changes
* (stale and dead nodes).
*/
class Memberlist {
public:
/**
* @brief Default constructor.
*/
Memberlist();
/**
* @brief Destructor.
*/
~Memberlist();
/**
* @brief Adds or updates a member in the list.
*
* If the member already exists, updates its information. Otherwise, adds a new member.
* @param ip The IP address of the member (as string).
* @param node The NodeInfo object containing member details.
* @return True if the member was added or updated, false otherwise.
*/
bool addOrUpdateMember(const std::string& ip, const NodeInfo& node);
/**
* @brief Adds a new member to the list.
*
* @param ip The IP address of the member (as string).
* @param node The NodeInfo object containing member details.
* @return True if the member was added, false if it already exists.
*/
bool addMember(const std::string& ip, const NodeInfo& node);
/**
* @brief Updates an existing member in the list.
*
* @param ip The IP address of the member (as string).
* @param node The updated NodeInfo object.
* @return True if the member was updated, false if it doesn't exist.
*/
bool updateMember(const std::string& ip, const NodeInfo& node);
/**
* @brief Removes a member from the list.
*
* @param ip The IP address of the member to remove (as string).
* @return True if the member was removed, false if it doesn't exist.
*/
bool removeMember(const std::string& ip);
/**
* @brief Retrieves a member by IP address.
*
* @param ip The IP address of the member (as string).
* @return Optional containing the NodeInfo if found, or std::nullopt if not found.
*/
std::optional<NodeInfo> getMember(const std::string& ip) const;
/**
* @brief Iterates over all members and calls the provided callback for each.
*
* @param callback Function to call for each member. Receives (ip, node) as parameters.
*/
void forEachMember(std::function<void(const std::string&, const NodeInfo&)> callback) const;
/**
* @brief Iterates over all members and calls the provided callback for each.
*
* @param callback Function to call for each member. Receives (ip, node) as parameters.
* If callback returns false, iteration stops.
* @return True if all members were processed, false if iteration was stopped early.
*/
bool forEachMemberUntil(std::function<bool(const std::string&, const NodeInfo&)> callback) const;
/**
* @brief Gets the number of members in the list.
*
* @return The number of members.
*/
size_t getMemberCount() const;
/**
* @brief Updates the status of all members based on current time and thresholds.
*
* Marks nodes as stale or dead based on their last seen time.
* @param currentTime The current time in milliseconds.
* @param staleThresholdMs Threshold for marking a node as stale (milliseconds).
* @param deadThresholdMs Threshold for marking a node as dead (milliseconds).
* @param onStatusChange Optional callback fired when a node's status changes.
*/
void updateAllNodeStatuses(unsigned long currentTime,
unsigned long staleThresholdMs,
unsigned long deadThresholdMs,
std::function<void(const std::string&, NodeInfo::Status, NodeInfo::Status)> onStatusChange = nullptr);
/**
* @brief Removes all dead members from the list.
*
* @return The number of members removed.
*/
size_t removeDeadMembers();
/**
* @brief Checks if a member exists in the list.
*
* @param ip The IP address of the member (as string).
* @return True if the member exists, false otherwise.
*/
bool hasMember(const std::string& ip) const;
/**
* @brief Clears all members from the list.
*/
void clear();
private:
std::map<std::string, NodeInfo> m_members; ///< Internal map holding the members.
};

View File

@@ -27,6 +27,8 @@ public:
void setWiFiConfig(const String& ssid, const String& password,
uint32_t connect_timeout_ms = 10000,
uint32_t retry_delay_ms = 500);
bool saveConfig();
void restartNode();
// Network status methods
bool isConnected() const { return WiFi.isConnected(); }

View File

@@ -2,12 +2,14 @@
#include <WiFiUdp.h>
#include <map>
#include "spore/types/NodeInfo.h"
#include <functional>
#include <string>
#include <initializer_list>
#include <memory>
#include "spore/types/NodeInfo.h"
#include "spore/types/Config.h"
#include "spore/types/ApiTypes.h"
#include "spore/core/Memberlist.h"
class NodeContext {
public:
@@ -18,8 +20,9 @@ public:
String hostname;
IPAddress localIP;
NodeInfo self;
std::map<String, NodeInfo>* memberList;
Config config;
std::unique_ptr<Memberlist> memberList;
::Config config;
std::map<String, String> constructorLabels; // Labels passed to constructor (not persisted)
using EventCallback = std::function<void(void*)>;
std::map<std::string, std::vector<EventCallback>> eventRegistry;
@@ -29,4 +32,5 @@ public:
void on(const std::string& event, EventCallback cb);
void fire(const std::string& event, void* data);
void onAny(AnyEventCallback cb);
void rebuildLabels(); // Rebuild self.labels from constructorLabels + config.labels
};

View File

@@ -5,24 +5,20 @@
// Cluster protocol and API constants
namespace ClusterProtocol {
constexpr const char* DISCOVERY_MSG = "CLUSTER_DISCOVERY";
constexpr const char* RESPONSE_MSG = "CLUSTER_RESPONSE";
constexpr const char* HEARTBEAT_MSG = "CLUSTER_HEARTBEAT";
constexpr const char* NODE_INFO_MSG = "CLUSTER_NODE_INFO";
constexpr const char* CLUSTER_EVENT_MSG = "CLUSTER_EVENT";
constexpr const char* HEARTBEAT_MSG = "cluster/heartbeat";
constexpr const char* NODE_UPDATE_MSG = "node/update";
constexpr const char* CLUSTER_EVENT_MSG = "cluster/event";
constexpr const char* RAW_MSG = "RAW";
constexpr uint16_t UDP_PORT = 4210;
// Increased buffer to accommodate node info JSON over UDP
constexpr size_t UDP_BUF_SIZE = 512;
// Increased buffer to accommodate larger RAW pixel streams and node info JSON over UDP
constexpr size_t UDP_BUF_SIZE = 2048;
constexpr const char* API_NODE_STATUS = "/api/node/status";
}
namespace TaskIntervals {
constexpr unsigned long SEND_DISCOVERY = 1000;
constexpr unsigned long LISTEN_FOR_DISCOVERY = 100;
constexpr unsigned long UPDATE_STATUS = 1000;
constexpr unsigned long PRINT_MEMBER_LIST = 5000;
constexpr unsigned long HEARTBEAT = 2000;
constexpr unsigned long UPDATE_ALL_MEMBERS_INFO = 10000;
}
constexpr unsigned long NODE_ACTIVE_THRESHOLD = 10000;

View File

@@ -7,6 +7,7 @@ class ClusterService : public Service {
public:
ClusterService(NodeContext& ctx);
void registerEndpoints(ApiServer& api) override;
void registerTasks(TaskManager& taskManager) override;
const char* getName() const override { return "Cluster"; }
private:

View File

@@ -1,12 +1,12 @@
#pragma once
#include "spore/Service.h"
#include "spore/util/CpuUsage.h"
#include <functional>
class MonitoringService : public Service {
public:
MonitoringService(CpuUsage& cpuUsage);
MonitoringService();
void registerEndpoints(ApiServer& api) override;
void registerTasks(TaskManager& taskManager) override;
const char* getName() const override { return "Monitoring"; }
// System resource information
@@ -14,17 +14,12 @@ public:
// CPU information
float currentCpuUsage;
float averageCpuUsage;
float maxCpuUsage;
float minCpuUsage;
unsigned long measurementCount;
bool isMeasuring;
// Memory information
size_t freeHeap;
size_t totalHeap;
size_t minFreeHeap;
size_t maxAllocHeap;
size_t heapFragmentation;
// Filesystem information
size_t totalBytes;
@@ -44,8 +39,5 @@ private:
void handleResourcesRequest(AsyncWebServerRequest* request);
// Helper methods
size_t calculateHeapFragmentation() const;
void getFilesystemInfo(size_t& totalBytes, size_t& usedBytes) const;
CpuUsage& cpuUsage;
};

View File

@@ -7,6 +7,7 @@ class NetworkService : public Service {
public:
NetworkService(NetworkManager& networkManager);
void registerEndpoints(ApiServer& api) override;
void registerTasks(TaskManager& taskManager) override;
const char* getName() const override { return "Network"; }
private:

View File

@@ -8,6 +8,7 @@ class NodeService : public Service {
public:
NodeService(NodeContext& ctx, ApiServer& apiServer);
void registerEndpoints(ApiServer& api) override;
void registerTasks(TaskManager& taskManager) override;
const char* getName() const override { return "Node"; }
private:
@@ -19,4 +20,6 @@ private:
void handleUpdateUpload(AsyncWebServerRequest* request, const String& filename, size_t index, uint8_t* data, size_t len, bool final);
void handleRestartRequest(AsyncWebServerRequest* request);
void handleEndpointsRequest(AsyncWebServerRequest* request);
void handleConfigRequest(AsyncWebServerRequest* request);
void handleGetConfigRequest(AsyncWebServerRequest* request);
};

View File

@@ -9,6 +9,7 @@ class StaticFileService : public Service {
public:
StaticFileService(NodeContext& ctx, ApiServer& apiServer);
void registerEndpoints(ApiServer& api) override;
void registerTasks(TaskManager& taskManager) override;
const char* getName() const override { return name.c_str(); }
private:

View File

@@ -7,6 +7,7 @@ class TaskService : public Service {
public:
TaskService(TaskManager& taskManager);
void registerEndpoints(ApiServer& api) override;
void registerTasks(TaskManager& taskManager) override;
const char* getName() const override { return "Task"; }
private:

View File

@@ -1,9 +1,33 @@
#pragma once
#include <Arduino.h>
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <map>
class Config {
public:
// Default Configuration Constants
static constexpr const char* DEFAULT_WIFI_SSID = "shroud";
static constexpr const char* DEFAULT_WIFI_PASSWORD = "th3r31sn0sp00n";
static constexpr uint16_t DEFAULT_UDP_PORT = 4210;
static constexpr uint16_t DEFAULT_API_SERVER_PORT = 80;
static constexpr unsigned long DEFAULT_CLUSTER_LISTEN_INTERVAL_MS = 10;
static constexpr unsigned long DEFAULT_HEARTBEAT_INTERVAL_MS = 5000;
static constexpr unsigned long DEFAULT_STATUS_UPDATE_INTERVAL_MS = 1000;
static constexpr unsigned long DEFAULT_NODE_UPDATE_BROADCAST_INTERVAL_MS = 5000;
static constexpr unsigned long DEFAULT_PRINT_INTERVAL_MS = 5000;
static constexpr unsigned long DEFAULT_NODE_ACTIVE_THRESHOLD_MS = 10000;
static constexpr unsigned long DEFAULT_NODE_INACTIVE_THRESHOLD_MS = 60000;
static constexpr unsigned long DEFAULT_NODE_DEAD_THRESHOLD_MS = 120000;
static constexpr unsigned long DEFAULT_WIFI_CONNECT_TIMEOUT_MS = 15000;
static constexpr unsigned long DEFAULT_WIFI_RETRY_DELAY_MS = 500;
static constexpr unsigned long DEFAULT_RESTART_DELAY_MS = 10;
static constexpr uint16_t DEFAULT_JSON_DOC_SIZE = 1024;
static constexpr uint32_t DEFAULT_LOW_MEMORY_THRESHOLD_BYTES = 10000;
static constexpr uint32_t DEFAULT_CRITICAL_MEMORY_THRESHOLD_BYTES = 5000;
static constexpr size_t DEFAULT_MAX_CONCURRENT_HTTP_REQUESTS = 3;
// WiFi Configuration
String wifi_ssid;
String wifi_password;
@@ -13,12 +37,9 @@ public:
uint16_t api_server_port;
// Cluster Configuration
unsigned long discovery_interval_ms;
unsigned long heartbeat_interval_ms;
unsigned long cluster_listen_interval_ms;
unsigned long status_update_interval_ms;
unsigned long member_info_update_interval_ms;
unsigned long print_interval_ms;
// Node Status Thresholds
unsigned long node_active_threshold_ms;
@@ -38,6 +59,17 @@ public:
uint32_t critical_memory_threshold_bytes;
size_t max_concurrent_http_requests;
// Custom Labels
std::map<String, String> labels;
// Constructor
Config();
// Persistence methods
bool saveToFile(const String& filename = "/config.json");
bool loadFromFile(const String& filename = "/config.json");
void setDefaults();
private:
static const char* CONFIG_FILE_PATH;
};

View File

@@ -9,6 +9,7 @@ struct NodeInfo {
String hostname;
IPAddress ip;
unsigned long lastSeen;
unsigned long uptime = 0; // milliseconds since node started
enum Status { ACTIVE, INACTIVE, DEAD } status;
struct Resources {
uint32_t freeHeap = 0;
@@ -17,7 +18,7 @@ struct NodeInfo {
uint32_t cpuFreqMHz = 0;
uint32_t flashChipSize = 0;
} resources;
unsigned long latency = 0; // ms from heartbeat broadcast to NODE_INFO receipt
unsigned long latency = 0; // ms from heartbeat broadcast to NODE_UPDATE receipt
std::vector<EndpointInfo> endpoints; // List of registered endpoints
std::map<String, String> labels; // Arbitrary node labels (key -> value)
};

View File

@@ -1,118 +0,0 @@
#pragma once
#include <Arduino.h>
/**
* @brief CPU usage measurement utility for ESP32/ESP8266
*
* This class provides methods to measure CPU usage by tracking idle time
* and calculating the percentage of time the CPU is busy vs idle.
*/
class CpuUsage {
public:
/**
* @brief Construct a new CpuUsage object
*/
CpuUsage();
/**
* @brief Destructor
*/
~CpuUsage() = default;
/**
* @brief Initialize the CPU usage measurement
* Call this once during setup
*/
void begin();
/**
* @brief Start measuring CPU usage for the current cycle
* Call this at the beginning of your main loop
*/
void startMeasurement();
/**
* @brief End measuring CPU usage for the current cycle
* Call this at the end of your main loop
*/
void endMeasurement();
/**
* @brief Get the current CPU usage percentage
* @return float CPU usage percentage (0.0 to 100.0)
*/
float getCpuUsage() const;
/**
* @brief Get the average CPU usage over the measurement window
* @return float Average CPU usage percentage (0.0 to 100.0)
*/
float getAverageCpuUsage() const;
/**
* @brief Get the maximum CPU usage recorded
* @return float Maximum CPU usage percentage (0.0 to 100.0)
*/
float getMaxCpuUsage() const;
/**
* @brief Get the minimum CPU usage recorded
* @return float Minimum CPU usage percentage (0.0 to 100.0)
*/
float getMinCpuUsage() const;
/**
* @brief Reset all CPU usage statistics
*/
void reset();
/**
* @brief Check if measurement is currently active
* @return true if measurement is active, false otherwise
*/
bool isMeasuring() const;
/**
* @brief Get the number of measurements taken
* @return unsigned long Number of measurements
*/
unsigned long getMeasurementCount() const;
private:
// Measurement state
bool _initialized;
bool _measuring;
unsigned long _measurementCount;
// Timing variables
unsigned long _cycleStartTime;
unsigned long _idleStartTime;
unsigned long _totalIdleTime;
unsigned long _totalCycleTime;
// Statistics
float _currentCpuUsage;
float _averageCpuUsage;
float _maxCpuUsage;
float _minCpuUsage;
unsigned long _totalCpuTime;
// Rolling average window
static constexpr size_t ROLLING_WINDOW_SIZE = 10;
float _rollingWindow[ROLLING_WINDOW_SIZE];
size_t _rollingIndex;
bool _rollingWindowFull;
/**
* @brief Update rolling average calculation
* @param value New value to add to rolling average
*/
void updateRollingAverage(float value);
/**
* @brief Update min/max statistics
* @param value New value to check against min/max
*/
void updateMinMax(float value);
};

View File

@@ -9,7 +9,7 @@
; https://docs.platformio.org/page/projectconf.html
[platformio]
default_envs = base
;default_envs = base
src_dir = .
data_dir = ${PROJECT_DIR}/examples/${PIOENV}/data
@@ -18,6 +18,15 @@ monitor_speed = 115200
lib_deps =
esp32async/ESPAsyncWebServer@^3.8.0
bblanchon/ArduinoJson@^7.4.2
build_flags =
-Os ; Optimize for size
-ffunction-sections ; Place each function in its own section
-fdata-sections ; Place data in separate sections
-Wl,--gc-sections ; Remove unused sections at link time
-DNDEBUG ; Disable debug assertions
-DVTABLES_IN_FLASH ; Move virtual tables to flash
-fno-exceptions ; Disable C++ exceptions
-fno-rtti ; Disable runtime type information
[env:base]
platform = platformio/espressif8266@^4.2.1
@@ -31,6 +40,7 @@ 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 ; 64KB -> FS Size
lib_deps = ${common.lib_deps}
build_flags = ${common.build_flags}
build_src_filter =
+<examples/base/*.cpp>
+<src/spore/*.cpp>
@@ -51,6 +61,7 @@ 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_flags = ${common.build_flags}
build_src_filter =
+<examples/base/*.cpp>
+<src/spore/*.cpp>
@@ -71,6 +82,7 @@ board_build.flash_mode = dout
board_build.ldscript = eagle.flash.1m64.ld
lib_deps = ${common.lib_deps}
;data_dir = examples/relay/data
build_flags = ${common.build_flags}
build_src_filter =
+<examples/relay/*.cpp>
+<src/spore/*.cpp>
@@ -91,7 +103,7 @@ 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 = -DLED_STRIP_PIN=2
build_flags = -DLED_STRIP_PIN=2 ;${common.build_flags}
build_src_filter =
+<examples/neopattern/*.cpp>
+<src/spore/*.cpp>
@@ -100,3 +112,69 @@ build_src_filter =
+<src/spore/types/*.cpp>
+<src/spore/util/*.cpp>
+<src/internal/*.cpp>
[env:pixelstream]
platform = platformio/espressif8266@^4.2.1
board = esp01_1m
framework = arduino
upload_speed = 115200
monitor_speed = 115200
board_build.filesystem = littlefs
board_build.flash_mode = dout
board_build.ldscript = eagle.flash.1m64.ld
lib_deps = ${common.lib_deps}
adafruit/Adafruit NeoPixel@^1.15.1
build_flags = ${common.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 ${common.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: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_flags = ${common.build_flags}
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>

View File

@@ -9,13 +9,19 @@
Spore::Spore() : ctx(), network(ctx), taskManager(ctx), cluster(ctx, taskManager),
apiServer(ctx, taskManager, ctx.config.api_server_port),
cpuUsage(), initialized(false), apiServerStarted(false) {
initialized(false), apiServerStarted(false) {
// Rebuild labels from constructor + config labels
ctx.rebuildLabels();
}
Spore::Spore(std::initializer_list<std::pair<String, String>> initialLabels)
: ctx(initialLabels), network(ctx), taskManager(ctx), cluster(ctx, taskManager),
apiServer(ctx, taskManager, ctx.config.api_server_port),
cpuUsage(), initialized(false), apiServerStarted(false) {
initialized(false), apiServerStarted(false) {
// Rebuild labels from constructor + config labels (config takes precedence)
ctx.rebuildLabels();
}
Spore::~Spore() {
@@ -34,9 +40,6 @@ void Spore::setup() {
// Initialize core components
initializeCore();
// Initialize CPU usage monitoring
cpuUsage.begin();
// Register core services
registerCoreServices();
@@ -72,20 +75,14 @@ void Spore::loop() {
return;
}
// Start CPU usage measurement
cpuUsage.startMeasurement();
// Execute main tasks
taskManager.execute();
// End CPU usage measurement before yield
cpuUsage.endMeasurement();
// Yield to allow other tasks to run
yield();
}
void Spore::addService(std::shared_ptr<Service> service) {
void Spore::registerService(std::shared_ptr<Service> service) {
if (!service) {
LOG_WARN("Spore", "Attempted to add null service");
return;
@@ -95,21 +92,22 @@ void Spore::addService(std::shared_ptr<Service> service) {
if (apiServerStarted) {
// If API server is already started, register the service immediately
apiServer.addService(*service);
LOG_INFO("Spore", "Added service '" + String(service->getName()) + "' to running API server");
apiServer.registerService(*service);
service->registerTasks(taskManager);
LOG_INFO("Spore", "Added service '" + String(service->getName()) + "' to running API server and task manager");
} else {
LOG_INFO("Spore", "Registered service '" + String(service->getName()) + "' (will be added to API server when begin() is called)");
}
}
void Spore::addService(Service* service) {
void Spore::registerService(Service* service) {
if (!service) {
LOG_WARN("Spore", "Attempted to add null service");
return;
}
// Wrap raw pointer in shared_ptr with no-op deleter to avoid double-delete
addService(std::shared_ptr<Service>(service, [](Service*){}));
registerService(std::shared_ptr<Service>(service, [](Service*){}));
}
@@ -134,7 +132,7 @@ void Spore::registerCoreServices() {
auto clusterService = std::make_shared<ClusterService>(ctx);
auto taskService = std::make_shared<TaskService>(taskManager);
auto staticFileService = std::make_shared<StaticFileService>(ctx, apiServer);
auto monitoringService = std::make_shared<MonitoringService>(cpuUsage);
auto monitoringService = std::make_shared<MonitoringService>();
// Add to services list
services.push_back(nodeService);
@@ -155,11 +153,12 @@ void Spore::startApiServer() {
LOG_INFO("Spore", "Starting API server...");
// Register all services with API server
// Register all services with API server and task manager
for (auto& service : services) {
if (service) {
apiServer.addService(*service);
LOG_INFO("Spore", "Added service '" + String(service->getName()) + "' to API server");
apiServer.registerService(*service);
service->registerTasks(taskManager);
LOG_INFO("Spore", "Added service '" + String(service->getName()) + "' to API server and task manager");
}
}

View File

@@ -23,15 +23,16 @@ void ApiServer::registerEndpoint(const String& uri, int method,
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});
}
String localIPStr = ctx.localIP.toString();
auto member = ctx.memberList->getMember(localIPStr.c_str());
if (member) {
NodeInfo updatedNode = *member;
updatedNode.endpoints.push_back(EndpointInfo{uri, method, params, serviceName, true});
ctx.memberList->updateMember(localIPStr.c_str(), updatedNode);
}
}
void ApiServer::addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler) {
void ApiServer::registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler) {
// Get current service name if available
String serviceName = "unknown";
if (!services.empty()) {
@@ -41,7 +42,7 @@ void ApiServer::addEndpoint(const String& uri, int method, std::function<void(As
server.on(uri.c_str(), method, requestHandler);
}
void ApiServer::addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
void ApiServer::registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
std::function<void(AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool)> uploadHandler) {
// Get current service name if available
String serviceName = "unknown";
@@ -53,7 +54,7 @@ void ApiServer::addEndpoint(const String& uri, int method, std::function<void(As
}
// Overloads that also record minimal capability specs
void ApiServer::addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
void ApiServer::registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
const std::vector<ParamSpec>& params) {
// Get current service name if available
String serviceName = "unknown";
@@ -64,7 +65,7 @@ void ApiServer::addEndpoint(const String& uri, int method, std::function<void(As
server.on(uri.c_str(), method, requestHandler);
}
void ApiServer::addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
void ApiServer::registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
std::function<void(AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool)> uploadHandler,
const std::vector<ParamSpec>& params) {
// Get current service name if available
@@ -76,7 +77,7 @@ void ApiServer::addEndpoint(const String& uri, int method, std::function<void(As
server.on(uri.c_str(), method, requestHandler, uploadHandler);
}
void ApiServer::addService(Service& service) {
void ApiServer::registerService(Service& service) {
services.push_back(service);
LOG_INFO("API", "Added service: " + String(service.getName()));
}
@@ -152,6 +153,11 @@ void ApiServer::setupWebSocket() {
// 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("");

View File

@@ -1,14 +1,15 @@
#include "spore/core/ClusterManager.h"
#include "spore/internal/Globals.h"
#include "spore/util/Logging.h"
#include "spore/types/NodeInfo.h"
ClusterManager::ClusterManager(NodeContext& ctx, TaskManager& taskMgr) : ctx(ctx), taskManager(taskMgr) {
// Register callback for node_discovered event
ctx.on("node_discovered", [this](void* data) {
// Register callback for node/discovered event - this fires when network is ready
ctx.on("node/discovered", [this](void* data) {
NodeInfo* node = static_cast<NodeInfo*>(data);
this->addOrUpdateNode(node->hostname, node->ip);
});
// Centralized broadcast handler: services fire 'cluster/broadcast' with CLUSTER_EVENT JSON payload
// 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) {
@@ -19,33 +20,36 @@ ClusterManager::ClusterManager(NodeContext& ctx, TaskManager& taskMgr) : ctx(ctx
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()));
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();
});
// Handler for node update broadcasts: services fire 'cluster/node/update' when their node info changes
ctx.on("cluster/node/update", [this](void* data) {
// Trigger immediate NODE_UPDATE broadcast when node info changes
broadcastNodeUpdate();
});
// Handler for memberlist changes: print memberlist when it changes
ctx.on("cluster/memberlist/changed", [this](void* data) {
printMemberList();
});
// Register tasks
registerTasks();
initMessageHandlers();
}
void ClusterManager::registerTasks() {
taskManager.registerTask("cluster_discovery", ctx.config.discovery_interval_ms, [this]() { sendDiscovery(); });
taskManager.registerTask("cluster_listen", ctx.config.cluster_listen_interval_ms, [this]() { listen(); });
taskManager.registerTask("status_update", ctx.config.status_update_interval_ms, [this]() { updateAllNodeStatuses(); removeDeadNodes(); });
taskManager.registerTask("print_members", ctx.config.print_interval_ms, [this]() { printMemberList(); });
taskManager.registerTask("heartbeat", ctx.config.heartbeat_interval_ms, [this]() { heartbeatTaskCallback(); });
taskManager.registerTask("cluster_update_members_info", ctx.config.member_info_update_interval_ms, [this]() { updateAllMembersInfoTaskCallback(); });
LOG_INFO("ClusterManager", "Registered all cluster tasks");
}
void ClusterManager::sendDiscovery() {
//LOG_DEBUG(ctx, "Cluster", "Sending discovery packet...");
ctx.udp->beginPacket("255.255.255.255", ctx.config.udp_port);
ctx.udp->write(ClusterProtocol::DISCOVERY_MSG);
ctx.udp->endPacket();
}
// Discovery functionality removed - using heartbeat-only approach
void ClusterManager::listen() {
int packetSize = ctx.udp->parsePacket();
@@ -68,10 +72,9 @@ void ClusterManager::listen() {
void ClusterManager::initMessageHandlers() {
messageHandlers.clear();
messageHandlers.push_back({ &ClusterManager::isDiscoveryMsg, [this](const char* msg){ this->onDiscovery(msg); }, "DISCOVERY" });
messageHandlers.push_back({ &ClusterManager::isRawMsg, [this](const char* msg){ this->onRawMessage(msg); }, "RAW" });
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::isNodeUpdateMsg, [this](const char* msg){ this->onNodeUpdate(msg); }, "NODE_UPDATE" });
messageHandlers.push_back({ &ClusterManager::isClusterEventMsg, [this](const char* msg){ this->onClusterEvent(msg); }, "CLUSTER_EVENT" });
}
@@ -93,134 +96,207 @@ void ClusterManager::handleIncomingMessage(const char* 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::isNodeUpdateMsg(const char* msg) {
return strncmp(msg, ClusterProtocol::NODE_UPDATE_MSG, strlen(ClusterProtocol::NODE_UPDATE_MSG)) == 0;
}
bool ClusterManager::isClusterEventMsg(const char* msg) {
return strncmp(msg, ClusterProtocol::CLUSTER_EVENT_MSG, strlen(ClusterProtocol::CLUSTER_EVENT_MSG)) == 0;
}
void ClusterManager::onDiscovery(const char* /*msg*/) {
ctx.udp->beginPacket(ctx.udp->remoteIP(), ctx.config.udp_port);
String response = String(ClusterProtocol::RESPONSE_MSG) + ":" + ctx.hostname;
ctx.udp->write(response.c_str());
ctx.udp->endPacket();
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::onHeartbeat(const char* /*msg*/) {
JsonDocument doc;
doc["freeHeap"] = ESP.getFreeHeap();
doc["chipId"] = ESP.getChipId();
doc["sdkVersion"] = ESP.getSdkVersion();
doc["cpuFreqMHz"] = ESP.getCpuFreqMHz();
doc["flashChipSize"] = ESP.getFlashChipSize();
// Discovery functionality removed - using heartbeat-only approach
if (ctx.memberList) {
auto it = ctx.memberList->find(ctx.hostname);
if (it != ctx.memberList->end()) {
void ClusterManager::onHeartbeat(const char* msg) {
// Extract hostname from heartbeat message: "cluster/heartbeat:hostname"
const char* colon = strchr(msg, ':');
if (!colon) {
LOG_WARN("Cluster", "Invalid heartbeat message format");
return;
}
String hostname = String(colon + 1);
IPAddress senderIP = ctx.udp->remoteIP();
// Update memberlist with the heartbeat
addOrUpdateNode(hostname, senderIP);
// Respond with minimal node info (hostname, ip, uptime, labels)
sendNodeInfo(hostname, senderIP);
}
void ClusterManager::onNodeUpdate(const char* msg) {
// Message format: "node/update:hostname:{json}"
const char* firstColon = strchr(msg, ':');
if (!firstColon) {
LOG_WARN("Cluster", "Invalid NODE_UPDATE message format");
return;
}
const char* secondColon = strchr(firstColon + 1, ':');
if (!secondColon) {
LOG_WARN("Cluster", "Invalid NODE_UPDATE message format");
return;
}
String hostnamePart = String(firstColon + 1);
String hostname = hostnamePart.substring(0, secondColon - firstColon - 1);
const char* jsonCStr = secondColon + 1;
JsonDocument doc;
DeserializationError err = deserializeJson(doc, jsonCStr);
if (err) {
LOG_WARN("Cluster", String("Failed to parse NODE_UPDATE JSON from ") + ctx.udp->remoteIP().toString());
return;
}
// The NODE_UPDATE contains info about the target node (hostname from message)
// but is sent FROM the responding node (ctx.udp->remoteIP())
// We need to find the responding node in the memberlist, not the target node
IPAddress respondingNodeIP = ctx.udp->remoteIP();
String respondingIPStr = respondingNodeIP.toString();
// Find the responding node by IP address
auto respondingMember = ctx.memberList->getMember(respondingIPStr.c_str());
if (!respondingMember) {
LOG_WARN("Cluster", String("Received NODE_UPDATE from unknown node: ") + respondingNodeIP.toString());
return;
}
// Calculate latency only if we recently sent a heartbeat (within last 1 second)
unsigned long latency = 0;
unsigned long now = millis();
if (lastHeartbeatSentAt != 0 && (now - lastHeartbeatSentAt) < 1000) { // 1 second window
latency = now - lastHeartbeatSentAt;
lastHeartbeatSentAt = 0; // Reset for next calculation
}
// Create updated node info
NodeInfo updatedNode = *respondingMember;
bool hostnameChanged = false;
bool labelsChanged = false;
// Update hostname if provided
if (doc["hostname"].is<const char*>()) {
String newHostname = doc["hostname"].as<const char*>();
if (updatedNode.hostname != newHostname) {
updatedNode.hostname = newHostname;
hostnameChanged = true;
}
}
// Update uptime if provided
if (doc["uptime"].is<unsigned long>()) {
updatedNode.uptime = doc["uptime"];
}
// Update labels if provided
if (doc["labels"].is<JsonObject>()) {
JsonObject labelsObj = doc["labels"].as<JsonObject>();
std::map<String, String> newLabels;
for (JsonPair kvp : labelsObj) {
const char* key = kvp.key().c_str();
const char* value = labelsObj[kvp.key()];
newLabels[key] = String(value);
}
// Compare with existing labels
if (newLabels != updatedNode.labels) {
labelsChanged = true;
updatedNode.labels = newLabels;
}
}
// Update timing and status
updatedNode.lastSeen = now;
updatedNode.status = NodeInfo::ACTIVE;
// Update latency if we calculated it (preserve existing value if not)
if (latency > 0) {
updatedNode.latency = latency;
}
// Persist the updated node info to the memberlist
ctx.memberList->updateMember(respondingIPStr.c_str(), updatedNode);
// Check if any fields changed that require broadcasting
bool nodeInfoChanged = hostnameChanged || labelsChanged;
if (nodeInfoChanged) {
// Fire cluster/node/update event to trigger broadcast
ctx.fire("cluster/node/update", nullptr);
}
LOG_DEBUG("Cluster", String("Updated responding node ") + updatedNode.hostname + " @ " + respondingNodeIP.toString() +
" | hostname: " + (hostnameChanged ? "changed" : "unchanged") +
" | labels: " + (labelsChanged ? "changed" : "unchanged") +
" | latency: " + (latency > 0 ? String(latency) + "ms" : "not calculated"));
}
void ClusterManager::sendNodeInfo(const String& targetHostname, const IPAddress& targetIP) {
JsonDocument doc;
// Get our node info for the response (we're the responding node)
String localIPStr = ctx.localIP.toString();
auto member = ctx.memberList->getMember(localIPStr.c_str());
if (member) {
const NodeInfo& node = *member;
// Response contains info about ourselves (the responding node)
doc["hostname"] = node.hostname;
doc["ip"] = node.ip.toString();
doc["uptime"] = node.uptime;
// Add labels if present
if (!node.labels.empty()) {
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) {
for (const auto& kv : node.labels) {
labelsObj[kv.first.c_str()] = kv.second;
}
}
} else {
// Fallback to basic info if not in memberlist
doc["hostname"] = ctx.hostname;
doc["ip"] = ctx.localIP.toString();
doc["uptime"] = millis();
}
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;
// Send NODE_UPDATE:targetHostname:{json about responding node}
ctx.udp->beginPacket(targetIP, ctx.config.udp_port);
String msg = String(ClusterProtocol::NODE_UPDATE_MSG) + ":" + targetHostname + ":" + json;
ctx.udp->write(msg.c_str());
ctx.udp->endPacket();
}
void ClusterManager::onResponse(const char* msg) {
char* hostPtr = const_cast<char*>(msg) + strlen(ClusterProtocol::RESPONSE_MSG) + 1;
String nodeHost = String(hostPtr);
addOrUpdateNode(nodeHost, ctx.udp->remoteIP());
}
void ClusterManager::onNodeInfo(const char* msg) {
char* p = const_cast<char*>(msg) + strlen(ClusterProtocol::NODE_INFO_MSG) + 1;
char* hostEnd = strchr(p, ':');
if (hostEnd) {
*hostEnd = '\0';
const char* hostCStr = p;
const char* jsonCStr = hostEnd + 1;
String nodeHost = String(hostCStr);
IPAddress senderIP = ctx.udp->remoteIP();
addOrUpdateNode(nodeHost, senderIP);
JsonDocument doc;
DeserializationError err = deserializeJson(doc, jsonCStr);
if (!err) {
auto& memberList = *ctx.memberList;
auto it = memberList.find(nodeHost);
if (it != memberList.end()) {
NodeInfo& node = it->second;
node.resources.freeHeap = doc["freeHeap"] | node.resources.freeHeap;
node.resources.chipId = doc["chipId"] | node.resources.chipId;
{
const char* sdk = doc["sdkVersion"] | node.resources.sdkVersion.c_str();
node.resources.sdkVersion = sdk ? String(sdk) : node.resources.sdkVersion;
}
node.resources.cpuFreqMHz = doc["cpuFreqMHz"] | node.resources.cpuFreqMHz;
node.resources.flashChipSize = doc["flashChipSize"] | node.resources.flashChipSize;
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());
}
}
LOG_DEBUG("Cluster", String("Sent NODE_UPDATE response to ") + targetHostname + " @ " + targetIP.toString());
}
void ClusterManager::onClusterEvent(const char* msg) {
// Message format: CLUSTER_EVENT:{"event":"...","data":"<json string>"}
// 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");
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)));
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());
LOG_ERROR("Cluster", String("Failed to parse cluster/event JSON from ") + ctx.udp->remoteIP().toString());
return;
}
// Robust extraction of event and data
@@ -244,7 +320,7 @@ void ClusterManager::onClusterEvent(const char* msg) {
if (eventStr.length() == 0 || data.length() == 0) {
String dbg;
serializeJson(doc, dbg);
LOG_WARN("Cluster", String("CLUSTER_EVENT missing 'event' or 'data' | payload=") + dbg);
LOG_WARN("Cluster", String("cluster/event missing 'event' or 'data' | payload=") + dbg);
return;
}
@@ -253,154 +329,85 @@ void ClusterManager::onClusterEvent(const char* msg) {
ctx.fire(eventKey, &data);
}
void ClusterManager::addOrUpdateNode(const String& nodeHost, IPAddress nodeIP) {
auto& memberList = *ctx.memberList;
// O(1) lookup instead of O(n) search
auto it = memberList.find(nodeHost);
if (it != memberList.end()) {
// Update existing node
it->second.ip = nodeIP;
it->second.lastSeen = millis();
//fetchNodeInfo(nodeIP); // Do not fetch here, handled by periodic task
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;
}
// Add new node
NodeInfo newNode;
newNode.hostname = nodeHost;
newNode.ip = nodeIP;
newNode.lastSeen = millis();
updateNodeStatus(newNode, newNode.lastSeen, ctx.config.node_inactive_threshold_ms, ctx.config.node_dead_threshold_ms);
memberList[nodeHost] = newNode;
LOG_INFO("Cluster", "Added node: " + nodeHost + " @ " + newNode.ip.toString() + " | Status: " + statusToStr(newNode.status) + " | last update: 0");
//fetchNodeInfo(nodeIP); // Do not fetch here, handled by periodic task
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::fetchNodeInfo(const IPAddress& ip) {
if(ip == ctx.localIP) {
LOG_DEBUG("Cluster", "Skipping fetch for local node");
return;
}
unsigned long requestStart = millis();
HTTPClient http;
WiFiClient client;
String url = "http://" + ip.toString() + ClusterProtocol::API_NODE_STATUS;
// Use RAII pattern to ensure http.end() is always called
bool httpInitialized = false;
bool success = false;
httpInitialized = http.begin(client, url);
if (!httpInitialized) {
LOG_ERROR("Cluster", "Failed to initialize HTTP client for " + ip.toString());
return;
}
// Set timeout to prevent hanging
http.setTimeout(5000); // 5 second timeout
int httpCode = http.GET();
unsigned long requestEnd = millis();
unsigned long requestDuration = requestEnd - requestStart;
if (httpCode == 200) {
String payload = http.getString();
// Use stack-allocated JsonDocument with proper cleanup
JsonDocument doc;
DeserializationError err = deserializeJson(doc, payload);
if (!err) {
auto& memberList = *ctx.memberList;
// Still need to iterate since we're searching by IP, not hostname
for (auto& pair : memberList) {
NodeInfo& node = pair.second;
if (node.ip == ip) {
// Update resources efficiently
node.resources.freeHeap = doc["freeHeap"];
node.resources.chipId = doc["chipId"];
node.resources.sdkVersion = (const char*)doc["sdkVersion"];
node.resources.cpuFreqMHz = doc["cpuFreqMHz"];
node.resources.flashChipSize = doc["flashChipSize"];
node.status = NodeInfo::ACTIVE;
node.latency = requestDuration;
node.lastSeen = millis();
// Clear and rebuild endpoints efficiently
node.endpoints.clear();
node.endpoints.reserve(10); // Pre-allocate to avoid reallocations
if (doc["api"].is<JsonArray>()) {
JsonArray apiArr = doc["api"].as<JsonArray>();
for (JsonObject apiObj : apiArr) {
// Use const char* to avoid String copies
const char* uri = apiObj["uri"];
int method = apiObj["method"];
// Create basic EndpointInfo without params for cluster nodes
EndpointInfo endpoint;
endpoint.uri = uri; // String assignment is more efficient than construction
endpoint.method = method;
endpoint.isLocal = false;
endpoint.serviceName = "remote";
node.endpoints.push_back(std::move(endpoint));
}
}
// Parse labels efficiently
node.labels.clear();
if (doc["labels"].is<JsonObject>()) {
JsonObject labelsObj = doc["labels"].as<JsonObject>();
for (JsonPair kvp : labelsObj) {
// Use const char* to avoid String copies
const char* key = kvp.key().c_str();
const char* value = labelsObj[kvp.key()];
node.labels[key] = value;
}
}
LOG_DEBUG("Cluster", "Fetched info for node: " + node.hostname + " @ " + ip.toString());
success = true;
break;
}
}
} else {
LOG_ERROR("Cluster", "JSON parse error for node @ " + ip.toString() + ": " + String(err.c_str()));
void ClusterManager::addOrUpdateNode(const String& nodeHost, IPAddress nodeIP) {
bool memberlistChanged = false;
String ipStr = nodeIP.toString();
// Check if member exists
auto existingMember = ctx.memberList->getMember(ipStr.c_str());
if (existingMember) {
// Update existing node - preserve all existing field values
NodeInfo updatedNode = *existingMember;
if (updatedNode.ip != nodeIP) {
updatedNode.ip = nodeIP;
memberlistChanged = true;
}
updatedNode.lastSeen = millis();
ctx.memberList->updateMember(ipStr.c_str(), updatedNode);
} else {
LOG_ERROR("Cluster", "Failed to fetch info for node @ " + ip.toString() + ", HTTP code: " + String(httpCode));
// Add new node
NodeInfo newNode;
newNode.hostname = nodeHost;
newNode.ip = nodeIP;
newNode.lastSeen = millis();
updateNodeStatus(newNode, newNode.lastSeen, ctx.config.node_inactive_threshold_ms, ctx.config.node_dead_threshold_ms);
// Initialize static resources if this is the local node being added for the first time
if (nodeIP == ctx.localIP && nodeHost == ctx.hostname) {
newNode.resources.chipId = ESP.getChipId();
newNode.resources.sdkVersion = String(ESP.getSdkVersion());
newNode.resources.cpuFreqMHz = ESP.getCpuFreqMHz();
newNode.resources.flashChipSize = ESP.getFlashChipSize();
LOG_DEBUG("Cluster", "Initialized static resources for local node");
}
ctx.memberList->addMember(ipStr.c_str(), newNode);
memberlistChanged = true;
LOG_INFO("Cluster", "Added node: " + nodeHost + " @ " + newNode.ip.toString() + " | Status: " + statusToStr(newNode.status) + " | last update: 0");
}
// Always ensure HTTP client is properly closed
if (httpInitialized) {
http.end();
}
// Log success/failure for debugging
if (!success) {
LOG_DEBUG("Cluster", "Failed to update node info for " + ip.toString());
// Fire event if memberlist changed
if (memberlistChanged) {
ctx.fire("cluster/memberlist/changed", nullptr);
}
}
void ClusterManager::heartbeatTaskCallback() {
auto& memberList = *ctx.memberList;
auto it = memberList.find(ctx.hostname);
if (it != memberList.end()) {
NodeInfo& node = it->second;
node.lastSeen = millis();
node.status = NodeInfo::ACTIVE;
updateLocalNodeResources();
ctx.fire("node_discovered", &node);
// Update local node resources and lastSeen since we're actively sending heartbeats
String localIPStr = ctx.localIP.toString();
auto member = ctx.memberList->getMember(localIPStr.c_str());
if (member) {
NodeInfo node = *member;
updateLocalNodeResources(node);
node.lastSeen = millis(); // Update lastSeen since we're actively participating
ctx.memberList->updateMember(localIPStr.c_str(), node);
}
// Broadcast heartbeat so peers can respond with their node info
// Broadcast heartbeat - peers will respond with NODE_UPDATE
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();
LOG_DEBUG("Cluster", String("Sent heartbeat: ") + ctx.hostname);
}
void ClusterManager::updateAllMembersInfoTaskCallback() {
@@ -408,61 +415,79 @@ void ClusterManager::updateAllMembersInfoTaskCallback() {
// No-op to reduce network and memory usage
}
void ClusterManager::updateAllNodeStatuses() {
auto& memberList = *ctx.memberList;
unsigned long now = millis();
for (auto& pair : memberList) {
NodeInfo& node = pair.second;
updateNodeStatus(node, now, ctx.config.node_inactive_threshold_ms, ctx.config.node_dead_threshold_ms);
void ClusterManager::broadcastNodeUpdate() {
// Broadcast our current node info as NODE_UPDATE to all cluster members
String localIPStr = ctx.localIP.toString();
auto member = ctx.memberList->getMember(localIPStr.c_str());
if (!member) {
return;
}
const NodeInfo& node = *member;
JsonDocument doc;
doc["hostname"] = node.hostname;
doc["uptime"] = node.uptime;
// Add labels if present
if (!node.labels.empty()) {
JsonObject labelsObj = doc["labels"].to<JsonObject>();
for (const auto& kv : node.labels) {
labelsObj[kv.first.c_str()] = kv.second;
}
}
String json;
serializeJson(doc, json);
// Broadcast to all cluster members
ctx.udp->beginPacket("255.255.255.255", ctx.config.udp_port);
String msg = String(ClusterProtocol::NODE_UPDATE_MSG) + ":" + ctx.hostname + ":" + json;
ctx.udp->write(msg.c_str());
ctx.udp->endPacket();
LOG_DEBUG("Cluster", String("Broadcasted NODE_UPDATE for ") + ctx.hostname);
}
void ClusterManager::updateAllNodeStatuses() {
unsigned long now = millis();
ctx.memberList->updateAllNodeStatuses(now, ctx.config.node_inactive_threshold_ms, ctx.config.node_dead_threshold_ms);
}
void ClusterManager::removeDeadNodes() {
auto& memberList = *ctx.memberList;
unsigned long now = millis();
// Use iterator to safely remove elements from map
for (auto it = memberList.begin(); it != memberList.end(); ) {
unsigned long diff = now - it->second.lastSeen;
if (it->second.status == NodeInfo::DEAD && diff > ctx.config.node_dead_threshold_ms) {
LOG_INFO("Cluster", "Removing node: " + it->second.hostname);
it = memberList.erase(it);
} else {
++it;
}
size_t removedCount = ctx.memberList->removeDeadMembers();
if (removedCount > 0) {
LOG_INFO("Cluster", String("Removed ") + removedCount + " dead nodes");
ctx.fire("cluster/memberlist/changed", nullptr);
}
}
void ClusterManager::printMemberList() {
auto& memberList = *ctx.memberList;
if (memberList.empty()) {
size_t count = ctx.memberList->getMemberCount();
if (count == 0) {
LOG_INFO("Cluster", "Member List: empty");
return;
}
LOG_INFO("Cluster", "Member List:");
for (const auto& pair : memberList) {
const NodeInfo& node = pair.second;
ctx.memberList->forEachMember([](const std::string& ip, const NodeInfo& node) {
LOG_INFO("Cluster", " " + node.hostname + " @ " + node.ip.toString() + " | Status: " + statusToStr(node.status) + " | last seen: " + String(millis() - node.lastSeen));
}
});
}
void ClusterManager::updateLocalNodeResources() {
auto& memberList = *ctx.memberList;
auto it = memberList.find(ctx.hostname);
if (it != memberList.end()) {
NodeInfo& node = it->second;
uint32_t freeHeap = ESP.getFreeHeap();
node.resources.freeHeap = freeHeap;
node.resources.chipId = ESP.getChipId();
node.resources.sdkVersion = String(ESP.getSdkVersion());
node.resources.cpuFreqMHz = ESP.getCpuFreqMHz();
node.resources.flashChipSize = ESP.getFlashChipSize();
// Log memory warnings if heap is getting low
if (freeHeap < ctx.config.low_memory_threshold_bytes) {
LOG_WARN("Cluster", "Low memory warning: " + String(freeHeap) + " bytes free");
} else if (freeHeap < ctx.config.critical_memory_threshold_bytes) {
LOG_ERROR("Cluster", "Critical memory warning: " + String(freeHeap) + " bytes free");
}
void ClusterManager::updateLocalNodeResources(NodeInfo& node) {
// Update node status and timing
node.lastSeen = millis();
node.status = NodeInfo::ACTIVE;
node.uptime = millis();
// Update dynamic resources (always updated)
uint32_t freeHeap = ESP.getFreeHeap();
node.resources.freeHeap = freeHeap;
// Log memory warnings if heap is getting low
if (freeHeap < ctx.config.low_memory_threshold_bytes) {
LOG_WARN("Cluster", "Low memory warning: " + String(freeHeap) + " bytes free");
} else if (freeHeap < ctx.config.critical_memory_threshold_bytes) {
LOG_ERROR("Cluster", "Critical memory warning: " + String(freeHeap) + " bytes free");
}
}

View File

@@ -0,0 +1,114 @@
#include "spore/core/Memberlist.h"
#include <algorithm>
Memberlist::Memberlist() = default;
Memberlist::~Memberlist() = default;
bool Memberlist::addOrUpdateMember(const std::string& ip, const NodeInfo& node) {
auto it = m_members.find(ip);
if (it != m_members.end()) {
// Update existing member
it->second = node;
it->second.lastSeen = millis(); // Update last seen time
return true;
} else {
// Add new member
NodeInfo newNode = node;
newNode.lastSeen = millis();
m_members[ip] = newNode;
return true;
}
}
bool Memberlist::addMember(const std::string& ip, const NodeInfo& node) {
if (m_members.find(ip) != m_members.end()) {
return false; // Member already exists
}
NodeInfo newNode = node;
newNode.lastSeen = millis();
m_members[ip] = newNode;
return true;
}
bool Memberlist::updateMember(const std::string& ip, const NodeInfo& node) {
auto it = m_members.find(ip);
if (it == m_members.end()) {
return false; // Member doesn't exist
}
it->second = node;
it->second.lastSeen = millis(); // Update last seen time
return true;
}
bool Memberlist::removeMember(const std::string& ip) {
auto it = m_members.find(ip);
if (it == m_members.end()) {
return false; // Member doesn't exist
}
m_members.erase(it);
return true;
}
std::optional<NodeInfo> Memberlist::getMember(const std::string& ip) const {
auto it = m_members.find(ip);
if (it != m_members.end()) {
return it->second;
}
return std::nullopt;
}
void Memberlist::forEachMember(std::function<void(const std::string&, const NodeInfo&)> callback) const {
for (const auto& pair : m_members) {
callback(pair.first, pair.second);
}
}
bool Memberlist::forEachMemberUntil(std::function<bool(const std::string&, const NodeInfo&)> callback) const {
for (const auto& pair : m_members) {
if (!callback(pair.first, pair.second)) {
return false;
}
}
return true;
}
size_t Memberlist::getMemberCount() const {
return m_members.size();
}
void Memberlist::updateAllNodeStatuses(unsigned long currentTime,
unsigned long staleThresholdMs,
unsigned long deadThresholdMs,
std::function<void(const std::string&, NodeInfo::Status, NodeInfo::Status)> onStatusChange) {
for (auto& [ip, node] : m_members) {
NodeInfo::Status oldStatus = node.status;
updateNodeStatus(node, currentTime, staleThresholdMs, deadThresholdMs);
if (oldStatus != node.status && onStatusChange) {
onStatusChange(ip, oldStatus, node.status);
}
}
}
size_t Memberlist::removeDeadMembers() {
size_t removedCount = 0;
auto it = m_members.begin();
while (it != m_members.end()) {
if (it->second.status == NodeInfo::Status::DEAD) {
it = m_members.erase(it);
++removedCount;
} else {
++it;
}
}
return removedCount;
}
bool Memberlist::hasMember(const std::string& ip) const {
return m_members.find(ip) != m_members.end();
}
void Memberlist::clear() {
m_members.clear();
}

View File

@@ -65,6 +65,16 @@ void NetworkManager::setWiFiConfig(const String& ssid, const String& password,
ctx.config.wifi_retry_delay_ms = retry_delay_ms;
}
bool NetworkManager::saveConfig() {
return ctx.config.saveToFile();
}
void NetworkManager::restartNode() {
LOG_INFO("NetworkManager", "Restarting node after WiFi configuration change...");
delay(100); // Give time for response to be sent
ESP.restart();
}
void NetworkManager::setHostnameFromMac() {
uint8_t mac[6];
WiFi.macAddress(mac);
@@ -109,14 +119,9 @@ void NetworkManager::setupWiFi() {
ctx.self.status = NodeInfo::ACTIVE;
// Ensure member list has an entry for this node
auto &memberList = *ctx.memberList;
auto existing = memberList.find(ctx.hostname);
if (existing == memberList.end()) {
memberList[ctx.hostname] = ctx.self;
} else {
existing->second = ctx.self;
}
String localIPStr = ctx.localIP.toString();
ctx.memberList->addOrUpdateMember(localIPStr.c_str(), ctx.self);
// Notify listeners that the node is (re)discovered
ctx.fire("node_discovered", &ctx.self);
ctx.fire("node/discovered", &ctx.self);
}

View File

@@ -2,7 +2,7 @@
NodeContext::NodeContext() {
udp = new WiFiUDP();
memberList = new std::map<String, NodeInfo>();
memberList = std::make_unique<Memberlist>();
hostname = "";
self.hostname = "";
self.ip = IPAddress();
@@ -12,13 +12,14 @@ NodeContext::NodeContext() {
NodeContext::NodeContext(std::initializer_list<std::pair<String, String>> initialLabels) : NodeContext() {
for (const auto& kv : initialLabels) {
constructorLabels[kv.first] = kv.second;
self.labels[kv.first] = kv.second;
}
}
NodeContext::~NodeContext() {
delete udp;
delete memberList;
// memberList is a unique_ptr, so no need to delete manually
}
void NodeContext::on(const std::string& event, EventCallback cb) {
@@ -37,3 +38,18 @@ void NodeContext::fire(const std::string& event, void* data) {
void NodeContext::onAny(AnyEventCallback cb) {
anyEventSubscribers.push_back(cb);
}
void NodeContext::rebuildLabels() {
// Clear current labels
self.labels.clear();
// Add constructor labels first
for (const auto& kv : constructorLabels) {
self.labels[kv.first] = kv.second;
}
// Add config labels (these override constructor labels if same key)
for (const auto& kv : config.labels) {
self.labels[kv.first] = kv.second;
}
}

View File

@@ -4,12 +4,12 @@
ClusterService::ClusterService(NodeContext& ctx) : ctx(ctx) {}
void ClusterService::registerEndpoints(ApiServer& api) {
api.addEndpoint("/api/cluster/members", HTTP_GET,
api.registerEndpoint("/api/cluster/members", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleMembersRequest(request); },
std::vector<ParamSpec>{});
// Generic cluster broadcast endpoint
api.addEndpoint("/api/cluster/event", HTTP_POST,
api.registerEndpoint("/api/cluster/event", HTTP_POST,
[this](AsyncWebServerRequest* request) {
if (!request->hasParam("event", true) || !request->hasParam("payload", true)) {
request->send(400, "application/json", "{\"error\":\"Missing 'event' or 'payload'\"}");
@@ -32,23 +32,21 @@ void ClusterService::registerEndpoints(ApiServer& api) {
});
}
void ClusterService::registerTasks(TaskManager& taskManager) {
// ClusterService doesn't register any tasks itself
}
void ClusterService::handleMembersRequest(AsyncWebServerRequest* request) {
JsonDocument doc;
JsonArray arr = doc["members"].to<JsonArray>();
for (const auto& pair : *ctx.memberList) {
const NodeInfo& node = pair.second;
ctx.memberList->forEachMember([&arr](const std::string& ip, const NodeInfo& node) {
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);
obj["resources"]["freeHeap"] = node.resources.freeHeap;
obj["resources"]["chipId"] = node.resources.chipId;
obj["resources"]["sdkVersion"] = node.resources.sdkVersion;
obj["resources"]["cpuFreqMHz"] = node.resources.cpuFreqMHz;
obj["resources"]["flashChipSize"] = node.resources.flashChipSize;
// Add labels if present
if (!node.labels.empty()) {
@@ -57,7 +55,7 @@ void ClusterService::handleMembersRequest(AsyncWebServerRequest* request) {
labelsObj[kv.first.c_str()] = kv.second;
}
}
}
});
String json;
serializeJson(doc, json);

View File

@@ -5,33 +5,31 @@
#include <FS.h>
#include <LittleFS.h>
MonitoringService::MonitoringService(CpuUsage& cpuUsage)
: cpuUsage(cpuUsage) {
MonitoringService::MonitoringService() {
}
void MonitoringService::registerEndpoints(ApiServer& api) {
api.addEndpoint("/api/monitoring/resources", HTTP_GET,
api.registerEndpoint("/api/monitoring/resources", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleResourcesRequest(request); },
std::vector<ParamSpec>{});
}
void MonitoringService::registerTasks(TaskManager& taskManager) {
// MonitoringService doesn't register any tasks itself
}
MonitoringService::SystemResources MonitoringService::getSystemResources() const {
SystemResources resources;
// CPU information
resources.currentCpuUsage = cpuUsage.getCpuUsage();
resources.averageCpuUsage = cpuUsage.getAverageCpuUsage();
resources.maxCpuUsage = cpuUsage.getMaxCpuUsage();
resources.minCpuUsage = cpuUsage.getMinCpuUsage();
resources.measurementCount = cpuUsage.getMeasurementCount();
resources.isMeasuring = cpuUsage.isMeasuring();
// CPU information - sending fixed value of 100
resources.currentCpuUsage = 100.0f;
resources.averageCpuUsage = 100.0f;
resources.measurementCount = 0;
resources.isMeasuring = false;
// Memory information - ESP8266 compatible
resources.freeHeap = ESP.getFreeHeap();
resources.totalHeap = 81920; // ESP8266 has ~80KB RAM
resources.minFreeHeap = 0; // Not available on ESP8266
resources.maxAllocHeap = 0; // Not available on ESP8266
resources.heapFragmentation = calculateHeapFragmentation();
// Filesystem information
getFilesystemInfo(resources.totalBytes, resources.usedBytes);
@@ -55,8 +53,6 @@ void MonitoringService::handleResourcesRequest(AsyncWebServerRequest* request) {
JsonObject cpu = doc["cpu"].to<JsonObject>();
cpu["current_usage"] = resources.currentCpuUsage;
cpu["average_usage"] = resources.averageCpuUsage;
cpu["max_usage"] = resources.maxCpuUsage;
cpu["min_usage"] = resources.minCpuUsage;
cpu["measurement_count"] = resources.measurementCount;
cpu["is_measuring"] = resources.isMeasuring;
@@ -64,9 +60,6 @@ void MonitoringService::handleResourcesRequest(AsyncWebServerRequest* request) {
JsonObject memory = doc["memory"].to<JsonObject>();
memory["free_heap"] = resources.freeHeap;
memory["total_heap"] = resources.totalHeap;
memory["min_free_heap"] = resources.minFreeHeap;
memory["max_alloc_heap"] = resources.maxAllocHeap;
memory["heap_fragmentation"] = resources.heapFragmentation;
memory["heap_usage_percent"] = resources.totalHeap > 0 ?
(float)(resources.totalHeap - resources.freeHeap) / (float)resources.totalHeap * 100.0f : 0.0f;
@@ -90,15 +83,6 @@ void MonitoringService::handleResourcesRequest(AsyncWebServerRequest* request) {
request->send(200, "application/json", json);
}
size_t MonitoringService::calculateHeapFragmentation() const {
size_t freeHeap = ESP.getFreeHeap();
size_t maxAllocHeap = 0; // Not available on ESP8266
if (maxAllocHeap == 0) return 0;
// Calculate fragmentation as percentage of free heap that can't be allocated in one block
return (freeHeap - maxAllocHeap) * 100 / freeHeap;
}
void MonitoringService::getFilesystemInfo(size_t& totalBytes, size_t& usedBytes) const {
totalBytes = 0;

View File

@@ -1,4 +1,5 @@
#include "spore/services/NetworkService.h"
#include "spore/util/Logging.h"
#include <ArduinoJson.h>
NetworkService::NetworkService(NetworkManager& networkManager)
@@ -6,20 +7,20 @@ NetworkService::NetworkService(NetworkManager& networkManager)
void NetworkService::registerEndpoints(ApiServer& api) {
// WiFi scanning endpoints
api.addEndpoint("/api/network/wifi/scan", HTTP_POST,
api.registerEndpoint("/api/network/wifi/scan", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleWifiScanRequest(request); },
std::vector<ParamSpec>{});
api.addEndpoint("/api/network/wifi/scan", HTTP_GET,
api.registerEndpoint("/api/network/wifi/scan", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleGetWifiNetworks(request); },
std::vector<ParamSpec>{});
// Network status and configuration endpoints
api.addEndpoint("/api/network/status", HTTP_GET,
api.registerEndpoint("/api/network/status", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleNetworkStatus(request); },
std::vector<ParamSpec>{});
api.addEndpoint("/api/network/wifi/config", HTTP_POST,
api.registerEndpoint("/api/network/wifi/config", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleSetWifiConfig(request); },
std::vector<ParamSpec>{
ParamSpec{String("ssid"), true, String("body"), String("string"), {}, String("")},
@@ -29,6 +30,10 @@ void NetworkService::registerEndpoints(ApiServer& api) {
});
}
void NetworkService::registerTasks(TaskManager& taskManager) {
// NetworkService doesn't register any tasks itself
}
void NetworkService::handleWifiScanRequest(AsyncWebServerRequest* request) {
networkManager.scanWifi();
@@ -112,21 +117,34 @@ void NetworkService::handleSetWifiConfig(AsyncWebServerRequest* request) {
retry_delay_ms = request->getParam("retry_delay_ms", true)->value().toInt();
}
// Update configuration
// Update configuration in memory
networkManager.setWiFiConfig(ssid, password, connect_timeout_ms, retry_delay_ms);
// Attempt to connect with new settings
networkManager.setupWiFi();
// Save configuration to persistent storage
bool configSaved = networkManager.saveConfig();
if (!configSaved) {
LOG_WARN("NetworkService", "Failed to save WiFi configuration to persistent storage");
}
// Prepare response
JsonDocument doc;
doc["status"] = "success";
doc["message"] = "WiFi configuration updated";
doc["connected"] = WiFi.isConnected();
if (WiFi.isConnected()) {
doc["ip"] = WiFi.localIP().toString();
}
doc["message"] = "WiFi configuration updated and saved";
doc["config_saved"] = configSaved;
doc["restarting"] = true;
String json;
serializeJson(doc, json);
request->send(200, "application/json", json);
// Send response before restarting
AsyncWebServerResponse* response = request->beginResponse(200, "application/json", json);
response->addHeader("Connection", "close");
request->send(response);
// Restart the node to apply new WiFi settings
request->onDisconnect([this]() {
LOG_INFO("NetworkService", "Restarting node to apply WiFi configuration...");
delay(100); // Give time for response to be sent
networkManager.restartNode();
});
}

View File

@@ -6,12 +6,12 @@ NodeService::NodeService(NodeContext& ctx, ApiServer& apiServer) : ctx(ctx), api
void NodeService::registerEndpoints(ApiServer& api) {
// Status endpoint
api.addEndpoint("/api/node/status", HTTP_GET,
api.registerEndpoint("/api/node/status", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
std::vector<ParamSpec>{});
// Update endpoint with file upload
api.addEndpoint("/api/node/update", HTTP_POST,
api.registerEndpoint("/api/node/update", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleUpdateRequest(request); },
[this](AsyncWebServerRequest* request, const String& filename, size_t index, uint8_t* data, size_t len, bool final) {
handleUpdateUpload(request, filename, index, data, len, final);
@@ -21,17 +21,29 @@ void NodeService::registerEndpoints(ApiServer& api) {
});
// Restart endpoint
api.addEndpoint("/api/node/restart", HTTP_POST,
api.registerEndpoint("/api/node/restart", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleRestartRequest(request); },
std::vector<ParamSpec>{});
// Endpoints endpoint
api.addEndpoint("/api/node/endpoints", HTTP_GET,
api.registerEndpoint("/api/node/endpoints", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleEndpointsRequest(request); },
std::vector<ParamSpec>{});
// Config endpoint for setting labels
api.registerEndpoint("/api/node/config", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleConfigRequest(request); },
std::vector<ParamSpec>{
ParamSpec{String("labels"), true, String("body"), String("json"), {}, String("")}
});
// Config endpoint for getting node configuration (without WiFi password)
api.registerEndpoint("/api/node/config", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleGetConfigRequest(request); },
std::vector<ParamSpec>{});
// Generic local event endpoint
api.addEndpoint("/api/node/event", HTTP_POST,
api.registerEndpoint("/api/node/event", HTTP_POST,
[this](AsyncWebServerRequest* request) {
if (!request->hasParam("event", true) || !request->hasParam("payload", true)) {
request->send(400, "application/json", "{\"error\":\"Missing 'event' or 'payload'\"}");
@@ -49,6 +61,10 @@ void NodeService::registerEndpoints(ApiServer& api) {
});
}
void NodeService::registerTasks(TaskManager& taskManager) {
// NodeService doesn't register any tasks itself
}
void NodeService::handleStatusRequest(AsyncWebServerRequest* request) {
JsonDocument doc;
doc["freeHeap"] = ESP.getFreeHeap();
@@ -58,20 +74,18 @@ void NodeService::handleStatusRequest(AsyncWebServerRequest* request) {
doc["flashChipSize"] = ESP.getFlashChipSize();
// Include local node labels if present
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()) {
auto member = ctx.memberList->getMember(ctx.hostname.c_str());
if (member) {
JsonObject labelsObj = doc["labels"].to<JsonObject>();
for (const auto& kv : member->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);
@@ -100,7 +114,7 @@ void NodeService::handleUpdateUpload(AsyncWebServerRequest* request, const Strin
LOG_ERROR("OTA", "Update failed: not enough space");
Update.printError(Serial);
AsyncWebServerResponse* response = request->beginResponse(500, "application/json",
"{\"status\": \"FAIL\"}");
"{\"status\": \"FAIL\", \"message\": \"Update failed: not enough space\"}");
response->addHeader("Connection", "close");
request->send(response);
return;
@@ -171,3 +185,108 @@ void NodeService::handleEndpointsRequest(AsyncWebServerRequest* request) {
serializeJson(doc, json);
request->send(200, "application/json", json);
}
void NodeService::handleConfigRequest(AsyncWebServerRequest* request) {
if (!request->hasParam("labels", true)) {
request->send(400, "application/json", "{\"error\":\"Missing 'labels' parameter\"}");
return;
}
String labelsJson = request->getParam("labels", true)->value();
// Parse the JSON
JsonDocument doc;
DeserializationError error = deserializeJson(doc, labelsJson);
if (error) {
request->send(400, "application/json", "{\"error\":\"Invalid JSON format: " + String(error.c_str()) + "\"}");
return;
}
// Update config labels
ctx.config.labels.clear();
if (doc.is<JsonObject>()) {
JsonObject labelsObj = doc.as<JsonObject>();
for (JsonPair kv : labelsObj) {
ctx.config.labels[kv.key().c_str()] = kv.value().as<String>();
}
}
// Rebuild self.labels from constructor + config labels
ctx.rebuildLabels();
// Update the member list entry for the local node if it exists
String localIPStr = ctx.localIP.toString();
auto member = ctx.memberList->getMember(localIPStr.c_str());
if (member) {
// Update the labels in the member list entry
NodeInfo updatedNode = *member;
updatedNode.labels.clear();
for (const auto& kv : ctx.self.labels) {
updatedNode.labels[kv.first] = kv.second;
}
ctx.memberList->updateMember(localIPStr.c_str(), updatedNode);
}
// Save config to file
if (ctx.config.saveToFile()) {
LOG_INFO("NodeService", "Labels updated and saved to config");
request->send(200, "application/json", "{\"status\":\"success\",\"message\":\"Labels updated and saved\"}");
} else {
LOG_ERROR("NodeService", "Failed to save labels to config file");
request->send(500, "application/json", "{\"error\":\"Failed to save configuration\"}");
}
}
void NodeService::handleGetConfigRequest(AsyncWebServerRequest* request) {
JsonDocument doc;
// WiFi Configuration (excluding password for security)
JsonObject wifiObj = doc["wifi"].to<JsonObject>();
wifiObj["ssid"] = ctx.config.wifi_ssid;
wifiObj["connect_timeout_ms"] = ctx.config.wifi_connect_timeout_ms;
wifiObj["retry_delay_ms"] = ctx.config.wifi_retry_delay_ms;
// Network Configuration
JsonObject networkObj = doc["network"].to<JsonObject>();
networkObj["udp_port"] = ctx.config.udp_port;
networkObj["api_server_port"] = ctx.config.api_server_port;
// Cluster Configuration
JsonObject clusterObj = doc["cluster"].to<JsonObject>();
clusterObj["heartbeat_interval_ms"] = ctx.config.heartbeat_interval_ms;
clusterObj["cluster_listen_interval_ms"] = ctx.config.cluster_listen_interval_ms;
clusterObj["status_update_interval_ms"] = ctx.config.status_update_interval_ms;
// Node Status Thresholds
JsonObject thresholdsObj = doc["thresholds"].to<JsonObject>();
thresholdsObj["node_active_threshold_ms"] = ctx.config.node_active_threshold_ms;
thresholdsObj["node_inactive_threshold_ms"] = ctx.config.node_inactive_threshold_ms;
thresholdsObj["node_dead_threshold_ms"] = ctx.config.node_dead_threshold_ms;
// System Configuration
JsonObject systemObj = doc["system"].to<JsonObject>();
systemObj["restart_delay_ms"] = ctx.config.restart_delay_ms;
systemObj["json_doc_size"] = ctx.config.json_doc_size;
// Memory Management
JsonObject memoryObj = doc["memory"].to<JsonObject>();
memoryObj["low_memory_threshold_bytes"] = ctx.config.low_memory_threshold_bytes;
memoryObj["critical_memory_threshold_bytes"] = ctx.config.critical_memory_threshold_bytes;
memoryObj["max_concurrent_http_requests"] = ctx.config.max_concurrent_http_requests;
// Custom Labels
if (!ctx.config.labels.empty()) {
JsonObject labelsObj = doc["labels"].to<JsonObject>();
for (const auto& kv : ctx.config.labels) {
labelsObj[kv.first.c_str()] = kv.second;
}
}
// Add metadata
doc["version"] = "1.0";
doc["retrieved_at"] = millis();
String json;
serializeJson(doc, json);
request->send(200, "application/json", json);
}

View File

@@ -20,3 +20,7 @@ void StaticFileService::registerEndpoints(ApiServer& api) {
api.serveStatic("/", LittleFS, "/public", "max-age=3600");
}
void StaticFileService::registerTasks(TaskManager& taskManager) {
// StaticFileService doesn't register any tasks itself
}

View File

@@ -5,11 +5,11 @@
TaskService::TaskService(TaskManager& taskManager) : taskManager(taskManager) {}
void TaskService::registerEndpoints(ApiServer& api) {
api.addEndpoint("/api/tasks/status", HTTP_GET,
api.registerEndpoint("/api/tasks/status", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
std::vector<ParamSpec>{});
api.addEndpoint("/api/tasks/control", HTTP_POST,
api.registerEndpoint("/api/tasks/control", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleControlRequest(request); },
std::vector<ParamSpec>{
ParamSpec{
@@ -31,6 +31,10 @@ void TaskService::registerEndpoints(ApiServer& api) {
});
}
void TaskService::registerTasks(TaskManager& taskManager) {
// TaskService doesn't register any tasks itself - it manages other tasks
}
void TaskService::handleStatusRequest(AsyncWebServerRequest* request) {
JsonDocument scratch;
auto taskStatuses = taskManager.getAllTaskStatuses(scratch);

View File

@@ -1,37 +1,194 @@
#include "spore/types/Config.h"
#include "spore/util/Logging.h"
const char* Config::CONFIG_FILE_PATH = "/config.json";
Config::Config() {
// Initialize LittleFS
if (!LittleFS.begin()) {
LOG_WARN("Config", "Failed to initialize LittleFS, using defaults");
setDefaults();
return;
}
// Try to load configuration from file
if (!loadFromFile()) {
LOG_INFO("Config", "No config file found, using defaults");
setDefaults();
// Save defaults to file for future use
saveToFile();
} else {
LOG_INFO("Config", "Configuration loaded from file");
}
}
void Config::setDefaults() {
// WiFi Configuration
wifi_ssid = "shroud";
wifi_password = "th3r31sn0sp00n";
wifi_ssid = DEFAULT_WIFI_SSID;
wifi_password = DEFAULT_WIFI_PASSWORD;
// Network Configuration
udp_port = 4210;
api_server_port = 80;
udp_port = DEFAULT_UDP_PORT;
api_server_port = DEFAULT_API_SERVER_PORT;
// Cluster Configuration
discovery_interval_ms = 1000; // TODO retire this in favor of heartbeat_interval_ms
cluster_listen_interval_ms = 10;
heartbeat_interval_ms = 5000;
status_update_interval_ms = 1000;
member_info_update_interval_ms = 10000; // TODO retire this in favor of heartbeat_interval_ms
print_interval_ms = 5000;
cluster_listen_interval_ms = DEFAULT_CLUSTER_LISTEN_INTERVAL_MS;
heartbeat_interval_ms = DEFAULT_HEARTBEAT_INTERVAL_MS;
status_update_interval_ms = DEFAULT_STATUS_UPDATE_INTERVAL_MS;
// Node Status Thresholds
node_active_threshold_ms = 10000;
node_inactive_threshold_ms = 60000;
node_dead_threshold_ms = 120000;
node_active_threshold_ms = DEFAULT_NODE_ACTIVE_THRESHOLD_MS;
node_inactive_threshold_ms = DEFAULT_NODE_INACTIVE_THRESHOLD_MS;
node_dead_threshold_ms = DEFAULT_NODE_DEAD_THRESHOLD_MS;
// WiFi Connection
wifi_connect_timeout_ms = 15000;
wifi_retry_delay_ms = 500;
wifi_connect_timeout_ms = DEFAULT_WIFI_CONNECT_TIMEOUT_MS;
wifi_retry_delay_ms = DEFAULT_WIFI_RETRY_DELAY_MS;
// System Configuration
restart_delay_ms = 10;
json_doc_size = 1024;
restart_delay_ms = DEFAULT_RESTART_DELAY_MS;
json_doc_size = DEFAULT_JSON_DOC_SIZE;
// Memory Management
low_memory_threshold_bytes = 10000; // 10KB
critical_memory_threshold_bytes = 5000; // 5KB
max_concurrent_http_requests = 3;
low_memory_threshold_bytes = DEFAULT_LOW_MEMORY_THRESHOLD_BYTES; // 10KB
critical_memory_threshold_bytes = DEFAULT_CRITICAL_MEMORY_THRESHOLD_BYTES; // 5KB
max_concurrent_http_requests = DEFAULT_MAX_CONCURRENT_HTTP_REQUESTS;
// Custom Labels - start empty by default
labels.clear();
}
bool Config::saveToFile(const String& filename) {
if (!LittleFS.begin()) {
LOG_ERROR("Config", "LittleFS not initialized, cannot save config");
return false;
}
File file = LittleFS.open(filename, "w");
if (!file) {
LOG_ERROR("Config", "Failed to open config file for writing: " + filename);
return false;
}
JsonDocument doc;
// WiFi Configuration
doc["wifi"]["ssid"] = wifi_ssid;
doc["wifi"]["password"] = wifi_password;
doc["wifi"]["connect_timeout_ms"] = wifi_connect_timeout_ms;
doc["wifi"]["retry_delay_ms"] = wifi_retry_delay_ms;
// Network Configuration
doc["network"]["udp_port"] = udp_port;
doc["network"]["api_server_port"] = api_server_port;
// Cluster Configuration
doc["cluster"]["heartbeat_interval_ms"] = heartbeat_interval_ms;
doc["cluster"]["cluster_listen_interval_ms"] = cluster_listen_interval_ms;
doc["cluster"]["status_update_interval_ms"] = status_update_interval_ms;
// Node Status Thresholds
doc["thresholds"]["node_active_threshold_ms"] = node_active_threshold_ms;
doc["thresholds"]["node_inactive_threshold_ms"] = node_inactive_threshold_ms;
doc["thresholds"]["node_dead_threshold_ms"] = node_dead_threshold_ms;
// System Configuration
doc["system"]["restart_delay_ms"] = restart_delay_ms;
doc["system"]["json_doc_size"] = json_doc_size;
// Memory Management
doc["memory"]["low_memory_threshold_bytes"] = low_memory_threshold_bytes;
doc["memory"]["critical_memory_threshold_bytes"] = critical_memory_threshold_bytes;
doc["memory"]["max_concurrent_http_requests"] = max_concurrent_http_requests;
// Custom Labels
JsonObject labelsObj = doc["labels"].to<JsonObject>();
for (const auto& kv : labels) {
labelsObj[kv.first] = kv.second;
}
// Add metadata
doc["_meta"]["version"] = "1.0";
doc["_meta"]["saved_at"] = millis();
size_t bytesWritten = serializeJson(doc, file);
file.close();
if (bytesWritten > 0) {
LOG_INFO("Config", "Configuration saved to " + filename + " (" + String(bytesWritten) + " bytes)");
return true;
} else {
LOG_ERROR("Config", "Failed to write configuration to file");
return false;
}
}
bool Config::loadFromFile(const String& filename) {
if (!LittleFS.begin()) {
LOG_ERROR("Config", "LittleFS not initialized, cannot load config");
return false;
}
if (!LittleFS.exists(filename)) {
LOG_DEBUG("Config", "Config file does not exist: " + filename);
return false;
}
File file = LittleFS.open(filename, "r");
if (!file) {
LOG_ERROR("Config", "Failed to open config file for reading: " + filename);
return false;
}
JsonDocument doc;
DeserializationError error = deserializeJson(doc, file);
file.close();
if (error) {
LOG_ERROR("Config", "Failed to parse config file: " + String(error.c_str()));
return false;
}
// Load WiFi Configuration with defaults
wifi_ssid = doc["wifi"]["ssid"] | DEFAULT_WIFI_SSID;
wifi_password = doc["wifi"]["password"] | DEFAULT_WIFI_PASSWORD;
wifi_connect_timeout_ms = doc["wifi"]["connect_timeout_ms"] | DEFAULT_WIFI_CONNECT_TIMEOUT_MS;
wifi_retry_delay_ms = doc["wifi"]["retry_delay_ms"] | DEFAULT_WIFI_RETRY_DELAY_MS;
// Load Network Configuration with defaults
udp_port = doc["network"]["udp_port"] | DEFAULT_UDP_PORT;
api_server_port = doc["network"]["api_server_port"] | DEFAULT_API_SERVER_PORT;
// Load Cluster Configuration with defaults
heartbeat_interval_ms = doc["cluster"]["heartbeat_interval_ms"] | DEFAULT_HEARTBEAT_INTERVAL_MS;
cluster_listen_interval_ms = doc["cluster"]["cluster_listen_interval_ms"] | DEFAULT_CLUSTER_LISTEN_INTERVAL_MS;
status_update_interval_ms = doc["cluster"]["status_update_interval_ms"] | DEFAULT_STATUS_UPDATE_INTERVAL_MS;
// Load Node Status Thresholds with defaults
node_active_threshold_ms = doc["thresholds"]["node_active_threshold_ms"] | DEFAULT_NODE_ACTIVE_THRESHOLD_MS;
node_inactive_threshold_ms = doc["thresholds"]["node_inactive_threshold_ms"] | DEFAULT_NODE_INACTIVE_THRESHOLD_MS;
node_dead_threshold_ms = doc["thresholds"]["node_dead_threshold_ms"] | DEFAULT_NODE_DEAD_THRESHOLD_MS;
// Load System Configuration with defaults
restart_delay_ms = doc["system"]["restart_delay_ms"] | DEFAULT_RESTART_DELAY_MS;
json_doc_size = doc["system"]["json_doc_size"] | DEFAULT_JSON_DOC_SIZE;
// Load Memory Management with defaults
low_memory_threshold_bytes = doc["memory"]["low_memory_threshold_bytes"] | DEFAULT_LOW_MEMORY_THRESHOLD_BYTES;
critical_memory_threshold_bytes = doc["memory"]["critical_memory_threshold_bytes"] | DEFAULT_CRITICAL_MEMORY_THRESHOLD_BYTES;
max_concurrent_http_requests = doc["memory"]["max_concurrent_http_requests"] | DEFAULT_MAX_CONCURRENT_HTTP_REQUESTS;
// Load Custom Labels
labels.clear();
if (doc["labels"].is<JsonObject>()) {
JsonObject labelsObj = doc["labels"].as<JsonObject>();
for (JsonPair kv : labelsObj) {
labels[kv.key().c_str()] = kv.value().as<String>();
}
}
LOG_DEBUG("Config", "Loaded WiFi SSID: " + wifi_ssid);
LOG_DEBUG("Config", "Config file version: " + String(doc["_meta"]["version"] | "unknown"));
return true;
}

View File

@@ -1,185 +0,0 @@
#include "spore/util/CpuUsage.h"
CpuUsage::CpuUsage()
: _initialized(false)
, _measuring(false)
, _measurementCount(0)
, _cycleStartTime(0)
, _idleStartTime(0)
, _totalIdleTime(0)
, _totalCycleTime(0)
, _currentCpuUsage(0.0f)
, _averageCpuUsage(0.0f)
, _maxCpuUsage(0.0f)
, _minCpuUsage(100.0f)
, _totalCpuTime(0)
, _rollingIndex(0)
, _rollingWindowFull(false) {
// Initialize rolling window
for (size_t i = 0; i < ROLLING_WINDOW_SIZE; ++i) {
_rollingWindow[i] = 0.0f;
}
}
void CpuUsage::begin() {
if (_initialized) {
return;
}
_initialized = true;
_measurementCount = 0;
_totalIdleTime = 0;
_totalCycleTime = 0;
_totalCpuTime = 0;
_currentCpuUsage = 0.0f;
_averageCpuUsage = 0.0f;
_maxCpuUsage = 0.0f;
_minCpuUsage = 100.0f;
_rollingIndex = 0;
_rollingWindowFull = false;
// Initialize rolling window
for (size_t i = 0; i < ROLLING_WINDOW_SIZE; ++i) {
_rollingWindow[i] = 0.0f;
}
}
void CpuUsage::startMeasurement() {
if (!_initialized) {
return;
}
if (_measuring) {
// If already measuring, end the previous measurement first
endMeasurement();
}
_measuring = true;
_cycleStartTime = millis();
_idleStartTime = millis();
}
void CpuUsage::endMeasurement() {
if (!_initialized || !_measuring) {
return;
}
unsigned long cycleEndTime = millis();
unsigned long cycleDuration = cycleEndTime - _cycleStartTime;
// Calculate idle time (time spent in yield() calls)
unsigned long idleTime = cycleEndTime - _idleStartTime;
// Calculate CPU usage
if (cycleDuration > 0) {
_currentCpuUsage = ((float)(cycleDuration - idleTime) / (float)cycleDuration) * 100.0f;
// Clamp to valid range
if (_currentCpuUsage < 0.0f) {
_currentCpuUsage = 0.0f;
} else if (_currentCpuUsage > 100.0f) {
_currentCpuUsage = 100.0f;
}
// Update statistics
_totalCycleTime += cycleDuration;
_totalIdleTime += idleTime;
_totalCpuTime += (cycleDuration - idleTime);
_measurementCount++;
// Update rolling average
updateRollingAverage(_currentCpuUsage);
// Update min/max
updateMinMax(_currentCpuUsage);
// Calculate overall average
if (_measurementCount > 0) {
_averageCpuUsage = ((float)_totalCpuTime / (float)_totalCycleTime) * 100.0f;
}
}
_measuring = false;
}
float CpuUsage::getCpuUsage() const {
return _currentCpuUsage;
}
float CpuUsage::getAverageCpuUsage() const {
if (_rollingWindowFull) {
return _averageCpuUsage;
} else if (_measurementCount > 0) {
// Calculate average from rolling window
float sum = 0.0f;
for (size_t i = 0; i < _rollingIndex; ++i) {
sum += _rollingWindow[i];
}
return sum / (float)_rollingIndex;
}
return 0.0f;
}
float CpuUsage::getMaxCpuUsage() const {
return _maxCpuUsage;
}
float CpuUsage::getMinCpuUsage() const {
return _minCpuUsage;
}
void CpuUsage::reset() {
_measurementCount = 0;
_totalIdleTime = 0;
_totalCycleTime = 0;
_totalCpuTime = 0;
_currentCpuUsage = 0.0f;
_averageCpuUsage = 0.0f;
_maxCpuUsage = 0.0f;
_minCpuUsage = 100.0f;
_rollingIndex = 0;
_rollingWindowFull = false;
// Reset rolling window
for (size_t i = 0; i < ROLLING_WINDOW_SIZE; ++i) {
_rollingWindow[i] = 0.0f;
}
}
bool CpuUsage::isMeasuring() const {
return _measuring;
}
unsigned long CpuUsage::getMeasurementCount() const {
return _measurementCount;
}
void CpuUsage::updateRollingAverage(float value) {
_rollingWindow[_rollingIndex] = value;
_rollingIndex++;
if (_rollingIndex >= ROLLING_WINDOW_SIZE) {
_rollingIndex = 0;
_rollingWindowFull = true;
}
// Calculate rolling average
float sum = 0.0f;
size_t count = _rollingWindowFull ? ROLLING_WINDOW_SIZE : _rollingIndex;
for (size_t i = 0; i < count; ++i) {
sum += _rollingWindow[i];
}
_averageCpuUsage = sum / (float)count;
}
void CpuUsage::updateMinMax(float value) {
if (value > _maxCpuUsage) {
_maxCpuUsage = value;
}
if (value < _minCpuUsage) {
_minCpuUsage = value;
}
}

1
test/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

View File

@@ -44,7 +44,7 @@ console.log(`Broadcasting color changes to http://${host}/api/cluster/event ...`
setInterval(() => {
const color = colors[idx % colors.length];
idx++;
const payload = { color, brightness: 128 };
const payload = { color, brightness: 80 };
console.log('Broadcasting color:', payload);
postClusterEvent('api/neopattern/color', payload);
}, 5000);

View File

@@ -15,7 +15,7 @@ ws.on('open', () => {
setInterval(() => {
const color = colors[idx % colors.length];
idx++;
const payload = { color, brightness: 128 };
const payload = { color, brightness: 80 };
const envelope = {
event: 'api/neopattern/color',
data: payload // server will serialize object payloads

View File

@@ -31,8 +31,8 @@ function toHex({ r, g, b }) {
let hue = 0;
const SAT = 1.0; // full saturation
const VAL = 1.0; // full value
const BRIGHTNESS = 128;
const UPDATE_RATE = 100; // 100 ms
const BRIGHTNESS = 80;
const UPDATE_RATE = 100; // ms
let timer = null;

View File

@@ -22,7 +22,7 @@ ws.on('open', () => {
setInterval(() => {
const color = colors[idx % colors.length];
idx++;
const payload = { color, brightness: 128 };
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));

28
test/node_modules/.package-lock.json generated vendored
View File

@@ -1,28 +0,0 @@
{
"name": "test",
"lockfileVersion": 3,
"requires": true,
"packages": {
"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
}
}
}
}
}

20
test/node_modules/ws/LICENSE generated vendored
View File

@@ -1,20 +0,0 @@
Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
Copyright (c) 2013 Arnout Kazemier and contributors
Copyright (c) 2016 Luigi Pinca and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

548
test/node_modules/ws/README.md generated vendored
View File

@@ -1,548 +0,0 @@
# ws: a Node.js WebSocket library
[![Version npm](https://img.shields.io/npm/v/ws.svg?logo=npm)](https://www.npmjs.com/package/ws)
[![CI](https://img.shields.io/github/actions/workflow/status/websockets/ws/ci.yml?branch=master&label=CI&logo=github)](https://github.com/websockets/ws/actions?query=workflow%3ACI+branch%3Amaster)
[![Coverage Status](https://img.shields.io/coveralls/websockets/ws/master.svg?logo=coveralls)](https://coveralls.io/github/websockets/ws)
ws is a simple to use, blazing fast, and thoroughly tested WebSocket client and
server implementation.
Passes the quite extensive Autobahn test suite: [server][server-report],
[client][client-report].
**Note**: This module does not work in the browser. The client in the docs is a
reference to a backend with the role of a client in the WebSocket communication.
Browser clients must use the native
[`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
object. To make the same code work seamlessly on Node.js and the browser, you
can use one of the many wrappers available on npm, like
[isomorphic-ws](https://github.com/heineiuo/isomorphic-ws).
## Table of Contents
- [Protocol support](#protocol-support)
- [Installing](#installing)
- [Opt-in for performance](#opt-in-for-performance)
- [Legacy opt-in for performance](#legacy-opt-in-for-performance)
- [API docs](#api-docs)
- [WebSocket compression](#websocket-compression)
- [Usage examples](#usage-examples)
- [Sending and receiving text data](#sending-and-receiving-text-data)
- [Sending binary data](#sending-binary-data)
- [Simple server](#simple-server)
- [External HTTP/S server](#external-https-server)
- [Multiple servers sharing a single HTTP/S server](#multiple-servers-sharing-a-single-https-server)
- [Client authentication](#client-authentication)
- [Server broadcast](#server-broadcast)
- [Round-trip time](#round-trip-time)
- [Use the Node.js streams API](#use-the-nodejs-streams-api)
- [Other examples](#other-examples)
- [FAQ](#faq)
- [How to get the IP address of the client?](#how-to-get-the-ip-address-of-the-client)
- [How to detect and close broken connections?](#how-to-detect-and-close-broken-connections)
- [How to connect via a proxy?](#how-to-connect-via-a-proxy)
- [Changelog](#changelog)
- [License](#license)
## Protocol support
- **HyBi drafts 07-12** (Use the option `protocolVersion: 8`)
- **HyBi drafts 13-17** (Current default, alternatively option
`protocolVersion: 13`)
## Installing
```
npm install ws
```
### Opt-in for performance
[bufferutil][] is an optional module that can be installed alongside the ws
module:
```
npm install --save-optional bufferutil
```
This is a binary addon that improves the performance of certain operations such
as masking and unmasking the data payload of the WebSocket frames. Prebuilt
binaries are available for the most popular platforms, so you don't necessarily
need to have a C++ compiler installed on your machine.
To force ws to not use bufferutil, use the
[`WS_NO_BUFFER_UTIL`](./doc/ws.md#ws_no_buffer_util) environment variable. This
can be useful to enhance security in systems where a user can put a package in
the package search path of an application of another user, due to how the
Node.js resolver algorithm works.
#### Legacy opt-in for performance
If you are running on an old version of Node.js (prior to v18.14.0), ws also
supports the [utf-8-validate][] module:
```
npm install --save-optional utf-8-validate
```
This contains a binary polyfill for [`buffer.isUtf8()`][].
To force ws not to use utf-8-validate, use the
[`WS_NO_UTF_8_VALIDATE`](./doc/ws.md#ws_no_utf_8_validate) environment variable.
## API docs
See [`/doc/ws.md`](./doc/ws.md) for Node.js-like documentation of ws classes and
utility functions.
## WebSocket compression
ws supports the [permessage-deflate extension][permessage-deflate] which enables
the client and server to negotiate a compression algorithm and its parameters,
and then selectively apply it to the data payloads of each WebSocket message.
The extension is disabled by default on the server and enabled by default on the
client. It adds a significant overhead in terms of performance and memory
consumption so we suggest to enable it only if it is really needed.
Note that Node.js has a variety of issues with high-performance compression,
where increased concurrency, especially on Linux, can lead to [catastrophic
memory fragmentation][node-zlib-bug] and slow performance. If you intend to use
permessage-deflate in production, it is worthwhile to set up a test
representative of your workload and ensure Node.js/zlib will handle it with
acceptable performance and memory usage.
Tuning of permessage-deflate can be done via the options defined below. You can
also use `zlibDeflateOptions` and `zlibInflateOptions`, which is passed directly
into the creation of [raw deflate/inflate streams][node-zlib-deflaterawdocs].
See [the docs][ws-server-options] for more options.
```js
import WebSocket, { WebSocketServer } from 'ws';
const wss = new WebSocketServer({
port: 8080,
perMessageDeflate: {
zlibDeflateOptions: {
// See zlib defaults.
chunkSize: 1024,
memLevel: 7,
level: 3
},
zlibInflateOptions: {
chunkSize: 10 * 1024
},
// Other options settable:
clientNoContextTakeover: true, // Defaults to negotiated value.
serverNoContextTakeover: true, // Defaults to negotiated value.
serverMaxWindowBits: 10, // Defaults to negotiated value.
// Below options specified as default values.
concurrencyLimit: 10, // Limits zlib concurrency for perf.
threshold: 1024 // Size (in bytes) below which messages
// should not be compressed if context takeover is disabled.
}
});
```
The client will only use the extension if it is supported and enabled on the
server. To always disable the extension on the client, set the
`perMessageDeflate` option to `false`.
```js
import WebSocket from 'ws';
const ws = new WebSocket('ws://www.host.com/path', {
perMessageDeflate: false
});
```
## Usage examples
### Sending and receiving text data
```js
import WebSocket from 'ws';
const ws = new WebSocket('ws://www.host.com/path');
ws.on('error', console.error);
ws.on('open', function open() {
ws.send('something');
});
ws.on('message', function message(data) {
console.log('received: %s', data);
});
```
### Sending binary data
```js
import WebSocket from 'ws';
const ws = new WebSocket('ws://www.host.com/path');
ws.on('error', console.error);
ws.on('open', function open() {
const array = new Float32Array(5);
for (var i = 0; i < array.length; ++i) {
array[i] = i / 2;
}
ws.send(array);
});
```
### Simple server
```js
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.on('error', console.error);
ws.on('message', function message(data) {
console.log('received: %s', data);
});
ws.send('something');
});
```
### External HTTP/S server
```js
import { createServer } from 'https';
import { readFileSync } from 'fs';
import { WebSocketServer } from 'ws';
const server = createServer({
cert: readFileSync('/path/to/cert.pem'),
key: readFileSync('/path/to/key.pem')
});
const wss = new WebSocketServer({ server });
wss.on('connection', function connection(ws) {
ws.on('error', console.error);
ws.on('message', function message(data) {
console.log('received: %s', data);
});
ws.send('something');
});
server.listen(8080);
```
### Multiple servers sharing a single HTTP/S server
```js
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
const server = createServer();
const wss1 = new WebSocketServer({ noServer: true });
const wss2 = new WebSocketServer({ noServer: true });
wss1.on('connection', function connection(ws) {
ws.on('error', console.error);
// ...
});
wss2.on('connection', function connection(ws) {
ws.on('error', console.error);
// ...
});
server.on('upgrade', function upgrade(request, socket, head) {
const { pathname } = new URL(request.url, 'wss://base.url');
if (pathname === '/foo') {
wss1.handleUpgrade(request, socket, head, function done(ws) {
wss1.emit('connection', ws, request);
});
} else if (pathname === '/bar') {
wss2.handleUpgrade(request, socket, head, function done(ws) {
wss2.emit('connection', ws, request);
});
} else {
socket.destroy();
}
});
server.listen(8080);
```
### Client authentication
```js
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
function onSocketError(err) {
console.error(err);
}
const server = createServer();
const wss = new WebSocketServer({ noServer: true });
wss.on('connection', function connection(ws, request, client) {
ws.on('error', console.error);
ws.on('message', function message(data) {
console.log(`Received message ${data} from user ${client}`);
});
});
server.on('upgrade', function upgrade(request, socket, head) {
socket.on('error', onSocketError);
// This function is not defined on purpose. Implement it with your own logic.
authenticate(request, function next(err, client) {
if (err || !client) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
socket.removeListener('error', onSocketError);
wss.handleUpgrade(request, socket, head, function done(ws) {
wss.emit('connection', ws, request, client);
});
});
});
server.listen(8080);
```
Also see the provided [example][session-parse-example] using `express-session`.
### Server broadcast
A client WebSocket broadcasting to all connected WebSocket clients, including
itself.
```js
import WebSocket, { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.on('error', console.error);
ws.on('message', function message(data, isBinary) {
wss.clients.forEach(function each(client) {
if (client.readyState === WebSocket.OPEN) {
client.send(data, { binary: isBinary });
}
});
});
});
```
A client WebSocket broadcasting to every other connected WebSocket clients,
excluding itself.
```js
import WebSocket, { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.on('error', console.error);
ws.on('message', function message(data, isBinary) {
wss.clients.forEach(function each(client) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(data, { binary: isBinary });
}
});
});
});
```
### Round-trip time
```js
import WebSocket from 'ws';
const ws = new WebSocket('wss://websocket-echo.com/');
ws.on('error', console.error);
ws.on('open', function open() {
console.log('connected');
ws.send(Date.now());
});
ws.on('close', function close() {
console.log('disconnected');
});
ws.on('message', function message(data) {
console.log(`Round-trip time: ${Date.now() - data} ms`);
setTimeout(function timeout() {
ws.send(Date.now());
}, 500);
});
```
### Use the Node.js streams API
```js
import WebSocket, { createWebSocketStream } from 'ws';
const ws = new WebSocket('wss://websocket-echo.com/');
const duplex = createWebSocketStream(ws, { encoding: 'utf8' });
duplex.on('error', console.error);
duplex.pipe(process.stdout);
process.stdin.pipe(duplex);
```
### Other examples
For a full example with a browser client communicating with a ws server, see the
examples folder.
Otherwise, see the test cases.
## FAQ
### How to get the IP address of the client?
The remote IP address can be obtained from the raw socket.
```js
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws, req) {
const ip = req.socket.remoteAddress;
ws.on('error', console.error);
});
```
When the server runs behind a proxy like NGINX, the de-facto standard is to use
the `X-Forwarded-For` header.
```js
wss.on('connection', function connection(ws, req) {
const ip = req.headers['x-forwarded-for'].split(',')[0].trim();
ws.on('error', console.error);
});
```
### How to detect and close broken connections?
Sometimes, the link between the server and the client can be interrupted in a
way that keeps both the server and the client unaware of the broken state of the
connection (e.g. when pulling the cord).
In these cases, ping messages can be used as a means to verify that the remote
endpoint is still responsive.
```js
import { WebSocketServer } from 'ws';
function heartbeat() {
this.isAlive = true;
}
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.isAlive = true;
ws.on('error', console.error);
ws.on('pong', heartbeat);
});
const interval = setInterval(function ping() {
wss.clients.forEach(function each(ws) {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on('close', function close() {
clearInterval(interval);
});
```
Pong messages are automatically sent in response to ping messages as required by
the spec.
Just like the server example above, your clients might as well lose connection
without knowing it. You might want to add a ping listener on your clients to
prevent that. A simple implementation would be:
```js
import WebSocket from 'ws';
function heartbeat() {
clearTimeout(this.pingTimeout);
// Use `WebSocket#terminate()`, which immediately destroys the connection,
// instead of `WebSocket#close()`, which waits for the close timer.
// Delay should be equal to the interval at which your server
// sends out pings plus a conservative assumption of the latency.
this.pingTimeout = setTimeout(() => {
this.terminate();
}, 30000 + 1000);
}
const client = new WebSocket('wss://websocket-echo.com/');
client.on('error', console.error);
client.on('open', heartbeat);
client.on('ping', heartbeat);
client.on('close', function clear() {
clearTimeout(this.pingTimeout);
});
```
### How to connect via a proxy?
Use a custom `http.Agent` implementation like [https-proxy-agent][] or
[socks-proxy-agent][].
## Changelog
We're using the GitHub [releases][changelog] for changelog entries.
## License
[MIT](LICENSE)
[`buffer.isutf8()`]: https://nodejs.org/api/buffer.html#bufferisutf8input
[bufferutil]: https://github.com/websockets/bufferutil
[changelog]: https://github.com/websockets/ws/releases
[client-report]: http://websockets.github.io/ws/autobahn/clients/
[https-proxy-agent]: https://github.com/TooTallNate/node-https-proxy-agent
[node-zlib-bug]: https://github.com/nodejs/node/issues/8871
[node-zlib-deflaterawdocs]:
https://nodejs.org/api/zlib.html#zlib_zlib_createdeflateraw_options
[permessage-deflate]: https://tools.ietf.org/html/rfc7692
[server-report]: http://websockets.github.io/ws/autobahn/servers/
[session-parse-example]: ./examples/express-session-parse
[socks-proxy-agent]: https://github.com/TooTallNate/node-socks-proxy-agent
[utf-8-validate]: https://github.com/websockets/utf-8-validate
[ws-server-options]: ./doc/ws.md#new-websocketserveroptions-callback

8
test/node_modules/ws/browser.js generated vendored
View File

@@ -1,8 +0,0 @@
'use strict';
module.exports = function () {
throw new Error(
'ws does not work in the browser. Browser clients must use the native ' +
'WebSocket object'
);
};

13
test/node_modules/ws/index.js generated vendored
View File

@@ -1,13 +0,0 @@
'use strict';
const WebSocket = require('./lib/websocket');
WebSocket.createWebSocketStream = require('./lib/stream');
WebSocket.Server = require('./lib/websocket-server');
WebSocket.Receiver = require('./lib/receiver');
WebSocket.Sender = require('./lib/sender');
WebSocket.WebSocket = WebSocket;
WebSocket.WebSocketServer = WebSocket.Server;
module.exports = WebSocket;

View File

@@ -1,131 +0,0 @@
'use strict';
const { EMPTY_BUFFER } = require('./constants');
const FastBuffer = Buffer[Symbol.species];
/**
* Merges an array of buffers into a new buffer.
*
* @param {Buffer[]} list The array of buffers to concat
* @param {Number} totalLength The total length of buffers in the list
* @return {Buffer} The resulting buffer
* @public
*/
function concat(list, totalLength) {
if (list.length === 0) return EMPTY_BUFFER;
if (list.length === 1) return list[0];
const target = Buffer.allocUnsafe(totalLength);
let offset = 0;
for (let i = 0; i < list.length; i++) {
const buf = list[i];
target.set(buf, offset);
offset += buf.length;
}
if (offset < totalLength) {
return new FastBuffer(target.buffer, target.byteOffset, offset);
}
return target;
}
/**
* Masks a buffer using the given mask.
*
* @param {Buffer} source The buffer to mask
* @param {Buffer} mask The mask to use
* @param {Buffer} output The buffer where to store the result
* @param {Number} offset The offset at which to start writing
* @param {Number} length The number of bytes to mask.
* @public
*/
function _mask(source, mask, output, offset, length) {
for (let i = 0; i < length; i++) {
output[offset + i] = source[i] ^ mask[i & 3];
}
}
/**
* Unmasks a buffer using the given mask.
*
* @param {Buffer} buffer The buffer to unmask
* @param {Buffer} mask The mask to use
* @public
*/
function _unmask(buffer, mask) {
for (let i = 0; i < buffer.length; i++) {
buffer[i] ^= mask[i & 3];
}
}
/**
* Converts a buffer to an `ArrayBuffer`.
*
* @param {Buffer} buf The buffer to convert
* @return {ArrayBuffer} Converted buffer
* @public
*/
function toArrayBuffer(buf) {
if (buf.length === buf.buffer.byteLength) {
return buf.buffer;
}
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.length);
}
/**
* Converts `data` to a `Buffer`.
*
* @param {*} data The data to convert
* @return {Buffer} The buffer
* @throws {TypeError}
* @public
*/
function toBuffer(data) {
toBuffer.readOnly = true;
if (Buffer.isBuffer(data)) return data;
let buf;
if (data instanceof ArrayBuffer) {
buf = new FastBuffer(data);
} else if (ArrayBuffer.isView(data)) {
buf = new FastBuffer(data.buffer, data.byteOffset, data.byteLength);
} else {
buf = Buffer.from(data);
toBuffer.readOnly = false;
}
return buf;
}
module.exports = {
concat,
mask: _mask,
toArrayBuffer,
toBuffer,
unmask: _unmask
};
/* istanbul ignore else */
if (!process.env.WS_NO_BUFFER_UTIL) {
try {
const bufferUtil = require('bufferutil');
module.exports.mask = function (source, mask, output, offset, length) {
if (length < 48) _mask(source, mask, output, offset, length);
else bufferUtil.mask(source, mask, output, offset, length);
};
module.exports.unmask = function (buffer, mask) {
if (buffer.length < 32) _unmask(buffer, mask);
else bufferUtil.unmask(buffer, mask);
};
} catch (e) {
// Continue regardless of the error.
}
}

View File

@@ -1,18 +0,0 @@
'use strict';
const BINARY_TYPES = ['nodebuffer', 'arraybuffer', 'fragments'];
const hasBlob = typeof Blob !== 'undefined';
if (hasBlob) BINARY_TYPES.push('blob');
module.exports = {
BINARY_TYPES,
EMPTY_BUFFER: Buffer.alloc(0),
GUID: '258EAFA5-E914-47DA-95CA-C5AB0DC85B11',
hasBlob,
kForOnEventAttribute: Symbol('kIsForOnEventAttribute'),
kListener: Symbol('kListener'),
kStatusCode: Symbol('status-code'),
kWebSocket: Symbol('websocket'),
NOOP: () => {}
};

View File

@@ -1,292 +0,0 @@
'use strict';
const { kForOnEventAttribute, kListener } = require('./constants');
const kCode = Symbol('kCode');
const kData = Symbol('kData');
const kError = Symbol('kError');
const kMessage = Symbol('kMessage');
const kReason = Symbol('kReason');
const kTarget = Symbol('kTarget');
const kType = Symbol('kType');
const kWasClean = Symbol('kWasClean');
/**
* Class representing an event.
*/
class Event {
/**
* Create a new `Event`.
*
* @param {String} type The name of the event
* @throws {TypeError} If the `type` argument is not specified
*/
constructor(type) {
this[kTarget] = null;
this[kType] = type;
}
/**
* @type {*}
*/
get target() {
return this[kTarget];
}
/**
* @type {String}
*/
get type() {
return this[kType];
}
}
Object.defineProperty(Event.prototype, 'target', { enumerable: true });
Object.defineProperty(Event.prototype, 'type', { enumerable: true });
/**
* Class representing a close event.
*
* @extends Event
*/
class CloseEvent extends Event {
/**
* Create a new `CloseEvent`.
*
* @param {String} type The name of the event
* @param {Object} [options] A dictionary object that allows for setting
* attributes via object members of the same name
* @param {Number} [options.code=0] The status code explaining why the
* connection was closed
* @param {String} [options.reason=''] A human-readable string explaining why
* the connection was closed
* @param {Boolean} [options.wasClean=false] Indicates whether or not the
* connection was cleanly closed
*/
constructor(type, options = {}) {
super(type);
this[kCode] = options.code === undefined ? 0 : options.code;
this[kReason] = options.reason === undefined ? '' : options.reason;
this[kWasClean] = options.wasClean === undefined ? false : options.wasClean;
}
/**
* @type {Number}
*/
get code() {
return this[kCode];
}
/**
* @type {String}
*/
get reason() {
return this[kReason];
}
/**
* @type {Boolean}
*/
get wasClean() {
return this[kWasClean];
}
}
Object.defineProperty(CloseEvent.prototype, 'code', { enumerable: true });
Object.defineProperty(CloseEvent.prototype, 'reason', { enumerable: true });
Object.defineProperty(CloseEvent.prototype, 'wasClean', { enumerable: true });
/**
* Class representing an error event.
*
* @extends Event
*/
class ErrorEvent extends Event {
/**
* Create a new `ErrorEvent`.
*
* @param {String} type The name of the event
* @param {Object} [options] A dictionary object that allows for setting
* attributes via object members of the same name
* @param {*} [options.error=null] The error that generated this event
* @param {String} [options.message=''] The error message
*/
constructor(type, options = {}) {
super(type);
this[kError] = options.error === undefined ? null : options.error;
this[kMessage] = options.message === undefined ? '' : options.message;
}
/**
* @type {*}
*/
get error() {
return this[kError];
}
/**
* @type {String}
*/
get message() {
return this[kMessage];
}
}
Object.defineProperty(ErrorEvent.prototype, 'error', { enumerable: true });
Object.defineProperty(ErrorEvent.prototype, 'message', { enumerable: true });
/**
* Class representing a message event.
*
* @extends Event
*/
class MessageEvent extends Event {
/**
* Create a new `MessageEvent`.
*
* @param {String} type The name of the event
* @param {Object} [options] A dictionary object that allows for setting
* attributes via object members of the same name
* @param {*} [options.data=null] The message content
*/
constructor(type, options = {}) {
super(type);
this[kData] = options.data === undefined ? null : options.data;
}
/**
* @type {*}
*/
get data() {
return this[kData];
}
}
Object.defineProperty(MessageEvent.prototype, 'data', { enumerable: true });
/**
* This provides methods for emulating the `EventTarget` interface. It's not
* meant to be used directly.
*
* @mixin
*/
const EventTarget = {
/**
* Register an event listener.
*
* @param {String} type A string representing the event type to listen for
* @param {(Function|Object)} handler The listener to add
* @param {Object} [options] An options object specifies characteristics about
* the event listener
* @param {Boolean} [options.once=false] A `Boolean` indicating that the
* listener should be invoked at most once after being added. If `true`,
* the listener would be automatically removed when invoked.
* @public
*/
addEventListener(type, handler, options = {}) {
for (const listener of this.listeners(type)) {
if (
!options[kForOnEventAttribute] &&
listener[kListener] === handler &&
!listener[kForOnEventAttribute]
) {
return;
}
}
let wrapper;
if (type === 'message') {
wrapper = function onMessage(data, isBinary) {
const event = new MessageEvent('message', {
data: isBinary ? data : data.toString()
});
event[kTarget] = this;
callListener(handler, this, event);
};
} else if (type === 'close') {
wrapper = function onClose(code, message) {
const event = new CloseEvent('close', {
code,
reason: message.toString(),
wasClean: this._closeFrameReceived && this._closeFrameSent
});
event[kTarget] = this;
callListener(handler, this, event);
};
} else if (type === 'error') {
wrapper = function onError(error) {
const event = new ErrorEvent('error', {
error,
message: error.message
});
event[kTarget] = this;
callListener(handler, this, event);
};
} else if (type === 'open') {
wrapper = function onOpen() {
const event = new Event('open');
event[kTarget] = this;
callListener(handler, this, event);
};
} else {
return;
}
wrapper[kForOnEventAttribute] = !!options[kForOnEventAttribute];
wrapper[kListener] = handler;
if (options.once) {
this.once(type, wrapper);
} else {
this.on(type, wrapper);
}
},
/**
* Remove an event listener.
*
* @param {String} type A string representing the event type to remove
* @param {(Function|Object)} handler The listener to remove
* @public
*/
removeEventListener(type, handler) {
for (const listener of this.listeners(type)) {
if (listener[kListener] === handler && !listener[kForOnEventAttribute]) {
this.removeListener(type, listener);
break;
}
}
}
};
module.exports = {
CloseEvent,
ErrorEvent,
Event,
EventTarget,
MessageEvent
};
/**
* Call an event listener
*
* @param {(Function|Object)} listener The listener to call
* @param {*} thisArg The value to use as `this`` when calling the listener
* @param {Event} event The event to pass to the listener
* @private
*/
function callListener(listener, thisArg, event) {
if (typeof listener === 'object' && listener.handleEvent) {
listener.handleEvent.call(listener, event);
} else {
listener.call(thisArg, event);
}
}

203
test/node_modules/ws/lib/extension.js generated vendored
View File

@@ -1,203 +0,0 @@
'use strict';
const { tokenChars } = require('./validation');
/**
* Adds an offer to the map of extension offers or a parameter to the map of
* parameters.
*
* @param {Object} dest The map of extension offers or parameters
* @param {String} name The extension or parameter name
* @param {(Object|Boolean|String)} elem The extension parameters or the
* parameter value
* @private
*/
function push(dest, name, elem) {
if (dest[name] === undefined) dest[name] = [elem];
else dest[name].push(elem);
}
/**
* Parses the `Sec-WebSocket-Extensions` header into an object.
*
* @param {String} header The field value of the header
* @return {Object} The parsed object
* @public
*/
function parse(header) {
const offers = Object.create(null);
let params = Object.create(null);
let mustUnescape = false;
let isEscaping = false;
let inQuotes = false;
let extensionName;
let paramName;
let start = -1;
let code = -1;
let end = -1;
let i = 0;
for (; i < header.length; i++) {
code = header.charCodeAt(i);
if (extensionName === undefined) {
if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (
i !== 0 &&
(code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */
) {
if (end === -1 && start !== -1) end = i;
} else if (code === 0x3b /* ';' */ || code === 0x2c /* ',' */) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
const name = header.slice(start, end);
if (code === 0x2c) {
push(offers, name, params);
params = Object.create(null);
} else {
extensionName = name;
}
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
} else if (paramName === undefined) {
if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (code === 0x20 || code === 0x09) {
if (end === -1 && start !== -1) end = i;
} else if (code === 0x3b || code === 0x2c) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
push(params, header.slice(start, end), true);
if (code === 0x2c) {
push(offers, extensionName, params);
params = Object.create(null);
extensionName = undefined;
}
start = end = -1;
} else if (code === 0x3d /* '=' */ && start !== -1 && end === -1) {
paramName = header.slice(start, i);
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
} else {
//
// The value of a quoted-string after unescaping must conform to the
// token ABNF, so only token characters are valid.
// Ref: https://tools.ietf.org/html/rfc6455#section-9.1
//
if (isEscaping) {
if (tokenChars[code] !== 1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (start === -1) start = i;
else if (!mustUnescape) mustUnescape = true;
isEscaping = false;
} else if (inQuotes) {
if (tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (code === 0x22 /* '"' */ && start !== -1) {
inQuotes = false;
end = i;
} else if (code === 0x5c /* '\' */) {
isEscaping = true;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
} else if (code === 0x22 && header.charCodeAt(i - 1) === 0x3d) {
inQuotes = true;
} else if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (start !== -1 && (code === 0x20 || code === 0x09)) {
if (end === -1) end = i;
} else if (code === 0x3b || code === 0x2c) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
let value = header.slice(start, end);
if (mustUnescape) {
value = value.replace(/\\/g, '');
mustUnescape = false;
}
push(params, paramName, value);
if (code === 0x2c) {
push(offers, extensionName, params);
params = Object.create(null);
extensionName = undefined;
}
paramName = undefined;
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
}
}
if (start === -1 || inQuotes || code === 0x20 || code === 0x09) {
throw new SyntaxError('Unexpected end of input');
}
if (end === -1) end = i;
const token = header.slice(start, end);
if (extensionName === undefined) {
push(offers, token, params);
} else {
if (paramName === undefined) {
push(params, token, true);
} else if (mustUnescape) {
push(params, paramName, token.replace(/\\/g, ''));
} else {
push(params, paramName, token);
}
push(offers, extensionName, params);
}
return offers;
}
/**
* Builds the `Sec-WebSocket-Extensions` header field value.
*
* @param {Object} extensions The map of extensions and parameters to format
* @return {String} A string representing the given object
* @public
*/
function format(extensions) {
return Object.keys(extensions)
.map((extension) => {
let configurations = extensions[extension];
if (!Array.isArray(configurations)) configurations = [configurations];
return configurations
.map((params) => {
return [extension]
.concat(
Object.keys(params).map((k) => {
let values = params[k];
if (!Array.isArray(values)) values = [values];
return values
.map((v) => (v === true ? k : `${k}=${v}`))
.join('; ');
})
)
.join('; ');
})
.join(', ');
})
.join(', ');
}
module.exports = { format, parse };

55
test/node_modules/ws/lib/limiter.js generated vendored
View File

@@ -1,55 +0,0 @@
'use strict';
const kDone = Symbol('kDone');
const kRun = Symbol('kRun');
/**
* A very simple job queue with adjustable concurrency. Adapted from
* https://github.com/STRML/async-limiter
*/
class Limiter {
/**
* Creates a new `Limiter`.
*
* @param {Number} [concurrency=Infinity] The maximum number of jobs allowed
* to run concurrently
*/
constructor(concurrency) {
this[kDone] = () => {
this.pending--;
this[kRun]();
};
this.concurrency = concurrency || Infinity;
this.jobs = [];
this.pending = 0;
}
/**
* Adds a job to the queue.
*
* @param {Function} job The job to run
* @public
*/
add(job) {
this.jobs.push(job);
this[kRun]();
}
/**
* Removes a job from the queue and runs it if possible.
*
* @private
*/
[kRun]() {
if (this.pending === this.concurrency) return;
if (this.jobs.length) {
const job = this.jobs.shift();
this.pending++;
job(this[kDone]);
}
}
}
module.exports = Limiter;

View File

@@ -1,528 +0,0 @@
'use strict';
const zlib = require('zlib');
const bufferUtil = require('./buffer-util');
const Limiter = require('./limiter');
const { kStatusCode } = require('./constants');
const FastBuffer = Buffer[Symbol.species];
const TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]);
const kPerMessageDeflate = Symbol('permessage-deflate');
const kTotalLength = Symbol('total-length');
const kCallback = Symbol('callback');
const kBuffers = Symbol('buffers');
const kError = Symbol('error');
//
// We limit zlib concurrency, which prevents severe memory fragmentation
// as documented in https://github.com/nodejs/node/issues/8871#issuecomment-250915913
// and https://github.com/websockets/ws/issues/1202
//
// Intentionally global; it's the global thread pool that's an issue.
//
let zlibLimiter;
/**
* permessage-deflate implementation.
*/
class PerMessageDeflate {
/**
* Creates a PerMessageDeflate instance.
*
* @param {Object} [options] Configuration options
* @param {(Boolean|Number)} [options.clientMaxWindowBits] Advertise support
* for, or request, a custom client window size
* @param {Boolean} [options.clientNoContextTakeover=false] Advertise/
* acknowledge disabling of client context takeover
* @param {Number} [options.concurrencyLimit=10] The number of concurrent
* calls to zlib
* @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the
* use of a custom server window size
* @param {Boolean} [options.serverNoContextTakeover=false] Request/accept
* disabling of server context takeover
* @param {Number} [options.threshold=1024] Size (in bytes) below which
* messages should not be compressed if context takeover is disabled
* @param {Object} [options.zlibDeflateOptions] Options to pass to zlib on
* deflate
* @param {Object} [options.zlibInflateOptions] Options to pass to zlib on
* inflate
* @param {Boolean} [isServer=false] Create the instance in either server or
* client mode
* @param {Number} [maxPayload=0] The maximum allowed message length
*/
constructor(options, isServer, maxPayload) {
this._maxPayload = maxPayload | 0;
this._options = options || {};
this._threshold =
this._options.threshold !== undefined ? this._options.threshold : 1024;
this._isServer = !!isServer;
this._deflate = null;
this._inflate = null;
this.params = null;
if (!zlibLimiter) {
const concurrency =
this._options.concurrencyLimit !== undefined
? this._options.concurrencyLimit
: 10;
zlibLimiter = new Limiter(concurrency);
}
}
/**
* @type {String}
*/
static get extensionName() {
return 'permessage-deflate';
}
/**
* Create an extension negotiation offer.
*
* @return {Object} Extension parameters
* @public
*/
offer() {
const params = {};
if (this._options.serverNoContextTakeover) {
params.server_no_context_takeover = true;
}
if (this._options.clientNoContextTakeover) {
params.client_no_context_takeover = true;
}
if (this._options.serverMaxWindowBits) {
params.server_max_window_bits = this._options.serverMaxWindowBits;
}
if (this._options.clientMaxWindowBits) {
params.client_max_window_bits = this._options.clientMaxWindowBits;
} else if (this._options.clientMaxWindowBits == null) {
params.client_max_window_bits = true;
}
return params;
}
/**
* Accept an extension negotiation offer/response.
*
* @param {Array} configurations The extension negotiation offers/reponse
* @return {Object} Accepted configuration
* @public
*/
accept(configurations) {
configurations = this.normalizeParams(configurations);
this.params = this._isServer
? this.acceptAsServer(configurations)
: this.acceptAsClient(configurations);
return this.params;
}
/**
* Releases all resources used by the extension.
*
* @public
*/
cleanup() {
if (this._inflate) {
this._inflate.close();
this._inflate = null;
}
if (this._deflate) {
const callback = this._deflate[kCallback];
this._deflate.close();
this._deflate = null;
if (callback) {
callback(
new Error(
'The deflate stream was closed while data was being processed'
)
);
}
}
}
/**
* Accept an extension negotiation offer.
*
* @param {Array} offers The extension negotiation offers
* @return {Object} Accepted configuration
* @private
*/
acceptAsServer(offers) {
const opts = this._options;
const accepted = offers.find((params) => {
if (
(opts.serverNoContextTakeover === false &&
params.server_no_context_takeover) ||
(params.server_max_window_bits &&
(opts.serverMaxWindowBits === false ||
(typeof opts.serverMaxWindowBits === 'number' &&
opts.serverMaxWindowBits > params.server_max_window_bits))) ||
(typeof opts.clientMaxWindowBits === 'number' &&
!params.client_max_window_bits)
) {
return false;
}
return true;
});
if (!accepted) {
throw new Error('None of the extension offers can be accepted');
}
if (opts.serverNoContextTakeover) {
accepted.server_no_context_takeover = true;
}
if (opts.clientNoContextTakeover) {
accepted.client_no_context_takeover = true;
}
if (typeof opts.serverMaxWindowBits === 'number') {
accepted.server_max_window_bits = opts.serverMaxWindowBits;
}
if (typeof opts.clientMaxWindowBits === 'number') {
accepted.client_max_window_bits = opts.clientMaxWindowBits;
} else if (
accepted.client_max_window_bits === true ||
opts.clientMaxWindowBits === false
) {
delete accepted.client_max_window_bits;
}
return accepted;
}
/**
* Accept the extension negotiation response.
*
* @param {Array} response The extension negotiation response
* @return {Object} Accepted configuration
* @private
*/
acceptAsClient(response) {
const params = response[0];
if (
this._options.clientNoContextTakeover === false &&
params.client_no_context_takeover
) {
throw new Error('Unexpected parameter "client_no_context_takeover"');
}
if (!params.client_max_window_bits) {
if (typeof this._options.clientMaxWindowBits === 'number') {
params.client_max_window_bits = this._options.clientMaxWindowBits;
}
} else if (
this._options.clientMaxWindowBits === false ||
(typeof this._options.clientMaxWindowBits === 'number' &&
params.client_max_window_bits > this._options.clientMaxWindowBits)
) {
throw new Error(
'Unexpected or invalid parameter "client_max_window_bits"'
);
}
return params;
}
/**
* Normalize parameters.
*
* @param {Array} configurations The extension negotiation offers/reponse
* @return {Array} The offers/response with normalized parameters
* @private
*/
normalizeParams(configurations) {
configurations.forEach((params) => {
Object.keys(params).forEach((key) => {
let value = params[key];
if (value.length > 1) {
throw new Error(`Parameter "${key}" must have only a single value`);
}
value = value[0];
if (key === 'client_max_window_bits') {
if (value !== true) {
const num = +value;
if (!Number.isInteger(num) || num < 8 || num > 15) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
value = num;
} else if (!this._isServer) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
} else if (key === 'server_max_window_bits') {
const num = +value;
if (!Number.isInteger(num) || num < 8 || num > 15) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
value = num;
} else if (
key === 'client_no_context_takeover' ||
key === 'server_no_context_takeover'
) {
if (value !== true) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
} else {
throw new Error(`Unknown parameter "${key}"`);
}
params[key] = value;
});
});
return configurations;
}
/**
* Decompress data. Concurrency limited.
*
* @param {Buffer} data Compressed data
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @public
*/
decompress(data, fin, callback) {
zlibLimiter.add((done) => {
this._decompress(data, fin, (err, result) => {
done();
callback(err, result);
});
});
}
/**
* Compress data. Concurrency limited.
*
* @param {(Buffer|String)} data Data to compress
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @public
*/
compress(data, fin, callback) {
zlibLimiter.add((done) => {
this._compress(data, fin, (err, result) => {
done();
callback(err, result);
});
});
}
/**
* Decompress data.
*
* @param {Buffer} data Compressed data
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @private
*/
_decompress(data, fin, callback) {
const endpoint = this._isServer ? 'client' : 'server';
if (!this._inflate) {
const key = `${endpoint}_max_window_bits`;
const windowBits =
typeof this.params[key] !== 'number'
? zlib.Z_DEFAULT_WINDOWBITS
: this.params[key];
this._inflate = zlib.createInflateRaw({
...this._options.zlibInflateOptions,
windowBits
});
this._inflate[kPerMessageDeflate] = this;
this._inflate[kTotalLength] = 0;
this._inflate[kBuffers] = [];
this._inflate.on('error', inflateOnError);
this._inflate.on('data', inflateOnData);
}
this._inflate[kCallback] = callback;
this._inflate.write(data);
if (fin) this._inflate.write(TRAILER);
this._inflate.flush(() => {
const err = this._inflate[kError];
if (err) {
this._inflate.close();
this._inflate = null;
callback(err);
return;
}
const data = bufferUtil.concat(
this._inflate[kBuffers],
this._inflate[kTotalLength]
);
if (this._inflate._readableState.endEmitted) {
this._inflate.close();
this._inflate = null;
} else {
this._inflate[kTotalLength] = 0;
this._inflate[kBuffers] = [];
if (fin && this.params[`${endpoint}_no_context_takeover`]) {
this._inflate.reset();
}
}
callback(null, data);
});
}
/**
* Compress data.
*
* @param {(Buffer|String)} data Data to compress
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @private
*/
_compress(data, fin, callback) {
const endpoint = this._isServer ? 'server' : 'client';
if (!this._deflate) {
const key = `${endpoint}_max_window_bits`;
const windowBits =
typeof this.params[key] !== 'number'
? zlib.Z_DEFAULT_WINDOWBITS
: this.params[key];
this._deflate = zlib.createDeflateRaw({
...this._options.zlibDeflateOptions,
windowBits
});
this._deflate[kTotalLength] = 0;
this._deflate[kBuffers] = [];
this._deflate.on('data', deflateOnData);
}
this._deflate[kCallback] = callback;
this._deflate.write(data);
this._deflate.flush(zlib.Z_SYNC_FLUSH, () => {
if (!this._deflate) {
//
// The deflate stream was closed while data was being processed.
//
return;
}
let data = bufferUtil.concat(
this._deflate[kBuffers],
this._deflate[kTotalLength]
);
if (fin) {
data = new FastBuffer(data.buffer, data.byteOffset, data.length - 4);
}
//
// Ensure that the callback will not be called again in
// `PerMessageDeflate#cleanup()`.
//
this._deflate[kCallback] = null;
this._deflate[kTotalLength] = 0;
this._deflate[kBuffers] = [];
if (fin && this.params[`${endpoint}_no_context_takeover`]) {
this._deflate.reset();
}
callback(null, data);
});
}
}
module.exports = PerMessageDeflate;
/**
* The listener of the `zlib.DeflateRaw` stream `'data'` event.
*
* @param {Buffer} chunk A chunk of data
* @private
*/
function deflateOnData(chunk) {
this[kBuffers].push(chunk);
this[kTotalLength] += chunk.length;
}
/**
* The listener of the `zlib.InflateRaw` stream `'data'` event.
*
* @param {Buffer} chunk A chunk of data
* @private
*/
function inflateOnData(chunk) {
this[kTotalLength] += chunk.length;
if (
this[kPerMessageDeflate]._maxPayload < 1 ||
this[kTotalLength] <= this[kPerMessageDeflate]._maxPayload
) {
this[kBuffers].push(chunk);
return;
}
this[kError] = new RangeError('Max payload size exceeded');
this[kError].code = 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH';
this[kError][kStatusCode] = 1009;
this.removeListener('data', inflateOnData);
//
// The choice to employ `zlib.reset()` over `zlib.close()` is dictated by the
// fact that in Node.js versions prior to 13.10.0, the callback for
// `zlib.flush()` is not called if `zlib.close()` is used. Utilizing
// `zlib.reset()` ensures that either the callback is invoked or an error is
// emitted.
//
this.reset();
}
/**
* The listener of the `zlib.InflateRaw` stream `'error'` event.
*
* @param {Error} err The emitted error
* @private
*/
function inflateOnError(err) {
//
// There is no need to call `Zlib#close()` as the handle is automatically
// closed when an error is emitted.
//
this[kPerMessageDeflate]._inflate = null;
if (this[kError]) {
this[kCallback](this[kError]);
return;
}
err[kStatusCode] = 1007;
this[kCallback](err);
}

706
test/node_modules/ws/lib/receiver.js generated vendored
View File

@@ -1,706 +0,0 @@
'use strict';
const { Writable } = require('stream');
const PerMessageDeflate = require('./permessage-deflate');
const {
BINARY_TYPES,
EMPTY_BUFFER,
kStatusCode,
kWebSocket
} = require('./constants');
const { concat, toArrayBuffer, unmask } = require('./buffer-util');
const { isValidStatusCode, isValidUTF8 } = require('./validation');
const FastBuffer = Buffer[Symbol.species];
const GET_INFO = 0;
const GET_PAYLOAD_LENGTH_16 = 1;
const GET_PAYLOAD_LENGTH_64 = 2;
const GET_MASK = 3;
const GET_DATA = 4;
const INFLATING = 5;
const DEFER_EVENT = 6;
/**
* HyBi Receiver implementation.
*
* @extends Writable
*/
class Receiver extends Writable {
/**
* Creates a Receiver instance.
*
* @param {Object} [options] Options object
* @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether
* any of the `'message'`, `'ping'`, and `'pong'` events can be emitted
* multiple times in the same tick
* @param {String} [options.binaryType=nodebuffer] The type for binary data
* @param {Object} [options.extensions] An object containing the negotiated
* extensions
* @param {Boolean} [options.isServer=false] Specifies whether to operate in
* client or server mode
* @param {Number} [options.maxPayload=0] The maximum allowed message length
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
* not to skip UTF-8 validation for text and close messages
*/
constructor(options = {}) {
super();
this._allowSynchronousEvents =
options.allowSynchronousEvents !== undefined
? options.allowSynchronousEvents
: true;
this._binaryType = options.binaryType || BINARY_TYPES[0];
this._extensions = options.extensions || {};
this._isServer = !!options.isServer;
this._maxPayload = options.maxPayload | 0;
this._skipUTF8Validation = !!options.skipUTF8Validation;
this[kWebSocket] = undefined;
this._bufferedBytes = 0;
this._buffers = [];
this._compressed = false;
this._payloadLength = 0;
this._mask = undefined;
this._fragmented = 0;
this._masked = false;
this._fin = false;
this._opcode = 0;
this._totalPayloadLength = 0;
this._messageLength = 0;
this._fragments = [];
this._errored = false;
this._loop = false;
this._state = GET_INFO;
}
/**
* Implements `Writable.prototype._write()`.
*
* @param {Buffer} chunk The chunk of data to write
* @param {String} encoding The character encoding of `chunk`
* @param {Function} cb Callback
* @private
*/
_write(chunk, encoding, cb) {
if (this._opcode === 0x08 && this._state == GET_INFO) return cb();
this._bufferedBytes += chunk.length;
this._buffers.push(chunk);
this.startLoop(cb);
}
/**
* Consumes `n` bytes from the buffered data.
*
* @param {Number} n The number of bytes to consume
* @return {Buffer} The consumed bytes
* @private
*/
consume(n) {
this._bufferedBytes -= n;
if (n === this._buffers[0].length) return this._buffers.shift();
if (n < this._buffers[0].length) {
const buf = this._buffers[0];
this._buffers[0] = new FastBuffer(
buf.buffer,
buf.byteOffset + n,
buf.length - n
);
return new FastBuffer(buf.buffer, buf.byteOffset, n);
}
const dst = Buffer.allocUnsafe(n);
do {
const buf = this._buffers[0];
const offset = dst.length - n;
if (n >= buf.length) {
dst.set(this._buffers.shift(), offset);
} else {
dst.set(new Uint8Array(buf.buffer, buf.byteOffset, n), offset);
this._buffers[0] = new FastBuffer(
buf.buffer,
buf.byteOffset + n,
buf.length - n
);
}
n -= buf.length;
} while (n > 0);
return dst;
}
/**
* Starts the parsing loop.
*
* @param {Function} cb Callback
* @private
*/
startLoop(cb) {
this._loop = true;
do {
switch (this._state) {
case GET_INFO:
this.getInfo(cb);
break;
case GET_PAYLOAD_LENGTH_16:
this.getPayloadLength16(cb);
break;
case GET_PAYLOAD_LENGTH_64:
this.getPayloadLength64(cb);
break;
case GET_MASK:
this.getMask();
break;
case GET_DATA:
this.getData(cb);
break;
case INFLATING:
case DEFER_EVENT:
this._loop = false;
return;
}
} while (this._loop);
if (!this._errored) cb();
}
/**
* Reads the first two bytes of a frame.
*
* @param {Function} cb Callback
* @private
*/
getInfo(cb) {
if (this._bufferedBytes < 2) {
this._loop = false;
return;
}
const buf = this.consume(2);
if ((buf[0] & 0x30) !== 0x00) {
const error = this.createError(
RangeError,
'RSV2 and RSV3 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_2_3'
);
cb(error);
return;
}
const compressed = (buf[0] & 0x40) === 0x40;
if (compressed && !this._extensions[PerMessageDeflate.extensionName]) {
const error = this.createError(
RangeError,
'RSV1 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_1'
);
cb(error);
return;
}
this._fin = (buf[0] & 0x80) === 0x80;
this._opcode = buf[0] & 0x0f;
this._payloadLength = buf[1] & 0x7f;
if (this._opcode === 0x00) {
if (compressed) {
const error = this.createError(
RangeError,
'RSV1 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_1'
);
cb(error);
return;
}
if (!this._fragmented) {
const error = this.createError(
RangeError,
'invalid opcode 0',
true,
1002,
'WS_ERR_INVALID_OPCODE'
);
cb(error);
return;
}
this._opcode = this._fragmented;
} else if (this._opcode === 0x01 || this._opcode === 0x02) {
if (this._fragmented) {
const error = this.createError(
RangeError,
`invalid opcode ${this._opcode}`,
true,
1002,
'WS_ERR_INVALID_OPCODE'
);
cb(error);
return;
}
this._compressed = compressed;
} else if (this._opcode > 0x07 && this._opcode < 0x0b) {
if (!this._fin) {
const error = this.createError(
RangeError,
'FIN must be set',
true,
1002,
'WS_ERR_EXPECTED_FIN'
);
cb(error);
return;
}
if (compressed) {
const error = this.createError(
RangeError,
'RSV1 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_1'
);
cb(error);
return;
}
if (
this._payloadLength > 0x7d ||
(this._opcode === 0x08 && this._payloadLength === 1)
) {
const error = this.createError(
RangeError,
`invalid payload length ${this._payloadLength}`,
true,
1002,
'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH'
);
cb(error);
return;
}
} else {
const error = this.createError(
RangeError,
`invalid opcode ${this._opcode}`,
true,
1002,
'WS_ERR_INVALID_OPCODE'
);
cb(error);
return;
}
if (!this._fin && !this._fragmented) this._fragmented = this._opcode;
this._masked = (buf[1] & 0x80) === 0x80;
if (this._isServer) {
if (!this._masked) {
const error = this.createError(
RangeError,
'MASK must be set',
true,
1002,
'WS_ERR_EXPECTED_MASK'
);
cb(error);
return;
}
} else if (this._masked) {
const error = this.createError(
RangeError,
'MASK must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_MASK'
);
cb(error);
return;
}
if (this._payloadLength === 126) this._state = GET_PAYLOAD_LENGTH_16;
else if (this._payloadLength === 127) this._state = GET_PAYLOAD_LENGTH_64;
else this.haveLength(cb);
}
/**
* Gets extended payload length (7+16).
*
* @param {Function} cb Callback
* @private
*/
getPayloadLength16(cb) {
if (this._bufferedBytes < 2) {
this._loop = false;
return;
}
this._payloadLength = this.consume(2).readUInt16BE(0);
this.haveLength(cb);
}
/**
* Gets extended payload length (7+64).
*
* @param {Function} cb Callback
* @private
*/
getPayloadLength64(cb) {
if (this._bufferedBytes < 8) {
this._loop = false;
return;
}
const buf = this.consume(8);
const num = buf.readUInt32BE(0);
//
// The maximum safe integer in JavaScript is 2^53 - 1. An error is returned
// if payload length is greater than this number.
//
if (num > Math.pow(2, 53 - 32) - 1) {
const error = this.createError(
RangeError,
'Unsupported WebSocket frame: payload length > 2^53 - 1',
false,
1009,
'WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH'
);
cb(error);
return;
}
this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4);
this.haveLength(cb);
}
/**
* Payload length has been read.
*
* @param {Function} cb Callback
* @private
*/
haveLength(cb) {
if (this._payloadLength && this._opcode < 0x08) {
this._totalPayloadLength += this._payloadLength;
if (this._totalPayloadLength > this._maxPayload && this._maxPayload > 0) {
const error = this.createError(
RangeError,
'Max payload size exceeded',
false,
1009,
'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'
);
cb(error);
return;
}
}
if (this._masked) this._state = GET_MASK;
else this._state = GET_DATA;
}
/**
* Reads mask bytes.
*
* @private
*/
getMask() {
if (this._bufferedBytes < 4) {
this._loop = false;
return;
}
this._mask = this.consume(4);
this._state = GET_DATA;
}
/**
* Reads data bytes.
*
* @param {Function} cb Callback
* @private
*/
getData(cb) {
let data = EMPTY_BUFFER;
if (this._payloadLength) {
if (this._bufferedBytes < this._payloadLength) {
this._loop = false;
return;
}
data = this.consume(this._payloadLength);
if (
this._masked &&
(this._mask[0] | this._mask[1] | this._mask[2] | this._mask[3]) !== 0
) {
unmask(data, this._mask);
}
}
if (this._opcode > 0x07) {
this.controlMessage(data, cb);
return;
}
if (this._compressed) {
this._state = INFLATING;
this.decompress(data, cb);
return;
}
if (data.length) {
//
// This message is not compressed so its length is the sum of the payload
// length of all fragments.
//
this._messageLength = this._totalPayloadLength;
this._fragments.push(data);
}
this.dataMessage(cb);
}
/**
* Decompresses data.
*
* @param {Buffer} data Compressed data
* @param {Function} cb Callback
* @private
*/
decompress(data, cb) {
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
perMessageDeflate.decompress(data, this._fin, (err, buf) => {
if (err) return cb(err);
if (buf.length) {
this._messageLength += buf.length;
if (this._messageLength > this._maxPayload && this._maxPayload > 0) {
const error = this.createError(
RangeError,
'Max payload size exceeded',
false,
1009,
'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'
);
cb(error);
return;
}
this._fragments.push(buf);
}
this.dataMessage(cb);
if (this._state === GET_INFO) this.startLoop(cb);
});
}
/**
* Handles a data message.
*
* @param {Function} cb Callback
* @private
*/
dataMessage(cb) {
if (!this._fin) {
this._state = GET_INFO;
return;
}
const messageLength = this._messageLength;
const fragments = this._fragments;
this._totalPayloadLength = 0;
this._messageLength = 0;
this._fragmented = 0;
this._fragments = [];
if (this._opcode === 2) {
let data;
if (this._binaryType === 'nodebuffer') {
data = concat(fragments, messageLength);
} else if (this._binaryType === 'arraybuffer') {
data = toArrayBuffer(concat(fragments, messageLength));
} else if (this._binaryType === 'blob') {
data = new Blob(fragments);
} else {
data = fragments;
}
if (this._allowSynchronousEvents) {
this.emit('message', data, true);
this._state = GET_INFO;
} else {
this._state = DEFER_EVENT;
setImmediate(() => {
this.emit('message', data, true);
this._state = GET_INFO;
this.startLoop(cb);
});
}
} else {
const buf = concat(fragments, messageLength);
if (!this._skipUTF8Validation && !isValidUTF8(buf)) {
const error = this.createError(
Error,
'invalid UTF-8 sequence',
true,
1007,
'WS_ERR_INVALID_UTF8'
);
cb(error);
return;
}
if (this._state === INFLATING || this._allowSynchronousEvents) {
this.emit('message', buf, false);
this._state = GET_INFO;
} else {
this._state = DEFER_EVENT;
setImmediate(() => {
this.emit('message', buf, false);
this._state = GET_INFO;
this.startLoop(cb);
});
}
}
}
/**
* Handles a control message.
*
* @param {Buffer} data Data to handle
* @return {(Error|RangeError|undefined)} A possible error
* @private
*/
controlMessage(data, cb) {
if (this._opcode === 0x08) {
if (data.length === 0) {
this._loop = false;
this.emit('conclude', 1005, EMPTY_BUFFER);
this.end();
} else {
const code = data.readUInt16BE(0);
if (!isValidStatusCode(code)) {
const error = this.createError(
RangeError,
`invalid status code ${code}`,
true,
1002,
'WS_ERR_INVALID_CLOSE_CODE'
);
cb(error);
return;
}
const buf = new FastBuffer(
data.buffer,
data.byteOffset + 2,
data.length - 2
);
if (!this._skipUTF8Validation && !isValidUTF8(buf)) {
const error = this.createError(
Error,
'invalid UTF-8 sequence',
true,
1007,
'WS_ERR_INVALID_UTF8'
);
cb(error);
return;
}
this._loop = false;
this.emit('conclude', code, buf);
this.end();
}
this._state = GET_INFO;
return;
}
if (this._allowSynchronousEvents) {
this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data);
this._state = GET_INFO;
} else {
this._state = DEFER_EVENT;
setImmediate(() => {
this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data);
this._state = GET_INFO;
this.startLoop(cb);
});
}
}
/**
* Builds an error object.
*
* @param {function(new:Error|RangeError)} ErrorCtor The error constructor
* @param {String} message The error message
* @param {Boolean} prefix Specifies whether or not to add a default prefix to
* `message`
* @param {Number} statusCode The status code
* @param {String} errorCode The exposed error code
* @return {(Error|RangeError)} The error
* @private
*/
createError(ErrorCtor, message, prefix, statusCode, errorCode) {
this._loop = false;
this._errored = true;
const err = new ErrorCtor(
prefix ? `Invalid WebSocket frame: ${message}` : message
);
Error.captureStackTrace(err, this.createError);
err.code = errorCode;
err[kStatusCode] = statusCode;
return err;
}
}
module.exports = Receiver;

602
test/node_modules/ws/lib/sender.js generated vendored
View File

@@ -1,602 +0,0 @@
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex" }] */
'use strict';
const { Duplex } = require('stream');
const { randomFillSync } = require('crypto');
const PerMessageDeflate = require('./permessage-deflate');
const { EMPTY_BUFFER, kWebSocket, NOOP } = require('./constants');
const { isBlob, isValidStatusCode } = require('./validation');
const { mask: applyMask, toBuffer } = require('./buffer-util');
const kByteLength = Symbol('kByteLength');
const maskBuffer = Buffer.alloc(4);
const RANDOM_POOL_SIZE = 8 * 1024;
let randomPool;
let randomPoolPointer = RANDOM_POOL_SIZE;
const DEFAULT = 0;
const DEFLATING = 1;
const GET_BLOB_DATA = 2;
/**
* HyBi Sender implementation.
*/
class Sender {
/**
* Creates a Sender instance.
*
* @param {Duplex} socket The connection socket
* @param {Object} [extensions] An object containing the negotiated extensions
* @param {Function} [generateMask] The function used to generate the masking
* key
*/
constructor(socket, extensions, generateMask) {
this._extensions = extensions || {};
if (generateMask) {
this._generateMask = generateMask;
this._maskBuffer = Buffer.alloc(4);
}
this._socket = socket;
this._firstFragment = true;
this._compress = false;
this._bufferedBytes = 0;
this._queue = [];
this._state = DEFAULT;
this.onerror = NOOP;
this[kWebSocket] = undefined;
}
/**
* Frames a piece of data according to the HyBi WebSocket protocol.
*
* @param {(Buffer|String)} data The data to frame
* @param {Object} options Options object
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
* FIN bit
* @param {Function} [options.generateMask] The function used to generate the
* masking key
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Buffer} [options.maskBuffer] The buffer used to store the masking
* key
* @param {Number} options.opcode The opcode
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
* modified
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
* RSV1 bit
* @return {(Buffer|String)[]} The framed data
* @public
*/
static frame(data, options) {
let mask;
let merge = false;
let offset = 2;
let skipMasking = false;
if (options.mask) {
mask = options.maskBuffer || maskBuffer;
if (options.generateMask) {
options.generateMask(mask);
} else {
if (randomPoolPointer === RANDOM_POOL_SIZE) {
/* istanbul ignore else */
if (randomPool === undefined) {
//
// This is lazily initialized because server-sent frames must not
// be masked so it may never be used.
//
randomPool = Buffer.alloc(RANDOM_POOL_SIZE);
}
randomFillSync(randomPool, 0, RANDOM_POOL_SIZE);
randomPoolPointer = 0;
}
mask[0] = randomPool[randomPoolPointer++];
mask[1] = randomPool[randomPoolPointer++];
mask[2] = randomPool[randomPoolPointer++];
mask[3] = randomPool[randomPoolPointer++];
}
skipMasking = (mask[0] | mask[1] | mask[2] | mask[3]) === 0;
offset = 6;
}
let dataLength;
if (typeof data === 'string') {
if (
(!options.mask || skipMasking) &&
options[kByteLength] !== undefined
) {
dataLength = options[kByteLength];
} else {
data = Buffer.from(data);
dataLength = data.length;
}
} else {
dataLength = data.length;
merge = options.mask && options.readOnly && !skipMasking;
}
let payloadLength = dataLength;
if (dataLength >= 65536) {
offset += 8;
payloadLength = 127;
} else if (dataLength > 125) {
offset += 2;
payloadLength = 126;
}
const target = Buffer.allocUnsafe(merge ? dataLength + offset : offset);
target[0] = options.fin ? options.opcode | 0x80 : options.opcode;
if (options.rsv1) target[0] |= 0x40;
target[1] = payloadLength;
if (payloadLength === 126) {
target.writeUInt16BE(dataLength, 2);
} else if (payloadLength === 127) {
target[2] = target[3] = 0;
target.writeUIntBE(dataLength, 4, 6);
}
if (!options.mask) return [target, data];
target[1] |= 0x80;
target[offset - 4] = mask[0];
target[offset - 3] = mask[1];
target[offset - 2] = mask[2];
target[offset - 1] = mask[3];
if (skipMasking) return [target, data];
if (merge) {
applyMask(data, mask, target, offset, dataLength);
return [target];
}
applyMask(data, mask, data, 0, dataLength);
return [target, data];
}
/**
* Sends a close message to the other peer.
*
* @param {Number} [code] The status code component of the body
* @param {(String|Buffer)} [data] The message component of the body
* @param {Boolean} [mask=false] Specifies whether or not to mask the message
* @param {Function} [cb] Callback
* @public
*/
close(code, data, mask, cb) {
let buf;
if (code === undefined) {
buf = EMPTY_BUFFER;
} else if (typeof code !== 'number' || !isValidStatusCode(code)) {
throw new TypeError('First argument must be a valid error code number');
} else if (data === undefined || !data.length) {
buf = Buffer.allocUnsafe(2);
buf.writeUInt16BE(code, 0);
} else {
const length = Buffer.byteLength(data);
if (length > 123) {
throw new RangeError('The message must not be greater than 123 bytes');
}
buf = Buffer.allocUnsafe(2 + length);
buf.writeUInt16BE(code, 0);
if (typeof data === 'string') {
buf.write(data, 2);
} else {
buf.set(data, 2);
}
}
const options = {
[kByteLength]: buf.length,
fin: true,
generateMask: this._generateMask,
mask,
maskBuffer: this._maskBuffer,
opcode: 0x08,
readOnly: false,
rsv1: false
};
if (this._state !== DEFAULT) {
this.enqueue([this.dispatch, buf, false, options, cb]);
} else {
this.sendFrame(Sender.frame(buf, options), cb);
}
}
/**
* Sends a ping message to the other peer.
*
* @param {*} data The message to send
* @param {Boolean} [mask=false] Specifies whether or not to mask `data`
* @param {Function} [cb] Callback
* @public
*/
ping(data, mask, cb) {
let byteLength;
let readOnly;
if (typeof data === 'string') {
byteLength = Buffer.byteLength(data);
readOnly = false;
} else if (isBlob(data)) {
byteLength = data.size;
readOnly = false;
} else {
data = toBuffer(data);
byteLength = data.length;
readOnly = toBuffer.readOnly;
}
if (byteLength > 125) {
throw new RangeError('The data size must not be greater than 125 bytes');
}
const options = {
[kByteLength]: byteLength,
fin: true,
generateMask: this._generateMask,
mask,
maskBuffer: this._maskBuffer,
opcode: 0x09,
readOnly,
rsv1: false
};
if (isBlob(data)) {
if (this._state !== DEFAULT) {
this.enqueue([this.getBlobData, data, false, options, cb]);
} else {
this.getBlobData(data, false, options, cb);
}
} else if (this._state !== DEFAULT) {
this.enqueue([this.dispatch, data, false, options, cb]);
} else {
this.sendFrame(Sender.frame(data, options), cb);
}
}
/**
* Sends a pong message to the other peer.
*
* @param {*} data The message to send
* @param {Boolean} [mask=false] Specifies whether or not to mask `data`
* @param {Function} [cb] Callback
* @public
*/
pong(data, mask, cb) {
let byteLength;
let readOnly;
if (typeof data === 'string') {
byteLength = Buffer.byteLength(data);
readOnly = false;
} else if (isBlob(data)) {
byteLength = data.size;
readOnly = false;
} else {
data = toBuffer(data);
byteLength = data.length;
readOnly = toBuffer.readOnly;
}
if (byteLength > 125) {
throw new RangeError('The data size must not be greater than 125 bytes');
}
const options = {
[kByteLength]: byteLength,
fin: true,
generateMask: this._generateMask,
mask,
maskBuffer: this._maskBuffer,
opcode: 0x0a,
readOnly,
rsv1: false
};
if (isBlob(data)) {
if (this._state !== DEFAULT) {
this.enqueue([this.getBlobData, data, false, options, cb]);
} else {
this.getBlobData(data, false, options, cb);
}
} else if (this._state !== DEFAULT) {
this.enqueue([this.dispatch, data, false, options, cb]);
} else {
this.sendFrame(Sender.frame(data, options), cb);
}
}
/**
* Sends a data message to the other peer.
*
* @param {*} data The message to send
* @param {Object} options Options object
* @param {Boolean} [options.binary=false] Specifies whether `data` is binary
* or text
* @param {Boolean} [options.compress=false] Specifies whether or not to
* compress `data`
* @param {Boolean} [options.fin=false] Specifies whether the fragment is the
* last one
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Function} [cb] Callback
* @public
*/
send(data, options, cb) {
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
let opcode = options.binary ? 2 : 1;
let rsv1 = options.compress;
let byteLength;
let readOnly;
if (typeof data === 'string') {
byteLength = Buffer.byteLength(data);
readOnly = false;
} else if (isBlob(data)) {
byteLength = data.size;
readOnly = false;
} else {
data = toBuffer(data);
byteLength = data.length;
readOnly = toBuffer.readOnly;
}
if (this._firstFragment) {
this._firstFragment = false;
if (
rsv1 &&
perMessageDeflate &&
perMessageDeflate.params[
perMessageDeflate._isServer
? 'server_no_context_takeover'
: 'client_no_context_takeover'
]
) {
rsv1 = byteLength >= perMessageDeflate._threshold;
}
this._compress = rsv1;
} else {
rsv1 = false;
opcode = 0;
}
if (options.fin) this._firstFragment = true;
const opts = {
[kByteLength]: byteLength,
fin: options.fin,
generateMask: this._generateMask,
mask: options.mask,
maskBuffer: this._maskBuffer,
opcode,
readOnly,
rsv1
};
if (isBlob(data)) {
if (this._state !== DEFAULT) {
this.enqueue([this.getBlobData, data, this._compress, opts, cb]);
} else {
this.getBlobData(data, this._compress, opts, cb);
}
} else if (this._state !== DEFAULT) {
this.enqueue([this.dispatch, data, this._compress, opts, cb]);
} else {
this.dispatch(data, this._compress, opts, cb);
}
}
/**
* Gets the contents of a blob as binary data.
*
* @param {Blob} blob The blob
* @param {Boolean} [compress=false] Specifies whether or not to compress
* the data
* @param {Object} options Options object
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
* FIN bit
* @param {Function} [options.generateMask] The function used to generate the
* masking key
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Buffer} [options.maskBuffer] The buffer used to store the masking
* key
* @param {Number} options.opcode The opcode
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
* modified
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
* RSV1 bit
* @param {Function} [cb] Callback
* @private
*/
getBlobData(blob, compress, options, cb) {
this._bufferedBytes += options[kByteLength];
this._state = GET_BLOB_DATA;
blob
.arrayBuffer()
.then((arrayBuffer) => {
if (this._socket.destroyed) {
const err = new Error(
'The socket was closed while the blob was being read'
);
//
// `callCallbacks` is called in the next tick to ensure that errors
// that might be thrown in the callbacks behave like errors thrown
// outside the promise chain.
//
process.nextTick(callCallbacks, this, err, cb);
return;
}
this._bufferedBytes -= options[kByteLength];
const data = toBuffer(arrayBuffer);
if (!compress) {
this._state = DEFAULT;
this.sendFrame(Sender.frame(data, options), cb);
this.dequeue();
} else {
this.dispatch(data, compress, options, cb);
}
})
.catch((err) => {
//
// `onError` is called in the next tick for the same reason that
// `callCallbacks` above is.
//
process.nextTick(onError, this, err, cb);
});
}
/**
* Dispatches a message.
*
* @param {(Buffer|String)} data The message to send
* @param {Boolean} [compress=false] Specifies whether or not to compress
* `data`
* @param {Object} options Options object
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
* FIN bit
* @param {Function} [options.generateMask] The function used to generate the
* masking key
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Buffer} [options.maskBuffer] The buffer used to store the masking
* key
* @param {Number} options.opcode The opcode
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
* modified
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
* RSV1 bit
* @param {Function} [cb] Callback
* @private
*/
dispatch(data, compress, options, cb) {
if (!compress) {
this.sendFrame(Sender.frame(data, options), cb);
return;
}
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
this._bufferedBytes += options[kByteLength];
this._state = DEFLATING;
perMessageDeflate.compress(data, options.fin, (_, buf) => {
if (this._socket.destroyed) {
const err = new Error(
'The socket was closed while data was being compressed'
);
callCallbacks(this, err, cb);
return;
}
this._bufferedBytes -= options[kByteLength];
this._state = DEFAULT;
options.readOnly = false;
this.sendFrame(Sender.frame(buf, options), cb);
this.dequeue();
});
}
/**
* Executes queued send operations.
*
* @private
*/
dequeue() {
while (this._state === DEFAULT && this._queue.length) {
const params = this._queue.shift();
this._bufferedBytes -= params[3][kByteLength];
Reflect.apply(params[0], this, params.slice(1));
}
}
/**
* Enqueues a send operation.
*
* @param {Array} params Send operation parameters.
* @private
*/
enqueue(params) {
this._bufferedBytes += params[3][kByteLength];
this._queue.push(params);
}
/**
* Sends a frame.
*
* @param {(Buffer | String)[]} list The frame to send
* @param {Function} [cb] Callback
* @private
*/
sendFrame(list, cb) {
if (list.length === 2) {
this._socket.cork();
this._socket.write(list[0]);
this._socket.write(list[1], cb);
this._socket.uncork();
} else {
this._socket.write(list[0], cb);
}
}
}
module.exports = Sender;
/**
* Calls queued callbacks with an error.
*
* @param {Sender} sender The `Sender` instance
* @param {Error} err The error to call the callbacks with
* @param {Function} [cb] The first callback
* @private
*/
function callCallbacks(sender, err, cb) {
if (typeof cb === 'function') cb(err);
for (let i = 0; i < sender._queue.length; i++) {
const params = sender._queue[i];
const callback = params[params.length - 1];
if (typeof callback === 'function') callback(err);
}
}
/**
* Handles a `Sender` error.
*
* @param {Sender} sender The `Sender` instance
* @param {Error} err The error
* @param {Function} [cb] The first pending callback
* @private
*/
function onError(sender, err, cb) {
callCallbacks(sender, err, cb);
sender.onerror(err);
}

161
test/node_modules/ws/lib/stream.js generated vendored
View File

@@ -1,161 +0,0 @@
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^WebSocket$" }] */
'use strict';
const WebSocket = require('./websocket');
const { Duplex } = require('stream');
/**
* Emits the `'close'` event on a stream.
*
* @param {Duplex} stream The stream.
* @private
*/
function emitClose(stream) {
stream.emit('close');
}
/**
* The listener of the `'end'` event.
*
* @private
*/
function duplexOnEnd() {
if (!this.destroyed && this._writableState.finished) {
this.destroy();
}
}
/**
* The listener of the `'error'` event.
*
* @param {Error} err The error
* @private
*/
function duplexOnError(err) {
this.removeListener('error', duplexOnError);
this.destroy();
if (this.listenerCount('error') === 0) {
// Do not suppress the throwing behavior.
this.emit('error', err);
}
}
/**
* Wraps a `WebSocket` in a duplex stream.
*
* @param {WebSocket} ws The `WebSocket` to wrap
* @param {Object} [options] The options for the `Duplex` constructor
* @return {Duplex} The duplex stream
* @public
*/
function createWebSocketStream(ws, options) {
let terminateOnDestroy = true;
const duplex = new Duplex({
...options,
autoDestroy: false,
emitClose: false,
objectMode: false,
writableObjectMode: false
});
ws.on('message', function message(msg, isBinary) {
const data =
!isBinary && duplex._readableState.objectMode ? msg.toString() : msg;
if (!duplex.push(data)) ws.pause();
});
ws.once('error', function error(err) {
if (duplex.destroyed) return;
// Prevent `ws.terminate()` from being called by `duplex._destroy()`.
//
// - If the `'error'` event is emitted before the `'open'` event, then
// `ws.terminate()` is a noop as no socket is assigned.
// - Otherwise, the error is re-emitted by the listener of the `'error'`
// event of the `Receiver` object. The listener already closes the
// connection by calling `ws.close()`. This allows a close frame to be
// sent to the other peer. If `ws.terminate()` is called right after this,
// then the close frame might not be sent.
terminateOnDestroy = false;
duplex.destroy(err);
});
ws.once('close', function close() {
if (duplex.destroyed) return;
duplex.push(null);
});
duplex._destroy = function (err, callback) {
if (ws.readyState === ws.CLOSED) {
callback(err);
process.nextTick(emitClose, duplex);
return;
}
let called = false;
ws.once('error', function error(err) {
called = true;
callback(err);
});
ws.once('close', function close() {
if (!called) callback(err);
process.nextTick(emitClose, duplex);
});
if (terminateOnDestroy) ws.terminate();
};
duplex._final = function (callback) {
if (ws.readyState === ws.CONNECTING) {
ws.once('open', function open() {
duplex._final(callback);
});
return;
}
// If the value of the `_socket` property is `null` it means that `ws` is a
// client websocket and the handshake failed. In fact, when this happens, a
// socket is never assigned to the websocket. Wait for the `'error'` event
// that will be emitted by the websocket.
if (ws._socket === null) return;
if (ws._socket._writableState.finished) {
callback();
if (duplex._readableState.endEmitted) duplex.destroy();
} else {
ws._socket.once('finish', function finish() {
// `duplex` is not destroyed here because the `'end'` event will be
// emitted on `duplex` after this `'finish'` event. The EOF signaling
// `null` chunk is, in fact, pushed when the websocket emits `'close'`.
callback();
});
ws.close();
}
};
duplex._read = function () {
if (ws.isPaused) ws.resume();
};
duplex._write = function (chunk, encoding, callback) {
if (ws.readyState === ws.CONNECTING) {
ws.once('open', function open() {
duplex._write(chunk, encoding, callback);
});
return;
}
ws.send(chunk, callback);
};
duplex.on('end', duplexOnEnd);
duplex.on('error', duplexOnError);
return duplex;
}
module.exports = createWebSocketStream;

View File

@@ -1,62 +0,0 @@
'use strict';
const { tokenChars } = require('./validation');
/**
* Parses the `Sec-WebSocket-Protocol` header into a set of subprotocol names.
*
* @param {String} header The field value of the header
* @return {Set} The subprotocol names
* @public
*/
function parse(header) {
const protocols = new Set();
let start = -1;
let end = -1;
let i = 0;
for (i; i < header.length; i++) {
const code = header.charCodeAt(i);
if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (
i !== 0 &&
(code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */
) {
if (end === -1 && start !== -1) end = i;
} else if (code === 0x2c /* ',' */) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
const protocol = header.slice(start, end);
if (protocols.has(protocol)) {
throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`);
}
protocols.add(protocol);
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
}
if (start === -1 || end !== -1) {
throw new SyntaxError('Unexpected end of input');
}
const protocol = header.slice(start, i);
if (protocols.has(protocol)) {
throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`);
}
protocols.add(protocol);
return protocols;
}
module.exports = { parse };

View File

@@ -1,152 +0,0 @@
'use strict';
const { isUtf8 } = require('buffer');
const { hasBlob } = require('./constants');
//
// Allowed token characters:
//
// '!', '#', '$', '%', '&', ''', '*', '+', '-',
// '.', 0-9, A-Z, '^', '_', '`', a-z, '|', '~'
//
// tokenChars[32] === 0 // ' '
// tokenChars[33] === 1 // '!'
// tokenChars[34] === 0 // '"'
// ...
//
// prettier-ignore
const tokenChars = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0 - 15
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16 - 31
0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, // 32 - 47
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, // 48 - 63
0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64 - 79
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, // 80 - 95
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96 - 111
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0 // 112 - 127
];
/**
* Checks if a status code is allowed in a close frame.
*
* @param {Number} code The status code
* @return {Boolean} `true` if the status code is valid, else `false`
* @public
*/
function isValidStatusCode(code) {
return (
(code >= 1000 &&
code <= 1014 &&
code !== 1004 &&
code !== 1005 &&
code !== 1006) ||
(code >= 3000 && code <= 4999)
);
}
/**
* Checks if a given buffer contains only correct UTF-8.
* Ported from https://www.cl.cam.ac.uk/%7Emgk25/ucs/utf8_check.c by
* Markus Kuhn.
*
* @param {Buffer} buf The buffer to check
* @return {Boolean} `true` if `buf` contains only correct UTF-8, else `false`
* @public
*/
function _isValidUTF8(buf) {
const len = buf.length;
let i = 0;
while (i < len) {
if ((buf[i] & 0x80) === 0) {
// 0xxxxxxx
i++;
} else if ((buf[i] & 0xe0) === 0xc0) {
// 110xxxxx 10xxxxxx
if (
i + 1 === len ||
(buf[i + 1] & 0xc0) !== 0x80 ||
(buf[i] & 0xfe) === 0xc0 // Overlong
) {
return false;
}
i += 2;
} else if ((buf[i] & 0xf0) === 0xe0) {
// 1110xxxx 10xxxxxx 10xxxxxx
if (
i + 2 >= len ||
(buf[i + 1] & 0xc0) !== 0x80 ||
(buf[i + 2] & 0xc0) !== 0x80 ||
(buf[i] === 0xe0 && (buf[i + 1] & 0xe0) === 0x80) || // Overlong
(buf[i] === 0xed && (buf[i + 1] & 0xe0) === 0xa0) // Surrogate (U+D800 - U+DFFF)
) {
return false;
}
i += 3;
} else if ((buf[i] & 0xf8) === 0xf0) {
// 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
if (
i + 3 >= len ||
(buf[i + 1] & 0xc0) !== 0x80 ||
(buf[i + 2] & 0xc0) !== 0x80 ||
(buf[i + 3] & 0xc0) !== 0x80 ||
(buf[i] === 0xf0 && (buf[i + 1] & 0xf0) === 0x80) || // Overlong
(buf[i] === 0xf4 && buf[i + 1] > 0x8f) ||
buf[i] > 0xf4 // > U+10FFFF
) {
return false;
}
i += 4;
} else {
return false;
}
}
return true;
}
/**
* Determines whether a value is a `Blob`.
*
* @param {*} value The value to be tested
* @return {Boolean} `true` if `value` is a `Blob`, else `false`
* @private
*/
function isBlob(value) {
return (
hasBlob &&
typeof value === 'object' &&
typeof value.arrayBuffer === 'function' &&
typeof value.type === 'string' &&
typeof value.stream === 'function' &&
(value[Symbol.toStringTag] === 'Blob' ||
value[Symbol.toStringTag] === 'File')
);
}
module.exports = {
isBlob,
isValidStatusCode,
isValidUTF8: _isValidUTF8,
tokenChars
};
if (isUtf8) {
module.exports.isValidUTF8 = function (buf) {
return buf.length < 24 ? _isValidUTF8(buf) : isUtf8(buf);
};
} /* istanbul ignore else */ else if (!process.env.WS_NO_UTF_8_VALIDATE) {
try {
const isValidUTF8 = require('utf-8-validate');
module.exports.isValidUTF8 = function (buf) {
return buf.length < 32 ? _isValidUTF8(buf) : isValidUTF8(buf);
};
} catch (e) {
// Continue regardless of the error.
}
}

View File

@@ -1,550 +0,0 @@
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex$", "caughtErrors": "none" }] */
'use strict';
const EventEmitter = require('events');
const http = require('http');
const { Duplex } = require('stream');
const { createHash } = require('crypto');
const extension = require('./extension');
const PerMessageDeflate = require('./permessage-deflate');
const subprotocol = require('./subprotocol');
const WebSocket = require('./websocket');
const { GUID, kWebSocket } = require('./constants');
const keyRegex = /^[+/0-9A-Za-z]{22}==$/;
const RUNNING = 0;
const CLOSING = 1;
const CLOSED = 2;
/**
* Class representing a WebSocket server.
*
* @extends EventEmitter
*/
class WebSocketServer extends EventEmitter {
/**
* Create a `WebSocketServer` instance.
*
* @param {Object} options Configuration options
* @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether
* any of the `'message'`, `'ping'`, and `'pong'` events can be emitted
* multiple times in the same tick
* @param {Boolean} [options.autoPong=true] Specifies whether or not to
* automatically send a pong in response to a ping
* @param {Number} [options.backlog=511] The maximum length of the queue of
* pending connections
* @param {Boolean} [options.clientTracking=true] Specifies whether or not to
* track clients
* @param {Function} [options.handleProtocols] A hook to handle protocols
* @param {String} [options.host] The hostname where to bind the server
* @param {Number} [options.maxPayload=104857600] The maximum allowed message
* size
* @param {Boolean} [options.noServer=false] Enable no server mode
* @param {String} [options.path] Accept only connections matching this path
* @param {(Boolean|Object)} [options.perMessageDeflate=false] Enable/disable
* permessage-deflate
* @param {Number} [options.port] The port where to bind the server
* @param {(http.Server|https.Server)} [options.server] A pre-created HTTP/S
* server to use
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
* not to skip UTF-8 validation for text and close messages
* @param {Function} [options.verifyClient] A hook to reject connections
* @param {Function} [options.WebSocket=WebSocket] Specifies the `WebSocket`
* class to use. It must be the `WebSocket` class or class that extends it
* @param {Function} [callback] A listener for the `listening` event
*/
constructor(options, callback) {
super();
options = {
allowSynchronousEvents: true,
autoPong: true,
maxPayload: 100 * 1024 * 1024,
skipUTF8Validation: false,
perMessageDeflate: false,
handleProtocols: null,
clientTracking: true,
verifyClient: null,
noServer: false,
backlog: null, // use default (511 as implemented in net.js)
server: null,
host: null,
path: null,
port: null,
WebSocket,
...options
};
if (
(options.port == null && !options.server && !options.noServer) ||
(options.port != null && (options.server || options.noServer)) ||
(options.server && options.noServer)
) {
throw new TypeError(
'One and only one of the "port", "server", or "noServer" options ' +
'must be specified'
);
}
if (options.port != null) {
this._server = http.createServer((req, res) => {
const body = http.STATUS_CODES[426];
res.writeHead(426, {
'Content-Length': body.length,
'Content-Type': 'text/plain'
});
res.end(body);
});
this._server.listen(
options.port,
options.host,
options.backlog,
callback
);
} else if (options.server) {
this._server = options.server;
}
if (this._server) {
const emitConnection = this.emit.bind(this, 'connection');
this._removeListeners = addListeners(this._server, {
listening: this.emit.bind(this, 'listening'),
error: this.emit.bind(this, 'error'),
upgrade: (req, socket, head) => {
this.handleUpgrade(req, socket, head, emitConnection);
}
});
}
if (options.perMessageDeflate === true) options.perMessageDeflate = {};
if (options.clientTracking) {
this.clients = new Set();
this._shouldEmitClose = false;
}
this.options = options;
this._state = RUNNING;
}
/**
* Returns the bound address, the address family name, and port of the server
* as reported by the operating system if listening on an IP socket.
* If the server is listening on a pipe or UNIX domain socket, the name is
* returned as a string.
*
* @return {(Object|String|null)} The address of the server
* @public
*/
address() {
if (this.options.noServer) {
throw new Error('The server is operating in "noServer" mode');
}
if (!this._server) return null;
return this._server.address();
}
/**
* Stop the server from accepting new connections and emit the `'close'` event
* when all existing connections are closed.
*
* @param {Function} [cb] A one-time listener for the `'close'` event
* @public
*/
close(cb) {
if (this._state === CLOSED) {
if (cb) {
this.once('close', () => {
cb(new Error('The server is not running'));
});
}
process.nextTick(emitClose, this);
return;
}
if (cb) this.once('close', cb);
if (this._state === CLOSING) return;
this._state = CLOSING;
if (this.options.noServer || this.options.server) {
if (this._server) {
this._removeListeners();
this._removeListeners = this._server = null;
}
if (this.clients) {
if (!this.clients.size) {
process.nextTick(emitClose, this);
} else {
this._shouldEmitClose = true;
}
} else {
process.nextTick(emitClose, this);
}
} else {
const server = this._server;
this._removeListeners();
this._removeListeners = this._server = null;
//
// The HTTP/S server was created internally. Close it, and rely on its
// `'close'` event.
//
server.close(() => {
emitClose(this);
});
}
}
/**
* See if a given request should be handled by this server instance.
*
* @param {http.IncomingMessage} req Request object to inspect
* @return {Boolean} `true` if the request is valid, else `false`
* @public
*/
shouldHandle(req) {
if (this.options.path) {
const index = req.url.indexOf('?');
const pathname = index !== -1 ? req.url.slice(0, index) : req.url;
if (pathname !== this.options.path) return false;
}
return true;
}
/**
* Handle a HTTP Upgrade request.
*
* @param {http.IncomingMessage} req The request object
* @param {Duplex} socket The network socket between the server and client
* @param {Buffer} head The first packet of the upgraded stream
* @param {Function} cb Callback
* @public
*/
handleUpgrade(req, socket, head, cb) {
socket.on('error', socketOnError);
const key = req.headers['sec-websocket-key'];
const upgrade = req.headers.upgrade;
const version = +req.headers['sec-websocket-version'];
if (req.method !== 'GET') {
const message = 'Invalid HTTP method';
abortHandshakeOrEmitwsClientError(this, req, socket, 405, message);
return;
}
if (upgrade === undefined || upgrade.toLowerCase() !== 'websocket') {
const message = 'Invalid Upgrade header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
return;
}
if (key === undefined || !keyRegex.test(key)) {
const message = 'Missing or invalid Sec-WebSocket-Key header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
return;
}
if (version !== 13 && version !== 8) {
const message = 'Missing or invalid Sec-WebSocket-Version header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message, {
'Sec-WebSocket-Version': '13, 8'
});
return;
}
if (!this.shouldHandle(req)) {
abortHandshake(socket, 400);
return;
}
const secWebSocketProtocol = req.headers['sec-websocket-protocol'];
let protocols = new Set();
if (secWebSocketProtocol !== undefined) {
try {
protocols = subprotocol.parse(secWebSocketProtocol);
} catch (err) {
const message = 'Invalid Sec-WebSocket-Protocol header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
return;
}
}
const secWebSocketExtensions = req.headers['sec-websocket-extensions'];
const extensions = {};
if (
this.options.perMessageDeflate &&
secWebSocketExtensions !== undefined
) {
const perMessageDeflate = new PerMessageDeflate(
this.options.perMessageDeflate,
true,
this.options.maxPayload
);
try {
const offers = extension.parse(secWebSocketExtensions);
if (offers[PerMessageDeflate.extensionName]) {
perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]);
extensions[PerMessageDeflate.extensionName] = perMessageDeflate;
}
} catch (err) {
const message =
'Invalid or unacceptable Sec-WebSocket-Extensions header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
return;
}
}
//
// Optionally call external client verification handler.
//
if (this.options.verifyClient) {
const info = {
origin:
req.headers[`${version === 8 ? 'sec-websocket-origin' : 'origin'}`],
secure: !!(req.socket.authorized || req.socket.encrypted),
req
};
if (this.options.verifyClient.length === 2) {
this.options.verifyClient(info, (verified, code, message, headers) => {
if (!verified) {
return abortHandshake(socket, code || 401, message, headers);
}
this.completeUpgrade(
extensions,
key,
protocols,
req,
socket,
head,
cb
);
});
return;
}
if (!this.options.verifyClient(info)) return abortHandshake(socket, 401);
}
this.completeUpgrade(extensions, key, protocols, req, socket, head, cb);
}
/**
* Upgrade the connection to WebSocket.
*
* @param {Object} extensions The accepted extensions
* @param {String} key The value of the `Sec-WebSocket-Key` header
* @param {Set} protocols The subprotocols
* @param {http.IncomingMessage} req The request object
* @param {Duplex} socket The network socket between the server and client
* @param {Buffer} head The first packet of the upgraded stream
* @param {Function} cb Callback
* @throws {Error} If called more than once with the same socket
* @private
*/
completeUpgrade(extensions, key, protocols, req, socket, head, cb) {
//
// Destroy the socket if the client has already sent a FIN packet.
//
if (!socket.readable || !socket.writable) return socket.destroy();
if (socket[kWebSocket]) {
throw new Error(
'server.handleUpgrade() was called more than once with the same ' +
'socket, possibly due to a misconfiguration'
);
}
if (this._state > RUNNING) return abortHandshake(socket, 503);
const digest = createHash('sha1')
.update(key + GUID)
.digest('base64');
const headers = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Accept: ${digest}`
];
const ws = new this.options.WebSocket(null, undefined, this.options);
if (protocols.size) {
//
// Optionally call external protocol selection handler.
//
const protocol = this.options.handleProtocols
? this.options.handleProtocols(protocols, req)
: protocols.values().next().value;
if (protocol) {
headers.push(`Sec-WebSocket-Protocol: ${protocol}`);
ws._protocol = protocol;
}
}
if (extensions[PerMessageDeflate.extensionName]) {
const params = extensions[PerMessageDeflate.extensionName].params;
const value = extension.format({
[PerMessageDeflate.extensionName]: [params]
});
headers.push(`Sec-WebSocket-Extensions: ${value}`);
ws._extensions = extensions;
}
//
// Allow external modification/inspection of handshake headers.
//
this.emit('headers', headers, req);
socket.write(headers.concat('\r\n').join('\r\n'));
socket.removeListener('error', socketOnError);
ws.setSocket(socket, head, {
allowSynchronousEvents: this.options.allowSynchronousEvents,
maxPayload: this.options.maxPayload,
skipUTF8Validation: this.options.skipUTF8Validation
});
if (this.clients) {
this.clients.add(ws);
ws.on('close', () => {
this.clients.delete(ws);
if (this._shouldEmitClose && !this.clients.size) {
process.nextTick(emitClose, this);
}
});
}
cb(ws, req);
}
}
module.exports = WebSocketServer;
/**
* Add event listeners on an `EventEmitter` using a map of <event, listener>
* pairs.
*
* @param {EventEmitter} server The event emitter
* @param {Object.<String, Function>} map The listeners to add
* @return {Function} A function that will remove the added listeners when
* called
* @private
*/
function addListeners(server, map) {
for (const event of Object.keys(map)) server.on(event, map[event]);
return function removeListeners() {
for (const event of Object.keys(map)) {
server.removeListener(event, map[event]);
}
};
}
/**
* Emit a `'close'` event on an `EventEmitter`.
*
* @param {EventEmitter} server The event emitter
* @private
*/
function emitClose(server) {
server._state = CLOSED;
server.emit('close');
}
/**
* Handle socket errors.
*
* @private
*/
function socketOnError() {
this.destroy();
}
/**
* Close the connection when preconditions are not fulfilled.
*
* @param {Duplex} socket The socket of the upgrade request
* @param {Number} code The HTTP response status code
* @param {String} [message] The HTTP response body
* @param {Object} [headers] Additional HTTP response headers
* @private
*/
function abortHandshake(socket, code, message, headers) {
//
// The socket is writable unless the user destroyed or ended it before calling
// `server.handleUpgrade()` or in the `verifyClient` function, which is a user
// error. Handling this does not make much sense as the worst that can happen
// is that some of the data written by the user might be discarded due to the
// call to `socket.end()` below, which triggers an `'error'` event that in
// turn causes the socket to be destroyed.
//
message = message || http.STATUS_CODES[code];
headers = {
Connection: 'close',
'Content-Type': 'text/html',
'Content-Length': Buffer.byteLength(message),
...headers
};
socket.once('finish', socket.destroy);
socket.end(
`HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r\n` +
Object.keys(headers)
.map((h) => `${h}: ${headers[h]}`)
.join('\r\n') +
'\r\n\r\n' +
message
);
}
/**
* Emit a `'wsClientError'` event on a `WebSocketServer` if there is at least
* one listener for it, otherwise call `abortHandshake()`.
*
* @param {WebSocketServer} server The WebSocket server
* @param {http.IncomingMessage} req The request object
* @param {Duplex} socket The socket of the upgrade request
* @param {Number} code The HTTP response status code
* @param {String} message The HTTP response body
* @param {Object} [headers] The HTTP response headers
* @private
*/
function abortHandshakeOrEmitwsClientError(
server,
req,
socket,
code,
message,
headers
) {
if (server.listenerCount('wsClientError')) {
const err = new Error(message);
Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError);
server.emit('wsClientError', err, socket, req);
} else {
abortHandshake(socket, code, message, headers);
}
}

1388
test/node_modules/ws/lib/websocket.js generated vendored

File diff suppressed because it is too large Load Diff

69
test/node_modules/ws/package.json generated vendored
View File

@@ -1,69 +0,0 @@
{
"name": "ws",
"version": "8.18.3",
"description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js",
"keywords": [
"HyBi",
"Push",
"RFC-6455",
"WebSocket",
"WebSockets",
"real-time"
],
"homepage": "https://github.com/websockets/ws",
"bugs": "https://github.com/websockets/ws/issues",
"repository": {
"type": "git",
"url": "git+https://github.com/websockets/ws.git"
},
"author": "Einar Otto Stangvik <einaros@gmail.com> (http://2x.io)",
"license": "MIT",
"main": "index.js",
"exports": {
".": {
"browser": "./browser.js",
"import": "./wrapper.mjs",
"require": "./index.js"
},
"./package.json": "./package.json"
},
"browser": "browser.js",
"engines": {
"node": ">=10.0.0"
},
"files": [
"browser.js",
"index.js",
"lib/*.js",
"wrapper.mjs"
],
"scripts": {
"test": "nyc --reporter=lcov --reporter=text mocha --throw-deprecation test/*.test.js",
"integration": "mocha --throw-deprecation test/*.integration.js",
"lint": "eslint . && prettier --check --ignore-path .gitignore \"**/*.{json,md,yaml,yml}\""
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
},
"devDependencies": {
"benchmark": "^2.1.4",
"bufferutil": "^4.0.1",
"eslint": "^9.0.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.0.0",
"globals": "^16.0.0",
"mocha": "^8.4.0",
"nyc": "^15.0.0",
"prettier": "^3.0.0",
"utf-8-validate": "^6.0.0"
}
}

8
test/node_modules/ws/wrapper.mjs generated vendored
View File

@@ -1,8 +0,0 @@
import createWebSocketStream from './lib/stream.js';
import Receiver from './lib/receiver.js';
import Sender from './lib/sender.js';
import WebSocket from './lib/websocket.js';
import WebSocketServer from './lib/websocket-server.js';
export { createWebSocketStream, Receiver, Sender, WebSocket, WebSocketServer };
export default WebSocket;

View File

@@ -1,5 +1,21 @@
{
"dependencies": {
"ws": "^8.18.3"
},
"scripts": {
"pixelstream:fade-green-blue": "node pixelstream/fade-green-blue.js",
"pixelstream:bouncing-ball": "node pixelstream/bouncing-ball.js",
"pixelstream:rainbow": "node pixelstream/rainbow.js",
"pixelstream:lava-lamp": "node pixelstream/lava-lamp.js",
"pixelstream:meteor-rain": "node pixelstream/meteor-rain.js",
"pixelstream:spiral-bloom": "node pixelstream/spiral-bloom.js",
"pixelstream:ocean-glimmer": "node pixelstream/ocean-glimmer.js",
"pixelstream:nebula-drift": "node pixelstream/nebula-drift.js",
"pixelstream:voxel-fireflies": "node pixelstream/voxel-fireflies.js",
"pixelstream:wormhole-tunnel": "node pixelstream/wormhole-tunnel.js",
"pixelstream:circuit-pulse": "node pixelstream/circuit-pulse.js",
"pixelstream:aurora-curtains": "node pixelstream/aurora-curtains.js",
"pixelstream:snek": "npm start --prefix pixelstream/snek",
"pixelstream:tetris": "npm start --prefix pixelstream/tetris"
}
}

View File

@@ -0,0 +1,115 @@
const dgram = require('dgram');
const {
clamp,
createFrame,
frameToPayload,
hexToRgb,
samplePalette,
toIndex,
} = require('./shared-frame-utils');
const DEFAULT_PORT = 4210;
const DEFAULT_WIDTH = 16;
const DEFAULT_HEIGHT = 16;
const DEFAULT_INTERVAL_MS = 65;
const BAND_COUNT = 5;
const WAVE_SPEED = 0.35;
const HORIZONTAL_SWAY = 0.45;
const paletteStops = [
{ stop: 0.0, color: hexToRgb('01010a') },
{ stop: 0.2, color: hexToRgb('041332') },
{ stop: 0.4, color: hexToRgb('0c3857') },
{ stop: 0.65, color: hexToRgb('1aa07a') },
{ stop: 0.85, color: hexToRgb('68d284') },
{ stop: 1.0, color: hexToRgb('f4f5c6') },
];
const host = process.argv[2];
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
if (!host) {
console.error('Usage: node aurora-curtains.js <device-ip> [port] [width] [height] [interval-ms]');
process.exit(1);
}
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) {
console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.');
process.exit(1);
}
if (width <= 0 || height <= 0) {
console.error('Matrix dimensions must be positive integers.');
process.exit(1);
}
const socket = dgram.createSocket('udp4');
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
const frame = createFrame(width, height);
const bands = createBands(BAND_COUNT, width);
let timeSeconds = 0;
const frameTimeSeconds = intervalMs / 1000;
if (isBroadcast) {
socket.bind(() => {
socket.setBroadcast(true);
});
}
socket.on('error', (error) => {
console.error('Socket error:', error.message);
});
function createBands(count, matrixWidth) {
const generatedBands = [];
for (let index = 0; index < count; ++index) {
generatedBands.push({
center: Math.random() * (matrixWidth - 1),
phase: Math.random() * Math.PI * 2,
width: 1.2 + Math.random() * 1.8,
});
}
return generatedBands;
}
function generateFrame() {
timeSeconds += frameTimeSeconds;
for (let row = 0; row < height; ++row) {
const verticalRatio = row / Math.max(1, height - 1);
for (let col = 0; col < width; ++col) {
let intensity = 0;
bands.forEach((band, index) => {
const sway = Math.sin(timeSeconds * WAVE_SPEED + band.phase + verticalRatio * Math.PI * 2) * HORIZONTAL_SWAY;
const center = band.center + sway * (index % 2 === 0 ? 1 : -1);
const distance = Math.abs(col - center);
const blurred = Math.exp(-(distance * distance) / (2 * band.width * band.width));
intensity += blurred * (0.8 + Math.sin(timeSeconds * 0.4 + index) * 0.2);
});
const normalized = clamp(intensity / bands.length, 0, 1);
const gradientBlend = clamp((normalized * 0.7 + verticalRatio * 0.3), 0, 1);
frame[toIndex(col, row, width)] = samplePalette(paletteStops, gradientBlend);
}
}
return frameToPayload(frame);
}
function sendFrame() {
const payload = generateFrame();
const message = Buffer.from(payload, 'utf8');
socket.send(message, port, host);
}
setInterval(sendFrame, intervalMs);
console.log(
`Streaming aurora curtains to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms)`
);

View File

@@ -0,0 +1,80 @@
const dgram = require('dgram');
const host = process.argv[2];
const port = parseInt(process.argv[3] || '4210', 10);
const pixels = parseInt(process.argv[4] || '64', 10);
const intervalMs = parseInt(process.argv[5] || '30', 10);
if (!host) {
console.error('Usage: node bouncing-ball.js <device-ip> [port] [pixels] [interval-ms]');
process.exit(1);
}
const socket = dgram.createSocket('udp4');
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
let position = Math.random() * (pixels - 1);
let velocity = randomVelocity();
function randomVelocity() {
const min = 0.15;
const max = 0.4;
const sign = Math.random() < 0.5 ? -1 : 1;
return (min + Math.random() * (max - min)) * sign;
}
function rebound(sign) {
velocity = randomVelocity() * sign;
}
function mix(a, b, t) {
return a + (b - a) * t;
}
function generateFrame() {
const dt = intervalMs / 1000;
position += velocity * dt * 60; // scale velocity to 60 FPS reference
if (position < 0) {
position = -position;
rebound(1);
} else if (position > pixels - 1) {
position = (pixels - 1) - (position - (pixels - 1));
rebound(-1);
}
const activeIndex = Math.max(0, Math.min(pixels - 1, Math.round(position)));
let payload = 'RAW:';
for (let i = 0; i < pixels; i++) {
if (i === activeIndex) {
payload += 'ff8000';
continue;
}
const distance = Math.abs(i - position);
const intensity = Math.max(0, 1 - distance);
const green = Math.round(mix(20, 200, intensity)).toString(16).padStart(2, '0');
const blue = Math.round(mix(40, 255, intensity)).toString(16).padStart(2, '0');
payload += '00' + green + blue;
}
return payload;
}
function sendFrame() {
const payload = generateFrame();
const message = Buffer.from(payload, 'utf8');
socket.send(message, port, host);
}
setInterval(sendFrame, intervalMs);
if (isBroadcast) {
socket.bind(() => {
socket.setBroadcast(true);
});
}
console.log(`Streaming bouncing ball pattern to ${host}:${port} with ${pixels} pixels (interval=${intervalMs}ms)`);

View File

@@ -0,0 +1,166 @@
const dgram = require('dgram');
const {
addHexColor,
createFrame,
fadeFrame,
frameToPayload,
hexToRgb,
samplePalette,
toIndex,
} = require('./shared-frame-utils');
const DEFAULT_PORT = 4210;
const DEFAULT_WIDTH = 16;
const DEFAULT_HEIGHT = 16;
const DEFAULT_INTERVAL_MS = 50;
const PATH_FADE = 0.85;
const PULSE_LENGTH = 6;
const paletteStops = [
{ stop: 0.0, color: hexToRgb('020209') },
{ stop: 0.3, color: hexToRgb('023047') },
{ stop: 0.6, color: hexToRgb('115173') },
{ stop: 0.8, color: hexToRgb('1ca78f') },
{ stop: 1.0, color: hexToRgb('94fdf3') },
];
const accentColors = ['14f5ff', 'a7ff4d', 'ffcc3f'];
const host = process.argv[2];
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
if (!host) {
console.error('Usage: node circuit-pulse.js <device-ip> [port] [width] [height] [interval-ms]');
process.exit(1);
}
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) {
console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.');
process.exit(1);
}
if (width <= 0 || height <= 0) {
console.error('Matrix dimensions must be positive integers.');
process.exit(1);
}
const socket = dgram.createSocket('udp4');
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
const frame = createFrame(width, height);
const paths = createPaths(width, height);
const pulses = createPulses(paths.length);
const frameTimeSeconds = intervalMs / 1000;
if (isBroadcast) {
socket.bind(() => {
socket.setBroadcast(true);
});
}
socket.on('error', (error) => {
console.error('Socket error:', error.message);
});
function createPaths(matrixWidth, matrixHeight) {
const horizontalStep = Math.max(2, Math.floor(matrixHeight / 4));
const verticalStep = Math.max(2, Math.floor(matrixWidth / 4));
const generatedPaths = [];
for (let y = 1; y < matrixHeight; y += horizontalStep) {
const path = [];
for (let x = 0; x < matrixWidth; ++x) {
path.push({ x, y });
}
generatedPaths.push(path);
}
for (let x = 2; x < matrixWidth; x += verticalStep) {
const path = [];
for (let y = 0; y < matrixHeight; ++y) {
path.push({ x, y });
}
generatedPaths.push(path);
}
return generatedPaths;
}
function createPulses(count) {
const pulseList = [];
for (let index = 0; index < count; ++index) {
pulseList.push(spawnPulse(index));
}
return pulseList;
}
function spawnPulse(pathIndex) {
const color = accentColors[pathIndex % accentColors.length];
return {
pathIndex,
position: 0,
speed: 3 + Math.random() * 2,
color,
};
}
function updatePulse(pulse, deltaSeconds) {
pulse.position += pulse.speed * deltaSeconds;
const path = paths[pulse.pathIndex];
if (!path || path.length === 0) {
return;
}
if (pulse.position >= path.length + PULSE_LENGTH) {
Object.assign(pulse, spawnPulse(pulse.pathIndex));
pulse.position = 0;
}
}
function renderPulse(pulse) {
const path = paths[pulse.pathIndex];
if (!path) {
return;
}
for (let offset = 0; offset < PULSE_LENGTH; ++offset) {
const index = Math.floor(pulse.position) - offset;
if (index < 0 || index >= path.length) {
continue;
}
const { x, y } = path[index];
const intensity = Math.max(0, 1 - offset / PULSE_LENGTH);
const baseColor = samplePalette(paletteStops, intensity);
frame[toIndex(x, y, width)] = baseColor;
addHexColor(frame, toIndex(x, y, width), pulse.color, intensity * 1.4);
}
}
function generateFrame() {
fadeFrame(frame, PATH_FADE);
pulses.forEach((pulse) => {
updatePulse(pulse, frameTimeSeconds);
renderPulse(pulse);
});
return frameToPayload(frame);
}
function sendFrame() {
const payload = generateFrame();
const message = Buffer.from(payload, 'utf8');
socket.send(message, port, host);
}
setInterval(sendFrame, intervalMs);
console.log(
`Streaming circuit pulse to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms, paths=${paths.length})`
);

View File

@@ -0,0 +1,55 @@
const dgram = require('dgram');
const host = process.argv[2];
const port = parseInt(process.argv[3] || '4210', 10);
const pixels = parseInt(process.argv[4] || '64', 10);
const speed = parseFloat(process.argv[5] || '0.5'); // cycles per second
if (!host) {
console.error('Usage: node fade-green-blue.js <device-ip> [port] [pixels] [speed-hz]');
process.exit(1);
}
const socket = dgram.createSocket('udp4');
const intervalMs = 50;
let tick = 0;
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
function generateFrame() {
const timeSeconds = (tick * intervalMs) / 1000;
const phase = timeSeconds * speed * Math.PI * 2;
const blend = (Math.sin(phase) + 1) * 0.5; // 0..1
const green = Math.round(255 * (1 - blend));
const blue = Math.round(255 * blend);
let payload = 'RAW:';
const gHex = green.toString(16).padStart(2, '0');
const bHex = blue.toString(16).padStart(2, '0');
for (let i = 0; i < pixels; i++) {
payload += '00';
payload += gHex;
payload += bHex;
}
return payload;
}
function sendFrame() {
const payload = generateFrame();
const message = Buffer.from(payload, 'utf8');
socket.send(message, port, host);
tick += 1;
}
setInterval(sendFrame, intervalMs);
if (isBroadcast) {
socket.bind(() => {
socket.setBroadcast(true);
});
}
console.log(`Streaming green/blue fade to ${host}:${port} with ${pixels} pixels (speed=${speed}Hz)`);

View File

@@ -0,0 +1,171 @@
const dgram = require('dgram');
const {
clamp,
hexToRgb,
samplePalette: samplePaletteFromStops,
toIndex,
} = require('./shared-frame-utils');
const DEFAULT_PORT = 4210;
const DEFAULT_WIDTH = 16;
const DEFAULT_HEIGHT = 16;
const DEFAULT_INTERVAL_MS = 60;
const DEFAULT_BLOB_COUNT = 6;
const BASE_BLOB_SPEED = 0.18;
const PHASE_SPEED_MIN = 0.6;
const PHASE_SPEED_MAX = 1.2;
const host = process.argv[2];
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
const blobCount = parseInt(process.argv[7] || String(DEFAULT_BLOB_COUNT), 10);
if (!host) {
console.error('Usage: node lava-lamp.js <device-ip> [port] [width] [height] [interval-ms] [blob-count]');
process.exit(1);
}
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs) || Number.isNaN(blobCount)) {
console.error('Invalid numeric argument. Expected integers for port, width, height, interval-ms, and blob-count.');
process.exit(1);
}
if (width <= 0 || height <= 0) {
console.error('Matrix dimensions must be positive integers.');
process.exit(1);
}
if (blobCount <= 0) {
console.error('Blob count must be a positive integer.');
process.exit(1);
}
const totalPixels = width * height;
const socket = dgram.createSocket('udp4');
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
const maxAxis = Math.max(width, height);
const minBlobRadius = Math.max(3, maxAxis * 0.18);
const maxBlobRadius = Math.max(minBlobRadius + 1, maxAxis * 0.38);
const frameTimeSeconds = intervalMs / 1000;
const paletteStops = [
{ stop: 0.0, color: hexToRgb('050319') },
{ stop: 0.28, color: hexToRgb('2a0c4f') },
{ stop: 0.55, color: hexToRgb('8f1f73') },
{ stop: 0.75, color: hexToRgb('ff4a22') },
{ stop: 0.9, color: hexToRgb('ff9333') },
{ stop: 1.0, color: hexToRgb('fff7b0') },
];
const blobs = createBlobs(blobCount);
if (isBroadcast) {
socket.bind(() => {
socket.setBroadcast(true);
});
}
socket.on('error', (error) => {
console.error('Socket error:', error.message);
});
function createBlobs(count) {
const blobList = [];
for (let index = 0; index < count; ++index) {
const angle = Math.random() * Math.PI * 2;
const speed = BASE_BLOB_SPEED * (0.6 + Math.random() * 0.8);
blobList.push({
x: Math.random() * Math.max(1, width - 1),
y: Math.random() * Math.max(1, height - 1),
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
minRadius: minBlobRadius * (0.6 + Math.random() * 0.3),
maxRadius: maxBlobRadius * (0.8 + Math.random() * 0.4),
intensity: 0.8 + Math.random() * 0.7,
phase: Math.random() * Math.PI * 2,
phaseVelocity: PHASE_SPEED_MIN + Math.random() * (PHASE_SPEED_MAX - PHASE_SPEED_MIN),
});
}
return blobList;
}
function updateBlobs(deltaSeconds) {
const maxX = Math.max(0, width - 1);
const maxY = Math.max(0, height - 1);
blobs.forEach((blob) => {
blob.x += blob.vx * deltaSeconds;
blob.y += blob.vy * deltaSeconds;
if (blob.x < 0) {
blob.x = -blob.x;
blob.vx = Math.abs(blob.vx);
} else if (blob.x > maxX) {
blob.x = 2 * maxX - blob.x;
blob.vx = -Math.abs(blob.vx);
}
if (blob.y < 0) {
blob.y = -blob.y;
blob.vy = Math.abs(blob.vy);
} else if (blob.y > maxY) {
blob.y = 2 * maxY - blob.y;
blob.vy = -Math.abs(blob.vy);
}
blob.phase += blob.phaseVelocity * deltaSeconds;
});
}
function generateFrame() {
updateBlobs(frameTimeSeconds);
const frame = new Array(totalPixels);
for (let row = 0; row < height; ++row) {
for (let col = 0; col < width; ++col) {
const energy = calculateEnergyAt(col, row);
const color = samplePaletteFromStops(paletteStops, energy);
frame[toIndex(col, row, width)] = color;
}
}
return 'RAW:' + frame.join('');
}
function calculateEnergyAt(col, row) {
let energy = 0;
blobs.forEach((blob) => {
const radius = getBlobRadius(blob);
const dx = col - blob.x;
const dy = row - blob.y;
const distance = Math.hypot(dx, dy);
const falloff = Math.max(0, 1 - distance / radius);
energy += blob.intensity * falloff * falloff;
});
return clamp(energy / blobs.length, 0, 1);
}
function getBlobRadius(blob) {
const oscillation = (Math.sin(blob.phase) + 1) * 0.5;
return blob.minRadius + (blob.maxRadius - blob.minRadius) * oscillation;
}
function sendFrame() {
const payload = generateFrame();
const message = Buffer.from(payload, 'utf8');
socket.send(message, port, host);
}
setInterval(sendFrame, intervalMs);
console.log(
`Streaming lava lamp to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms, blobs=${blobCount})`,
);

View File

@@ -0,0 +1,132 @@
const dgram = require('dgram');
const {
clamp,
createFrame,
fadeFrame,
frameToPayload,
hexToRgb,
samplePalette,
toIndex,
} = require('./shared-frame-utils');
const DEFAULT_PORT = 4210;
const DEFAULT_WIDTH = 16;
const DEFAULT_HEIGHT = 16;
const DEFAULT_INTERVAL_MS = 45;
const DEFAULT_METEOR_COUNT = 12;
const BASE_SPEED_MIN = 4;
const BASE_SPEED_MAX = 10;
const TRAIL_DECAY = 0.76;
const paletteStops = [
{ stop: 0.0, color: hexToRgb('0a0126') },
{ stop: 0.3, color: hexToRgb('123d8b') },
{ stop: 0.7, color: hexToRgb('21c7d9') },
{ stop: 1.0, color: hexToRgb('f7ffff') },
];
const host = process.argv[2];
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
const meteorCount = parseInt(process.argv[7] || String(DEFAULT_METEOR_COUNT), 10);
if (!host) {
console.error('Usage: node meteor-rain.js <device-ip> [port] [width] [height] [interval-ms] [meteor-count]');
process.exit(1);
}
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs) || Number.isNaN(meteorCount)) {
console.error('Invalid numeric argument. Expected integers for port, width, height, interval-ms, and meteor-count.');
process.exit(1);
}
if (width <= 0 || height <= 0) {
console.error('Matrix dimensions must be positive integers.');
process.exit(1);
}
if (meteorCount <= 0) {
console.error('Meteor count must be a positive integer.');
process.exit(1);
}
const socket = dgram.createSocket('udp4');
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
const frame = createFrame(width, height);
const meteors = createMeteors(meteorCount, width, height);
const frameTimeSeconds = intervalMs / 1000;
if (isBroadcast) {
socket.bind(() => {
socket.setBroadcast(true);
});
}
socket.on('error', (error) => {
console.error('Socket error:', error.message);
});
function createMeteors(count, matrixWidth, matrixHeight) {
const meteorList = [];
for (let index = 0; index < count; ++index) {
meteorList.push(spawnMeteor(matrixWidth, matrixHeight));
}
return meteorList;
}
function spawnMeteor(matrixWidth, matrixHeight) {
const angle = (Math.PI / 4) * (0.6 + Math.random() * 0.8);
const speed = BASE_SPEED_MIN + Math.random() * (BASE_SPEED_MAX - BASE_SPEED_MIN);
return {
x: Math.random() * matrixWidth,
y: -Math.random() * matrixHeight,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
};
}
function drawMeteor(meteor) {
const col = Math.round(meteor.x);
const row = Math.round(meteor.y);
if (col < 0 || col >= width || row < 0 || row >= height) {
return;
}
const energy = clamp(1.2 - Math.random() * 0.2, 0, 1);
frame[toIndex(col, row, width)] = samplePalette(paletteStops, energy);
}
function updateMeteors(deltaSeconds) {
meteors.forEach((meteor, index) => {
meteor.x += meteor.vx * deltaSeconds;
meteor.y += meteor.vy * deltaSeconds;
drawMeteor(meteor);
if (meteor.x > width + 1 || meteor.y > height + 1) {
meteors[index] = spawnMeteor(width, height);
}
});
}
function generateFrame() {
fadeFrame(frame, TRAIL_DECAY);
updateMeteors(frameTimeSeconds);
return frameToPayload(frame);
}
function sendFrame() {
const payload = generateFrame();
const message = Buffer.from(payload, 'utf8');
socket.send(message, port, host);
}
setInterval(sendFrame, intervalMs);
console.log(
`Streaming meteor rain to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms, meteors=${meteorCount})`,
);

View File

@@ -0,0 +1,104 @@
const dgram = require('dgram');
const {
clamp,
createFrame,
fadeFrame,
frameToPayload,
hexToRgb,
samplePalette,
toIndex,
} = require('./shared-frame-utils');
const DEFAULT_PORT = 4210;
const DEFAULT_WIDTH = 16;
const DEFAULT_HEIGHT = 16;
const DEFAULT_INTERVAL_MS = 70;
const PRIMARY_SPEED = 0.15;
const SECONDARY_SPEED = 0.32;
const WAVE_SCALE = 0.75;
const paletteStops = [
{ stop: 0.0, color: hexToRgb('100406') },
{ stop: 0.25, color: hexToRgb('2e0f1f') },
{ stop: 0.5, color: hexToRgb('6a1731') },
{ stop: 0.7, color: hexToRgb('b63b32') },
{ stop: 0.85, color: hexToRgb('f48b2a') },
{ stop: 1.0, color: hexToRgb('ffe9b0') },
];
const host = process.argv[2];
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
if (!host) {
console.error('Usage: node nebula-drift.js <device-ip> [port] [width] [height] [interval-ms]');
process.exit(1);
}
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) {
console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.');
process.exit(1);
}
if (width <= 0 || height <= 0) {
console.error('Matrix dimensions must be positive integers.');
process.exit(1);
}
const socket = dgram.createSocket('udp4');
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
const frame = createFrame(width, height);
let timeSeconds = 0;
const frameTimeSeconds = intervalMs / 1000;
if (isBroadcast) {
socket.bind(() => {
socket.setBroadcast(true);
});
}
socket.on('error', (error) => {
console.error('Socket error:', error.message);
});
function layeredWave(u, v, speed, offset) {
return Math.sin((u * 3 + v * 2) * Math.PI * WAVE_SCALE + timeSeconds * speed + offset);
}
function generateFrame() {
timeSeconds += frameTimeSeconds;
for (let row = 0; row < height; ++row) {
const v = row / Math.max(1, height - 1);
for (let col = 0; col < width; ++col) {
const u = col / Math.max(1, width - 1);
const primary = layeredWave(u, v, PRIMARY_SPEED, 0);
const secondary = layeredWave(v, u, SECONDARY_SPEED, Math.PI / 4);
const tertiary = Math.sin((u + v) * Math.PI * 1.5 + timeSeconds * 0.18);
const combined = 0.45 * primary + 0.35 * secondary + 0.2 * tertiary;
const envelope = Math.sin((u * v) * Math.PI * 2 + timeSeconds * 0.1) * 0.25 + 0.75;
const value = clamp((combined * 0.5 + 0.5) * envelope, 0, 1);
frame[toIndex(col, row, width)] = samplePalette(paletteStops, value);
}
}
return frameToPayload(frame);
}
function sendFrame() {
const payload = generateFrame();
const message = Buffer.from(payload, 'utf8');
socket.send(message, port, host);
}
setInterval(sendFrame, intervalMs);
console.log(
`Streaming nebula drift to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms)`
);

View File

@@ -0,0 +1,95 @@
const dgram = require('dgram');
const {
clamp,
createFrame,
frameToPayload,
hexToRgb,
samplePalette,
toIndex,
} = require('./shared-frame-utils');
const DEFAULT_PORT = 4210;
const DEFAULT_WIDTH = 16;
const DEFAULT_HEIGHT = 16;
const DEFAULT_INTERVAL_MS = 50;
const SHIMMER = 0.08;
const paletteStops = [
{ stop: 0.0, color: hexToRgb('031521') },
{ stop: 0.35, color: hexToRgb('024f6d') },
{ stop: 0.65, color: hexToRgb('13a4a1') },
{ stop: 0.85, color: hexToRgb('67dcd0') },
{ stop: 1.0, color: hexToRgb('fcdba4') },
];
const host = process.argv[2];
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
if (!host) {
console.error('Usage: node ocean-glimmer.js <device-ip> [port] [width] [height] [interval-ms]');
process.exit(1);
}
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) {
console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.');
process.exit(1);
}
if (width <= 0 || height <= 0) {
console.error('Matrix dimensions must be positive integers.');
process.exit(1);
}
const socket = dgram.createSocket('udp4');
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
const frame = createFrame(width, height);
let timeSeconds = 0;
const frameTimeSeconds = intervalMs / 1000;
if (isBroadcast) {
socket.bind(() => {
socket.setBroadcast(true);
});
}
socket.on('error', (error) => {
console.error('Socket error:', error.message);
});
function generateFrame() {
timeSeconds += frameTimeSeconds;
for (let row = 0; row < height; ++row) {
const v = row / Math.max(1, height - 1);
for (let col = 0; col < width; ++col) {
const u = col / Math.max(1, width - 1);
const base =
0.33 +
0.26 * Math.sin(u * Math.PI * 2 + timeSeconds * 1.2) +
0.26 * Math.sin(v * Math.PI * 2 - timeSeconds * 0.9) +
0.26 * Math.sin((u + v) * Math.PI * 2 + timeSeconds * 0.5);
const noise = (Math.random() - 0.5) * SHIMMER;
const value = clamp(base + noise, 0, 1);
frame[toIndex(col, row, width)] = samplePalette(paletteStops, value);
}
}
return frameToPayload(frame);
}
function sendFrame() {
const payload = generateFrame();
const message = Buffer.from(payload, 'utf8');
socket.send(message, port, host);
}
setInterval(sendFrame, intervalMs);
console.log(
`Streaming ocean glimmer to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms)`,
);

View File

@@ -0,0 +1,59 @@
const dgram = require('dgram');
const host = process.argv[2];
const port = parseInt(process.argv[3] || '4210', 10);
const pixels = parseInt(process.argv[4] || '64', 10);
const intervalMs = parseInt(process.argv[5] || '30', 10);
if (!host) {
console.error('Usage: node rainbow.js <device-ip> [port] [pixels] [interval-ms]');
process.exit(1);
}
const socket = dgram.createSocket('udp4');
let offset = 0;
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
function wheel(pos) {
pos = 255 - pos;
if (pos < 85) {
return [255 - pos * 3, 0, pos * 3];
}
if (pos < 170) {
pos -= 85;
return [0, pos * 3, 255 - pos * 3];
}
pos -= 170;
return [pos * 3, 255 - pos * 3, 0];
}
function generateFrame() {
let payload = 'RAW:';
for (let i = 0; i < pixels; i++) {
const colorIndex = (i * 256 / pixels + offset) & 255;
const [r, g, b] = wheel(colorIndex);
payload += r.toString(16).padStart(2, '0');
payload += g.toString(16).padStart(2, '0');
payload += b.toString(16).padStart(2, '0');
}
offset = (offset + 1) & 255;
return payload;
}
function sendFrame() {
const payload = generateFrame();
const message = Buffer.from(payload, 'utf8');
socket.send(message, port, host);
}
setInterval(sendFrame, intervalMs);
if (isBroadcast) {
socket.bind(() => {
socket.setBroadcast(true);
});
}
console.log(`Streaming rainbow pattern to ${host}:${port} with ${pixels} pixels (interval=${intervalMs}ms)`);

View File

@@ -0,0 +1,118 @@
const SERPENTINE_WIRING = true;
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function hexToRgb(hex) {
const normalizedHex = hex.trim().toLowerCase();
const value = normalizedHex.startsWith('#') ? normalizedHex.slice(1) : normalizedHex;
return {
r: parseInt(value.slice(0, 2), 16),
g: parseInt(value.slice(2, 4), 16),
b: parseInt(value.slice(4, 6), 16),
};
}
function rgbToHex(rgb) {
return toHex(rgb.r) + toHex(rgb.g) + toHex(rgb.b);
}
function toHex(value) {
const boundedValue = clamp(Math.round(value), 0, 255);
const hex = boundedValue.toString(16);
return hex.length === 1 ? '0' + hex : hex;
}
function lerpRgb(lhs, rhs, t) {
return {
r: Math.round(lhs.r + (rhs.r - lhs.r) * t),
g: Math.round(lhs.g + (rhs.g - lhs.g) * t),
b: Math.round(lhs.b + (rhs.b - lhs.b) * t),
};
}
function samplePalette(paletteStops, value) {
const clampedValue = clamp(value, 0, 1);
for (let index = 0; index < paletteStops.length - 1; ++index) {
const left = paletteStops[index];
const right = paletteStops[index + 1];
if (clampedValue <= right.stop) {
const span = right.stop - left.stop || 1;
const t = clamp((clampedValue - left.stop) / span, 0, 1);
const interpolatedColor = lerpRgb(left.color, right.color, t);
return rgbToHex(interpolatedColor);
}
}
return rgbToHex(paletteStops[paletteStops.length - 1].color);
}
function toIndex(col, row, width, serpentine = SERPENTINE_WIRING) {
if (!serpentine || row % 2 === 0) {
return row * width + col;
}
return row * width + (width - 1 - col);
}
function createFrame(width, height, fill = '000000') {
return new Array(width * height).fill(fill);
}
function frameToPayload(frame) {
return 'RAW:' + frame.join('');
}
function fadeFrame(frame, factor) {
for (let index = 0; index < frame.length; ++index) {
const hex = frame[index];
const r = parseInt(hex.slice(0, 2), 16) * factor;
const g = parseInt(hex.slice(2, 4), 16) * factor;
const b = parseInt(hex.slice(4, 6), 16) * factor;
frame[index] = toHex(r) + toHex(g) + toHex(b);
}
}
function addRgbToFrame(frame, index, rgb) {
if (index < 0 || index >= frame.length) {
return;
}
const current = hexToRgb(frame[index]);
const updated = {
r: clamp(current.r + rgb.r, 0, 255),
g: clamp(current.g + rgb.g, 0, 255),
b: clamp(current.b + rgb.b, 0, 255),
};
frame[index] = rgbToHex(updated);
}
function addHexColor(frame, index, hexColor, intensity = 1) {
if (intensity <= 0) {
return;
}
const base = hexToRgb(hexColor);
addRgbToFrame(frame, index, {
r: base.r * intensity,
g: base.g * intensity,
b: base.b * intensity,
});
}
module.exports = {
SERPENTINE_WIRING,
clamp,
hexToRgb,
rgbToHex,
lerpRgb,
samplePalette,
toIndex,
createFrame,
frameToPayload,
fadeFrame,
addRgbToFrame,
addHexColor,
};

1319
test/pixelstream/snek/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
{
"name": "snek",
"version": "1.0.0",
"description": "",
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.21.2",
"ws": "^8.18.3"
}
}

View File

@@ -0,0 +1,274 @@
(() => {
const GRID_SIZE = 16;
const TICK_MS = 120; // game speed
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d', { alpha: false });
const scoreEl = document.getElementById('score');
const wsDot = document.getElementById('ws-dot');
const wsLabel = document.getElementById('ws-label');
// NeoPixel matrix transmission config
// Most WS2812(B) LEDs expect GRB color order. Many matrices are wired serpentine.
const MATRIX_WIDTH = canvas.width;
const MATRIX_HEIGHT = canvas.height;
const COLOR_ORDER = 'RGB'; // one of: RGB, GRB, BRG, BGR, RBG, GBR
const BRIGHTNESS = 1.0; // 0.0 .. 1.0 scalar
const SERPENTINE = true; // true if every other row is reversed
const FLIP_X = true; // set true if physical matrix is mirrored horizontally
const FLIP_Y = false; // set true if physical matrix is flipped vertically
/** Game state */
let snakeSegments = [];
let currentDirection = { x: 1, y: 0 };
let pendingDirection = { x: 1, y: 0 };
let appleCell = null;
let score = 0;
let isGameOver = false;
let sendingFrame = false;
let ws;
function connectWebSocket() {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const url = `${protocol}://${location.host}/ws`;
ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
setWsStatus('connecting');
ws.addEventListener('open', () => setWsStatus('open'));
ws.addEventListener('close', () => setWsStatus('closed'));
ws.addEventListener('error', () => setWsStatus('error'));
}
function clampToByte(value) {
if (value < 0) return 0;
if (value > 255) return 255;
return value | 0;
}
function applyBrightness(value) {
// value is 0..255, BRIGHTNESS is 0..1
return clampToByte(Math.round(value * BRIGHTNESS));
}
function mapXYToLinearIndex(x, y, width, height) {
// Apply optional flips to align with physical orientation
const mappedX = FLIP_X ? (width - 1 - x) : x;
const mappedY = FLIP_Y ? (height - 1 - y) : y;
// Serpentine wiring reverses every other row (commonly odd rows)
const isOddRow = (mappedY % 2) === 1;
const columnInRow = (SERPENTINE && isOddRow) ? (width - 1 - mappedX) : mappedX;
return (mappedY * width) + columnInRow;
}
function writePixelWithColorOrder(target, baseIndex, r, g, b) {
// target is a Uint8Array, baseIndex is pixelIndex * 3
switch (COLOR_ORDER) {
case 'RGB':
target[baseIndex + 0] = r;
target[baseIndex + 1] = g;
target[baseIndex + 2] = b;
break;
case 'GRB':
target[baseIndex + 0] = g;
target[baseIndex + 1] = r;
target[baseIndex + 2] = b;
break;
case 'BRG':
target[baseIndex + 0] = b;
target[baseIndex + 1] = r;
target[baseIndex + 2] = g;
break;
case 'BGR':
target[baseIndex + 0] = b;
target[baseIndex + 1] = g;
target[baseIndex + 2] = r;
break;
case 'RBG':
target[baseIndex + 0] = r;
target[baseIndex + 1] = b;
target[baseIndex + 2] = g;
break;
case 'GBR':
target[baseIndex + 0] = g;
target[baseIndex + 1] = b;
target[baseIndex + 2] = r;
break;
default:
// Fallback to GRB if misconfigured
target[baseIndex + 0] = g;
target[baseIndex + 1] = r;
target[baseIndex + 2] = b;
break;
}
}
function encodeCanvasToNeoPixelFrame() {
// Produces headerless frame: width*height pixels, 3 bytes per pixel in COLOR_ORDER
const width = MATRIX_WIDTH;
const height = MATRIX_HEIGHT;
const out = new Uint8Array(width * height * 3);
const src = ctx.getImageData(0, 0, width, height).data; // RGBA
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const srcIndex = ((y * width) + x) * 4;
const r = applyBrightness(src[srcIndex + 0]);
const g = applyBrightness(src[srcIndex + 1]);
const b = applyBrightness(src[srcIndex + 2]);
const pixelIndex = mapXYToLinearIndex(x, y, width, height);
const base = pixelIndex * 3;
writePixelWithColorOrder(out, base, r, g, b);
}
}
return out;
}
function setWsStatus(state) {
if (state === 'open') {
wsDot.classList.add('ok');
wsLabel.textContent = 'WS: connected';
} else if (state === 'connecting') {
wsDot.classList.remove('ok');
wsLabel.textContent = 'WS: connecting…';
} else if (state === 'closed') {
wsDot.classList.remove('ok');
wsLabel.textContent = 'WS: disconnected';
// try to reconnect after a delay
setTimeout(connectWebSocket, 1000);
} else {
wsDot.classList.remove('ok');
wsLabel.textContent = 'WS: error';
}
}
function resetGame() {
snakeSegments = [ { x: 4, y: 8 }, { x: 3, y: 8 }, { x: 2, y: 8 } ];
currentDirection = { x: 1, y: 0 };
pendingDirection = { x: 1, y: 0 };
score = 0;
scoreEl.textContent = String(score);
isGameOver = false;
placeApple();
}
function placeApple() {
const occupied = new Set(snakeSegments.map(c => `${c.x},${c.y}`));
let x, y;
do {
x = Math.floor(Math.random() * GRID_SIZE);
y = Math.floor(Math.random() * GRID_SIZE);
} while (occupied.has(`${x},${y}`));
appleCell = { x, y };
}
function stepGame() {
if (isGameOver) return;
// Commit pending direction (prevents double-turning in one tick)
if ((pendingDirection.x !== -currentDirection.x) || (pendingDirection.y !== -currentDirection.y)) {
currentDirection = pendingDirection;
}
const head = snakeSegments[0];
const newHead = { x: head.x + currentDirection.x, y: head.y + currentDirection.y };
// Wall collision
if (newHead.x < 0 || newHead.x >= GRID_SIZE || newHead.y < 0 || newHead.y >= GRID_SIZE) {
isGameOver = true;
return;
}
// Self collision
for (let i = 0; i < snakeSegments.length; i++) {
const seg = snakeSegments[i];
if (seg.x === newHead.x && seg.y === newHead.y) {
isGameOver = true;
return;
}
}
// Move snake
snakeSegments.unshift(newHead);
const ateApple = (newHead.x === appleCell.x && newHead.y === appleCell.y);
if (ateApple) {
score += 1;
scoreEl.textContent = String(score);
placeApple();
} else {
snakeSegments.pop();
}
}
function renderGame() {
// Background
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Apple
ctx.fillStyle = '#d23';
ctx.fillRect(appleCell.x, appleCell.y, 1, 1);
// Snake
ctx.fillStyle = '#3bd16f';
for (const cell of snakeSegments) {
ctx.fillRect(cell.x, cell.y, 1, 1);
}
if (isGameOver) {
// Simple overlay pixel art for game over (draw a cross)
ctx.fillStyle = '#f33';
for (let i = 0; i < GRID_SIZE; i++) {
ctx.fillRect(i, i, 1, 1);
ctx.fillRect(GRID_SIZE - 1 - i, i, 1, 1);
}
}
}
function sendFrame() {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (sendingFrame) return;
sendingFrame = true;
try {
const frame = encodeCanvasToNeoPixelFrame();
// Send ArrayBuffer view; receivers expect raw 3-byte-per-pixel stream
ws.send(frame.buffer);
} catch (_) {
// ignore
} finally {
sendingFrame = false;
}
}
function gameLoop() {
stepGame();
renderGame();
sendFrame();
}
// Controls
window.addEventListener('keydown', (e) => {
if (e.key === ' ') {
resetGame();
return;
}
if (e.key === 'ArrowUp' && currentDirection.y !== 1) {
pendingDirection = { x: 0, y: -1 };
} else if (e.key === 'ArrowDown' && currentDirection.y !== -1) {
pendingDirection = { x: 0, y: 1 };
} else if (e.key === 'ArrowLeft' && currentDirection.x !== 1) {
pendingDirection = { x: -1, y: 0 };
} else if (e.key === 'ArrowRight' && currentDirection.x !== -1) {
pendingDirection = { x: 1, y: 0 };
}
});
// Start
resetGame();
connectWebSocket();
setInterval(gameLoop, TICK_MS);
})();

View File

@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Snek 16x16</title>
<style>
:root {
color-scheme: dark;
}
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #0b0b0b;
color: #e8e8e8;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
margin: 0;
padding: 24px 12px;
}
h2 { margin: 0 0 4px 0; font-weight: 600; }
#board {
width: 640px; /* make it big */
height: 640px; /* make it big */
image-rendering: pixelated;
border: 1px solid #333;
background: #000;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
}
.hud {
display: flex;
gap: 20px;
align-items: center;
justify-content: center;
flex-wrap: wrap;
}
.status {
display: inline-flex;
gap: 8px;
align-items: center;
}
.dot { width: 10px; height: 10px; border-radius: 50%; background: #a33; }
.dot.ok { background: #3a3; }
.pill { padding: 2px 8px; border: 1px solid #333; border-radius: 999px; font-size: 12px; }
.hint { color: #aaa; font-size: 12px; }
</style>
</head>
<body>
<h2>16×16 Snek</h2>
<div class="hud">
<div>Score: <span id="score">0</span></div>
<div class="status"><span class="dot" id="ws-dot"></span><span class="pill" id="ws-label">WS: connecting…</span></div>
</div>
<canvas id="board" width="16" height="16"></canvas>
<div class="hint">Arrow keys to move. Space to restart.</div>
<script src="app.js"></script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More