Compare commits
3 Commits
7bd3e87271
...
refactorin
| Author | SHA1 | Date | |
|---|---|---|---|
| 025ac7b810 | |||
| a9f56c1279 | |||
| 594b5e3af6 |
58
README.md
58
README.md
@@ -9,8 +9,6 @@ SPORE is a cluster engine for ESP8266 microcontrollers that provides automatic n
|
||||
- [Features](#features)
|
||||
- [Supported Hardware](#supported-hardware)
|
||||
- [Architecture](#architecture)
|
||||
- [Cluster Broadcast](#cluster-broadcast)
|
||||
- [Streaming API](#streaming-api)
|
||||
- [API Reference](#api-reference)
|
||||
- [Configuration](#configuration)
|
||||
- [Development](#development)
|
||||
@@ -28,8 +26,6 @@ SPORE is a cluster engine for ESP8266 microcontrollers that provides automatic n
|
||||
- **Service Registry**: Dynamic API endpoint discovery and registration
|
||||
- **Health Monitoring**: Real-time node status tracking with resource monitoring
|
||||
- **Event System**: Local and cluster-wide event publishing/subscription
|
||||
- **Cluster Broadcast**: Centralized UDP broadcast of events (CLUSTER_EVENT)
|
||||
- **Streaming API**: WebSocket bridge for real-time event send/receive
|
||||
- **Over-The-Air Updates**: Seamless firmware updates across the cluster
|
||||
- **REST API**: HTTP-based cluster management and monitoring
|
||||
- **Capability Discovery**: Automatic API endpoint and service capability detection
|
||||
@@ -93,12 +89,12 @@ void setup() {
|
||||
spore.setup();
|
||||
|
||||
// Create and register custom services
|
||||
RelayService* relayService = new RelayService(spore.getContext(), spore.getTaskManager(), 2);
|
||||
spore.registerService(relayService);
|
||||
RelayService* relayService = new RelayService(spore.getTaskManager(), 2);
|
||||
spore.addService(relayService);
|
||||
|
||||
// Or using smart pointers
|
||||
auto sensorService = std::make_shared<SensorService>(spore.getContext(), spore.getTaskManager());
|
||||
spore.registerService(sensorService);
|
||||
auto sensorService = std::make_shared<SensorService>();
|
||||
spore.addService(sensorService);
|
||||
|
||||
// Start the API server and complete initialization
|
||||
spore.begin();
|
||||
@@ -107,52 +103,6 @@ void setup() {
|
||||
|
||||
**Examples:** See [`examples/base/`](./examples/base/) for basic usage and [`examples/relay/`](./examples/relay/) for custom service integration.
|
||||
|
||||
## Cluster Broadcast
|
||||
|
||||
Broadcast an event to all peers using the centralized core broadcaster. Services never touch UDP directly; instead they fire a local event that the core transmits as a `CLUSTER_EVENT`.
|
||||
|
||||
Usage:
|
||||
|
||||
```cpp
|
||||
// 1) Apply locally via the same event your service already handles
|
||||
JsonDocument payload;
|
||||
payload["pattern"] = "rainbow_cycle";
|
||||
payload["brightness"] = 100;
|
||||
String payloadStr; serializeJson(payload, payloadStr);
|
||||
ctx.fire("api/neopattern", &payloadStr);
|
||||
|
||||
// 2) Broadcast to peers via the core
|
||||
JsonDocument envelope;
|
||||
envelope["event"] = "api/neopattern";
|
||||
envelope["data"] = payloadStr; // JSON string
|
||||
String eventJson; serializeJson(envelope, eventJson);
|
||||
ctx.fire("cluster/broadcast", &eventJson);
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The core sends subnet-directed broadcasts (e.g., 192.168.1.255) for reliability.
|
||||
- Peers receive `CLUSTER_EVENT` and forward to local subscribers with `ctx.fire(event, data)`.
|
||||
- `data` can be a JSON string or nested JSON; receivers handle both.
|
||||
|
||||
📖 See the dedicated guide: [`docs/ClusterBroadcast.md`](./docs/ClusterBroadcast.md)
|
||||
|
||||
## Streaming API
|
||||
|
||||
Real-time event bridge available at `/ws` using WebSocket.
|
||||
|
||||
- Send JSON `{ event, payload }` to dispatch events via `ctx.fire`.
|
||||
- Receive all local events as `{ event, payload }`.
|
||||
|
||||
Examples:
|
||||
```json
|
||||
{ "event": "api/neopattern/color", "payload": { "color": "#FF0000", "brightness": 128 } }
|
||||
```
|
||||
```json
|
||||
{ "event": "cluster/broadcast", "payload": { "event": "api/neopattern/color", "data": { "color": "#00FF00" } } }
|
||||
```
|
||||
|
||||
📖 See the dedicated guide: [`docs/StreamingAPI.md`](./docs/StreamingAPI.md)
|
||||
|
||||
## API Reference
|
||||
|
||||
The system provides a comprehensive RESTful API for monitoring and controlling the embedded device. All endpoints return JSON responses and support standard HTTP status codes.
|
||||
|
||||
417
ctl.sh
417
ctl.sh
@@ -4,36 +4,6 @@ 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
|
||||
}
|
||||
@@ -99,393 +69,6 @@ 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
|
||||
}
|
||||
|
||||
60
docs/API.md
60
docs/API.md
@@ -15,18 +15,12 @@ The SPORE system provides a comprehensive RESTful API for monitoring and control
|
||||
|
||||
| Endpoint | Method | Description | Response |
|
||||
|----------|--------|-------------|----------|
|
||||
| `/api/node/status` | GET | System resource information | System metrics |
|
||||
| `/api/node/status` | GET | System resource information and API endpoint registry | System metrics and API catalog |
|
||||
| `/api/node/endpoints` | GET | API endpoints and parameters | Detailed endpoint specifications |
|
||||
| `/api/cluster/members` | GET | Cluster membership and node health information | Cluster topology and health status |
|
||||
| `/api/node/update` | POST | Handle firmware updates via OTA | Update progress and status |
|
||||
| `/api/node/restart` | POST | Trigger system restart | Restart confirmation |
|
||||
|
||||
### Monitoring API
|
||||
|
||||
| Endpoint | Method | Description | Response |
|
||||
|----------|--------|-------------|----------|
|
||||
| `/api/monitoring/resources` | GET | CPU, memory, filesystem, and uptime | System resource metrics |
|
||||
|
||||
### Network Management API
|
||||
|
||||
| Endpoint | Method | Description | Response |
|
||||
@@ -146,7 +140,7 @@ Controls the execution state of individual tasks. Supports enabling, disabling,
|
||||
|
||||
#### GET /api/node/status
|
||||
|
||||
Returns comprehensive system resource information including memory usage and chip details. For a list of available API endpoints, use `/api/node/endpoints`.
|
||||
Returns comprehensive system resource information including memory usage, chip details, and a registry of all available API endpoints.
|
||||
|
||||
**Response Fields:**
|
||||
- `freeHeap`: Available RAM in bytes
|
||||
@@ -174,7 +168,7 @@ Returns comprehensive system resource information including memory usage and chi
|
||||
|
||||
#### GET /api/node/endpoints
|
||||
|
||||
Returns detailed information about all available API endpoints, including their parameters, types, and validation rules. Methods are returned as strings (e.g., "GET", "POST").
|
||||
Returns detailed information about all available API endpoints, including their parameters, types, and validation rules.
|
||||
|
||||
**Response Fields:**
|
||||
- `endpoints[]`: Array of endpoint capability objects
|
||||
@@ -242,54 +236,6 @@ Initiates an over-the-air firmware update. The firmware file should be uploaded
|
||||
|
||||
Triggers a system restart. The response will be sent before the restart occurs.
|
||||
|
||||
### Monitoring
|
||||
|
||||
#### GET /api/monitoring/resources
|
||||
|
||||
Returns real-time system resource metrics.
|
||||
|
||||
Response Fields:
|
||||
- `cpu.current_usage`: Current CPU usage percent
|
||||
- `cpu.average_usage`: Average CPU usage percent
|
||||
- `cpu.max_usage`: Max observed CPU usage
|
||||
- `cpu.min_usage`: Min observed CPU usage
|
||||
- `cpu.measurement_count`: Number of measurements
|
||||
- `cpu.is_measuring`: Whether measurement is active
|
||||
- `memory.free_heap`: Free heap bytes
|
||||
- `memory.total_heap`: Total heap bytes (approximate)
|
||||
- `memory.heap_fragmentation`: Fragmentation percent (0 on ESP8266)
|
||||
- `filesystem.total_bytes`: LittleFS total bytes
|
||||
- `filesystem.used_bytes`: Used bytes
|
||||
- `filesystem.free_bytes`: Free bytes
|
||||
- `filesystem.usage_percent`: Usage percent
|
||||
- `system.uptime_ms`: Uptime in milliseconds
|
||||
Example Response:
|
||||
```json
|
||||
{
|
||||
"cpu": {
|
||||
"current_usage": 3.5,
|
||||
"average_usage": 2.1,
|
||||
"max_usage": 15.2,
|
||||
"min_usage": 0.0,
|
||||
"measurement_count": 120,
|
||||
"is_measuring": true
|
||||
},
|
||||
"memory": {
|
||||
"free_heap": 48748,
|
||||
"total_heap": 81920,
|
||||
"heap_fragmentation": 0
|
||||
},
|
||||
"filesystem": {
|
||||
"total_bytes": 65536,
|
||||
"used_bytes": 10240,
|
||||
"free_bytes": 55296,
|
||||
"usage_percent": 15.6
|
||||
},
|
||||
"system": {
|
||||
"uptime_ms": 123456
|
||||
}
|
||||
}
|
||||
```
|
||||
### Network Management
|
||||
|
||||
#### GET /api/network/status
|
||||
|
||||
@@ -21,15 +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**: Services register endpoints via `registerEndpoints(ApiServer&)`
|
||||
- **Dynamic Endpoint Registration**: Automatic API endpoint discovery
|
||||
- **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
|
||||
- **Cooperative Multitasking**: Background task management system
|
||||
- **Task Lifecycle Management**: Automatic task execution and monitoring
|
||||
- **Resource Optimization**: Efficient task scheduling and execution
|
||||
|
||||
### Node Context
|
||||
- **Central Context**: Shared resources and configuration
|
||||
@@ -42,89 +40,27 @@ The cluster uses a UDP-based discovery protocol for automatic node detection:
|
||||
|
||||
### Discovery Process
|
||||
|
||||
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
|
||||
1. **Discovery Broadcast**: Nodes periodically send UDP packets on port 4210
|
||||
2. **Response Handling**: Nodes respond with their hostname and IP address
|
||||
3. **Member Management**: Discovered nodes are automatically added to the cluster
|
||||
4. **Health Monitoring**: Continuous status checking via HTTP API calls
|
||||
|
||||
### Protocol Details
|
||||
|
||||
- **UDP Port**: 4210 (configurable via `Config.udp_port`)
|
||||
- **Heartbeat Message**: `CLUSTER_HEARTBEAT:hostname`
|
||||
- **Node Update Message**: `NODE_UPDATE:hostname:{json}`
|
||||
- **UDP Port**: 4210 (configurable)
|
||||
- **Discovery Message**: `CLUSTER_DISCOVERY`
|
||||
- **Response Message**: `CLUSTER_RESPONSE`
|
||||
- **Broadcast Address**: 255.255.255.255
|
||||
- **Listen Interval**: `Config.cluster_listen_interval_ms` (default 10 ms)
|
||||
- **Heartbeat Interval**: `Config.heartbeat_interval_ms` (default 5000 ms)
|
||||
|
||||
### Message Formats
|
||||
|
||||
- **Heartbeat**: `CLUSTER_HEARTBEAT:hostname`
|
||||
- Sender: each node, broadcast to 255.255.255.255:`udp_port` on interval
|
||||
- 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. **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 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:
|
||||
- **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)
|
||||
- **Listen Interval**: `Config.cluster_listen_interval_ms` (default 10 ms)
|
||||
- **Heartbeat Interval**: `Config.heartbeat_interval_ms` (default 5000 ms)
|
||||
- **Discovery Interval**: 1 second (configurable)
|
||||
- **Listen Interval**: 100ms (configurable)
|
||||
|
||||
### Node Status Categories
|
||||
|
||||
Nodes are automatically categorized by their activity:
|
||||
|
||||
- **ACTIVE**: lastSeen < `node_inactive_threshold_ms` (default 10s)
|
||||
- **INACTIVE**: < `node_dead_threshold_ms` (default 120s)
|
||||
- **DEAD**: ≥ `node_dead_threshold_ms`
|
||||
- **ACTIVE**: Responding within 10 seconds
|
||||
- **INACTIVE**: No response for 10-60 seconds
|
||||
- **DEAD**: No response for over 60 seconds
|
||||
|
||||
## Task Scheduling System
|
||||
|
||||
@@ -132,11 +68,14 @@ The system runs several background tasks at different intervals:
|
||||
|
||||
### Core System Tasks
|
||||
|
||||
| Task | Interval (default) | Purpose |
|
||||
|------|--------------------|---------|
|
||||
| `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 |
|
||||
| Task | Interval | Purpose |
|
||||
|------|----------|---------|
|
||||
| **Discovery Send** | 1 second | Send UDP discovery packets |
|
||||
| **Discovery Listen** | 100ms | Listen for discovery responses |
|
||||
| **Status Updates** | 1 second | Monitor cluster member health |
|
||||
| **Heartbeat** | 2 seconds | Maintain cluster connectivity |
|
||||
| **Member Info** | 10 seconds | Update detailed node information |
|
||||
| **Debug Output** | 5 seconds | Print cluster status |
|
||||
|
||||
### Task Management Features
|
||||
|
||||
@@ -153,12 +92,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
|
||||
});
|
||||
```
|
||||
@@ -167,13 +106,16 @@ 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 to cluster
|
||||
- **`cluster_updated`**: Cluster membership changed
|
||||
- **`resource_update`**: Node resources updated
|
||||
- **`health_check`**: Node health status changed
|
||||
|
||||
## Resource Monitoring
|
||||
|
||||
@@ -213,8 +155,10 @@ The system includes automatic WiFi fallback for robust operation:
|
||||
|
||||
### Configuration
|
||||
|
||||
- **Hostname**: Derived from MAC (`esp-<mac>`) and assigned to `ctx.hostname`
|
||||
- **AP Mode**: If STA connection fails, device switches to AP mode with configured SSID/password
|
||||
- **SSID Format**: `SPORE_<MAC_LAST_4>`
|
||||
- **Password**: Configurable fallback password
|
||||
- **IP Range**: 192.168.4.x subnet
|
||||
- **Gateway**: 192.168.4.1
|
||||
|
||||
## Cluster Topology
|
||||
|
||||
@@ -226,30 +170,32 @@ The system includes automatic WiFi fallback for robust operation:
|
||||
|
||||
### Network Architecture
|
||||
|
||||
- UDP broadcast-based discovery and heartbeats on local subnet
|
||||
- Optional HTTP polling (disabled by default; node info exchanged via UDP)
|
||||
- **Mesh-like Structure**: Nodes can communicate with each other
|
||||
- **Dynamic Routing**: Automatic path discovery between nodes
|
||||
- **Load Distribution**: Tasks distributed across available nodes
|
||||
- **Fault Tolerance**: Automatic failover and recovery
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Node Discovery
|
||||
1. **UDP Broadcast**: Nodes broadcast discovery packets on port 4210
|
||||
2. **UDP Response**: Receiving nodes respond with hostname
|
||||
2. **UDP Response**: Receiving nodes responds with hostname
|
||||
3. **Registration**: Discovered nodes are added to local cluster member list
|
||||
|
||||
### Health Monitoring
|
||||
1. **Periodic Checks**: Cluster manager updates node status categories
|
||||
2. **Status Collection**: Each node updates resources via UDP node-info messages
|
||||
1. **Periodic Checks**: Cluster manager polls member nodes every 1 second
|
||||
2. **Status Collection**: Each node returns resource usage and health metrics
|
||||
|
||||
### Task Management
|
||||
1. **Scheduling**: `TaskManager` executes registered tasks at configured intervals
|
||||
2. **Execution**: Tasks run cooperatively in the main loop without preemption
|
||||
3. **Monitoring**: Task status is exposed via REST (`/api/tasks/status`)
|
||||
1. **Scheduling**: TaskScheduler executes registered tasks at configured intervals
|
||||
2. **Execution**: Tasks run cooperatively, yielding control to other tasks
|
||||
3. **Monitoring**: Task status and results are exposed via REST API endpoints
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
### Memory Usage
|
||||
|
||||
- **Base System**: ~15-20KB RAM (device dependent)
|
||||
- **Base System**: ~15-20KB RAM
|
||||
- **Per Task**: ~100-200 bytes per task
|
||||
- **Cluster Members**: ~50-100 bytes per member
|
||||
- **API Endpoints**: ~20-30 bytes per endpoint
|
||||
@@ -273,7 +219,7 @@ The system includes automatic WiFi fallback for robust operation:
|
||||
### Current Implementation
|
||||
|
||||
- **Network Access**: Local network only (no internet exposure)
|
||||
- **Authentication**: None currently implemented; LAN-only access assumed
|
||||
- **Authentication**: None currently implemented
|
||||
- **Data Validation**: Basic input validation
|
||||
- **Resource Limits**: Memory and processing constraints
|
||||
|
||||
@@ -302,51 +248,6 @@ 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
|
||||
@@ -436,8 +337,6 @@ 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
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
## Cluster Broadcast (CLUSTER_EVENT)
|
||||
|
||||
### Overview
|
||||
|
||||
Spore supports cluster-wide event broadcasting via UDP. Services publish a local event, and the core broadcasts it to peers as a `CLUSTER_EVENT`. Peers receive the event and forward it to local subscribers through the internal event bus.
|
||||
|
||||
- **Local trigger**: `ctx.fire("cluster/broadcast", eventJson)`
|
||||
- **UDP message**: `CLUSTER_EVENT:{json}`
|
||||
- **Receiver action**: Parses `{json}` and calls `ctx.fire(event, data)`
|
||||
|
||||
This centralizes network broadcast in core, so services never touch UDP directly.
|
||||
|
||||
### Message format
|
||||
|
||||
- UDP payload prefix: `CLUSTER_EVENT:`
|
||||
- JSON body:
|
||||
```json
|
||||
{
|
||||
"event": "<event-name>",
|
||||
"data": "<json-string>" // or an inline JSON object/array
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The receiver accepts `data` as either a JSON string or a nested JSON object/array. Nested JSON is serialized back to a string before firing the local event.
|
||||
- Keep payloads small (UDP, default buffer 512 bytes).
|
||||
|
||||
### Core responsibilities
|
||||
|
||||
- `ClusterManager` registers a centralized handler:
|
||||
- Subscribes to `cluster/broadcast` to send the provided event JSON over UDP broadcast.
|
||||
- Listens for incoming UDP `CLUSTER_EVENT` messages and forwards them to local subscribers via `ctx.fire(event, data)`.
|
||||
- Broadcast target uses subnet-directed broadcast (e.g., `192.168.1.255`) for better reliability. Both nodes must share the same `udp_port`.
|
||||
|
||||
### Service responsibilities
|
||||
|
||||
Services send and receive events using the local event bus.
|
||||
|
||||
1) Subscribe to an event name and apply state from `data`:
|
||||
```cpp
|
||||
ctx.on("api/neopattern", [this](void* dataPtr) {
|
||||
String* jsonStr = static_cast<String*>(dataPtr);
|
||||
if (!jsonStr) return;
|
||||
JsonDocument doc;
|
||||
if (deserializeJson(doc, *jsonStr)) return;
|
||||
JsonObject obj = doc.as<JsonObject>();
|
||||
// Parse and apply fields from obj
|
||||
});
|
||||
```
|
||||
|
||||
2) Build a control payload and update locally via the same event:
|
||||
```cpp
|
||||
JsonDocument payload;
|
||||
payload["pattern"] = "rainbow_cycle"; // example
|
||||
payload["brightness"] = 100;
|
||||
String payloadStr; serializeJson(payload, payloadStr);
|
||||
ctx.fire("api/neopattern", &payloadStr);
|
||||
``;
|
||||
|
||||
3) Broadcast to peers by delegating to core:
|
||||
```cpp
|
||||
JsonDocument envelope;
|
||||
envelope["event"] = "api/neopattern";
|
||||
envelope["data"] = payloadStr; // JSON string
|
||||
String eventJson; serializeJson(envelope, eventJson);
|
||||
ctx.fire("cluster/broadcast", &eventJson);
|
||||
```
|
||||
|
||||
With this flow, services have a single codepath for applying state (the event handler). Broadcasting simply reuses the same payload.
|
||||
|
||||
### Logging
|
||||
|
||||
- Core logs source IP, payload length, and event name for received `CLUSTER_EVENT`s.
|
||||
- Services can log when submitting `cluster/broadcast` and when applying control events.
|
||||
|
||||
### Networking considerations
|
||||
|
||||
- Ensure all nodes:
|
||||
- Listen on the same `udp_port`.
|
||||
- Are in the same subnet (for subnet-directed broadcast).
|
||||
- Some networks may block global broadcast (`255.255.255.255`). Subnet-directed broadcast is used by default.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- If peers do not react:
|
||||
- Confirm logs show `CLUSTER_EVENT raw from <ip>` on the receiver.
|
||||
- Verify UDP port alignment and WiFi connection/subnet.
|
||||
- Check payload size (<512 bytes by default) and JSON validity.
|
||||
- Ensure the service subscribed to the correct `event` name and handles `data`.
|
||||
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
# 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
|
||||
@@ -20,99 +20,57 @@
|
||||
|
||||
```
|
||||
spore/
|
||||
├── src/ # Source code (framework under src/spore)
|
||||
│ └── spore/
|
||||
│ ├── Spore.cpp # Framework lifecycle (setup/begin/loop)
|
||||
│ ├── core/ # Core components
|
||||
│ │ ├── ApiServer.cpp # HTTP API server implementation
|
||||
│ │ ├── ClusterManager.cpp # Cluster management logic
|
||||
│ │ ├── NetworkManager.cpp # WiFi and network handling
|
||||
│ │ ├── TaskManager.cpp # Background task management
|
||||
│ │ └── NodeContext.cpp # Central context and events
|
||||
│ ├── services/ # Built-in services (implement Service interface)
|
||||
│ │ ├── NodeService.cpp # registerEndpoints() + registerTasks()
|
||||
│ │ ├── NetworkService.cpp # registerEndpoints() + registerTasks()
|
||||
│ │ ├── ClusterService.cpp # registerEndpoints() + registerTasks()
|
||||
│ │ ├── TaskService.cpp # registerEndpoints() + registerTasks()
|
||||
│ │ ├── StaticFileService.cpp # registerEndpoints() + registerTasks()
|
||||
│ │ └── MonitoringService.cpp # registerEndpoints() + registerTasks()
|
||||
│ └── types/ # Shared types
|
||||
├── src/ # Source code
|
||||
│ ├── main.cpp # Main application entry point
|
||||
│ ├── ApiServer.cpp # HTTP API server implementation
|
||||
│ ├── ClusterManager.cpp # Cluster management logic
|
||||
│ ├── NetworkManager.cpp # WiFi and network handling
|
||||
│ ├── TaskManager.cpp # Background task management
|
||||
│ └── NodeContext.cpp # Central context and events
|
||||
├── include/ # Header files
|
||||
├── examples/ # Example apps per env (base, relay, neopattern)
|
||||
├── lib/ # Library files
|
||||
├── docs/ # Documentation
|
||||
├── api/ # OpenAPI specification
|
||||
├── platformio.ini # PlatformIO configuration
|
||||
└── ctl.sh # Build and deployment scripts
|
||||
├── examples/ # Example code
|
||||
├── test/ # Test files
|
||||
├── platformio.ini # PlatformIO configuration
|
||||
└── ctl.sh # Build and deployment scripts
|
||||
```
|
||||
|
||||
## PlatformIO Configuration
|
||||
|
||||
### Framework and Board
|
||||
|
||||
The project uses PlatformIO with the following configuration (excerpt):
|
||||
The project uses PlatformIO with the following configuration:
|
||||
|
||||
```ini
|
||||
[platformio]
|
||||
default_envs = base
|
||||
src_dir = .
|
||||
data_dir = ${PROJECT_DIR}/examples/${PIOENV}/data
|
||||
|
||||
[common]
|
||||
monitor_speed = 115200
|
||||
lib_deps =
|
||||
esp32async/ESPAsyncWebServer@^3.8.0
|
||||
bblanchon/ArduinoJson@^7.4.2
|
||||
|
||||
[env:base]
|
||||
[env:esp01_1m]
|
||||
platform = platformio/espressif8266@^4.2.1
|
||||
board = esp01_1m
|
||||
framework = arduino
|
||||
upload_speed = 115200
|
||||
monitor_speed = 115200
|
||||
board_build.f_cpu = 80000000L
|
||||
board_build.flash_mode = qio
|
||||
board_build.filesystem = littlefs
|
||||
; note: somehow partition table is not working, so we need to use the ldscript
|
||||
board_build.ldscript = eagle.flash.1m64.ld
|
||||
lib_deps = ${common.lib_deps}
|
||||
build_src_filter =
|
||||
+<examples/base/*.cpp>
|
||||
+<src/spore/*.cpp>
|
||||
+<src/spore/core/*.cpp>
|
||||
+<src/spore/services/*.cpp>
|
||||
+<src/spore/types/*.cpp>
|
||||
+<src/spore/util/*.cpp>
|
||||
+<src/internal/*.cpp>
|
||||
|
||||
[env:d1_mini]
|
||||
platform = platformio/espressif8266@^4.2.1
|
||||
board = d1_mini
|
||||
framework = arduino
|
||||
upload_speed = 115200
|
||||
monitor_speed = 115200
|
||||
board_build.filesystem = littlefs
|
||||
board_build.flash_mode = dio ; D1 Mini uses DIO on 4 Mbit flash
|
||||
board_build.flash_size = 4M
|
||||
board_build.ldscript = eagle.flash.4m1m.ld
|
||||
lib_deps = ${common.lib_deps}
|
||||
build_src_filter =
|
||||
+<examples/base/*.cpp>
|
||||
+<src/spore/*.cpp>
|
||||
+<src/spore/core/*.cpp>
|
||||
+<src/spore/services/*.cpp>
|
||||
+<src/spore/types/*.cpp>
|
||||
+<src/spore/util/*.cpp>
|
||||
+<src/internal/*.cpp>
|
||||
flash_mode = dout
|
||||
```
|
||||
|
||||
### Key Configuration Details
|
||||
|
||||
- **Framework**: Arduino
|
||||
- **Board**: ESP-01 with 1MB flash
|
||||
- **Upload Speed**: 115200 baud
|
||||
- **Flash Mode**: DOUT (required for ESP-01S)
|
||||
- **Build Type**: Release (optimized for production)
|
||||
|
||||
### Dependencies
|
||||
|
||||
The project requires the following libraries (resolved via PlatformIO):
|
||||
The project requires the following libraries:
|
||||
|
||||
```ini
|
||||
lib_deps =
|
||||
esp32async/ESPAsyncWebServer@^3.8.0
|
||||
bblanchon/ArduinoJson@^7.4.2
|
||||
arkhipenko/TaskScheduler@^3.8.5
|
||||
ESP8266HTTPClient@1.2
|
||||
ESP8266WiFi@1.0
|
||||
```
|
||||
|
||||
### Filesystem, Linker Scripts, and Flash Layout
|
||||
@@ -145,6 +103,7 @@ Notes:
|
||||
- If you need a different FS size, select an appropriate ldscript variant and keep `board_build.filesystem = littlefs`.
|
||||
- On ESP8266, custom partition CSVs are not used for layout; the linker script defines the flash map. This project removed prior `board_build.partitions` usage in favor of explicit `board_build.ldscript` entries per environment.
|
||||
|
||||
|
||||
## Building
|
||||
|
||||
### Basic Build Commands
|
||||
@@ -349,7 +308,7 @@ export API_NODE=192.168.1.100
|
||||
Key configuration files:
|
||||
|
||||
- **`platformio.ini`**: Build and upload configuration
|
||||
- **`src/spore/types/Config.cpp`**: Default runtime configuration
|
||||
- **`src/Config.cpp`**: Application configuration
|
||||
- **`.env`**: Environment variables
|
||||
- **`ctl.sh`**: Build and deployment scripts
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
# Monitoring Service
|
||||
|
||||
Exposes system resource metrics via HTTP for observability.
|
||||
|
||||
## Overview
|
||||
|
||||
- **Service name**: `MonitoringService`
|
||||
- **Endpoint**: `GET /api/monitoring/resources`
|
||||
- **Metrics**: CPU usage, memory, filesystem, uptime
|
||||
|
||||
## Endpoint
|
||||
|
||||
### GET /api/monitoring/resources
|
||||
|
||||
Returns real-time system resource metrics.
|
||||
|
||||
Response fields:
|
||||
- `cpu.current_usage`: Current CPU usage percent
|
||||
- `cpu.average_usage`: Average CPU usage percent
|
||||
- `cpu.max_usage`: Max observed CPU usage
|
||||
- `cpu.min_usage`: Min observed CPU usage
|
||||
- `cpu.measurement_count`: Number of measurements
|
||||
- `cpu.is_measuring`: Whether measurement is active
|
||||
- `memory.free_heap`: Free heap bytes
|
||||
- `memory.total_heap`: Total heap bytes (approximate)
|
||||
- `memory.min_free_heap`: Minimum free heap (0 on ESP8266)
|
||||
- `memory.max_alloc_heap`: Max allocatable heap (0 on ESP8266)
|
||||
- `memory.heap_fragmentation`: Fragmentation percent (0 on ESP8266)
|
||||
- `filesystem.total_bytes`: LittleFS total bytes
|
||||
- `filesystem.used_bytes`: Used bytes
|
||||
- `filesystem.free_bytes`: Free bytes
|
||||
- `filesystem.usage_percent`: Usage percent
|
||||
- `system.uptime_ms`: Uptime in milliseconds
|
||||
- `system.uptime_seconds`: Uptime in seconds
|
||||
- `system.uptime_formatted`: Human-readable uptime
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"cpu": {
|
||||
"current_usage": 3.5,
|
||||
"average_usage": 2.1,
|
||||
"max_usage": 15.2,
|
||||
"min_usage": 0.0,
|
||||
"measurement_count": 120,
|
||||
"is_measuring": true
|
||||
},
|
||||
"memory": {
|
||||
"free_heap": 48748,
|
||||
"total_heap": 81920,
|
||||
"min_free_heap": 0,
|
||||
"max_alloc_heap": 0,
|
||||
"heap_fragmentation": 0,
|
||||
"heap_usage_percent": 40.4
|
||||
},
|
||||
"filesystem": {
|
||||
"total_bytes": 65536,
|
||||
"used_bytes": 10240,
|
||||
"free_bytes": 55296,
|
||||
"usage_percent": 15.6
|
||||
},
|
||||
"system": {
|
||||
"uptime_ms": 123456,
|
||||
"uptime_seconds": 123,
|
||||
"uptime_formatted": "0h 2m 3s"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `MonitoringService` reads from `CpuUsage` and ESP8266 SDK APIs.
|
||||
- Filesystem metrics are gathered from LittleFS.
|
||||
- CPU measurement is bracketed by `Spore::loop()` calling `cpuUsage.startMeasurement()` and `cpuUsage.endMeasurement()`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If `filesystem.total_bytes` is zero, ensure LittleFS is enabled in `platformio.ini` and an FS image is uploaded.
|
||||
- CPU usage values remain zero until the main loop runs and CPU measurement is started.
|
||||
@@ -15,8 +15,15 @@ Complete API reference with detailed endpoint documentation, examples, and integ
|
||||
- Task management workflows
|
||||
- Cluster monitoring examples
|
||||
|
||||
### 📖 [MonitoringService.md](./MonitoringService.md)
|
||||
System resource monitoring API for CPU, memory, filesystem, and uptime.
|
||||
### 📖 [TaskManager.md](./TaskManager.md)
|
||||
Comprehensive guide to the TaskManager system for background task management.
|
||||
|
||||
**Includes:**
|
||||
- Basic usage examples
|
||||
- Advanced binding techniques
|
||||
- Task status monitoring
|
||||
- API integration details
|
||||
- Performance considerations
|
||||
|
||||
### 📖 [TaskManagement.md](./TaskManagement.md)
|
||||
Complete guide to the task management system with examples and best practices.
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
## Streaming API (WebSocket)
|
||||
|
||||
### Overview
|
||||
|
||||
The streaming API exposes an event-driven WebSocket at `/ws`. It bridges between external clients and the internal event bus:
|
||||
|
||||
- Incoming WebSocket JSON `{ event, payload }` → `ctx.fire(event, payload)`
|
||||
- Local events → broadcasted to all connected WebSocket clients as `{ event, payload }`
|
||||
|
||||
This allows real-time control and observation of the system without polling.
|
||||
|
||||
### URL
|
||||
|
||||
- `ws://<device-ip>/ws`
|
||||
|
||||
### Message Format
|
||||
|
||||
- Client → Device
|
||||
```json
|
||||
{
|
||||
"event": "<event-name>",
|
||||
"payload": "<json-string>" | { /* inline JSON */ }
|
||||
}
|
||||
```
|
||||
|
||||
- Device → Client
|
||||
```json
|
||||
{
|
||||
"event": "<event-name>",
|
||||
"payload": "<json-string>"
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The device accepts `payload` as a string or a JSON object/array. Objects are serialized into a string before dispatching to local subscribers to keep a consistent downstream contract.
|
||||
- A minimal ack `{ "ok": true }` is sent after a valid inbound message.
|
||||
|
||||
#### Echo suppression (origin tagging)
|
||||
|
||||
- To prevent the sender from receiving an immediate echo of its own message, the server injects a private field into JSON payloads:
|
||||
- `_origin: "ws:<clientId>"`
|
||||
- When re-broadcasting local events to WebSocket clients, the server:
|
||||
- Strips the `_origin` field from the outgoing payload
|
||||
- Skips the originating `clientId` so only other clients receive the message
|
||||
- If a payload is not valid JSON (plain string), no origin tag is injected and the message may be echoed
|
||||
|
||||
### Event Bus Integration
|
||||
|
||||
- The WebSocket registers an `onAny` subscriber to `NodeContext` so that all local events are mirrored to clients.
|
||||
- Services should subscribe to specific events via `ctx.on("<name>", ...)`.
|
||||
|
||||
### Examples
|
||||
|
||||
1) Set a solid color on NeoPattern:
|
||||
```json
|
||||
{
|
||||
"event": "api/neopattern/color",
|
||||
"payload": { "color": "#FF0000", "brightness": 128 }
|
||||
}
|
||||
```
|
||||
|
||||
2) Broadcast a cluster event (delegated to core):
|
||||
```json
|
||||
{
|
||||
"event": "cluster/broadcast",
|
||||
"payload": {
|
||||
"event": "api/neopattern/color",
|
||||
"data": { "color": "#00FF00", "brightness": 128 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reference Implementation
|
||||
|
||||
- WebSocket setup and bridging are implemented in `ApiServer`.
|
||||
- Global event subscription uses `NodeContext::onAny`.
|
||||
|
||||
Related docs:
|
||||
- [`ClusterBroadcast.md`](./ClusterBroadcast.md) — centralized UDP broadcasting and CLUSTER_EVENT format
|
||||
|
||||
### Things to consider
|
||||
|
||||
- High-frequency updates can overwhelm ESP8266:
|
||||
- Frequent JSON parse/serialize and `String` allocations fragment heap and may cause resets (e.g., Exception(3)).
|
||||
- UDP broadcast on every message amplifies load; WiFi/UDP buffers can back up.
|
||||
- Prefer ≥50–100 ms intervals; microbursts at 10 ms are risky.
|
||||
- Throttle and coalesce:
|
||||
- Add a minimum interval in the core `cluster/broadcast` handler.
|
||||
- Optionally drop redundant updates (e.g., same color as previous).
|
||||
- Reduce allocations:
|
||||
- Reuse `StaticJsonDocument`/preallocated buffers in hot paths.
|
||||
- Avoid re-serializing when possible; pass-through payload strings.
|
||||
- Reserve `String` capacity when reuse is needed.
|
||||
- Yielding:
|
||||
- Call `yield()` in long-running or bursty paths to avoid WDT.
|
||||
- Packet size:
|
||||
- Keep payloads small to fit `ClusterProtocol::UDP_BUF_SIZE` and reduce airtime.
|
||||
|
||||
@@ -11,31 +11,6 @@ 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
|
||||
@@ -52,46 +27,6 @@ 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)
|
||||
@@ -218,62 +153,7 @@ taskManager.registerTask("lambda_task", 2000,
|
||||
|
||||
## Adding Custom Tasks
|
||||
|
||||
### 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
|
||||
### Method 1: Using std::bind (Recommended)
|
||||
|
||||
1. **Create your service class**:
|
||||
```cpp
|
||||
@@ -301,7 +181,7 @@ taskManager.registerTask("lambda_task", 2000,
|
||||
std::bind(&SensorService::calibrateSensors, &sensors));
|
||||
```
|
||||
|
||||
### Method 3: Traditional Functions
|
||||
### Method 2: Traditional Functions
|
||||
|
||||
1. **Define your task function**:
|
||||
```cpp
|
||||
@@ -428,80 +308,38 @@ curl -X POST http://192.168.1.100/api/tasks/control \
|
||||
|
||||
## Best Practices
|
||||
|
||||
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
|
||||
1. **Use std::bind for member functions**: Cleaner than wrapper functions
|
||||
2. **Group related tasks**: Register multiple related operations in a single task
|
||||
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 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
|
||||
## Migration from Wrapper Functions
|
||||
|
||||
### Before (with wrapper functions):
|
||||
```cpp
|
||||
void discoverySendTask() { cluster.sendDiscovery(); }
|
||||
void clusterListenTask() { cluster.listen(); }
|
||||
void discoveryListenTask() { cluster.listenForDiscovery(); }
|
||||
|
||||
taskManager.registerTask("discovery_send", interval, discoverySendTask);
|
||||
taskManager.registerTask("cluster_listen", interval, clusterListenTask);
|
||||
taskManager.registerTask("discovery_listen", interval, discoveryListenTask);
|
||||
```
|
||||
|
||||
### After (with std::bind):
|
||||
```cpp
|
||||
taskManager.registerTask("discovery_send", interval,
|
||||
std::bind(&ClusterManager::sendDiscovery, &cluster));
|
||||
taskManager.registerTask("cluster_listen", interval,
|
||||
std::bind(&ClusterManager::listen, &cluster));
|
||||
taskManager.registerTask("discovery_listen", interval,
|
||||
std::bind(&ClusterManager::listenForDiscovery, &cluster));
|
||||
```
|
||||
|
||||
## Compatibility
|
||||
|
||||
- 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
|
||||
- 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
|
||||
- All existing TaskManager methods remain unchanged
|
||||
- The Service interface provides a cleaner, more organized approach for framework integration
|
||||
- New status monitoring methods are additive and don't break existing functionality
|
||||
|
||||
## Related Documentation
|
||||
|
||||
|
||||
@@ -1,478 +0,0 @@
|
||||
# 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
|
||||
@@ -1,248 +0,0 @@
|
||||
#include "MultiMatrixService.h"
|
||||
#include "spore/core/ApiServer.h"
|
||||
#include "spore/util/Logging.h"
|
||||
#include <ArduinoJson.h>
|
||||
#include <SoftwareSerial.h>
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t DEFAULT_VOLUME = 15;
|
||||
constexpr char API_STATUS_ENDPOINT[] = "/api/audio/status";
|
||||
constexpr char API_CONTROL_ENDPOINT[] = "/api/audio";
|
||||
constexpr char EVENT_TOPIC[] = "audio/player";
|
||||
}
|
||||
|
||||
MultiMatrixService::MultiMatrixService(NodeContext& ctx, TaskManager& taskManager, uint8_t rxPin, uint8_t txPin, uint8_t potentiometerPin)
|
||||
: m_ctx(ctx),
|
||||
m_taskManager(taskManager),
|
||||
m_serial(std::make_unique<SoftwareSerial>(rxPin, txPin)),
|
||||
m_potentiometerPin(potentiometerPin),
|
||||
m_volume(DEFAULT_VOLUME),
|
||||
m_playerReady(false),
|
||||
m_loopEnabled(false) {
|
||||
pinMode(m_potentiometerPin, INPUT);
|
||||
m_serial->begin(9600);
|
||||
|
||||
// DFPlayer Mini requires time to initialize after power-on
|
||||
delay(1000);
|
||||
|
||||
if (m_player.begin(*m_serial)) {
|
||||
m_playerReady = true;
|
||||
m_player.setTimeOut(500);
|
||||
m_player.EQ(DFPLAYER_EQ_NORMAL);
|
||||
m_player.volume(m_volume);
|
||||
LOG_INFO("MultiMatrixService", "DFPlayer initialized successfully");
|
||||
publishEvent("ready");
|
||||
} else {
|
||||
LOG_ERROR("MultiMatrixService", "Failed to initialize DFPlayer");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
#include <algorithm>
|
||||
#include <memory>
|
||||
#include <SoftwareSerial.h>
|
||||
#include <DFRobotDFPlayerMini.h>
|
||||
#include "spore/Service.h"
|
||||
#include "spore/core/TaskManager.h"
|
||||
#include "spore/core/NodeContext.h"
|
||||
|
||||
class ApiServer;
|
||||
class AsyncWebServerRequest;
|
||||
class MultiMatrixService : public Service {
|
||||
public:
|
||||
MultiMatrixService(NodeContext& ctx, TaskManager& taskManager, uint8_t rxPin, uint8_t txPin, uint8_t potentiometerPin);
|
||||
void registerEndpoints(ApiServer& api) override;
|
||||
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;
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
# Multi-Matrix
|
||||
|
||||
This example combines different capabilities:
|
||||
- Spore base stack
|
||||
- use PixelStreamController in Matrix mode 16x16
|
||||
- DFRobotDFPlayerMini audio playback
|
||||
- analog potentiometer to controll audio volume
|
||||
- API and web interface to control the audio player (start, stop, pause, next / previous track, volume)
|
||||
|
||||
## MCU
|
||||
- Wemos D1 Mini
|
||||
|
||||
## Pin Configuration
|
||||
```
|
||||
#define MP3PLAYER_PIN_RX D3
|
||||
#define MP3PLAYER_PIN_TX D4
|
||||
#define MATRIX_PIN D2
|
||||
#define POTI_PIN A0
|
||||
```
|
||||
@@ -1,508 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Multi-Matrix Audio Player</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.player-container {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
padding: 3rem 2.5rem;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.player-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.player-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 1rem;
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
border-radius: 20px;
|
||||
font-size: 0.875rem;
|
||||
color: #667eea;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #10b981;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-dot.inactive {
|
||||
background: #ef4444;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.main-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
margin: 2.5rem 0;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
|
||||
.control-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
font-size: 1.5rem;
|
||||
box-shadow: 0 6px 25px rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
|
||||
.secondary-controls {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 2rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
padding: 0.6rem 1.25rem;
|
||||
border: 2px solid #667eea;
|
||||
background: white;
|
||||
color: #667eea;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.secondary-btn:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.secondary-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.volume-section {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.volume-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.volume-value {
|
||||
color: #667eea;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(to right, #667eea 0%, #764ba2 100%);
|
||||
outline: none;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.volume-slider:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(102, 126, 234, 0.5);
|
||||
border: 2px solid #667eea;
|
||||
}
|
||||
|
||||
.volume-slider::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(102, 126, 234, 0.5);
|
||||
border: 2px solid #667eea;
|
||||
}
|
||||
|
||||
.loop-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
border-radius: 12px;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.loop-label {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 52px;
|
||||
height: 28px;
|
||||
background: #cbd5e1;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.toggle-switch.active {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.toggle-switch.active .toggle-slider {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.player-container {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.player-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="player-container">
|
||||
<div class="player-header">
|
||||
<h1 class="player-title">Multi-Matrix Audio</h1>
|
||||
<div class="status-badge">
|
||||
<span class="status-dot" id="statusDot"></span>
|
||||
<span id="statusText">Connecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-controls">
|
||||
<button class="control-btn" onclick="sendAction('previous')" title="Previous">
|
||||
<span class="icon">⏮</span>
|
||||
</button>
|
||||
<button class="control-btn play-btn" id="playBtn" onclick="togglePlayPause()" title="Play/Pause">
|
||||
<span class="icon" id="playIcon">▶</span>
|
||||
</button>
|
||||
<button class="control-btn" onclick="sendAction('next')" title="Next">
|
||||
<span class="icon">⏭</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="secondary-controls">
|
||||
<button class="secondary-btn" onclick="sendAction('stop')">⏹ Stop</button>
|
||||
<button class="secondary-btn" onclick="sendAction('resume')">▶ Resume</button>
|
||||
</div>
|
||||
|
||||
<div class="volume-section">
|
||||
<div class="volume-header">
|
||||
<span>🔊 Volume</span>
|
||||
<span class="volume-value" id="volumeDisplay">15</span>
|
||||
</div>
|
||||
<input type="range"
|
||||
class="volume-slider"
|
||||
id="volumeSlider"
|
||||
min="0"
|
||||
max="30"
|
||||
value="15"
|
||||
oninput="updateVolumeDisplay(this.value)"
|
||||
onchange="setVolume(this.value)">
|
||||
</div>
|
||||
|
||||
<div class="loop-toggle">
|
||||
<div class="loop-label">
|
||||
<span>🔁 Loop Mode</span>
|
||||
</div>
|
||||
<div class="toggle-switch" id="loopToggle" onclick="toggleLoop()">
|
||||
<div class="toggle-slider"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let isPlaying = false;
|
||||
let loopEnabled = false;
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/audio/status');
|
||||
if (!response.ok) {
|
||||
throw new Error('Status request failed');
|
||||
}
|
||||
const status = await response.json();
|
||||
updateUIStatus(status);
|
||||
} catch (error) {
|
||||
document.getElementById('statusText').textContent = 'Error';
|
||||
document.getElementById('statusDot').className = 'status-dot inactive';
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateUIStatus(status) {
|
||||
const statusDot = document.getElementById('statusDot');
|
||||
const statusText = document.getElementById('statusText');
|
||||
|
||||
if (status.ready) {
|
||||
statusDot.className = 'status-dot';
|
||||
statusText.textContent = 'Ready';
|
||||
} else {
|
||||
statusDot.className = 'status-dot inactive';
|
||||
statusText.textContent = 'Not Ready';
|
||||
}
|
||||
|
||||
if ('volume' in status) {
|
||||
document.getElementById('volumeSlider').value = status.volume;
|
||||
document.getElementById('volumeDisplay').textContent = status.volume;
|
||||
}
|
||||
|
||||
if ('loop' in status) {
|
||||
loopEnabled = status.loop;
|
||||
const loopToggle = document.getElementById('loopToggle');
|
||||
if (loopEnabled) {
|
||||
loopToggle.classList.add('active');
|
||||
} else {
|
||||
loopToggle.classList.remove('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendAction(action, params = {}) {
|
||||
try {
|
||||
const formData = new URLSearchParams({ action, ...params });
|
||||
const response = await fetch('/api/audio', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: formData
|
||||
});
|
||||
const result = await response.json();
|
||||
updateUIStatus(result);
|
||||
|
||||
if (action === 'play' || action === 'resume') {
|
||||
isPlaying = true;
|
||||
updatePlayButton();
|
||||
} else if (action === 'pause' || action === 'stop') {
|
||||
isPlaying = false;
|
||||
updatePlayButton();
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
alert(result.message || 'Action failed');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Request failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlayPause() {
|
||||
if (isPlaying) {
|
||||
sendAction('pause');
|
||||
} else {
|
||||
sendAction('play');
|
||||
}
|
||||
}
|
||||
|
||||
function updatePlayButton() {
|
||||
const playIcon = document.getElementById('playIcon');
|
||||
playIcon.textContent = isPlaying ? '⏸' : '▶';
|
||||
}
|
||||
|
||||
function updateVolumeDisplay(value) {
|
||||
document.getElementById('volumeDisplay').textContent = value;
|
||||
}
|
||||
|
||||
function setVolume(value) {
|
||||
sendAction('volume', { volume: value });
|
||||
}
|
||||
|
||||
function toggleLoop() {
|
||||
loopEnabled = !loopEnabled;
|
||||
const loopToggle = document.getElementById('loopToggle');
|
||||
if (loopEnabled) {
|
||||
loopToggle.classList.add('active');
|
||||
} else {
|
||||
loopToggle.classList.remove('active');
|
||||
}
|
||||
sendAction('loop', { loop: loopEnabled });
|
||||
}
|
||||
|
||||
fetchStatus();
|
||||
setInterval(fetchStatus, 5000);
|
||||
|
||||
function setupWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(protocol + '//' + window.location.host + '/ws');
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
if (payload.event === 'audio/player' && payload.payload) {
|
||||
const data = JSON.parse(payload.payload);
|
||||
|
||||
// Skip debug messages
|
||||
if (data.action === 'pot_debug') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('volume' in data) {
|
||||
document.getElementById('volumeSlider').value = data.volume;
|
||||
document.getElementById('volumeDisplay').textContent = data.volume;
|
||||
}
|
||||
|
||||
if ('loop' in data) {
|
||||
loopEnabled = data.loop;
|
||||
const loopToggle = document.getElementById('loopToggle');
|
||||
if (loopEnabled) {
|
||||
loopToggle.classList.add('active');
|
||||
} else {
|
||||
loopToggle.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
if (data.action) {
|
||||
const statusText = document.getElementById('statusText');
|
||||
const statusDot = document.getElementById('statusDot');
|
||||
|
||||
if (data.action === 'play' || data.action === 'resume' || data.action === 'next' || data.action === 'previous') {
|
||||
statusText.textContent = 'Playing';
|
||||
statusDot.className = 'status-dot';
|
||||
isPlaying = true;
|
||||
updatePlayButton();
|
||||
} else if (data.action === 'pause') {
|
||||
statusText.textContent = 'Paused';
|
||||
statusDot.className = 'status-dot';
|
||||
isPlaying = false;
|
||||
updatePlayButton();
|
||||
} else if (data.action === 'stop') {
|
||||
statusText.textContent = 'Stopped';
|
||||
statusDot.className = 'status-dot inactive';
|
||||
isPlaying = false;
|
||||
updatePlayButton();
|
||||
} else if (data.action === 'ready') {
|
||||
statusText.textContent = 'Ready';
|
||||
statusDot.className = 'status-dot';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('WebSocket parse error', err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setTimeout(setupWebSocket, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
setupWebSocket();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,54 +0,0 @@
|
||||
#include <Arduino.h>
|
||||
#include <memory>
|
||||
#include "spore/Spore.h"
|
||||
#include "spore/util/Logging.h"
|
||||
#include "../pixelstream/PixelStreamController.h"
|
||||
#include "MultiMatrixService.h"
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t MATRIX_PIN = D2;
|
||||
constexpr uint16_t MATRIX_PIXEL_COUNT = 256;
|
||||
constexpr uint8_t MATRIX_BRIGHTNESS = 40;
|
||||
constexpr uint16_t MATRIX_WIDTH = 16;
|
||||
constexpr bool MATRIX_SERPENTINE = false;
|
||||
constexpr neoPixelType MATRIX_PIXEL_TYPE = NEO_GRB + NEO_KHZ800;
|
||||
constexpr uint8_t MP3PLAYER_PIN_RX = D3;
|
||||
constexpr uint8_t MP3PLAYER_PIN_TX = D4;
|
||||
constexpr uint8_t POTENTIOMETER_PIN = A0;
|
||||
}
|
||||
|
||||
Spore spore({
|
||||
{"app", "multimatrix"},
|
||||
{"role", "media"},
|
||||
{"matrix", String(MATRIX_PIXEL_COUNT)}
|
||||
});
|
||||
|
||||
std::unique_ptr<PixelStreamController> pixelController;
|
||||
std::shared_ptr<MultiMatrixService> audioService;
|
||||
|
||||
void setup() {
|
||||
spore.setup();
|
||||
|
||||
PixelStreamConfig config{
|
||||
MATRIX_PIN,
|
||||
MATRIX_PIXEL_COUNT,
|
||||
MATRIX_BRIGHTNESS,
|
||||
MATRIX_WIDTH,
|
||||
MATRIX_SERPENTINE,
|
||||
MATRIX_PIXEL_TYPE
|
||||
};
|
||||
|
||||
pixelController = std::make_unique<PixelStreamController>(spore.getContext(), config);
|
||||
pixelController->begin();
|
||||
|
||||
audioService = std::make_shared<MultiMatrixService>(spore.getContext(), spore.getTaskManager(), MP3PLAYER_PIN_RX, MP3PLAYER_PIN_TX, POTENTIOMETER_PIN);
|
||||
spore.registerService(audioService);
|
||||
|
||||
spore.begin();
|
||||
|
||||
LOG_INFO("MultiMatrix", "Setup complete");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
spore.loop();
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
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();
|
||||
@@ -12,21 +13,40 @@ 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() {
|
||||
// No frameBuffer to clean up
|
||||
if (frameBuffer) {
|
||||
free(frameBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
// Removed unused handleStream and drawFrameBuffer functions
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
void NeoPattern::onCompleteDefault(int pixels)
|
||||
{
|
||||
//Serial.println("onCompleteDefault");
|
||||
// FIXME no specific code
|
||||
if (ActivePattern == THEATER_CHASE || ActivePattern == RAINBOW_CYCLE)
|
||||
if (ActivePattern == THEATER_CHASE)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -107,12 +127,7 @@ void NeoPattern::RainbowCycleUpdate()
|
||||
setPixelColor(i, Wheel(((i * 256 / numPixels()) + Index) & 255));
|
||||
}
|
||||
show();
|
||||
// RainbowCycle is continuous, just increment Index
|
||||
Index++;
|
||||
if (Index >= 255)
|
||||
{
|
||||
Index = 0;
|
||||
}
|
||||
Increment();
|
||||
}
|
||||
|
||||
// Initialize for a Theater Chase
|
||||
|
||||
@@ -50,14 +50,19 @@ class NeoPattern : public Adafruit_NeoPixel
|
||||
uint16_t completed = 0;
|
||||
|
||||
// Callback on completion of pattern
|
||||
void (*OnComplete)(int);
|
||||
void (*OnComplete)(int);
|
||||
|
||||
uint8_t *frameBuffer;
|
||||
int bufferSize = 0;
|
||||
|
||||
// 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 functions removed
|
||||
// Stream handling
|
||||
void handleStream(uint8_t *data, size_t len);
|
||||
void drawFrameBuffer(int w, uint8_t *frame, int length);
|
||||
|
||||
// Pattern completion
|
||||
void onCompleteDefault(int pixels);
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
#include "NeoPatternService.h"
|
||||
#include "spore/core/ApiServer.h"
|
||||
#include "spore/util/Logging.h"
|
||||
#include "spore/internal/Globals.h"
|
||||
#include <ArduinoJson.h>
|
||||
#include <ESP8266WiFi.h>
|
||||
|
||||
NeoPatternService::NeoPatternService(NodeContext& ctx, TaskManager& taskMgr, const NeoPixelConfig& config)
|
||||
NeoPatternService::NeoPatternService(TaskManager& taskMgr, const NeoPixelConfig& config)
|
||||
: taskManager(taskMgr),
|
||||
ctx(ctx),
|
||||
config(config),
|
||||
activePattern(NeoPatternType::RAINBOW_CYCLE),
|
||||
direction(NeoDirection::FORWARD),
|
||||
@@ -34,7 +31,7 @@ NeoPatternService::NeoPatternService(NodeContext& ctx, TaskManager& taskMgr, con
|
||||
neoPattern->Direction = static_cast<::direction>(direction);
|
||||
|
||||
registerPatterns();
|
||||
registerEventHandlers();
|
||||
registerTasks();
|
||||
initialized = true;
|
||||
|
||||
LOG_INFO("NeoPattern", "Service initialized");
|
||||
@@ -48,17 +45,17 @@ NeoPatternService::~NeoPatternService() {
|
||||
|
||||
void NeoPatternService::registerEndpoints(ApiServer& api) {
|
||||
// Status endpoint
|
||||
api.registerEndpoint("/api/neopattern/status", HTTP_GET,
|
||||
api.addEndpoint("/api/neopattern/status", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
// Patterns list endpoint
|
||||
api.registerEndpoint("/api/neopattern/patterns", HTTP_GET,
|
||||
api.addEndpoint("/api/neopattern/patterns", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handlePatternsRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
// Control endpoint
|
||||
api.registerEndpoint("/api/neopattern", HTTP_POST,
|
||||
api.addEndpoint("/api/neopattern", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleControlRequest(request); },
|
||||
std::vector<ParamSpec>{
|
||||
ParamSpec{String("pattern"), false, String("body"), String("string"), patternNamesVector()},
|
||||
@@ -67,12 +64,11 @@ void NeoPatternService::registerEndpoints(ApiServer& api) {
|
||||
ParamSpec{String("brightness"), false, String("body"), String("numberRange"), {}, String("80")},
|
||||
ParamSpec{String("total_steps"), false, String("body"), String("numberRange"), {}, String("16")},
|
||||
ParamSpec{String("direction"), false, String("body"), String("string"), {String("forward"), String("reverse")}},
|
||||
ParamSpec{String("interval"), false, String("body"), String("number"), {}, String("100")},
|
||||
ParamSpec{String("broadcast"), false, String("body"), String("boolean"), {}}
|
||||
ParamSpec{String("interval"), false, String("body"), String("number"), {}, String("100")}
|
||||
});
|
||||
|
||||
// State endpoint for complex state updates
|
||||
api.registerEndpoint("/api/neopattern/state", HTTP_POST,
|
||||
api.addEndpoint("/api/neopattern/state", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleStateRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
}
|
||||
@@ -123,49 +119,61 @@ void NeoPatternService::handlePatternsRequest(AsyncWebServerRequest* request) {
|
||||
|
||||
void NeoPatternService::handleControlRequest(AsyncWebServerRequest* request) {
|
||||
bool updated = false;
|
||||
bool broadcast = false;
|
||||
|
||||
if (request->hasParam("broadcast", true)) {
|
||||
String b = request->getParam("broadcast", true)->value();
|
||||
broadcast = b.equalsIgnoreCase("true") || b == "1";
|
||||
if (request->hasParam("pattern", true)) {
|
||||
String name = request->getParam("pattern", true)->value();
|
||||
if (isValidPattern(name)) {
|
||||
setPatternByName(name);
|
||||
updated = true;
|
||||
} else {
|
||||
// Invalid pattern name - could add error handling here
|
||||
LOG_WARN("NeoPattern", "Invalid pattern name: " + name);
|
||||
}
|
||||
}
|
||||
|
||||
// Build JSON payload from provided params (single source of truth)
|
||||
JsonDocument payload;
|
||||
bool any = false;
|
||||
if (request->hasParam("pattern", true)) { payload["pattern"] = request->getParam("pattern", true)->value(); any = true; }
|
||||
if (request->hasParam("color", true)) { payload["color"] = request->getParam("color", true)->value(); any = true; }
|
||||
if (request->hasParam("color2", true)) { payload["color2"] = request->getParam("color2", true)->value(); any = true; }
|
||||
if (request->hasParam("brightness", true)) { payload["brightness"] = request->getParam("brightness", true)->value(); any = true; }
|
||||
if (request->hasParam("total_steps", true)) { payload["total_steps"] = request->getParam("total_steps", true)->value(); any = true; }
|
||||
if (request->hasParam("direction", true)) { payload["direction"] = request->getParam("direction", true)->value(); any = true; }
|
||||
if (request->hasParam("interval", true)) { payload["interval"] = request->getParam("interval", true)->value(); any = true; }
|
||||
|
||||
String payloadStr;
|
||||
serializeJson(payload, payloadStr);
|
||||
|
||||
// Always apply locally via event so we have a single codepath for updates
|
||||
if (any) {
|
||||
std::string ev = "api/neopattern";
|
||||
String localData = payloadStr;
|
||||
LOG_INFO("NeoPattern", String("Applying local api/neopattern via event payloadLen=") + String(payloadStr.length()));
|
||||
ctx.fire(ev, &localData);
|
||||
if (request->hasParam("color", true)) {
|
||||
String colorStr = request->getParam("color", true)->value();
|
||||
uint32_t color = parseColor(colorStr);
|
||||
setColor(color);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
// Broadcast to peers if requested (delegate to core broadcast handler)
|
||||
if (broadcast && any) {
|
||||
JsonDocument eventDoc;
|
||||
eventDoc["event"] = "api/neopattern";
|
||||
eventDoc["data"] = payloadStr; // data is JSON string
|
||||
if (request->hasParam("color2", true)) {
|
||||
String colorStr = request->getParam("color2", true)->value();
|
||||
uint32_t color = parseColor(colorStr);
|
||||
setColor2(color);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
String eventJson;
|
||||
serializeJson(eventDoc, eventJson);
|
||||
if (request->hasParam("brightness", true)) {
|
||||
int b = request->getParam("brightness", true)->value().toInt();
|
||||
if (b < 0) b = 0;
|
||||
if (b > 255) b = 255;
|
||||
setBrightness(static_cast<uint8_t>(b));
|
||||
updated = true;
|
||||
}
|
||||
|
||||
LOG_INFO("NeoPattern", String("Submitting cluster/broadcast for api/neopattern payloadLen=") + String(payloadStr.length()));
|
||||
std::string ev = "cluster/broadcast";
|
||||
String eventStr = eventJson;
|
||||
ctx.fire(ev, &eventStr);
|
||||
if (request->hasParam("total_steps", true)) {
|
||||
int steps = request->getParam("total_steps", true)->value().toInt();
|
||||
if (steps > 0) {
|
||||
setTotalSteps(static_cast<uint16_t>(steps));
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (request->hasParam("direction", true)) {
|
||||
String dirStr = request->getParam("direction", true)->value();
|
||||
NeoDirection dir = (dirStr.equalsIgnoreCase("reverse")) ? NeoDirection::REVERSE : NeoDirection::FORWARD;
|
||||
setDirection(dir);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (request->hasParam("interval", true)) {
|
||||
unsigned long interval = request->getParam("interval", true)->value().toInt();
|
||||
if (interval > 0) {
|
||||
setUpdateInterval(interval);
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Return current state
|
||||
@@ -184,139 +192,6 @@ void NeoPatternService::handleControlRequest(AsyncWebServerRequest* request) {
|
||||
serializeJson(resp, json);
|
||||
request->send(200, "application/json", json);
|
||||
}
|
||||
void NeoPatternService::registerEventHandlers() {
|
||||
ctx.on("api/neopattern", [this](void* dataPtr) {
|
||||
String* jsonStr = static_cast<String*>(dataPtr);
|
||||
if (!jsonStr) {
|
||||
LOG_WARN("NeoPattern", "Received api/neopattern with null dataPtr");
|
||||
return;
|
||||
}
|
||||
LOG_INFO("NeoPattern", String("Received api/neopattern event dataLen=") + String(jsonStr->length()));
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, *jsonStr);
|
||||
if (err) {
|
||||
LOG_WARN("NeoPattern", String("Failed to parse CLUSTER_EVENT data: ") + err.c_str());
|
||||
return;
|
||||
}
|
||||
JsonObject obj = doc.as<JsonObject>();
|
||||
bool applied = applyControlParams(obj);
|
||||
if (applied) {
|
||||
LOG_INFO("NeoPattern", "Applied control from CLUSTER_EVENT");
|
||||
}
|
||||
});
|
||||
|
||||
// Solid color event: sets all pixels to the same color
|
||||
ctx.on("api/neopattern/color", [this](void* dataPtr) {
|
||||
String* jsonStr = static_cast<String*>(dataPtr);
|
||||
if (!jsonStr) {
|
||||
LOG_WARN("NeoPattern", "Received api/neopattern/color with null dataPtr");
|
||||
return;
|
||||
}
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, *jsonStr);
|
||||
if (err) {
|
||||
LOG_WARN("NeoPattern", String("Failed to parse color event data: ") + err.c_str());
|
||||
return;
|
||||
}
|
||||
JsonObject obj = doc.as<JsonObject>();
|
||||
// color can be string or number
|
||||
String colorStr;
|
||||
if (obj["color"].is<const char*>() || obj["color"].is<String>()) {
|
||||
colorStr = obj["color"].as<String>();
|
||||
} else if (obj["color"].is<long>() || obj["color"].is<int>()) {
|
||||
colorStr = String(obj["color"].as<long>());
|
||||
} else {
|
||||
LOG_WARN("NeoPattern", "api/neopattern/color missing 'color'");
|
||||
return;
|
||||
}
|
||||
|
||||
// Optional brightness
|
||||
if (obj["brightness"].is<int>() || obj["brightness"].is<long>()) {
|
||||
int b = obj["brightness"].as<int>();
|
||||
if (b < 0) b = 0; if (b > 255) b = 255;
|
||||
setBrightness(static_cast<uint8_t>(b));
|
||||
}
|
||||
|
||||
uint32_t color = parseColor(colorStr);
|
||||
setPattern(NeoPatternType::NONE);
|
||||
setColor(color);
|
||||
LOG_INFO("NeoPattern", String("Set solid color ") + colorStr);
|
||||
});
|
||||
}
|
||||
|
||||
bool NeoPatternService::applyControlParams(const JsonObject& obj) {
|
||||
bool updated = false;
|
||||
if (obj["pattern"].is<const char*>() || obj["pattern"].is<String>()) {
|
||||
String name = obj["pattern"].as<String>();
|
||||
if (isValidPattern(name)) {
|
||||
setPatternByName(name);
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
if (obj["color"].is<const char*>() || obj["color"].is<String>() || obj["color"].is<long>() || obj["color"].is<int>()) {
|
||||
String colorStr;
|
||||
if (obj["color"].is<long>() || obj["color"].is<int>()) {
|
||||
colorStr = String(obj["color"].as<long>());
|
||||
} else {
|
||||
colorStr = obj["color"].as<String>();
|
||||
}
|
||||
uint32_t color = parseColor(colorStr);
|
||||
setColor(color);
|
||||
updated = true;
|
||||
}
|
||||
if (obj["color2"].is<const char*>() || obj["color2"].is<String>() || obj["color2"].is<long>() || obj["color2"].is<int>()) {
|
||||
String colorStr;
|
||||
if (obj["color2"].is<long>() || obj["color2"].is<int>()) {
|
||||
colorStr = String(obj["color2"].as<long>());
|
||||
} else {
|
||||
colorStr = obj["color2"].as<String>();
|
||||
}
|
||||
uint32_t color = parseColor(colorStr);
|
||||
setColor2(color);
|
||||
updated = true;
|
||||
}
|
||||
if (obj["brightness"].is<int>() || obj["brightness"].is<long>() || obj["brightness"].is<const char*>() || obj["brightness"].is<String>()) {
|
||||
int b = 0;
|
||||
if (obj["brightness"].is<int>() || obj["brightness"].is<long>()) {
|
||||
b = obj["brightness"].as<int>();
|
||||
} else {
|
||||
b = String(obj["brightness"].as<String>()).toInt();
|
||||
}
|
||||
if (b < 0) {
|
||||
b = 0;
|
||||
}
|
||||
if (b > 255) {
|
||||
b = 255;
|
||||
}
|
||||
setBrightness(static_cast<uint8_t>(b));
|
||||
updated = true;
|
||||
}
|
||||
if (obj["total_steps"].is<int>() || obj["total_steps"].is<long>() || obj["total_steps"].is<const char*>() || obj["total_steps"].is<String>()) {
|
||||
int steps = 0;
|
||||
if (obj["total_steps"].is<int>() || obj["total_steps"].is<long>()) {
|
||||
steps = obj["total_steps"].as<int>();
|
||||
} else {
|
||||
steps = String(obj["total_steps"].as<String>()).toInt();
|
||||
}
|
||||
if (steps > 0) { setTotalSteps(static_cast<uint16_t>(steps)); updated = true; }
|
||||
}
|
||||
if (obj["direction"].is<const char*>() || obj["direction"].is<String>()) {
|
||||
String dirStr = obj["direction"].as<String>();
|
||||
NeoDirection dir = (dirStr.equalsIgnoreCase("reverse")) ? NeoDirection::REVERSE : NeoDirection::FORWARD;
|
||||
setDirection(dir);
|
||||
updated = true;
|
||||
}
|
||||
if (obj["interval"].is<int>() || obj["interval"].is<long>() || obj["interval"].is<const char*>() || obj["interval"].is<String>()) {
|
||||
unsigned long interval = 0;
|
||||
if (obj["interval"].is<int>() || obj["interval"].is<long>()) {
|
||||
interval = obj["interval"].as<unsigned long>();
|
||||
} else {
|
||||
interval = String(obj["interval"].as<String>()).toInt();
|
||||
}
|
||||
if (interval > 0) { setUpdateInterval(interval); updated = true; }
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
void NeoPatternService::handleStateRequest(AsyncWebServerRequest* request) {
|
||||
if (request->contentType() != "application/json") {
|
||||
@@ -339,31 +214,9 @@ void NeoPatternService::setPattern(NeoPatternType pattern) {
|
||||
currentState.pattern = static_cast<uint>(pattern);
|
||||
neoPattern->ActivePattern = static_cast<::pattern>(pattern);
|
||||
resetStateForPattern(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;
|
||||
}
|
||||
|
||||
// Initialize the pattern using the registry
|
||||
patternRegistry.initializePattern(static_cast<uint8_t>(pattern));
|
||||
}
|
||||
|
||||
void NeoPatternService::setPatternByName(const String& name) {
|
||||
@@ -424,7 +277,7 @@ NeoPatternState NeoPatternService::getState() const {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
void NeoPatternService::registerTasks(TaskManager& taskManager) {
|
||||
void NeoPatternService::registerTasks() {
|
||||
taskManager.registerTask("neopattern_update", updateIntervalMs, [this]() { update(); });
|
||||
taskManager.registerTask("neopattern_status_print", 10000, [this]() {
|
||||
LOG_INFO("NeoPattern", "Status update");
|
||||
@@ -447,7 +300,7 @@ void NeoPatternService::registerPatterns() {
|
||||
"rainbow_cycle",
|
||||
static_cast<uint8_t>(NeoPatternType::RAINBOW_CYCLE),
|
||||
"Rainbow cycle pattern",
|
||||
nullptr, // No initializer needed, state is set up in setPattern
|
||||
[this]() { neoPattern->RainbowCycle(updateIntervalMs, static_cast<::direction>(direction)); },
|
||||
[this]() { updateRainbowCycle(); },
|
||||
false, // doesn't require color2
|
||||
true // supports direction
|
||||
@@ -457,7 +310,7 @@ void NeoPatternService::registerPatterns() {
|
||||
"theater_chase",
|
||||
static_cast<uint8_t>(NeoPatternType::THEATER_CHASE),
|
||||
"Theater chase pattern",
|
||||
nullptr, // No initializer needed, state is set up in setPattern
|
||||
[this]() { neoPattern->TheaterChase(currentState.color, currentState.color2, updateIntervalMs, static_cast<::direction>(direction)); },
|
||||
[this]() { updateTheaterChase(); },
|
||||
true, // requires color2
|
||||
true // supports direction
|
||||
@@ -467,7 +320,7 @@ void NeoPatternService::registerPatterns() {
|
||||
"color_wipe",
|
||||
static_cast<uint8_t>(NeoPatternType::COLOR_WIPE),
|
||||
"Color wipe pattern",
|
||||
nullptr, // No initializer needed, state is set up in setPattern
|
||||
[this]() { neoPattern->ColorWipe(currentState.color, updateIntervalMs, static_cast<::direction>(direction)); },
|
||||
[this]() { updateColorWipe(); },
|
||||
false, // doesn't require color2
|
||||
true // supports direction
|
||||
@@ -477,7 +330,7 @@ void NeoPatternService::registerPatterns() {
|
||||
"scanner",
|
||||
static_cast<uint8_t>(NeoPatternType::SCANNER),
|
||||
"Scanner pattern",
|
||||
nullptr, // No initializer needed, state is set up in setPattern
|
||||
[this]() { neoPattern->Scanner(currentState.color, updateIntervalMs); },
|
||||
[this]() { updateScanner(); },
|
||||
false, // doesn't require color2
|
||||
false // doesn't support direction
|
||||
@@ -487,7 +340,7 @@ void NeoPatternService::registerPatterns() {
|
||||
"fade",
|
||||
static_cast<uint8_t>(NeoPatternType::FADE),
|
||||
"Fade pattern",
|
||||
nullptr, // No initializer needed, state is set up in setPattern
|
||||
[this]() { neoPattern->Fade(currentState.color, currentState.color2, currentState.totalSteps, updateIntervalMs, static_cast<::direction>(direction)); },
|
||||
[this]() { updateFade(); },
|
||||
true, // requires color2
|
||||
true // supports direction
|
||||
@@ -497,7 +350,7 @@ void NeoPatternService::registerPatterns() {
|
||||
"fire",
|
||||
static_cast<uint8_t>(NeoPatternType::FIRE),
|
||||
"Fire effect pattern",
|
||||
nullptr, // No initializer needed, state is set up in setPattern
|
||||
[this]() { neoPattern->Fire(50, 120); },
|
||||
[this]() { updateFire(); },
|
||||
false, // doesn't require color2
|
||||
false // doesn't support direction
|
||||
@@ -521,7 +374,7 @@ void NeoPatternService::resetStateForPattern(NeoPatternType pattern) {
|
||||
neoPattern->Index = 0;
|
||||
neoPattern->Direction = static_cast<::direction>(direction);
|
||||
neoPattern->completed = 0;
|
||||
// Don't reset lastUpdateMs to 0, keep the current timing
|
||||
lastUpdateMs = 0;
|
||||
}
|
||||
|
||||
uint32_t NeoPatternService::parseColor(const String& colorStr) const {
|
||||
@@ -559,10 +412,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));
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#pragma once
|
||||
#include "spore/Service.h"
|
||||
#include "spore/core/TaskManager.h"
|
||||
#include "spore/core/NodeContext.h"
|
||||
#include "NeoPattern.h"
|
||||
#include "NeoPatternState.h"
|
||||
#include "NeoPixelConfig.h"
|
||||
@@ -26,11 +25,10 @@ public:
|
||||
REVERSE
|
||||
};
|
||||
|
||||
NeoPatternService(NodeContext& ctx, TaskManager& taskMgr, const NeoPixelConfig& config);
|
||||
NeoPatternService(TaskManager& taskMgr, const NeoPixelConfig& config);
|
||||
~NeoPatternService();
|
||||
|
||||
void registerEndpoints(ApiServer& api) override;
|
||||
void registerTasks(TaskManager& taskManager) override;
|
||||
const char* getName() const override { return "NeoPattern"; }
|
||||
|
||||
// Pattern control methods
|
||||
@@ -48,10 +46,9 @@ public:
|
||||
NeoPatternState getState() const;
|
||||
|
||||
private:
|
||||
void registerTasks();
|
||||
void registerPatterns();
|
||||
void update();
|
||||
void registerEventHandlers();
|
||||
bool applyControlParams(const JsonObject& obj);
|
||||
|
||||
// Pattern updaters
|
||||
void updateRainbowCycle();
|
||||
@@ -83,7 +80,6 @@ private:
|
||||
String getPatternDescription(const String& name) const;
|
||||
|
||||
TaskManager& taskManager;
|
||||
NodeContext& ctx;
|
||||
NeoPattern* neoPattern;
|
||||
NeoPixelConfig config;
|
||||
NeoPatternState currentState;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
#endif
|
||||
|
||||
#ifndef NEOPIXEL_LENGTH
|
||||
#define NEOPIXEL_LENGTH 16
|
||||
#define NEOPIXEL_LENGTH 8
|
||||
#endif
|
||||
|
||||
#ifndef NEOPIXEL_BRIGHTNESS
|
||||
@@ -45,8 +45,8 @@ void setup() {
|
||||
);
|
||||
|
||||
// Create and add custom service
|
||||
neoPatternService = new NeoPatternService(spore.getContext(), spore.getTaskManager(), config);
|
||||
spore.registerService(neoPatternService);
|
||||
neoPatternService = new NeoPatternService(spore.getTaskManager(), config);
|
||||
spore.addService(neoPatternService);
|
||||
|
||||
// Start the API server and complete initialization
|
||||
spore.begin();
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
#include "PixelStreamController.h"
|
||||
|
||||
namespace {
|
||||
constexpr int COMPONENTS_PER_PIXEL = 3;
|
||||
}
|
||||
|
||||
PixelStreamController::PixelStreamController(NodeContext& ctxRef, const PixelStreamConfig& cfg)
|
||||
: ctx(ctxRef), config(cfg), pixels(cfg.pixelCount, cfg.pin, cfg.pixelType) {
|
||||
}
|
||||
|
||||
void PixelStreamController::begin() {
|
||||
pixels.begin();
|
||||
pixels.setBrightness(config.brightness);
|
||||
// Default all pixels to green so we can verify hardware before streaming frames
|
||||
for (uint16_t i = 0; i < config.pixelCount; ++i) {
|
||||
pixels.setPixelColor(i, pixels.Color(0, 255, 0));
|
||||
}
|
||||
pixels.show();
|
||||
|
||||
ctx.on("udp/raw", [this](void* data) {
|
||||
this->handleEvent(data);
|
||||
});
|
||||
|
||||
LOG_INFO("PixelStream", String("PixelStreamController ready on pin ") + String(config.pin) + " with " + String(config.pixelCount) + " pixels");
|
||||
}
|
||||
|
||||
void PixelStreamController::handleEvent(void* data) {
|
||||
if (data == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
String* payload = static_cast<String*>(data);
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!applyFrame(*payload)) {
|
||||
LOG_WARN("PixelStream", String("Ignoring RAW payload with invalid length (") + String(payload->length()) + ")");
|
||||
}
|
||||
}
|
||||
|
||||
bool PixelStreamController::applyFrame(const String& payload) {
|
||||
static constexpr std::size_t frameWidth = COMPONENTS_PER_PIXEL * 2;
|
||||
const std::size_t payloadLength = static_cast<std::size_t>(payload.length());
|
||||
|
||||
if (payloadLength == 0 || (payloadLength % frameWidth) != 0) {
|
||||
LOG_WARN("PixelStream", String("Payload size ") + String(payloadLength) + " is not a multiple of " + String(frameWidth));
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint16_t framesProvided = static_cast<uint16_t>(payloadLength / frameWidth);
|
||||
const uint16_t pixelsToUpdate = std::min(config.pixelCount, framesProvided);
|
||||
|
||||
for (uint16_t index = 0; index < pixelsToUpdate; ++index) {
|
||||
const std::size_t base = static_cast<std::size_t>(index) * frameWidth;
|
||||
FrameComponents components{};
|
||||
if (!tryParsePixel(payload, base, components)) {
|
||||
LOG_WARN("PixelStream", String("Invalid hex data at pixel index ") + String(index));
|
||||
return false;
|
||||
}
|
||||
const uint16_t hardwareIndex = mapPixelIndex(index);
|
||||
pixels.setPixelColor(hardwareIndex, pixels.Color(components.red, components.green, components.blue));
|
||||
}
|
||||
|
||||
// Clear any remaining pixels so stale data is removed when fewer frames are provided
|
||||
for (uint16_t index = pixelsToUpdate; index < config.pixelCount; ++index) {
|
||||
const uint16_t hardwareIndex = mapPixelIndex(index);
|
||||
pixels.setPixelColor(hardwareIndex, 0);
|
||||
}
|
||||
|
||||
pixels.show();
|
||||
return true;
|
||||
}
|
||||
|
||||
uint16_t PixelStreamController::mapPixelIndex(uint16_t logicalIndex) const {
|
||||
if (config.matrixWidth == 0) {
|
||||
return logicalIndex;
|
||||
}
|
||||
|
||||
const uint16_t row = logicalIndex / config.matrixWidth;
|
||||
const uint16_t col = logicalIndex % config.matrixWidth;
|
||||
|
||||
if (!config.matrixSerpentine || (row % 2 == 0)) {
|
||||
return row * config.matrixWidth + col;
|
||||
}
|
||||
|
||||
const uint16_t reversedCol = (config.matrixWidth - 1) - col;
|
||||
return row * config.matrixWidth + reversedCol;
|
||||
}
|
||||
|
||||
int PixelStreamController::hexToNibble(char c) {
|
||||
if (c >= '0' && c <= '9') {
|
||||
return c - '0';
|
||||
}
|
||||
if (c >= 'a' && c <= 'f') {
|
||||
return 10 + c - 'a';
|
||||
}
|
||||
if (c >= 'A' && c <= 'F') {
|
||||
return 10 + c - 'A';
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool PixelStreamController::tryParsePixel(const String& payload, std::size_t startIndex, FrameComponents& components) const {
|
||||
static constexpr std::size_t frameWidth = COMPONENTS_PER_PIXEL * 2;
|
||||
if (startIndex + frameWidth > static_cast<std::size_t>(payload.length())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const int rHi = hexToNibble(payload[startIndex]);
|
||||
const int rLo = hexToNibble(payload[startIndex + 1]);
|
||||
const int gHi = hexToNibble(payload[startIndex + 2]);
|
||||
const int gLo = hexToNibble(payload[startIndex + 3]);
|
||||
const int bHi = hexToNibble(payload[startIndex + 4]);
|
||||
const int bLo = hexToNibble(payload[startIndex + 5]);
|
||||
|
||||
if (rHi < 0 || rLo < 0 || gHi < 0 || gLo < 0 || bHi < 0 || bLo < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
components.red = static_cast<uint8_t>((rHi << 4) | rLo);
|
||||
components.green = static_cast<uint8_t>((gHi << 4) | gLo);
|
||||
components.blue = static_cast<uint8_t>((bHi << 4) | bLo);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Adafruit_NeoPixel.h>
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include "spore/core/NodeContext.h"
|
||||
#include "spore/util/Logging.h"
|
||||
|
||||
struct PixelStreamConfig {
|
||||
uint8_t pin;
|
||||
uint16_t pixelCount;
|
||||
uint8_t brightness;
|
||||
uint16_t matrixWidth;
|
||||
bool matrixSerpentine;
|
||||
neoPixelType pixelType;
|
||||
};
|
||||
|
||||
class PixelStreamController {
|
||||
public:
|
||||
PixelStreamController(NodeContext& ctx, const PixelStreamConfig& config);
|
||||
void begin();
|
||||
|
||||
private:
|
||||
struct FrameComponents {
|
||||
uint8_t red;
|
||||
uint8_t green;
|
||||
uint8_t blue;
|
||||
};
|
||||
|
||||
bool tryParsePixel(const String& payload, std::size_t startIndex, FrameComponents& components) const;
|
||||
void handleEvent(void* data);
|
||||
bool applyFrame(const String& payload);
|
||||
uint16_t mapPixelIndex(uint16_t logicalIndex) const;
|
||||
static int hexToNibble(char c);
|
||||
|
||||
NodeContext& ctx;
|
||||
PixelStreamConfig config;
|
||||
Adafruit_NeoPixel pixels;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# PixelStream Example
|
||||
|
||||
This example demonstrates how to consume the `udp/raw` cluster event and drive a NeoPixel strip or matrix directly from streamed RGB data. Frames are provided as hex encoded byte triplets (`RRGGBB` per pixel).
|
||||
|
||||
## Features
|
||||
|
||||
- Subscribes to `udp/raw` via `NodeContext::on`.
|
||||
- Converts incoming frames into pixel colors for strips or matrices.
|
||||
- Supports serpentine (zig-zag) matrix wiring.
|
||||
|
||||
## Payload Format
|
||||
|
||||
Each packet is expected to be `RAW:` followed by `pixelCount * 3 * 2` hexadecimal characters. For example, for 8 pixels:
|
||||
|
||||
```
|
||||
RAW:FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Strip Mode
|
||||
|
||||
Upload the example with `PIXEL_MATRIX_WIDTH` set to 0 (default). Send frames containing `PIXEL_COUNT * 3` bytes as hex.
|
||||
|
||||
### Matrix Mode
|
||||
|
||||
Set `PIXEL_MATRIX_WIDTH` to the number of columns. The controller remaps even/odd rows to support serpentine wiring.
|
||||
|
||||
## Configuration
|
||||
|
||||
Adjust `PIXEL_PIN`, `PIXEL_COUNT`, `PIXEL_BRIGHTNESS`, `PIXEL_MATRIX_WIDTH`, `PIXEL_MATRIX_SERPENTINE`, and `PIXEL_TYPE` through build defines or editing `main.cpp`.
|
||||
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
#include <Arduino.h>
|
||||
#include "spore/Spore.h"
|
||||
#include "spore/util/Logging.h"
|
||||
#include "PixelStreamController.h"
|
||||
|
||||
#ifndef PIXEL_PIN
|
||||
#define PIXEL_PIN 2
|
||||
#endif
|
||||
|
||||
#ifndef PIXEL_COUNT
|
||||
#define PIXEL_COUNT 16
|
||||
#endif
|
||||
|
||||
#ifndef PIXEL_BRIGHTNESS
|
||||
#define PIXEL_BRIGHTNESS 80
|
||||
#endif
|
||||
|
||||
#ifndef PIXEL_MATRIX_WIDTH
|
||||
#define PIXEL_MATRIX_WIDTH 16
|
||||
#endif
|
||||
|
||||
#ifndef PIXEL_MATRIX_SERPENTINE
|
||||
#define PIXEL_MATRIX_SERPENTINE 0
|
||||
#endif
|
||||
|
||||
#ifndef PIXEL_TYPE
|
||||
#define PIXEL_TYPE NEO_GRB + NEO_KHZ800
|
||||
#endif
|
||||
|
||||
Spore spore({
|
||||
{"app", "pixelstream"},
|
||||
{"role", "led"},
|
||||
{"pixels", String(PIXEL_COUNT)}
|
||||
});
|
||||
|
||||
PixelStreamController* controller = nullptr;
|
||||
|
||||
void setup() {
|
||||
spore.setup();
|
||||
|
||||
PixelStreamConfig config{
|
||||
static_cast<uint8_t>(PIXEL_PIN),
|
||||
static_cast<uint16_t>(PIXEL_COUNT),
|
||||
static_cast<uint8_t>(PIXEL_BRIGHTNESS),
|
||||
static_cast<uint16_t>(PIXEL_MATRIX_WIDTH),
|
||||
static_cast<bool>(PIXEL_MATRIX_SERPENTINE),
|
||||
static_cast<neoPixelType>(PIXEL_TYPE)
|
||||
};
|
||||
|
||||
controller = new PixelStreamController(spore.getContext(), config);
|
||||
controller->begin();
|
||||
|
||||
spore.begin();
|
||||
}
|
||||
|
||||
void loop() {
|
||||
spore.loop();
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ void setup() {
|
||||
spore.setup();
|
||||
|
||||
relayService = new RelayService(spore.getContext(), spore.getTaskManager(), RELAY_PIN);
|
||||
spore.registerService(relayService);
|
||||
spore.addService(relayService);
|
||||
|
||||
spore.begin();
|
||||
}
|
||||
|
||||
@@ -7,14 +7,15 @@ 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.registerEndpoint("/api/relay/status", HTTP_GET,
|
||||
api.addEndpoint("/api/relay/status", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
api.registerEndpoint("/api/relay", HTTP_POST,
|
||||
api.addEndpoint("/api/relay", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleControlRequest(request); },
|
||||
std::vector<ParamSpec>{
|
||||
ParamSpec{String("state"), true, String("body"), String("string"),
|
||||
@@ -81,7 +82,7 @@ void RelayService::toggle() {
|
||||
}
|
||||
}
|
||||
|
||||
void RelayService::registerTasks(TaskManager& taskManager) {
|
||||
void RelayService::registerTasks() {
|
||||
taskManager.registerTask("relay_status_print", 5000, [this]() {
|
||||
LOG_INFO("RelayService", "Status - pin: " + String(relayPin) + ", state: " + (relayOn ? "ON" : "OFF"));
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ 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();
|
||||
@@ -16,6 +15,8 @@ public:
|
||||
void toggle();
|
||||
|
||||
private:
|
||||
void registerTasks();
|
||||
|
||||
NodeContext& ctx;
|
||||
TaskManager& taskManager;
|
||||
int relayPin;
|
||||
|
||||
@@ -23,7 +23,7 @@ void setup() {
|
||||
|
||||
// Create and add custom service
|
||||
relayService = new RelayService(spore.getContext(), spore.getTaskManager(), RELAY_PIN);
|
||||
spore.registerService(relayService);
|
||||
spore.addService(relayService);
|
||||
|
||||
// Start the API server and complete initialization
|
||||
spore.begin();
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
#pragma once
|
||||
#include "spore/core/ApiServer.h"
|
||||
|
||||
class TaskManager;
|
||||
|
||||
class Service {
|
||||
public:
|
||||
virtual ~Service() = default;
|
||||
virtual void registerEndpoints(ApiServer& api) = 0;
|
||||
virtual void registerTasks(TaskManager& taskManager) = 0;
|
||||
virtual const char* getName() const = 0;
|
||||
};
|
||||
|
||||
@@ -25,8 +25,8 @@ public:
|
||||
void loop();
|
||||
|
||||
// Service management
|
||||
void registerService(std::shared_ptr<Service> service);
|
||||
void registerService(Service* service);
|
||||
void addService(std::shared_ptr<Service> service);
|
||||
void addService(Service* service);
|
||||
|
||||
// Access to core components
|
||||
NodeContext& getContext() { return ctx; }
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <ESPAsyncWebServer.h>
|
||||
#include <AsyncWebSocket.h>
|
||||
#include <Updater.h>
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
@@ -12,6 +11,7 @@
|
||||
#include "spore/types/NodeInfo.h"
|
||||
#include "spore/core/TaskManager.h"
|
||||
#include "spore/types/ApiTypes.h"
|
||||
#include "spore/util/Logging.h"
|
||||
|
||||
class Service; // Forward declaration
|
||||
|
||||
@@ -19,16 +19,18 @@ class ApiServer {
|
||||
public:
|
||||
ApiServer(NodeContext& ctx, TaskManager& taskMgr, uint16_t port = 80);
|
||||
void begin();
|
||||
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 registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
const std::vector<ParamSpec>& params);
|
||||
void registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
void addService(Service& service);
|
||||
void addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
const String& serviceName = "unknown");
|
||||
void addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
std::function<void(AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool)> uploadHandler,
|
||||
const std::vector<ParamSpec>& params);
|
||||
const String& serviceName = "unknown");
|
||||
|
||||
void addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
const std::vector<ParamSpec>& params, const String& serviceName = "unknown");
|
||||
void addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
std::function<void(AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool)> uploadHandler,
|
||||
const std::vector<ParamSpec>& params, const String& serviceName = "unknown");
|
||||
|
||||
// Static file serving
|
||||
void serveStatic(const String& uri, fs::FS& fs, const String& path, const String& cache_header = "");
|
||||
@@ -40,18 +42,13 @@ public:
|
||||
|
||||
private:
|
||||
AsyncWebServer server;
|
||||
AsyncWebSocket ws{ "/ws" };
|
||||
NodeContext& ctx;
|
||||
TaskManager& taskManager;
|
||||
std::vector<std::reference_wrapper<Service>> services;
|
||||
std::vector<EndpointInfo> endpoints; // Single source of truth for endpoints
|
||||
std::vector<AsyncWebSocketClient*> wsClients;
|
||||
|
||||
// Internal helpers
|
||||
void registerEndpoint(const String& uri, int method,
|
||||
const std::vector<ParamSpec>& params,
|
||||
const String& serviceName);
|
||||
|
||||
// WebSocket helpers
|
||||
void setupWebSocket();
|
||||
};
|
||||
|
||||
@@ -7,14 +7,13 @@
|
||||
#include <ArduinoJson.h>
|
||||
#include <ESP8266HTTPClient.h>
|
||||
#include <map>
|
||||
#include <vector>
|
||||
#include <functional>
|
||||
|
||||
class ClusterManager {
|
||||
public:
|
||||
ClusterManager(NodeContext& ctx, TaskManager& taskMgr);
|
||||
void registerTasks();
|
||||
void listen();
|
||||
void sendDiscovery();
|
||||
void listenForDiscovery();
|
||||
void addOrUpdateNode(const String& nodeHost, IPAddress nodeIP);
|
||||
void updateAllNodeStatuses();
|
||||
void removeDeadNodes();
|
||||
@@ -24,26 +23,7 @@ public:
|
||||
void updateLocalNodeResources();
|
||||
void heartbeatTaskCallback();
|
||||
void updateAllMembersInfoTaskCallback();
|
||||
void broadcastNodeUpdate();
|
||||
private:
|
||||
NodeContext& ctx;
|
||||
TaskManager& taskManager;
|
||||
struct MessageHandler {
|
||||
bool (*predicate)(const char*);
|
||||
std::function<void(const char*)> handle;
|
||||
const char* name;
|
||||
};
|
||||
void initMessageHandlers();
|
||||
void handleIncomingMessage(const char* incoming);
|
||||
static bool isHeartbeatMsg(const char* msg);
|
||||
static bool isNodeUpdateMsg(const char* msg);
|
||||
static bool isClusterEventMsg(const char* msg);
|
||||
static bool isRawMsg(const char* msg);
|
||||
void onHeartbeat(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;
|
||||
};
|
||||
|
||||
@@ -27,8 +27,6 @@ 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(); }
|
||||
|
||||
@@ -19,16 +19,11 @@ public:
|
||||
IPAddress localIP;
|
||||
NodeInfo self;
|
||||
std::map<String, NodeInfo>* memberList;
|
||||
::Config config;
|
||||
std::map<String, String> constructorLabels; // Labels passed to constructor (not persisted)
|
||||
Config config;
|
||||
|
||||
using EventCallback = std::function<void(void*)>;
|
||||
std::map<std::string, std::vector<EventCallback>> eventRegistry;
|
||||
using AnyEventCallback = std::function<void(const std::string&, void*)>;
|
||||
std::vector<AnyEventCallback> anyEventSubscribers;
|
||||
|
||||
void on(const std::string& event, EventCallback cb);
|
||||
void fire(const std::string& event, void* data);
|
||||
void onAny(AnyEventCallback cb);
|
||||
void rebuildLabels(); // Rebuild self.labels from constructorLabels + config.labels
|
||||
};
|
||||
|
||||
@@ -5,21 +5,20 @@
|
||||
|
||||
// Cluster protocol and API constants
|
||||
namespace ClusterProtocol {
|
||||
// Simplified heartbeat-only protocol
|
||||
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 const char* DISCOVERY_MSG = "CLUSTER_DISCOVERY";
|
||||
constexpr const char* RESPONSE_MSG = "CLUSTER_RESPONSE";
|
||||
constexpr uint16_t UDP_PORT = 4210;
|
||||
// Increased buffer to accommodate larger RAW pixel streams and node info JSON over UDP
|
||||
constexpr size_t UDP_BUF_SIZE = 2048;
|
||||
constexpr size_t UDP_BUF_SIZE = 64;
|
||||
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;
|
||||
|
||||
@@ -7,7 +7,6 @@ class ClusterService : public Service {
|
||||
public:
|
||||
ClusterService(NodeContext& ctx);
|
||||
void registerEndpoints(ApiServer& api) override;
|
||||
void registerTasks(TaskManager& taskManager) override;
|
||||
const char* getName() const override { return "Cluster"; }
|
||||
|
||||
private:
|
||||
|
||||
@@ -7,7 +7,6 @@ class MonitoringService : public Service {
|
||||
public:
|
||||
MonitoringService(CpuUsage& cpuUsage);
|
||||
void registerEndpoints(ApiServer& api) override;
|
||||
void registerTasks(TaskManager& taskManager) override;
|
||||
const char* getName() const override { return "Monitoring"; }
|
||||
|
||||
// System resource information
|
||||
|
||||
@@ -7,7 +7,6 @@ class NetworkService : public Service {
|
||||
public:
|
||||
NetworkService(NetworkManager& networkManager);
|
||||
void registerEndpoints(ApiServer& api) override;
|
||||
void registerTasks(TaskManager& taskManager) override;
|
||||
const char* getName() const override { return "Network"; }
|
||||
|
||||
private:
|
||||
|
||||
@@ -8,7 +8,6 @@ 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:
|
||||
@@ -20,6 +19,4 @@ 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);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ class StaticFileService : public Service {
|
||||
public:
|
||||
StaticFileService(NodeContext& ctx, ApiServer& apiServer);
|
||||
void registerEndpoints(ApiServer& api) override;
|
||||
void registerTasks(TaskManager& taskManager) override;
|
||||
const char* getName() const override { return name.c_str(); }
|
||||
|
||||
private:
|
||||
|
||||
@@ -7,7 +7,6 @@ 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:
|
||||
|
||||
114
include/spore/types/ApiResponse.h
Normal file
114
include/spore/types/ApiResponse.h
Normal file
@@ -0,0 +1,114 @@
|
||||
#pragma once
|
||||
#include "spore/util/JsonSerializable.h"
|
||||
#include "spore/util/Logging.h"
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
namespace spore {
|
||||
namespace types {
|
||||
|
||||
/**
|
||||
* Base class for API responses that can be serialized to JSON
|
||||
* Handles complete JsonDocument creation and serialization
|
||||
*/
|
||||
class ApiResponse {
|
||||
protected:
|
||||
mutable JsonDocument doc;
|
||||
|
||||
public:
|
||||
virtual ~ApiResponse() = default;
|
||||
|
||||
/**
|
||||
* Get the complete JSON string representation of this response
|
||||
* @return JSON string ready to send as HTTP response
|
||||
*/
|
||||
virtual String toJsonString() const {
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the JsonDocument for direct manipulation if needed
|
||||
* @return Reference to the internal JsonDocument
|
||||
*/
|
||||
JsonDocument& getDocument() { return doc; }
|
||||
const JsonDocument& getDocument() const { return doc; }
|
||||
|
||||
/**
|
||||
* Clear the document and reset for reuse
|
||||
*/
|
||||
virtual void clear() {
|
||||
doc.clear();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Base class for API responses that contain a collection of serializable items
|
||||
*/
|
||||
template<typename ItemType>
|
||||
class CollectionResponse : public ApiResponse {
|
||||
protected:
|
||||
String collectionKey;
|
||||
|
||||
public:
|
||||
explicit CollectionResponse(const String& key) : collectionKey(key) {}
|
||||
|
||||
/**
|
||||
* Add a serializable item to the collection
|
||||
* @param item The serializable item to add
|
||||
*/
|
||||
void addItem(const util::JsonSerializable& item) {
|
||||
// Ensure the array exists and get a reference to it
|
||||
if (!doc[collectionKey].is<JsonArray>()) {
|
||||
doc[collectionKey] = JsonArray();
|
||||
}
|
||||
JsonArray arr = doc[collectionKey].as<JsonArray>();
|
||||
JsonObject obj = arr.add<JsonObject>();
|
||||
item.toJson(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a serializable item to the collection (move version)
|
||||
* @param item The serializable item to add
|
||||
*/
|
||||
void addItem(util::JsonSerializable&& item) {
|
||||
// Ensure the array exists and get a reference to it
|
||||
if (!doc[collectionKey].is<JsonArray>()) {
|
||||
doc[collectionKey] = JsonArray();
|
||||
}
|
||||
JsonArray arr = doc[collectionKey].as<JsonArray>();
|
||||
JsonObject obj = arr.add<JsonObject>();
|
||||
item.toJson(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple items from a container
|
||||
* @param items Container of serializable items
|
||||
*/
|
||||
template<typename Container>
|
||||
void addItems(const Container& items) {
|
||||
// Ensure the array exists and get a reference to it
|
||||
if (!doc[collectionKey].is<JsonArray>()) {
|
||||
doc[collectionKey] = JsonArray();
|
||||
}
|
||||
JsonArray arr = doc[collectionKey].as<JsonArray>();
|
||||
for (const auto& item : items) {
|
||||
JsonObject obj = arr.add<JsonObject>();
|
||||
item.toJson(obj);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current number of items in the collection
|
||||
* @return Number of items
|
||||
*/
|
||||
size_t getItemCount() const {
|
||||
if (doc[collectionKey].is<JsonArray>()) {
|
||||
return doc[collectionKey].as<JsonArray>().size();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace types
|
||||
} // namespace spore
|
||||
47
include/spore/types/ClusterResponse.h
Normal file
47
include/spore/types/ClusterResponse.h
Normal file
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
#include "ApiResponse.h"
|
||||
#include "NodeInfoSerializable.h"
|
||||
#include <map>
|
||||
|
||||
namespace spore {
|
||||
namespace types {
|
||||
|
||||
/**
|
||||
* Response class for cluster members endpoint
|
||||
* Handles complete JSON document creation for cluster member data
|
||||
*/
|
||||
class ClusterMembersResponse : public CollectionResponse<NodeInfoSerializable> {
|
||||
public:
|
||||
ClusterMembersResponse() : CollectionResponse("members") {}
|
||||
|
||||
/**
|
||||
* Add a single node to the response
|
||||
* @param node The NodeInfo to add
|
||||
*/
|
||||
void addNode(const NodeInfo& node) {
|
||||
addItem(NodeInfoSerializable(const_cast<NodeInfo&>(node)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple nodes from a member list
|
||||
* @param memberList Map of hostname to NodeInfo
|
||||
*/
|
||||
void addNodes(const std::map<String, NodeInfo>& memberList) {
|
||||
for (const auto& pair : memberList) {
|
||||
addNode(pair.second);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add nodes from a pointer to member list
|
||||
* @param memberList Pointer to map of hostname to NodeInfo
|
||||
*/
|
||||
void addNodes(const std::map<String, NodeInfo>* memberList) {
|
||||
if (memberList) {
|
||||
addNodes(*memberList);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace types
|
||||
} // namespace spore
|
||||
@@ -1,33 +1,9 @@
|
||||
#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;
|
||||
@@ -37,9 +13,11 @@ 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;
|
||||
@@ -59,17 +37,6 @@ 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;
|
||||
};
|
||||
76
include/spore/types/EndpointInfoSerializable.h
Normal file
76
include/spore/types/EndpointInfoSerializable.h
Normal file
@@ -0,0 +1,76 @@
|
||||
#pragma once
|
||||
#include "ApiTypes.h"
|
||||
#include "spore/util/JsonSerializable.h"
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
namespace spore {
|
||||
namespace types {
|
||||
|
||||
/**
|
||||
* Serializable wrapper for EndpointInfo that implements JsonSerializable interface
|
||||
* Handles conversion between EndpointInfo struct and JSON representation
|
||||
*/
|
||||
class EndpointInfoSerializable : public util::JsonSerializable {
|
||||
private:
|
||||
const EndpointInfo& endpoint;
|
||||
|
||||
public:
|
||||
explicit EndpointInfoSerializable(const EndpointInfo& ep) : endpoint(ep) {}
|
||||
|
||||
/**
|
||||
* Serialize EndpointInfo to JsonObject
|
||||
*/
|
||||
void toJson(JsonObject& obj) const override {
|
||||
obj["uri"] = endpoint.uri;
|
||||
obj["method"] = endpoint.method;
|
||||
|
||||
// Add parameters if present
|
||||
if (!endpoint.params.empty()) {
|
||||
JsonArray paramsArr = obj["params"].to<JsonArray>();
|
||||
for (const auto& param : endpoint.params) {
|
||||
JsonObject paramObj = paramsArr.add<JsonObject>();
|
||||
paramObj["name"] = param.name;
|
||||
paramObj["location"] = param.location;
|
||||
paramObj["required"] = param.required;
|
||||
paramObj["type"] = param.type;
|
||||
|
||||
if (!param.values.empty()) {
|
||||
JsonArray valuesArr = paramObj["values"].to<JsonArray>();
|
||||
for (const auto& value : param.values) {
|
||||
valuesArr.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (param.defaultValue.length() > 0) {
|
||||
paramObj["default"] = param.defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize EndpointInfo from JsonObject
|
||||
*/
|
||||
void fromJson(const JsonObject& obj) override {
|
||||
// Note: This would require modifying the EndpointInfo struct to be mutable
|
||||
// For now, this is a placeholder as EndpointInfo is typically read-only
|
||||
// in the context where this serialization is used
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Convenience function to create a JsonArray from a collection of EndpointInfo objects
|
||||
*/
|
||||
template<typename Container>
|
||||
JsonArray endpointInfoToJsonArray(JsonDocument& doc, const Container& endpoints) {
|
||||
JsonArray arr = doc.to<JsonArray>();
|
||||
for (const auto& endpoint : endpoints) {
|
||||
EndpointInfoSerializable serializable(endpoint);
|
||||
JsonObject obj = arr.add<JsonObject>();
|
||||
serializable.toJson(obj);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
} // namespace types
|
||||
} // namespace spore
|
||||
@@ -9,7 +9,6 @@ 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;
|
||||
@@ -18,7 +17,7 @@ struct NodeInfo {
|
||||
uint32_t cpuFreqMHz = 0;
|
||||
uint32_t flashChipSize = 0;
|
||||
} resources;
|
||||
unsigned long latency = 0; // ms from heartbeat broadcast to NODE_UPDATE receipt
|
||||
unsigned long latency = 0; // ms since lastSeen
|
||||
std::vector<EndpointInfo> endpoints; // List of registered endpoints
|
||||
std::map<String, String> labels; // Arbitrary node labels (key -> value)
|
||||
};
|
||||
|
||||
141
include/spore/types/NodeInfoSerializable.h
Normal file
141
include/spore/types/NodeInfoSerializable.h
Normal file
@@ -0,0 +1,141 @@
|
||||
#pragma once
|
||||
#include "NodeInfo.h"
|
||||
#include "ApiTypes.h"
|
||||
#include "spore/util/JsonSerializable.h"
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
namespace spore {
|
||||
namespace types {
|
||||
|
||||
/**
|
||||
* Serializable wrapper for NodeInfo that implements JsonSerializable interface
|
||||
* Handles conversion between NodeInfo struct and JSON representation
|
||||
*/
|
||||
class NodeInfoSerializable : public util::JsonSerializable {
|
||||
private:
|
||||
NodeInfo& nodeInfo;
|
||||
|
||||
public:
|
||||
explicit NodeInfoSerializable(NodeInfo& node) : nodeInfo(node) {}
|
||||
|
||||
/**
|
||||
* Serialize NodeInfo to JsonObject
|
||||
* Maps all NodeInfo fields to appropriate JSON structure
|
||||
*/
|
||||
void toJson(JsonObject& obj) const override {
|
||||
obj["hostname"] = nodeInfo.hostname;
|
||||
obj["ip"] = nodeInfo.ip.toString();
|
||||
obj["lastSeen"] = nodeInfo.lastSeen;
|
||||
obj["latency"] = nodeInfo.latency;
|
||||
obj["status"] = statusToStr(nodeInfo.status);
|
||||
|
||||
// Serialize resources
|
||||
JsonObject resources = obj["resources"].to<JsonObject>();
|
||||
resources["freeHeap"] = nodeInfo.resources.freeHeap;
|
||||
resources["chipId"] = nodeInfo.resources.chipId;
|
||||
resources["sdkVersion"] = nodeInfo.resources.sdkVersion;
|
||||
resources["cpuFreqMHz"] = nodeInfo.resources.cpuFreqMHz;
|
||||
resources["flashChipSize"] = nodeInfo.resources.flashChipSize;
|
||||
|
||||
// Serialize labels if present
|
||||
if (!nodeInfo.labels.empty()) {
|
||||
JsonObject labels = obj["labels"].to<JsonObject>();
|
||||
for (const auto& kv : nodeInfo.labels) {
|
||||
labels[kv.first.c_str()] = kv.second;
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize endpoints if present
|
||||
if (!nodeInfo.endpoints.empty()) {
|
||||
JsonArray endpoints = obj["api"].to<JsonArray>();
|
||||
for (const auto& endpoint : nodeInfo.endpoints) {
|
||||
JsonObject endpointObj = endpoints.add<JsonObject>();
|
||||
endpointObj["uri"] = endpoint.uri;
|
||||
endpointObj["method"] = endpoint.method;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize NodeInfo from JsonObject
|
||||
* Populates NodeInfo fields from JSON structure
|
||||
*/
|
||||
void fromJson(const JsonObject& obj) override {
|
||||
nodeInfo.hostname = obj["hostname"].as<String>();
|
||||
|
||||
// Parse IP address
|
||||
const char* ipStr = obj["ip"];
|
||||
if (ipStr) {
|
||||
nodeInfo.ip.fromString(ipStr);
|
||||
}
|
||||
|
||||
nodeInfo.lastSeen = obj["lastSeen"].as<unsigned long>();
|
||||
nodeInfo.latency = obj["latency"].as<unsigned long>();
|
||||
|
||||
// Parse status
|
||||
const char* statusStr = obj["status"];
|
||||
if (statusStr) {
|
||||
if (strcmp(statusStr, "ACTIVE") == 0) {
|
||||
nodeInfo.status = NodeInfo::ACTIVE;
|
||||
} else if (strcmp(statusStr, "INACTIVE") == 0) {
|
||||
nodeInfo.status = NodeInfo::INACTIVE;
|
||||
} else if (strcmp(statusStr, "DEAD") == 0) {
|
||||
nodeInfo.status = NodeInfo::DEAD;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse resources
|
||||
if (obj["resources"].is<JsonObject>()) {
|
||||
JsonObject resources = obj["resources"].as<JsonObject>();
|
||||
nodeInfo.resources.freeHeap = resources["freeHeap"].as<uint32_t>();
|
||||
nodeInfo.resources.chipId = resources["chipId"].as<uint32_t>();
|
||||
nodeInfo.resources.sdkVersion = resources["sdkVersion"].as<String>();
|
||||
nodeInfo.resources.cpuFreqMHz = resources["cpuFreqMHz"].as<uint32_t>();
|
||||
nodeInfo.resources.flashChipSize = resources["flashChipSize"].as<uint32_t>();
|
||||
}
|
||||
|
||||
// Parse labels
|
||||
nodeInfo.labels.clear();
|
||||
if (obj["labels"].is<JsonObject>()) {
|
||||
JsonObject labels = obj["labels"].as<JsonObject>();
|
||||
for (JsonPair kvp : labels) {
|
||||
nodeInfo.labels[kvp.key().c_str()] = kvp.value().as<String>();
|
||||
}
|
||||
}
|
||||
|
||||
// Parse endpoints
|
||||
nodeInfo.endpoints.clear();
|
||||
if (obj["api"].is<JsonArray>()) {
|
||||
JsonArray endpoints = obj["api"].as<JsonArray>();
|
||||
for (JsonObject endpointObj : endpoints) {
|
||||
EndpointInfo endpoint;
|
||||
endpoint.uri = endpointObj["uri"].as<String>();
|
||||
endpoint.method = endpointObj["method"].as<int>();
|
||||
endpoint.isLocal = false;
|
||||
endpoint.serviceName = "remote";
|
||||
nodeInfo.endpoints.push_back(std::move(endpoint));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Convenience function to create a JsonArray from a collection of NodeInfo objects
|
||||
* @param doc The JsonDocument to create the array in
|
||||
* @param nodes Collection of NodeInfo objects
|
||||
* @return A JsonArray containing all serialized NodeInfo objects
|
||||
*/
|
||||
template<typename Container>
|
||||
JsonArray nodeInfoToJsonArray(JsonDocument& doc, const Container& nodes) {
|
||||
JsonArray arr = doc.to<JsonArray>();
|
||||
for (const auto& pair : nodes) {
|
||||
const NodeInfo& node = pair.second;
|
||||
NodeInfoSerializable serializable(const_cast<NodeInfo&>(node));
|
||||
JsonObject obj = arr.add<JsonObject>();
|
||||
serializable.toJson(obj);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
} // namespace types
|
||||
} // namespace spore
|
||||
102
include/spore/types/NodeResponse.h
Normal file
102
include/spore/types/NodeResponse.h
Normal file
@@ -0,0 +1,102 @@
|
||||
#pragma once
|
||||
#include "ApiResponse.h"
|
||||
#include "EndpointInfoSerializable.h"
|
||||
#include "NodeInfo.h"
|
||||
#include "spore/util/Logging.h"
|
||||
#include <vector>
|
||||
|
||||
namespace spore {
|
||||
namespace types {
|
||||
|
||||
/**
|
||||
* Response class for node status endpoint
|
||||
* Handles complete JSON document creation for node status data
|
||||
*/
|
||||
class NodeStatusResponse : public ApiResponse {
|
||||
public:
|
||||
/**
|
||||
* Set basic system information
|
||||
*/
|
||||
void setSystemInfo() {
|
||||
doc["freeHeap"] = ESP.getFreeHeap();
|
||||
doc["chipId"] = ESP.getChipId();
|
||||
doc["sdkVersion"] = ESP.getSdkVersion();
|
||||
doc["cpuFreqMHz"] = ESP.getCpuFreqMHz();
|
||||
doc["flashChipSize"] = ESP.getFlashChipSize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add labels to the response
|
||||
* @param labels Map of label key-value pairs
|
||||
*/
|
||||
void addLabels(const std::map<String, String>& labels) {
|
||||
if (!labels.empty()) {
|
||||
JsonObject labelsObj = doc["labels"].to<JsonObject>();
|
||||
for (const auto& kv : labels) {
|
||||
labelsObj[kv.first.c_str()] = kv.second;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build complete response with system info and labels
|
||||
* @param labels Optional labels to include
|
||||
*/
|
||||
void buildCompleteResponse(const std::map<String, String>& labels = {}) {
|
||||
setSystemInfo();
|
||||
addLabels(labels);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Response class for node endpoints endpoint
|
||||
* Handles complete JSON document creation for endpoint data
|
||||
*/
|
||||
class NodeEndpointsResponse : public CollectionResponse<EndpointInfoSerializable> {
|
||||
public:
|
||||
NodeEndpointsResponse() : CollectionResponse("endpoints") {}
|
||||
|
||||
/**
|
||||
* Add a single endpoint to the response
|
||||
* @param endpoint The EndpointInfo to add
|
||||
*/
|
||||
void addEndpoint(const EndpointInfo& endpoint) {
|
||||
addItem(EndpointInfoSerializable(endpoint));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple endpoints from a container
|
||||
* @param endpoints Container of EndpointInfo objects
|
||||
*/
|
||||
void addEndpoints(const std::vector<EndpointInfo>& endpoints) {
|
||||
for (const auto& endpoint : endpoints) {
|
||||
addEndpoint(endpoint);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Response class for simple status operations (update, restart)
|
||||
* Handles simple JSON responses for node operations
|
||||
*/
|
||||
class NodeOperationResponse : public ApiResponse {
|
||||
public:
|
||||
/**
|
||||
* Set success response
|
||||
* @param status Status message
|
||||
*/
|
||||
void setSuccess(const String& status) {
|
||||
doc["status"] = status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set error response
|
||||
* @param status Error status message
|
||||
*/
|
||||
void setError(const String& status) {
|
||||
doc["status"] = status;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace types
|
||||
} // namespace spore
|
||||
99
include/spore/types/TaskInfoSerializable.h
Normal file
99
include/spore/types/TaskInfoSerializable.h
Normal file
@@ -0,0 +1,99 @@
|
||||
#pragma once
|
||||
#include "spore/util/JsonSerializable.h"
|
||||
#include <ArduinoJson.h>
|
||||
#include <Arduino.h>
|
||||
|
||||
namespace spore {
|
||||
namespace types {
|
||||
|
||||
/**
|
||||
* Serializable wrapper for task information that implements JsonSerializable interface
|
||||
* Handles conversion between task data and JSON representation
|
||||
*/
|
||||
class TaskInfoSerializable : public util::JsonSerializable {
|
||||
private:
|
||||
String taskName;
|
||||
const JsonObject& taskData;
|
||||
|
||||
public:
|
||||
TaskInfoSerializable(const String& name, const JsonObject& data)
|
||||
: taskName(name), taskData(data) {}
|
||||
|
||||
TaskInfoSerializable(const std::string& name, const JsonObject& data)
|
||||
: taskName(name.c_str()), taskData(data) {}
|
||||
|
||||
/**
|
||||
* Serialize task info to JsonObject
|
||||
*/
|
||||
void toJson(JsonObject& obj) const override {
|
||||
obj["name"] = taskName;
|
||||
obj["interval"] = taskData["interval"];
|
||||
obj["enabled"] = taskData["enabled"];
|
||||
obj["running"] = taskData["running"];
|
||||
obj["autoStart"] = taskData["autoStart"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize task info from JsonObject
|
||||
* Note: This is read-only for task status, so fromJson is not implemented
|
||||
*/
|
||||
void fromJson(const JsonObject& obj) override {
|
||||
// Task info is typically read-only in this context
|
||||
// Implementation would go here if needed
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Serializable wrapper for system information
|
||||
*/
|
||||
class SystemInfoSerializable : public util::JsonSerializable {
|
||||
public:
|
||||
/**
|
||||
* Serialize system info to JsonObject
|
||||
*/
|
||||
void toJson(JsonObject& obj) const override {
|
||||
obj["freeHeap"] = ESP.getFreeHeap();
|
||||
obj["uptime"] = millis();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize system info from JsonObject
|
||||
* Note: System info is typically read-only, so fromJson is not implemented
|
||||
*/
|
||||
void fromJson(const JsonObject& obj) override {
|
||||
// System info is typically read-only
|
||||
// Implementation would go here if needed
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Serializable wrapper for task summary information
|
||||
*/
|
||||
class TaskSummarySerializable : public util::JsonSerializable {
|
||||
private:
|
||||
size_t totalTasks;
|
||||
size_t activeTasks;
|
||||
|
||||
public:
|
||||
TaskSummarySerializable(size_t total, size_t active)
|
||||
: totalTasks(total), activeTasks(active) {}
|
||||
|
||||
/**
|
||||
* Serialize task summary to JsonObject
|
||||
*/
|
||||
void toJson(JsonObject& obj) const override {
|
||||
obj["totalTasks"] = totalTasks;
|
||||
obj["activeTasks"] = activeTasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize task summary from JsonObject
|
||||
*/
|
||||
void fromJson(const JsonObject& obj) override {
|
||||
totalTasks = obj["totalTasks"].as<size_t>();
|
||||
activeTasks = obj["activeTasks"].as<size_t>();
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace types
|
||||
} // namespace spore
|
||||
194
include/spore/types/TaskResponse.h
Normal file
194
include/spore/types/TaskResponse.h
Normal file
@@ -0,0 +1,194 @@
|
||||
#pragma once
|
||||
#include "ApiResponse.h"
|
||||
#include "TaskInfoSerializable.h"
|
||||
#include <map>
|
||||
|
||||
namespace spore {
|
||||
namespace types {
|
||||
|
||||
/**
|
||||
* Response class for task status endpoint
|
||||
* Handles complete JSON document creation for task status data
|
||||
*/
|
||||
class TaskStatusResponse : public ApiResponse {
|
||||
public:
|
||||
/**
|
||||
* Set the task summary information
|
||||
* @param totalTasks Total number of tasks
|
||||
* @param activeTasks Number of active tasks
|
||||
*/
|
||||
void setSummary(size_t totalTasks, size_t activeTasks) {
|
||||
TaskSummarySerializable summary(totalTasks, activeTasks);
|
||||
JsonObject summaryObj = doc["summary"].to<JsonObject>();
|
||||
summary.toJson(summaryObj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single task to the response
|
||||
* @param taskName Name of the task
|
||||
* @param taskData Task data as JsonObject
|
||||
*/
|
||||
void addTask(const String& taskName, const JsonObject& taskData) {
|
||||
TaskInfoSerializable serializable(taskName, taskData);
|
||||
if (!doc["tasks"].is<JsonArray>()) {
|
||||
doc["tasks"] = JsonArray();
|
||||
}
|
||||
JsonArray tasksArr = doc["tasks"].as<JsonArray>();
|
||||
JsonObject taskObj = tasksArr.add<JsonObject>();
|
||||
serializable.toJson(taskObj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single task to the response (std::string version)
|
||||
* @param taskName Name of the task
|
||||
* @param taskData Task data as JsonObject
|
||||
*/
|
||||
void addTask(const std::string& taskName, const JsonObject& taskData) {
|
||||
TaskInfoSerializable serializable(taskName, taskData);
|
||||
if (!doc["tasks"].is<JsonArray>()) {
|
||||
doc["tasks"] = JsonArray();
|
||||
}
|
||||
JsonArray tasksArr = doc["tasks"].as<JsonArray>();
|
||||
JsonObject taskObj = tasksArr.add<JsonObject>();
|
||||
serializable.toJson(taskObj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple tasks from a task statuses map
|
||||
* @param taskStatuses Map of task name to task data
|
||||
*/
|
||||
void addTasks(const std::map<String, JsonObject>& taskStatuses) {
|
||||
for (const auto& pair : taskStatuses) {
|
||||
addTask(pair.first, pair.second);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the system information
|
||||
*/
|
||||
void setSystemInfo() {
|
||||
SystemInfoSerializable systemInfo;
|
||||
JsonObject systemObj = doc["system"].to<JsonObject>();
|
||||
systemInfo.toJson(systemObj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build complete response with all components
|
||||
* @param taskStatuses Map of task name to task data
|
||||
*/
|
||||
void buildCompleteResponse(const std::map<String, JsonObject>& taskStatuses) {
|
||||
// Set summary
|
||||
size_t totalTasks = taskStatuses.size();
|
||||
size_t activeTasks = 0;
|
||||
for (const auto& pair : taskStatuses) {
|
||||
if (pair.second["enabled"]) {
|
||||
activeTasks++;
|
||||
}
|
||||
}
|
||||
setSummary(totalTasks, activeTasks);
|
||||
|
||||
// Add all tasks
|
||||
addTasks(taskStatuses);
|
||||
|
||||
// Set system info
|
||||
setSystemInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build complete response with all components from vector
|
||||
* @param taskStatuses Vector of pairs of task name to task data
|
||||
*/
|
||||
void buildCompleteResponse(const std::vector<std::pair<std::string, JsonObject>>& taskStatuses) {
|
||||
// Clear the document first since getAllTaskStatuses creates a root array
|
||||
doc.clear();
|
||||
|
||||
// Set summary
|
||||
size_t totalTasks = taskStatuses.size();
|
||||
size_t activeTasks = 0;
|
||||
for (const auto& pair : taskStatuses) {
|
||||
if (pair.second["enabled"]) {
|
||||
activeTasks++;
|
||||
}
|
||||
}
|
||||
setSummary(totalTasks, activeTasks);
|
||||
|
||||
// Add all tasks - extract data before clearing to avoid invalid references
|
||||
JsonArray tasksArr = doc["tasks"].to<JsonArray>();
|
||||
for (const auto& pair : taskStatuses) {
|
||||
// Extract data from JsonObject before it becomes invalid
|
||||
String taskName = pair.first.c_str();
|
||||
unsigned long interval = pair.second["interval"];
|
||||
bool enabled = pair.second["enabled"];
|
||||
bool running = pair.second["running"];
|
||||
bool autoStart = pair.second["autoStart"];
|
||||
|
||||
// Create new JsonObject in our document
|
||||
JsonObject taskObj = tasksArr.add<JsonObject>();
|
||||
taskObj["name"] = taskName;
|
||||
taskObj["interval"] = interval;
|
||||
taskObj["enabled"] = enabled;
|
||||
taskObj["running"] = running;
|
||||
taskObj["autoStart"] = autoStart;
|
||||
}
|
||||
|
||||
// Set system info
|
||||
setSystemInfo();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Response class for task control operations
|
||||
* Handles JSON responses for task enable/disable/start/stop operations
|
||||
*/
|
||||
class TaskControlResponse : public ApiResponse {
|
||||
public:
|
||||
/**
|
||||
* Set the response data for a task control operation
|
||||
* @param success Whether the operation was successful
|
||||
* @param message Response message
|
||||
* @param taskName Name of the task
|
||||
* @param action Action performed
|
||||
*/
|
||||
void setResponse(bool success, const String& message, const String& taskName, const String& action) {
|
||||
doc["success"] = success;
|
||||
doc["message"] = message;
|
||||
doc["task"] = taskName;
|
||||
doc["action"] = action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add detailed task information to the response
|
||||
* @param taskName Name of the task
|
||||
* @param enabled Whether task is enabled
|
||||
* @param running Whether task is running
|
||||
* @param interval Task interval
|
||||
*/
|
||||
void addTaskDetails(const String& taskName, bool enabled, bool running, unsigned long interval) {
|
||||
JsonObject taskDetails = doc["taskDetails"].to<JsonObject>();
|
||||
taskDetails["name"] = taskName;
|
||||
taskDetails["enabled"] = enabled;
|
||||
taskDetails["running"] = running;
|
||||
taskDetails["interval"] = interval;
|
||||
|
||||
// Add system info
|
||||
SystemInfoSerializable systemInfo;
|
||||
JsonObject systemObj = taskDetails["system"].to<JsonObject>();
|
||||
systemInfo.toJson(systemObj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set error response
|
||||
* @param message Error message
|
||||
* @param example Optional example for correct usage
|
||||
*/
|
||||
void setError(const String& message, const String& example = "") {
|
||||
doc["success"] = false;
|
||||
doc["message"] = message;
|
||||
if (example.length() > 0) {
|
||||
doc["example"] = example;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace types
|
||||
} // namespace spore
|
||||
56
include/spore/util/JsonSerializable.h
Normal file
56
include/spore/util/JsonSerializable.h
Normal file
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
namespace spore {
|
||||
namespace util {
|
||||
|
||||
/**
|
||||
* Abstract base class for objects that can be serialized to/from JSON
|
||||
* Provides a clean interface for converting objects to JsonObject and back
|
||||
*/
|
||||
class JsonSerializable {
|
||||
public:
|
||||
virtual ~JsonSerializable() = default;
|
||||
|
||||
/**
|
||||
* Serialize this object to a JsonObject
|
||||
* @param obj The JsonObject to populate with this object's data
|
||||
*/
|
||||
virtual void toJson(JsonObject& obj) const = 0;
|
||||
|
||||
/**
|
||||
* Deserialize this object from a JsonObject
|
||||
* @param obj The JsonObject containing the data to populate this object
|
||||
*/
|
||||
virtual void fromJson(const JsonObject& obj) = 0;
|
||||
|
||||
/**
|
||||
* Convenience method to create a JsonObject from this object
|
||||
* @param doc The JsonDocument to create the object in
|
||||
* @return A JsonObject containing this object's serialized data
|
||||
*/
|
||||
JsonObject toJsonObject(JsonDocument& doc) const {
|
||||
JsonObject obj = doc.to<JsonObject>();
|
||||
toJson(obj);
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method to create a JsonArray from a collection of serializable objects
|
||||
* @param doc The JsonDocument to create the array in
|
||||
* @param objects Collection of objects implementing JsonSerializable
|
||||
* @return A JsonArray containing all serialized objects
|
||||
*/
|
||||
template<typename Container>
|
||||
static JsonArray toJsonArray(JsonDocument& doc, const Container& objects) {
|
||||
JsonArray arr = doc.to<JsonArray>();
|
||||
for (const auto& obj : objects) {
|
||||
JsonObject item = arr.add<JsonObject>();
|
||||
obj.toJson(item);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace util
|
||||
} // namespace spore
|
||||
@@ -9,7 +9,7 @@
|
||||
; https://docs.platformio.org/page/projectconf.html
|
||||
|
||||
[platformio]
|
||||
;default_envs = base
|
||||
default_envs = base
|
||||
src_dir = .
|
||||
data_dir = ${PROJECT_DIR}/examples/${PIOENV}/data
|
||||
|
||||
@@ -100,68 +100,3 @@ build_src_filter =
|
||||
+<src/spore/types/*.cpp>
|
||||
+<src/spore/util/*.cpp>
|
||||
+<src/internal/*.cpp>
|
||||
|
||||
[env:pixelstream]
|
||||
platform = platformio/espressif8266@^4.2.1
|
||||
board = esp01_1m
|
||||
framework = arduino
|
||||
upload_speed = 115200
|
||||
monitor_speed = 115200
|
||||
board_build.filesystem = littlefs
|
||||
board_build.flash_mode = dout
|
||||
board_build.ldscript = eagle.flash.1m64.ld
|
||||
lib_deps = ${common.lib_deps}
|
||||
adafruit/Adafruit NeoPixel@^1.15.1
|
||||
build_flags =
|
||||
build_src_filter =
|
||||
+<examples/pixelstream/*.cpp>
|
||||
+<src/spore/*.cpp>
|
||||
+<src/spore/core/*.cpp>
|
||||
+<src/spore/services/*.cpp>
|
||||
+<src/spore/types/*.cpp>
|
||||
+<src/spore/util/*.cpp>
|
||||
+<src/internal/*.cpp>
|
||||
|
||||
[env:pixelstream_d1]
|
||||
platform = platformio/espressif8266@^4.2.1
|
||||
board = d1_mini
|
||||
framework = arduino
|
||||
upload_speed = 115200
|
||||
monitor_speed = 115200
|
||||
board_build.filesystem = littlefs
|
||||
board_build.flash_mode = dout
|
||||
board_build.ldscript = eagle.flash.4m1m.ld
|
||||
lib_deps = ${common.lib_deps}
|
||||
adafruit/Adafruit NeoPixel@^1.15.1
|
||||
build_flags = -DPIXEL_PIN=TX -DPIXEL_COUNT=256 -DMATRIX_WIDTH=16
|
||||
build_src_filter =
|
||||
+<examples/pixelstream/*.cpp>
|
||||
+<src/spore/*.cpp>
|
||||
+<src/spore/core/*.cpp>
|
||||
+<src/spore/services/*.cpp>
|
||||
+<src/spore/types/*.cpp>
|
||||
+<src/spore/util/*.cpp>
|
||||
+<src/internal/*.cpp>
|
||||
|
||||
[env:multimatrix]
|
||||
platform = platformio/espressif8266@^4.2.1
|
||||
board = d1_mini
|
||||
framework = arduino
|
||||
upload_speed = 115200
|
||||
monitor_speed = 115200
|
||||
board_build.filesystem = littlefs
|
||||
board_build.flash_mode = dio
|
||||
board_build.flash_size = 4M
|
||||
board_build.ldscript = eagle.flash.4m1m.ld
|
||||
lib_deps = ${common.lib_deps}
|
||||
adafruit/Adafruit NeoPixel@^1.15.1
|
||||
dfrobot/DFRobotDFPlayerMini@^1.0.6
|
||||
build_src_filter =
|
||||
+<examples/multimatrix/*.cpp>
|
||||
+<examples/pixelstream/PixelStreamController.cpp>
|
||||
+<src/spore/*.cpp>
|
||||
+<src/spore/core/*.cpp>
|
||||
+<src/spore/services/*.cpp>
|
||||
+<src/spore/types/*.cpp>
|
||||
+<src/spore/util/*.cpp>
|
||||
+<src/internal/*.cpp>
|
||||
|
||||
@@ -10,18 +10,12 @@
|
||||
Spore::Spore() : ctx(), network(ctx), taskManager(ctx), cluster(ctx, taskManager),
|
||||
apiServer(ctx, taskManager, ctx.config.api_server_port),
|
||||
cpuUsage(), 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) {
|
||||
|
||||
// Rebuild labels from constructor + config labels (config takes precedence)
|
||||
ctx.rebuildLabels();
|
||||
}
|
||||
|
||||
Spore::~Spore() {
|
||||
@@ -91,7 +85,7 @@ void Spore::loop() {
|
||||
yield();
|
||||
}
|
||||
|
||||
void Spore::registerService(std::shared_ptr<Service> service) {
|
||||
void Spore::addService(std::shared_ptr<Service> service) {
|
||||
if (!service) {
|
||||
LOG_WARN("Spore", "Attempted to add null service");
|
||||
return;
|
||||
@@ -101,22 +95,21 @@ void Spore::registerService(std::shared_ptr<Service> service) {
|
||||
|
||||
if (apiServerStarted) {
|
||||
// If API server is already started, register the service immediately
|
||||
apiServer.registerService(*service);
|
||||
service->registerTasks(taskManager);
|
||||
LOG_INFO("Spore", "Added service '" + String(service->getName()) + "' to running API server and task manager");
|
||||
apiServer.addService(*service);
|
||||
LOG_INFO("Spore", "Added service '" + String(service->getName()) + "' to running API server");
|
||||
} else {
|
||||
LOG_INFO("Spore", "Registered service '" + String(service->getName()) + "' (will be added to API server when begin() is called)");
|
||||
}
|
||||
}
|
||||
|
||||
void Spore::registerService(Service* service) {
|
||||
void Spore::addService(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
|
||||
registerService(std::shared_ptr<Service>(service, [](Service*){}));
|
||||
addService(std::shared_ptr<Service>(service, [](Service*){}));
|
||||
}
|
||||
|
||||
|
||||
@@ -162,12 +155,11 @@ void Spore::startApiServer() {
|
||||
|
||||
LOG_INFO("Spore", "Starting API server...");
|
||||
|
||||
// Register all services with API server and task manager
|
||||
// Register all services with API server
|
||||
for (auto& service : services) {
|
||||
if (service) {
|
||||
apiServer.registerService(*service);
|
||||
service->registerTasks(taskManager);
|
||||
LOG_INFO("Spore", "Added service '" + String(service->getName()) + "' to API server and task manager");
|
||||
apiServer.addService(*service);
|
||||
LOG_INFO("Spore", "Added service '" + String(service->getName()) + "' to API server");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,62 +21,36 @@ void ApiServer::registerEndpoint(const String& uri, int method,
|
||||
const String& serviceName) {
|
||||
// Add to local endpoints
|
||||
endpoints.push_back(EndpointInfo{uri, method, params, serviceName, true});
|
||||
|
||||
// Update cluster if needed
|
||||
if (ctx.memberList && !ctx.memberList->empty()) {
|
||||
auto it = ctx.memberList->find(ctx.hostname);
|
||||
if (it != ctx.memberList->end()) {
|
||||
it->second.endpoints.push_back(EndpointInfo{uri, method, params, serviceName, true});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ApiServer::registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler) {
|
||||
// Get current service name if available
|
||||
String serviceName = "unknown";
|
||||
if (!services.empty()) {
|
||||
serviceName = services.back().get().getName();
|
||||
}
|
||||
void ApiServer::addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
const String& serviceName) {
|
||||
registerEndpoint(uri, method, {}, serviceName);
|
||||
server.on(uri.c_str(), method, 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";
|
||||
if (!services.empty()) {
|
||||
serviceName = services.back().get().getName();
|
||||
}
|
||||
void ApiServer::addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
std::function<void(AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool)> uploadHandler,
|
||||
const String& serviceName) {
|
||||
registerEndpoint(uri, method, {}, serviceName);
|
||||
server.on(uri.c_str(), method, requestHandler, uploadHandler);
|
||||
}
|
||||
|
||||
// Overloads that also record minimal capability specs
|
||||
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";
|
||||
if (!services.empty()) {
|
||||
serviceName = services.back().get().getName();
|
||||
}
|
||||
void ApiServer::addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
const std::vector<ParamSpec>& params, const String& serviceName) {
|
||||
registerEndpoint(uri, method, params, serviceName);
|
||||
server.on(uri.c_str(), method, requestHandler);
|
||||
}
|
||||
|
||||
void ApiServer::registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
void ApiServer::addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
std::function<void(AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool)> uploadHandler,
|
||||
const std::vector<ParamSpec>& params) {
|
||||
// Get current service name if available
|
||||
String serviceName = "unknown";
|
||||
if (!services.empty()) {
|
||||
serviceName = services.back().get().getName();
|
||||
}
|
||||
const std::vector<ParamSpec>& params, const String& serviceName) {
|
||||
registerEndpoint(uri, method, params, serviceName);
|
||||
server.on(uri.c_str(), method, requestHandler, uploadHandler);
|
||||
}
|
||||
|
||||
void ApiServer::registerService(Service& service) {
|
||||
void ApiServer::addService(Service& service) {
|
||||
services.push_back(service);
|
||||
LOG_INFO("API", "Added service: " + String(service.getName()));
|
||||
}
|
||||
@@ -87,10 +61,6 @@ void ApiServer::serveStatic(const String& uri, fs::FS& fs, const String& path, c
|
||||
}
|
||||
|
||||
void ApiServer::begin() {
|
||||
// Setup streaming API (WebSocket)
|
||||
setupWebSocket();
|
||||
server.addHandler(&ws);
|
||||
|
||||
// Register all service endpoints
|
||||
for (auto& service : services) {
|
||||
service.get().registerEndpoints(*this);
|
||||
@@ -99,95 +69,3 @@ void ApiServer::begin() {
|
||||
|
||||
server.begin();
|
||||
}
|
||||
|
||||
void ApiServer::setupWebSocket() {
|
||||
ws.onEvent([this](AsyncWebSocket* server, AsyncWebSocketClient* client, AwsEventType type, void* arg, uint8_t* data, size_t len) {
|
||||
if (type == WS_EVT_DATA) {
|
||||
AwsFrameInfo* info = (AwsFrameInfo*)arg;
|
||||
if (info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) {
|
||||
// Parse directly from the raw buffer with explicit length
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, (const char*)data, len);
|
||||
if (!err) {
|
||||
LOG_DEBUG("API", "Received event: " + String(doc["event"].as<String>()));
|
||||
String eventName = doc["event"].as<String>();
|
||||
String payloadStr;
|
||||
if (doc["payload"].is<const char*>()) {
|
||||
payloadStr = doc["payload"].as<const char*>();
|
||||
} else if (!doc["payload"].isNull()) {
|
||||
// If payload is an object/array, serialize it
|
||||
String tmp; serializeJson(doc["payload"], tmp); payloadStr = tmp;
|
||||
}
|
||||
// Allow empty payload; services may treat it as defaults
|
||||
if (eventName.length() > 0) {
|
||||
// Inject origin tag into payload JSON if possible
|
||||
String enriched = payloadStr;
|
||||
if (payloadStr.length() > 0) {
|
||||
JsonDocument pd;
|
||||
if (!deserializeJson(pd, payloadStr)) {
|
||||
pd["_origin"] = String("ws:") + String(client->id());
|
||||
String tmp; serializeJson(pd, tmp); enriched = tmp;
|
||||
} else {
|
||||
// If payload is plain string, leave as-is (no origin)
|
||||
}
|
||||
}
|
||||
std::string ev = eventName.c_str();
|
||||
ctx.fire(ev, &enriched);
|
||||
// Acknowledge
|
||||
client->text("{\"ok\":true}");
|
||||
} else {
|
||||
client->text("{\"error\":\"Missing 'event'\"}");
|
||||
}
|
||||
} else {
|
||||
client->text("{\"error\":\"Invalid JSON\"}");
|
||||
}
|
||||
}
|
||||
} else if (type == WS_EVT_CONNECT) {
|
||||
client->text("{\"hello\":\"ws connected\"}");
|
||||
wsClients.push_back(client);
|
||||
} else if (type == WS_EVT_DISCONNECT) {
|
||||
wsClients.erase(std::remove(wsClients.begin(), wsClients.end(), client), wsClients.end());
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to all local events and forward to websocket clients
|
||||
ctx.onAny([this](const std::string& event, void* dataPtr) {
|
||||
// Ignore raw UDP frames
|
||||
if (event == "udp/raw") {
|
||||
return;
|
||||
}
|
||||
|
||||
String* payloadStrPtr = static_cast<String*>(dataPtr);
|
||||
String payloadStr = payloadStrPtr ? *payloadStrPtr : String("");
|
||||
|
||||
// Extract and strip origin if present
|
||||
String origin;
|
||||
String cleanedPayload = payloadStr;
|
||||
if (payloadStr.length() > 0) {
|
||||
JsonDocument pd;
|
||||
if (!deserializeJson(pd, payloadStr)) {
|
||||
if (pd["_origin"].is<const char*>()) {
|
||||
origin = pd["_origin"].as<const char*>();
|
||||
pd.remove("_origin");
|
||||
String tmp; serializeJson(pd, tmp); cleanedPayload = tmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
JsonDocument outDoc;
|
||||
outDoc["event"] = event.c_str();
|
||||
outDoc["payload"] = cleanedPayload;
|
||||
String out; serializeJson(outDoc, out);
|
||||
|
||||
if (origin.startsWith("ws:")) {
|
||||
uint32_t originId = (uint32_t)origin.substring(3).toInt();
|
||||
for (auto* c : wsClients) {
|
||||
if (c && c->id() != originId) {
|
||||
c->text(out);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ws.textAll(out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,382 +3,80 @@
|
||||
#include "spore/util/Logging.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
|
||||
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
|
||||
ctx.on("cluster/broadcast", [this](void* data) {
|
||||
String* jsonStr = static_cast<String*>(data);
|
||||
if (!jsonStr) {
|
||||
LOG_WARN("Cluster", "cluster/broadcast called with null data");
|
||||
return;
|
||||
}
|
||||
// Subnet-directed broadcast (more reliable than 255.255.255.255 on some networks)
|
||||
IPAddress ip = WiFi.localIP();
|
||||
IPAddress mask = WiFi.subnetMask();
|
||||
IPAddress bcast(ip[0] | ~mask[0], ip[1] | ~mask[1], ip[2] | ~mask[2], ip[3] | ~mask[3]);
|
||||
LOG_DEBUG("Cluster", String("Broadcasting CLUSTER_EVENT to ") + bcast.toString() + " len=" + String(jsonStr->length()));
|
||||
this->ctx.udp->beginPacket(bcast, this->ctx.config.udp_port);
|
||||
String msg = String(ClusterProtocol::CLUSTER_EVENT_MSG) + ":" + *jsonStr;
|
||||
this->ctx.udp->write(msg.c_str());
|
||||
this->ctx.udp->endPacket();
|
||||
});
|
||||
|
||||
// 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_listen", ctx.config.cluster_listen_interval_ms, [this]() { listen(); });
|
||||
taskManager.registerTask("discovery_send", ctx.config.discovery_interval_ms, [this]() { sendDiscovery(); });
|
||||
taskManager.registerTask("discovery_listen", ctx.config.discovery_interval_ms / 10, [this]() { listenForDiscovery(); });
|
||||
taskManager.registerTask("status_update", ctx.config.status_update_interval_ms, [this]() { updateAllNodeStatuses(); removeDeadNodes(); });
|
||||
taskManager.registerTask("print_members", ctx.config.print_interval_ms, [this]() { printMemberList(); });
|
||||
taskManager.registerTask("heartbeat", ctx.config.heartbeat_interval_ms, [this]() { heartbeatTaskCallback(); });
|
||||
taskManager.registerTask("update_members_info", ctx.config.member_info_update_interval_ms, [this]() { updateAllMembersInfoTaskCallback(); });
|
||||
LOG_INFO("ClusterManager", "Registered all cluster tasks");
|
||||
}
|
||||
|
||||
// Discovery functionality removed - using heartbeat-only approach
|
||||
|
||||
void ClusterManager::listen() {
|
||||
int packetSize = ctx.udp->parsePacket();
|
||||
if (!packetSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
char incoming[ClusterProtocol::UDP_BUF_SIZE];
|
||||
int len = ctx.udp->read(incoming, ClusterProtocol::UDP_BUF_SIZE);
|
||||
if (len <= 0) {
|
||||
return;
|
||||
}
|
||||
if (len >= (int)ClusterProtocol::UDP_BUF_SIZE) {
|
||||
incoming[ClusterProtocol::UDP_BUF_SIZE - 1] = 0;
|
||||
} else {
|
||||
incoming[len] = 0;
|
||||
}
|
||||
handleIncomingMessage(incoming);
|
||||
}
|
||||
|
||||
void ClusterManager::initMessageHandlers() {
|
||||
messageHandlers.clear();
|
||||
messageHandlers.push_back({ &ClusterManager::isRawMsg, [this](const char* msg){ this->onRawMessage(msg); }, "RAW" });
|
||||
messageHandlers.push_back({ &ClusterManager::isHeartbeatMsg, [this](const char* msg){ this->onHeartbeat(msg); }, "HEARTBEAT" });
|
||||
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" });
|
||||
}
|
||||
|
||||
void ClusterManager::handleIncomingMessage(const char* incoming) {
|
||||
for (const auto& h : messageHandlers) {
|
||||
if (h.predicate(incoming)) {
|
||||
h.handle(incoming);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Unknown message - log first token
|
||||
const char* colon = strchr(incoming, ':');
|
||||
String head;
|
||||
if (colon) {
|
||||
head = String(incoming).substring(0, colon - incoming);
|
||||
} else {
|
||||
head = String(incoming);
|
||||
}
|
||||
LOG_DEBUG("Cluster", String("Unknown cluster message: ") + head);
|
||||
}
|
||||
|
||||
bool ClusterManager::isHeartbeatMsg(const char* msg) {
|
||||
return strncmp(msg, ClusterProtocol::HEARTBEAT_MSG, strlen(ClusterProtocol::HEARTBEAT_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;
|
||||
}
|
||||
|
||||
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] == ':';
|
||||
}
|
||||
|
||||
// Discovery functionality removed - using heartbeat-only approach
|
||||
|
||||
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();
|
||||
auto& memberList = *ctx.memberList;
|
||||
|
||||
// Find the responding node by IP address
|
||||
NodeInfo* respondingNode = nullptr;
|
||||
for (auto& pair : memberList) {
|
||||
if (pair.second.ip == respondingNodeIP) {
|
||||
respondingNode = &pair.second;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (respondingNode) {
|
||||
// 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
|
||||
}
|
||||
|
||||
// Update the responding node's information
|
||||
bool hostnameChanged = false;
|
||||
if (doc["hostname"].is<const char*>()) {
|
||||
String newHostname = doc["hostname"].as<const char*>();
|
||||
if (respondingNode->hostname != newHostname) {
|
||||
respondingNode->hostname = newHostname;
|
||||
hostnameChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (doc["uptime"].is<unsigned long>()) {
|
||||
respondingNode->uptime = doc["uptime"];
|
||||
}
|
||||
|
||||
// Update labels if provided
|
||||
bool labelsChanged = false;
|
||||
if (doc["labels"].is<JsonObject>()) {
|
||||
// Check if labels actually changed
|
||||
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 != respondingNode->labels) {
|
||||
labelsChanged = true;
|
||||
respondingNode->labels = newLabels;
|
||||
}
|
||||
}
|
||||
|
||||
respondingNode->lastSeen = now;
|
||||
respondingNode->status = NodeInfo::ACTIVE;
|
||||
|
||||
// Update latency if we calculated it (preserve existing value if not)
|
||||
if (latency > 0) {
|
||||
respondingNode->latency = latency;
|
||||
}
|
||||
|
||||
// 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 ") + respondingNode->hostname + " @ " + respondingNodeIP.toString() +
|
||||
" | hostname: " + (hostnameChanged ? "changed" : "unchanged") +
|
||||
" | labels: " + (labelsChanged ? "changed" : "unchanged") +
|
||||
" | latency: " + (latency > 0 ? String(latency) + "ms" : "not calculated"));
|
||||
} else {
|
||||
LOG_WARN("Cluster", String("Received NODE_UPDATE from unknown node: ") + respondingNodeIP.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void ClusterManager::sendNodeInfo(const String& targetHostname, const IPAddress& targetIP) {
|
||||
JsonDocument doc;
|
||||
|
||||
// Get our node info for the response (we're the responding node)
|
||||
auto& memberList = *ctx.memberList;
|
||||
auto it = memberList.find(ctx.hostname);
|
||||
if (it != memberList.end()) {
|
||||
const NodeInfo& node = it->second;
|
||||
|
||||
// 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 : node.labels) {
|
||||
labelsObj[kv.first.c_str()] = kv.second;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to basic info
|
||||
doc["hostname"] = ctx.hostname;
|
||||
doc["ip"] = ctx.localIP.toString();
|
||||
doc["uptime"] = millis();
|
||||
}
|
||||
|
||||
String json;
|
||||
serializeJson(doc, 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());
|
||||
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();
|
||||
|
||||
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>"}
|
||||
const char* jsonStart = msg + strlen(ClusterProtocol::CLUSTER_EVENT_MSG) + 1; // skip prefix and ':'
|
||||
if (*jsonStart == '\0') {
|
||||
LOG_DEBUG("Cluster", "CLUSTER_EVENT received with empty payload");
|
||||
return;
|
||||
void ClusterManager::listenForDiscovery() {
|
||||
int packetSize = ctx.udp->parsePacket();
|
||||
if (packetSize) {
|
||||
char incoming[ClusterProtocol::UDP_BUF_SIZE];
|
||||
int len = ctx.udp->read(incoming, ClusterProtocol::UDP_BUF_SIZE);
|
||||
if (len > 0) {
|
||||
incoming[len] = 0;
|
||||
}
|
||||
//LOG_DEBUG(ctx, "UDP", "Packet received: " + String(incoming));
|
||||
if (strcmp(incoming, ClusterProtocol::DISCOVERY_MSG) == 0) {
|
||||
//LOG_DEBUG(ctx, "UDP", "Discovery request from: " + ctx.udp->remoteIP().toString());
|
||||
ctx.udp->beginPacket(ctx.udp->remoteIP(), ctx.config.udp_port);
|
||||
String response = String(ClusterProtocol::RESPONSE_MSG) + ":" + ctx.hostname;
|
||||
ctx.udp->write(response.c_str());
|
||||
ctx.udp->endPacket();
|
||||
//LOG_DEBUG(ctx, "UDP", "Sent response with hostname: " + ctx.hostname);
|
||||
} else if (strncmp(incoming, ClusterProtocol::RESPONSE_MSG, strlen(ClusterProtocol::RESPONSE_MSG)) == 0) {
|
||||
char* hostPtr = incoming + strlen(ClusterProtocol::RESPONSE_MSG) + 1;
|
||||
String nodeHost = String(hostPtr);
|
||||
addOrUpdateNode(nodeHost, ctx.udp->remoteIP());
|
||||
}
|
||||
}
|
||||
LOG_DEBUG("Cluster", String("CLUSTER_EVENT raw from ") + ctx.udp->remoteIP().toString() + " len=" + String(strlen(jsonStart)));
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, jsonStart);
|
||||
if (err) {
|
||||
LOG_ERROR("Cluster", String("Failed to parse CLUSTER_EVENT JSON from ") + ctx.udp->remoteIP().toString());
|
||||
return;
|
||||
}
|
||||
// Robust extraction of event and data
|
||||
String eventStr;
|
||||
if (doc["event"].is<const char*>()) {
|
||||
eventStr = doc["event"].as<const char*>();
|
||||
} else if (doc["event"].is<String>()) {
|
||||
eventStr = doc["event"].as<String>();
|
||||
}
|
||||
|
||||
String data;
|
||||
if (doc["data"].is<const char*>()) {
|
||||
data = doc["data"].as<const char*>();
|
||||
} else if (doc["data"].is<JsonVariantConst>()) {
|
||||
// If data is a nested JSON object/array, serialize it back to string
|
||||
String tmp;
|
||||
serializeJson(doc["data"], tmp);
|
||||
data = tmp;
|
||||
}
|
||||
|
||||
if (eventStr.length() == 0 || data.length() == 0) {
|
||||
String dbg;
|
||||
serializeJson(doc, dbg);
|
||||
LOG_WARN("Cluster", String("CLUSTER_EVENT missing 'event' or 'data' | payload=") + dbg);
|
||||
return;
|
||||
}
|
||||
|
||||
std::string eventKey(eventStr.c_str());
|
||||
LOG_DEBUG("Cluster", String("Firing event '") + eventStr + "' with dataLen=" + String(data.length()));
|
||||
ctx.fire(eventKey, &data);
|
||||
}
|
||||
|
||||
void ClusterManager::onRawMessage(const char* msg) {
|
||||
const std::size_t prefixLen = strlen(ClusterProtocol::RAW_MSG);
|
||||
if (msg[prefixLen] != ':') {
|
||||
LOG_WARN("Cluster", "RAW message received without payload delimiter");
|
||||
return;
|
||||
}
|
||||
|
||||
const char* payloadStart = msg + prefixLen + 1;
|
||||
if (*payloadStart == '\0') {
|
||||
LOG_WARN("Cluster", "RAW message received with empty payload");
|
||||
return;
|
||||
}
|
||||
|
||||
String payload(payloadStart);
|
||||
ctx.fire("udp/raw", &payload);
|
||||
}
|
||||
|
||||
void ClusterManager::addOrUpdateNode(const String& nodeHost, IPAddress nodeIP) {
|
||||
auto& memberList = *ctx.memberList;
|
||||
bool memberlistChanged = false;
|
||||
|
||||
|
||||
// O(1) lookup instead of O(n) search
|
||||
auto it = memberList.find(nodeHost);
|
||||
if (it != memberList.end()) {
|
||||
// Update existing node - preserve all existing field values
|
||||
if (it->second.ip != nodeIP) {
|
||||
it->second.ip = nodeIP;
|
||||
memberlistChanged = true;
|
||||
}
|
||||
// Update existing node
|
||||
it->second.ip = nodeIP;
|
||||
it->second.lastSeen = millis();
|
||||
// Note: Other fields like latency, uptime, labels, etc. are preserved
|
||||
//fetchNodeInfo(nodeIP); // Do not fetch here, handled by periodic task
|
||||
} else {
|
||||
// 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;
|
||||
memberlistChanged = true;
|
||||
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
|
||||
return;
|
||||
}
|
||||
|
||||
// Fire event if memberlist changed
|
||||
if (memberlistChanged) {
|
||||
ctx.fire("cluster/memberlist/changed", nullptr);
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// unused http client to fetch complete node info
|
||||
void ClusterManager::fetchNodeInfo(const IPAddress& ip) {
|
||||
if(ip == ctx.localIP) {
|
||||
LOG_DEBUG("Cluster", "Skipping fetch for local node");
|
||||
@@ -493,58 +191,34 @@ void ClusterManager::heartbeatTaskCallback() {
|
||||
NodeInfo& node = it->second;
|
||||
node.lastSeen = millis();
|
||||
node.status = NodeInfo::ACTIVE;
|
||||
node.uptime = millis(); // Update uptime
|
||||
updateLocalNodeResources();
|
||||
addOrUpdateNode(ctx.hostname, ctx.localIP);
|
||||
ctx.fire("node_discovered", &node);
|
||||
}
|
||||
|
||||
// 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() {
|
||||
// HTTP-based member info fetching disabled; node info is provided via UDP responses to heartbeats
|
||||
// No-op to reduce network and memory usage
|
||||
}
|
||||
|
||||
void ClusterManager::broadcastNodeUpdate() {
|
||||
// Broadcast our current node info as NODE_UPDATE to all cluster members
|
||||
auto& memberList = *ctx.memberList;
|
||||
auto it = memberList.find(ctx.hostname);
|
||||
if (it == memberList.end()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const NodeInfo& node = it->second;
|
||||
|
||||
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;
|
||||
|
||||
// Limit concurrent HTTP requests to prevent memory pressure
|
||||
const size_t maxConcurrentRequests = ctx.config.max_concurrent_http_requests;
|
||||
size_t requestCount = 0;
|
||||
|
||||
for (auto& pair : memberList) {
|
||||
const NodeInfo& node = pair.second;
|
||||
if (node.ip != ctx.localIP) {
|
||||
// Only process a limited number of requests per cycle
|
||||
if (requestCount >= maxConcurrentRequests) {
|
||||
LOG_DEBUG("Cluster", "Limiting concurrent HTTP requests to prevent memory pressure");
|
||||
break;
|
||||
}
|
||||
|
||||
fetchNodeInfo(node.ip);
|
||||
requestCount++;
|
||||
|
||||
// Add small delay between requests to prevent overwhelming the system
|
||||
delay(100);
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -559,24 +233,17 @@ void ClusterManager::updateAllNodeStatuses() {
|
||||
void ClusterManager::removeDeadNodes() {
|
||||
auto& memberList = *ctx.memberList;
|
||||
unsigned long now = millis();
|
||||
bool memberlistChanged = false;
|
||||
|
||||
|
||||
// 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);
|
||||
memberlistChanged = true;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
// Fire event if memberlist changed
|
||||
if (memberlistChanged) {
|
||||
ctx.fire("cluster/memberlist/changed", nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
void ClusterManager::printMemberList() {
|
||||
|
||||
@@ -65,16 +65,6 @@ 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);
|
||||
@@ -128,5 +118,5 @@ void NetworkManager::setupWiFi() {
|
||||
}
|
||||
|
||||
// Notify listeners that the node is (re)discovered
|
||||
ctx.fire("node/discovered", &ctx.self);
|
||||
ctx.fire("node_discovered", &ctx.self);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -30,26 +29,4 @@ void NodeContext::fire(const std::string& event, void* data) {
|
||||
for (auto& cb : eventRegistry[event]) {
|
||||
cb(data);
|
||||
}
|
||||
for (auto& acb : anyEventSubscribers) {
|
||||
acb(event, data);
|
||||
}
|
||||
}
|
||||
|
||||
void NodeContext::onAny(AnyEventCallback cb) {
|
||||
anyEventSubscribers.push_back(cb);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +1,19 @@
|
||||
#include "spore/services/ClusterService.h"
|
||||
#include "spore/core/ApiServer.h"
|
||||
#include "spore/types/ClusterResponse.h"
|
||||
|
||||
using spore::types::ClusterMembersResponse;
|
||||
|
||||
ClusterService::ClusterService(NodeContext& ctx) : ctx(ctx) {}
|
||||
|
||||
void ClusterService::registerEndpoints(ApiServer& api) {
|
||||
api.registerEndpoint("/api/cluster/members", HTTP_GET,
|
||||
api.addEndpoint("/api/cluster/members", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleMembersRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
// Generic cluster broadcast endpoint
|
||||
api.registerEndpoint("/api/cluster/event", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) {
|
||||
if (!request->hasParam("event", true) || !request->hasParam("payload", true)) {
|
||||
request->send(400, "application/json", "{\"error\":\"Missing 'event' or 'payload'\"}");
|
||||
return;
|
||||
}
|
||||
String eventName = request->getParam("event", true)->value();
|
||||
String payloadStr = request->getParam("payload", true)->value();
|
||||
JsonDocument envelope;
|
||||
envelope["event"] = eventName;
|
||||
envelope["data"] = payloadStr; // pass payload as JSON string
|
||||
String eventJson;
|
||||
serializeJson(envelope, eventJson);
|
||||
std::string ev = "cluster/broadcast";
|
||||
ctx.fire(ev, &eventJson);
|
||||
request->send(200, "application/json", "{\"ok\":true}");
|
||||
},
|
||||
std::vector<ParamSpec>{
|
||||
ParamSpec{String("event"), true, String("body"), String("string"), {}},
|
||||
ParamSpec{String("payload"), true, String("body"), String("string"), {}}
|
||||
});
|
||||
}
|
||||
|
||||
void ClusterService::registerTasks(TaskManager& taskManager) {
|
||||
// ClusterService doesn't register any tasks itself
|
||||
std::vector<ParamSpec>{}, "ClusterService");
|
||||
}
|
||||
|
||||
void ClusterService::handleMembersRequest(AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
JsonArray arr = doc["members"].to<JsonArray>();
|
||||
|
||||
for (const auto& pair : *ctx.memberList) {
|
||||
const NodeInfo& node = pair.second;
|
||||
JsonObject obj = arr.add<JsonObject>();
|
||||
obj["hostname"] = node.hostname;
|
||||
obj["ip"] = node.ip.toString();
|
||||
obj["lastSeen"] = node.lastSeen;
|
||||
obj["latency"] = node.latency;
|
||||
obj["status"] = statusToStr(node.status);
|
||||
|
||||
// Add labels if present
|
||||
if (!node.labels.empty()) {
|
||||
JsonObject labelsObj = obj["labels"].to<JsonObject>();
|
||||
for (const auto& kv : node.labels) {
|
||||
labelsObj[kv.first.c_str()] = kv.second;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
request->send(200, "application/json", json);
|
||||
ClusterMembersResponse response;
|
||||
response.addNodes(ctx.memberList);
|
||||
request->send(200, "application/json", response.toJsonString());
|
||||
}
|
||||
|
||||
@@ -10,13 +10,9 @@ MonitoringService::MonitoringService(CpuUsage& cpuUsage)
|
||||
}
|
||||
|
||||
void MonitoringService::registerEndpoints(ApiServer& api) {
|
||||
api.registerEndpoint("/api/monitoring/resources", HTTP_GET,
|
||||
api.addEndpoint("/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
|
||||
std::vector<ParamSpec>{}, "MonitoringService");
|
||||
}
|
||||
|
||||
MonitoringService::SystemResources MonitoringService::getSystemResources() const {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "spore/services/NetworkService.h"
|
||||
#include "spore/util/Logging.h"
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
NetworkService::NetworkService(NetworkManager& networkManager)
|
||||
@@ -7,31 +6,27 @@ NetworkService::NetworkService(NetworkManager& networkManager)
|
||||
|
||||
void NetworkService::registerEndpoints(ApiServer& api) {
|
||||
// WiFi scanning endpoints
|
||||
api.registerEndpoint("/api/network/wifi/scan", HTTP_POST,
|
||||
api.addEndpoint("/api/network/wifi/scan", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleWifiScanRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
std::vector<ParamSpec>{}, "NetworkService");
|
||||
|
||||
api.registerEndpoint("/api/network/wifi/scan", HTTP_GET,
|
||||
api.addEndpoint("/api/network/wifi/scan", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleGetWifiNetworks(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
std::vector<ParamSpec>{}, "NetworkService");
|
||||
|
||||
// Network status and configuration endpoints
|
||||
api.registerEndpoint("/api/network/status", HTTP_GET,
|
||||
api.addEndpoint("/api/network/status", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleNetworkStatus(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
std::vector<ParamSpec>{}, "NetworkService");
|
||||
|
||||
api.registerEndpoint("/api/network/wifi/config", HTTP_POST,
|
||||
api.addEndpoint("/api/network/wifi/config", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleSetWifiConfig(request); },
|
||||
std::vector<ParamSpec>{
|
||||
ParamSpec{String("ssid"), true, String("body"), String("string"), {}, String("")},
|
||||
ParamSpec{String("password"), true, String("body"), String("string"), {}, String("")},
|
||||
ParamSpec{String("connect_timeout_ms"), false, String("body"), String("number"), {}, String("10000")},
|
||||
ParamSpec{String("retry_delay_ms"), false, String("body"), String("number"), {}, String("500")}
|
||||
});
|
||||
}
|
||||
|
||||
void NetworkService::registerTasks(TaskManager& taskManager) {
|
||||
// NetworkService doesn't register any tasks itself
|
||||
}, "NetworkService");
|
||||
}
|
||||
|
||||
void NetworkService::handleWifiScanRequest(AsyncWebServerRequest* request) {
|
||||
@@ -117,34 +112,21 @@ void NetworkService::handleSetWifiConfig(AsyncWebServerRequest* request) {
|
||||
retry_delay_ms = request->getParam("retry_delay_ms", true)->value().toInt();
|
||||
}
|
||||
|
||||
// Update configuration in memory
|
||||
// Update configuration
|
||||
networkManager.setWiFiConfig(ssid, password, connect_timeout_ms, retry_delay_ms);
|
||||
|
||||
// Save configuration to persistent storage
|
||||
bool configSaved = networkManager.saveConfig();
|
||||
if (!configSaved) {
|
||||
LOG_WARN("NetworkService", "Failed to save WiFi configuration to persistent storage");
|
||||
}
|
||||
// Attempt to connect with new settings
|
||||
networkManager.setupWiFi();
|
||||
|
||||
// Prepare response
|
||||
JsonDocument doc;
|
||||
doc["status"] = "success";
|
||||
doc["message"] = "WiFi configuration updated and saved";
|
||||
doc["config_saved"] = configSaved;
|
||||
doc["restarting"] = true;
|
||||
doc["message"] = "WiFi configuration updated";
|
||||
doc["connected"] = WiFi.isConnected();
|
||||
if (WiFi.isConnected()) {
|
||||
doc["ip"] = WiFi.localIP().toString();
|
||||
}
|
||||
|
||||
String json;
|
||||
serializeJson(doc, 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();
|
||||
});
|
||||
request->send(200, "application/json", json);
|
||||
}
|
||||
|
||||
@@ -1,105 +1,67 @@
|
||||
#include "spore/services/NodeService.h"
|
||||
#include "spore/core/ApiServer.h"
|
||||
#include "spore/util/Logging.h"
|
||||
#include "spore/types/NodeResponse.h"
|
||||
|
||||
using spore::types::NodeStatusResponse;
|
||||
using spore::types::NodeOperationResponse;
|
||||
using spore::types::NodeEndpointsResponse;
|
||||
|
||||
NodeService::NodeService(NodeContext& ctx, ApiServer& apiServer) : ctx(ctx), apiServer(apiServer) {}
|
||||
|
||||
void NodeService::registerEndpoints(ApiServer& api) {
|
||||
// Status endpoint
|
||||
api.registerEndpoint("/api/node/status", HTTP_GET,
|
||||
api.addEndpoint("/api/node/status", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
std::vector<ParamSpec>{}, "NodeService");
|
||||
|
||||
// Update endpoint with file upload
|
||||
api.registerEndpoint("/api/node/update", HTTP_POST,
|
||||
api.addEndpoint("/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);
|
||||
},
|
||||
std::vector<ParamSpec>{
|
||||
ParamSpec{String("firmware"), true, String("body"), String("file"), {}, String("")}
|
||||
});
|
||||
}, "NodeService");
|
||||
|
||||
// Restart endpoint
|
||||
api.registerEndpoint("/api/node/restart", HTTP_POST,
|
||||
api.addEndpoint("/api/node/restart", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleRestartRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
std::vector<ParamSpec>{}, "NodeService");
|
||||
|
||||
// Endpoints endpoint
|
||||
api.registerEndpoint("/api/node/endpoints", HTTP_GET,
|
||||
api.addEndpoint("/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.registerEndpoint("/api/node/event", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) {
|
||||
if (!request->hasParam("event", true) || !request->hasParam("payload", true)) {
|
||||
request->send(400, "application/json", "{\"error\":\"Missing 'event' or 'payload'\"}");
|
||||
return;
|
||||
}
|
||||
String eventName = request->getParam("event", true)->value();
|
||||
String payloadStr = request->getParam("payload", true)->value();
|
||||
std::string ev = eventName.c_str();
|
||||
ctx.fire(ev, &payloadStr);
|
||||
request->send(200, "application/json", "{\"ok\":true}");
|
||||
},
|
||||
std::vector<ParamSpec>{
|
||||
ParamSpec{String("event"), true, String("body"), String("string"), {}},
|
||||
ParamSpec{String("payload"), true, String("body"), String("string"), {}}
|
||||
});
|
||||
}
|
||||
|
||||
void NodeService::registerTasks(TaskManager& taskManager) {
|
||||
// NodeService doesn't register any tasks itself
|
||||
std::vector<ParamSpec>{}, "NodeService");
|
||||
}
|
||||
|
||||
void NodeService::handleStatusRequest(AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
doc["freeHeap"] = ESP.getFreeHeap();
|
||||
doc["chipId"] = ESP.getChipId();
|
||||
doc["sdkVersion"] = ESP.getSdkVersion();
|
||||
doc["cpuFreqMHz"] = ESP.getCpuFreqMHz();
|
||||
doc["flashChipSize"] = ESP.getFlashChipSize();
|
||||
|
||||
// Include local node labels if present
|
||||
NodeStatusResponse response;
|
||||
|
||||
// Get labels from member list or self
|
||||
std::map<String, String> labels;
|
||||
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;
|
||||
}
|
||||
labels = it->second.labels;
|
||||
} 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;
|
||||
}
|
||||
labels = ctx.self.labels;
|
||||
}
|
||||
}
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
request->send(200, "application/json", json);
|
||||
|
||||
response.buildCompleteResponse(labels);
|
||||
request->send(200, "application/json", response.toJsonString());
|
||||
}
|
||||
|
||||
void NodeService::handleUpdateRequest(AsyncWebServerRequest* request) {
|
||||
bool success = !Update.hasError();
|
||||
AsyncWebServerResponse* response = request->beginResponse(200, "application/json",
|
||||
success ? "{\"status\": \"OK\"}" : "{\"status\": \"FAIL\"}");
|
||||
response->addHeader("Connection", "close");
|
||||
request->send(response);
|
||||
NodeOperationResponse response;
|
||||
response.setSuccess(success ? "OK" : "FAIL");
|
||||
|
||||
AsyncWebServerResponse* httpResponse = request->beginResponse(200, "application/json", response.toJsonString());
|
||||
httpResponse->addHeader("Connection", "close");
|
||||
request->send(httpResponse);
|
||||
request->onDisconnect([this]() {
|
||||
LOG_INFO("API", "Restart device");
|
||||
delay(10);
|
||||
@@ -142,10 +104,12 @@ void NodeService::handleUpdateUpload(AsyncWebServerRequest* request, const Strin
|
||||
}
|
||||
|
||||
void NodeService::handleRestartRequest(AsyncWebServerRequest* request) {
|
||||
AsyncWebServerResponse* response = request->beginResponse(200, "application/json",
|
||||
"{\"status\": \"restarting\"}");
|
||||
response->addHeader("Connection", "close");
|
||||
request->send(response);
|
||||
NodeOperationResponse response;
|
||||
response.setSuccess("restarting");
|
||||
|
||||
AsyncWebServerResponse* httpResponse = request->beginResponse(200, "application/json", response.toJsonString());
|
||||
httpResponse->addHeader("Connection", "close");
|
||||
request->send(httpResponse);
|
||||
request->onDisconnect([this]() {
|
||||
LOG_INFO("API", "Restart device");
|
||||
delay(10);
|
||||
@@ -154,141 +118,7 @@ void NodeService::handleRestartRequest(AsyncWebServerRequest* request) {
|
||||
}
|
||||
|
||||
void NodeService::handleEndpointsRequest(AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
JsonArray endpointsArr = doc["endpoints"].to<JsonArray>();
|
||||
|
||||
// Add all registered endpoints from ApiServer
|
||||
for (const auto& endpoint : apiServer.getEndpoints()) {
|
||||
JsonObject obj = endpointsArr.add<JsonObject>();
|
||||
obj["uri"] = endpoint.uri;
|
||||
obj["method"] = ApiServer::methodToStr(endpoint.method);
|
||||
if (!endpoint.params.empty()) {
|
||||
JsonArray paramsArr = obj["params"].to<JsonArray>();
|
||||
for (const auto& ps : endpoint.params) {
|
||||
JsonObject p = paramsArr.add<JsonObject>();
|
||||
p["name"] = ps.name;
|
||||
p["location"] = ps.location;
|
||||
p["required"] = ps.required;
|
||||
p["type"] = ps.type;
|
||||
if (!ps.values.empty()) {
|
||||
JsonArray allowed = p["values"].to<JsonArray>();
|
||||
for (const auto& v : ps.values) {
|
||||
allowed.add(v);
|
||||
}
|
||||
}
|
||||
if (ps.defaultValue.length() > 0) {
|
||||
p["default"] = ps.defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
request->send(200, "application/json", json);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// TODO think of a better way to update the member list entry for the local node
|
||||
// Update the member list entry for this node if it exists
|
||||
if (ctx.memberList) {
|
||||
auto it = ctx.memberList->find(ctx.hostname);
|
||||
if (it != ctx.memberList->end()) {
|
||||
// Update the labels in the member list entry
|
||||
it->second.labels.clear();
|
||||
for (const auto& kv : ctx.self.labels) {
|
||||
it->second.labels[kv.first] = kv.second;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
NodeEndpointsResponse response;
|
||||
response.addEndpoints(apiServer.getEndpoints());
|
||||
request->send(200, "application/json", response.toJsonString());
|
||||
}
|
||||
|
||||
@@ -20,7 +20,3 @@ void StaticFileService::registerEndpoints(ApiServer& api) {
|
||||
api.serveStatic("/", LittleFS, "/public", "max-age=3600");
|
||||
}
|
||||
|
||||
void StaticFileService::registerTasks(TaskManager& taskManager) {
|
||||
// StaticFileService doesn't register any tasks itself
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
#include "spore/services/TaskService.h"
|
||||
#include "spore/core/ApiServer.h"
|
||||
#include "spore/types/TaskResponse.h"
|
||||
#include <algorithm>
|
||||
|
||||
using spore::types::TaskStatusResponse;
|
||||
using spore::types::TaskControlResponse;
|
||||
|
||||
TaskService::TaskService(TaskManager& taskManager) : taskManager(taskManager) {}
|
||||
|
||||
void TaskService::registerEndpoints(ApiServer& api) {
|
||||
api.registerEndpoint("/api/tasks/status", HTTP_GET,
|
||||
api.addEndpoint("/api/tasks/status", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
std::vector<ParamSpec>{}, "TaskService");
|
||||
|
||||
api.registerEndpoint("/api/tasks/control", HTTP_POST,
|
||||
api.addEndpoint("/api/tasks/control", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleControlRequest(request); },
|
||||
std::vector<ParamSpec>{
|
||||
ParamSpec{
|
||||
@@ -28,40 +32,19 @@ void TaskService::registerEndpoints(ApiServer& api) {
|
||||
{String("enable"), String("disable"), String("start"), String("stop"), String("status")},
|
||||
String("")
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void TaskService::registerTasks(TaskManager& taskManager) {
|
||||
// TaskService doesn't register any tasks itself - it manages other tasks
|
||||
}, "TaskService");
|
||||
}
|
||||
|
||||
void TaskService::handleStatusRequest(AsyncWebServerRequest* request) {
|
||||
TaskStatusResponse response;
|
||||
|
||||
// Get task statuses using a separate document to avoid reference issues
|
||||
JsonDocument scratch;
|
||||
auto taskStatuses = taskManager.getAllTaskStatuses(scratch);
|
||||
|
||||
JsonDocument doc;
|
||||
JsonObject summaryObj = doc["summary"].to<JsonObject>();
|
||||
summaryObj["totalTasks"] = taskStatuses.size();
|
||||
summaryObj["activeTasks"] = std::count_if(taskStatuses.begin(), taskStatuses.end(),
|
||||
[](const auto& pair) { return pair.second["enabled"]; });
|
||||
|
||||
JsonArray tasksArr = doc["tasks"].to<JsonArray>();
|
||||
for (const auto& taskPair : taskStatuses) {
|
||||
JsonObject taskObj = tasksArr.add<JsonObject>();
|
||||
taskObj["name"] = taskPair.first;
|
||||
taskObj["interval"] = taskPair.second["interval"];
|
||||
taskObj["enabled"] = taskPair.second["enabled"];
|
||||
taskObj["running"] = taskPair.second["running"];
|
||||
taskObj["autoStart"] = taskPair.second["autoStart"];
|
||||
}
|
||||
|
||||
JsonObject systemObj = doc["system"].to<JsonObject>();
|
||||
systemObj["freeHeap"] = ESP.getFreeHeap();
|
||||
systemObj["uptime"] = millis();
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
request->send(200, "application/json", json);
|
||||
// Build the complete response with the task data
|
||||
response.buildCompleteResponse(taskStatuses);
|
||||
request->send(200, "application/json", response.toJsonString());
|
||||
}
|
||||
|
||||
void TaskService::handleControlRequest(AsyncWebServerRequest* request) {
|
||||
@@ -92,50 +75,27 @@ void TaskService::handleControlRequest(AsyncWebServerRequest* request) {
|
||||
success = true;
|
||||
message = "Task status retrieved";
|
||||
|
||||
JsonDocument statusDoc;
|
||||
statusDoc["success"] = success;
|
||||
statusDoc["message"] = message;
|
||||
statusDoc["task"] = taskName;
|
||||
statusDoc["action"] = action;
|
||||
TaskControlResponse response;
|
||||
response.setResponse(success, message, taskName, action);
|
||||
response.addTaskDetails(taskName,
|
||||
taskManager.isTaskEnabled(taskName.c_str()),
|
||||
taskManager.isTaskRunning(taskName.c_str()),
|
||||
taskManager.getTaskInterval(taskName.c_str()));
|
||||
|
||||
statusDoc["taskDetails"] = JsonObject();
|
||||
JsonObject taskDetails = statusDoc["taskDetails"];
|
||||
taskDetails["name"] = taskName;
|
||||
taskDetails["enabled"] = taskManager.isTaskEnabled(taskName.c_str());
|
||||
taskDetails["running"] = taskManager.isTaskRunning(taskName.c_str());
|
||||
taskDetails["interval"] = taskManager.getTaskInterval(taskName.c_str());
|
||||
|
||||
taskDetails["system"] = JsonObject();
|
||||
JsonObject systemInfo = taskDetails["system"];
|
||||
systemInfo["freeHeap"] = ESP.getFreeHeap();
|
||||
systemInfo["uptime"] = millis();
|
||||
|
||||
String statusJson;
|
||||
serializeJson(statusDoc, statusJson);
|
||||
request->send(200, "application/json", statusJson);
|
||||
request->send(200, "application/json", response.toJsonString());
|
||||
return;
|
||||
} else {
|
||||
success = false;
|
||||
message = "Invalid action. Use: enable, disable, start, stop, or status";
|
||||
}
|
||||
|
||||
JsonDocument doc;
|
||||
doc["success"] = success;
|
||||
doc["message"] = message;
|
||||
doc["task"] = taskName;
|
||||
doc["action"] = action;
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
request->send(success ? 200 : 400, "application/json", json);
|
||||
TaskControlResponse response;
|
||||
response.setResponse(success, message, taskName, action);
|
||||
request->send(success ? 200 : 400, "application/json", response.toJsonString());
|
||||
} else {
|
||||
JsonDocument doc;
|
||||
doc["success"] = false;
|
||||
doc["message"] = "Missing parameters. Required: task, action";
|
||||
doc["example"] = "{\"task\": \"discovery_send\", \"action\": \"status\"}";
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
request->send(400, "application/json", json);
|
||||
TaskControlResponse response;
|
||||
response.setError("Missing parameters. Required: task, action",
|
||||
"{\"task\": \"discovery_send\", \"action\": \"status\"}");
|
||||
request->send(400, "application/json", response.toJsonString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,194 +1,36 @@
|
||||
#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 = DEFAULT_WIFI_SSID;
|
||||
wifi_password = DEFAULT_WIFI_PASSWORD;
|
||||
wifi_ssid = "shroud";
|
||||
wifi_password = "th3r31sn0sp00n";
|
||||
|
||||
// Network Configuration
|
||||
udp_port = DEFAULT_UDP_PORT;
|
||||
api_server_port = DEFAULT_API_SERVER_PORT;
|
||||
udp_port = 4210;
|
||||
api_server_port = 80;
|
||||
|
||||
// Cluster Configuration
|
||||
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;
|
||||
discovery_interval_ms = 1000;
|
||||
heartbeat_interval_ms = 2000;
|
||||
status_update_interval_ms = 1000;
|
||||
member_info_update_interval_ms = 10000;
|
||||
print_interval_ms = 5000;
|
||||
|
||||
// Node Status Thresholds
|
||||
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;
|
||||
node_active_threshold_ms = 10000;
|
||||
node_inactive_threshold_ms = 60000;
|
||||
node_dead_threshold_ms = 120000;
|
||||
|
||||
// WiFi Connection
|
||||
wifi_connect_timeout_ms = DEFAULT_WIFI_CONNECT_TIMEOUT_MS;
|
||||
wifi_retry_delay_ms = DEFAULT_WIFI_RETRY_DELAY_MS;
|
||||
wifi_connect_timeout_ms = 15000;
|
||||
wifi_retry_delay_ms = 500;
|
||||
|
||||
// System Configuration
|
||||
restart_delay_ms = DEFAULT_RESTART_DELAY_MS;
|
||||
json_doc_size = DEFAULT_JSON_DOC_SIZE;
|
||||
restart_delay_ms = 10;
|
||||
json_doc_size = 1024;
|
||||
|
||||
// Memory Management
|
||||
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;
|
||||
low_memory_threshold_bytes = 10000; // 10KB
|
||||
critical_memory_threshold_bytes = 5000; // 5KB
|
||||
max_concurrent_http_requests = 3;
|
||||
}
|
||||
1
test/.gitignore
vendored
1
test/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
node_modules/
|
||||
69
test/README
69
test/README
@@ -1,64 +1,11 @@
|
||||
# Test Scripts
|
||||
|
||||
This directory contains JavaScript test scripts to interact with the Spore device, primarily for testing cluster event broadcasting.
|
||||
This directory is intended for PlatformIO Test Runner and project tests.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
These scripts require [Node.js](https://nodejs.org/) to be installed on your system.
|
||||
|
||||
## How to Run
|
||||
|
||||
### 1. HTTP Cluster Broadcast Color (`test/http-cluster-broadcast-color.js`)
|
||||
|
||||
This script sends HTTP POST requests to the `/api/cluster/event` endpoint on your Spore device. It broadcasts NeoPattern color changes across the cluster every 5 seconds.
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
node test/http-cluster-broadcast-color.js <device-ip>
|
||||
```
|
||||
Example:
|
||||
```
|
||||
node test/http-cluster-broadcast-color.js 10.0.1.53
|
||||
```
|
||||
This will broadcast `{ event: "api/neopattern/color", data: { color: "#RRGGBB", brightness: 128 } }` every 5 seconds to the cluster via `/api/cluster/event`.
|
||||
|
||||
### 2. WS Local Color Setter (`test/ws-color-client.js`)
|
||||
|
||||
Connects to the device WebSocket (`/ws`) and sets a solid color locally (non-broadcast) every 5 seconds by firing `api/neopattern/color`.
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
node test/ws-color-client.js ws://<device-ip>/ws
|
||||
```
|
||||
Example:
|
||||
```
|
||||
node test/ws-color-client.js ws://10.0.1.53/ws
|
||||
```
|
||||
|
||||
### 3. WS Cluster Broadcast Color (`test/ws-cluster-broadcast-color.js`)
|
||||
|
||||
Connects to the device WebSocket (`/ws`) and broadcasts a color change to all peers every 5 seconds by firing `cluster/broadcast` with the proper envelope.
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
node test/ws-cluster-broadcast-color.js ws://<device-ip>/ws
|
||||
```
|
||||
Example:
|
||||
```
|
||||
node test/ws-cluster-broadcast-color.js ws://10.0.1.53/ws
|
||||
```
|
||||
|
||||
### 4. WS Cluster Broadcast Rainbow (`test/ws-cluster-broadcast-rainbow.js`)
|
||||
|
||||
Broadcasts a smooth rainbow color transition over WebSocket using `cluster/broadcast` and the `api/neopattern/color` event. Update rate defaults to `UPDATE_RATE` in the script (e.g., 100 ms).
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
node test/ws-cluster-broadcast-rainbow.js ws://<device-ip>/ws
|
||||
```
|
||||
Example:
|
||||
```
|
||||
node test/ws-cluster-broadcast-rainbow.js ws://10.0.1.53/ws
|
||||
```
|
||||
Note: Very fast update intervals (e.g., 10 ms) may saturate links or the device.
|
||||
Unit Testing is a software testing method by which individual units of
|
||||
source code, sets of one or more MCU program modules together with associated
|
||||
control data, usage procedures, and operating procedures, are tested to
|
||||
determine whether they are fit for use. Unit testing finds problems early
|
||||
in the development cycle.
|
||||
|
||||
More information about PlatformIO Unit Testing:
|
||||
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
// Simple HTTP client to broadcast a neopattern color change to the cluster
|
||||
// Usage: node cluster-broadcast-color.js 10.0.1.53
|
||||
|
||||
const http = require('http');
|
||||
|
||||
const host = process.argv[2] || '127.0.0.1';
|
||||
const port = 80;
|
||||
|
||||
const colors = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF'];
|
||||
let idx = 0;
|
||||
|
||||
function postClusterEvent(event, payloadObj) {
|
||||
const payload = encodeURIComponent(JSON.stringify(payloadObj));
|
||||
const body = `event=${encodeURIComponent(event)}&payload=${payload}`;
|
||||
|
||||
const options = {
|
||||
host,
|
||||
port,
|
||||
path: '/api/cluster/event',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': Buffer.byteLength(body)
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
console.log('Response:', res.statusCode, data);
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
console.error('Request error:', err.message);
|
||||
});
|
||||
|
||||
req.write(body);
|
||||
req.end();
|
||||
}
|
||||
|
||||
console.log(`Broadcasting color changes to http://${host}/api/cluster/event ...`);
|
||||
setInterval(() => {
|
||||
const color = colors[idx % colors.length];
|
||||
idx++;
|
||||
const payload = { color, brightness: 80 };
|
||||
console.log('Broadcasting color:', payload);
|
||||
postClusterEvent('api/neopattern/color', payload);
|
||||
}, 5000);
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
// WebSocket client to broadcast neopattern color changes across the cluster
|
||||
// Usage: node ws-cluster-broadcast-color.js ws://<device-ip>/ws
|
||||
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const url = process.argv[2] || 'ws://127.0.0.1/ws';
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
const colors = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF'];
|
||||
let idx = 0;
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('Connected to', url);
|
||||
// Broadcast color change every 5 seconds via cluster/broadcast
|
||||
setInterval(() => {
|
||||
const color = colors[idx % colors.length];
|
||||
idx++;
|
||||
const payload = { color, brightness: 80 };
|
||||
const envelope = {
|
||||
event: 'api/neopattern/color',
|
||||
data: payload // server will serialize object payloads
|
||||
};
|
||||
const msg = { event: 'cluster/broadcast', payload: envelope };
|
||||
ws.send(JSON.stringify(msg));
|
||||
console.log('Broadcasted color event', payload);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
console.log('Received:', msg);
|
||||
} catch (e) {
|
||||
console.log('Received raw:', data.toString());
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('WebSocket error:', err.message);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('WebSocket closed');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
// WebSocket client to broadcast smooth rainbow color changes across the cluster
|
||||
// Usage: node ws-cluster-broadcast-rainbow.js ws://<device-ip>/ws
|
||||
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const url = process.argv[2] || 'ws://127.0.0.1/ws';
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
function hsvToRgb(h, s, v) {
|
||||
const c = v * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = v - c;
|
||||
let r = 0, g = 0, b = 0;
|
||||
if (h < 60) { r = c; g = x; b = 0; }
|
||||
else if (h < 120) { r = x; g = c; b = 0; }
|
||||
else if (h < 180) { r = 0; g = c; b = x; }
|
||||
else if (h < 240) { r = 0; g = x; b = c; }
|
||||
else if (h < 300) { r = x; g = 0; b = c; }
|
||||
else { r = c; g = 0; b = x; }
|
||||
const R = Math.round((r + m) * 255);
|
||||
const G = Math.round((g + m) * 255);
|
||||
const B = Math.round((b + m) * 255);
|
||||
return { r: R, g: G, b: B };
|
||||
}
|
||||
|
||||
function toHex({ r, g, b }) {
|
||||
const h = (n) => n.toString(16).padStart(2, '0').toUpperCase();
|
||||
return `#${h(r)}${h(g)}${h(b)}`;
|
||||
}
|
||||
|
||||
let hue = 0;
|
||||
const SAT = 1.0; // full saturation
|
||||
const VAL = 1.0; // full value
|
||||
const BRIGHTNESS = 80;
|
||||
const UPDATE_RATE = 100; // ms
|
||||
|
||||
let timer = null;
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('Connected to', url);
|
||||
// UPDATE_RATE ms updates (10 Hz). Be aware this can saturate slow links.
|
||||
timer = setInterval(() => {
|
||||
const rgb = hsvToRgb(hue, SAT, VAL);
|
||||
const color = toHex(rgb);
|
||||
const envelope = {
|
||||
event: 'api/neopattern/color',
|
||||
data: { color, brightness: BRIGHTNESS }
|
||||
};
|
||||
const msg = { event: 'cluster/broadcast', payload: envelope };
|
||||
try {
|
||||
ws.send(JSON.stringify(msg));
|
||||
} catch (_) {}
|
||||
hue = (hue + 2) % 360; // advance hue (adjust for speed)
|
||||
}, UPDATE_RATE);
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
// Optionally throttle logs: comment out for quieter output
|
||||
// console.log('WS:', data.toString());
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('WebSocket error:', err.message);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
if (timer) clearInterval(timer);
|
||||
console.log('WebSocket closed');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
// Simple WebSocket client to test streaming API color changes
|
||||
// Usage: node ws-color-client.js ws://<device-ip>/ws
|
||||
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const url = process.argv[2] || 'ws://127.0.0.1/ws';
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
const colors = [
|
||||
'#FF0000', // red
|
||||
'#00FF00', // green
|
||||
'#0000FF', // blue
|
||||
'#FFFF00', // yellow
|
||||
'#FF00FF', // magenta
|
||||
'#00FFFF' // cyan
|
||||
];
|
||||
let idx = 0;
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('Connected to', url);
|
||||
// Send a message every 5 seconds to set solid color
|
||||
setInterval(() => {
|
||||
const color = colors[idx % colors.length];
|
||||
idx++;
|
||||
const payload = { color, brightness: 80 };
|
||||
// Send payload as an object (server supports string or object)
|
||||
const msg = { event: 'api/neopattern/color', payload };
|
||||
ws.send(JSON.stringify(msg));
|
||||
console.log('Sent color event', payload);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
console.log('Received:', msg);
|
||||
} catch (e) {
|
||||
console.log('Received raw:', data.toString());
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('WebSocket error:', err.message);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('WebSocket closed');
|
||||
});
|
||||
33
test/package-lock.json
generated
33
test/package-lock.json
generated
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"name": "test",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"scripts": {
|
||||
"pixelstream:fade-green-blue": "node pixelstream/fade-green-blue.js",
|
||||
"pixelstream:bouncing-ball": "node pixelstream/bouncing-ball.js",
|
||||
"pixelstream:rainbow": "node pixelstream/rainbow.js",
|
||||
"pixelstream:lava-lamp": "node pixelstream/lava-lamp.js",
|
||||
"pixelstream:meteor-rain": "node pixelstream/meteor-rain.js",
|
||||
"pixelstream:spiral-bloom": "node pixelstream/spiral-bloom.js",
|
||||
"pixelstream:ocean-glimmer": "node pixelstream/ocean-glimmer.js",
|
||||
"pixelstream:nebula-drift": "node pixelstream/nebula-drift.js",
|
||||
"pixelstream:voxel-fireflies": "node pixelstream/voxel-fireflies.js",
|
||||
"pixelstream:wormhole-tunnel": "node pixelstream/wormhole-tunnel.js",
|
||||
"pixelstream:circuit-pulse": "node pixelstream/circuit-pulse.js",
|
||||
"pixelstream:aurora-curtains": "node pixelstream/aurora-curtains.js",
|
||||
"pixelstream:snek": "npm start --prefix pixelstream/snek",
|
||||
"pixelstream:tetris": "npm start --prefix pixelstream/tetris"
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
clamp,
|
||||
createFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 65;
|
||||
const BAND_COUNT = 5;
|
||||
const WAVE_SPEED = 0.35;
|
||||
const HORIZONTAL_SWAY = 0.45;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('01010a') },
|
||||
{ stop: 0.2, color: hexToRgb('041332') },
|
||||
{ stop: 0.4, color: hexToRgb('0c3857') },
|
||||
{ stop: 0.65, color: hexToRgb('1aa07a') },
|
||||
{ stop: 0.85, color: hexToRgb('68d284') },
|
||||
{ stop: 1.0, color: hexToRgb('f4f5c6') },
|
||||
];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node aurora-curtains.js <device-ip> [port] [width] [height] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
const bands = createBands(BAND_COUNT, width);
|
||||
let timeSeconds = 0;
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function createBands(count, matrixWidth) {
|
||||
const generatedBands = [];
|
||||
for (let index = 0; index < count; ++index) {
|
||||
generatedBands.push({
|
||||
center: Math.random() * (matrixWidth - 1),
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
width: 1.2 + Math.random() * 1.8,
|
||||
});
|
||||
}
|
||||
return generatedBands;
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
timeSeconds += frameTimeSeconds;
|
||||
|
||||
for (let row = 0; row < height; ++row) {
|
||||
const verticalRatio = row / Math.max(1, height - 1);
|
||||
for (let col = 0; col < width; ++col) {
|
||||
let intensity = 0;
|
||||
|
||||
bands.forEach((band, index) => {
|
||||
const sway = Math.sin(timeSeconds * WAVE_SPEED + band.phase + verticalRatio * Math.PI * 2) * HORIZONTAL_SWAY;
|
||||
const center = band.center + sway * (index % 2 === 0 ? 1 : -1);
|
||||
const distance = Math.abs(col - center);
|
||||
const blurred = Math.exp(-(distance * distance) / (2 * band.width * band.width));
|
||||
intensity += blurred * (0.8 + Math.sin(timeSeconds * 0.4 + index) * 0.2);
|
||||
});
|
||||
|
||||
const normalized = clamp(intensity / bands.length, 0, 1);
|
||||
const gradientBlend = clamp((normalized * 0.7 + verticalRatio * 0.3), 0, 1);
|
||||
frame[toIndex(col, row, width)] = samplePalette(paletteStops, gradientBlend);
|
||||
}
|
||||
}
|
||||
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming aurora curtains to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms)`
|
||||
);
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || '4210', 10);
|
||||
const pixels = parseInt(process.argv[4] || '64', 10);
|
||||
const intervalMs = parseInt(process.argv[5] || '30', 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node bouncing-ball.js <device-ip> [port] [pixels] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
|
||||
let position = Math.random() * (pixels - 1);
|
||||
let velocity = randomVelocity();
|
||||
|
||||
function randomVelocity() {
|
||||
const min = 0.15;
|
||||
const max = 0.4;
|
||||
const sign = Math.random() < 0.5 ? -1 : 1;
|
||||
return (min + Math.random() * (max - min)) * sign;
|
||||
}
|
||||
|
||||
function rebound(sign) {
|
||||
velocity = randomVelocity() * sign;
|
||||
}
|
||||
|
||||
function mix(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
const dt = intervalMs / 1000;
|
||||
position += velocity * dt * 60; // scale velocity to 60 FPS reference
|
||||
|
||||
if (position < 0) {
|
||||
position = -position;
|
||||
rebound(1);
|
||||
} else if (position > pixels - 1) {
|
||||
position = (pixels - 1) - (position - (pixels - 1));
|
||||
rebound(-1);
|
||||
}
|
||||
|
||||
const activeIndex = Math.max(0, Math.min(pixels - 1, Math.round(position)));
|
||||
|
||||
let payload = 'RAW:';
|
||||
for (let i = 0; i < pixels; i++) {
|
||||
if (i === activeIndex) {
|
||||
payload += 'ff8000';
|
||||
continue;
|
||||
}
|
||||
|
||||
const distance = Math.abs(i - position);
|
||||
const intensity = Math.max(0, 1 - distance);
|
||||
const green = Math.round(mix(20, 200, intensity)).toString(16).padStart(2, '0');
|
||||
const blue = Math.round(mix(40, 255, intensity)).toString(16).padStart(2, '0');
|
||||
payload += '00' + green + blue;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Streaming bouncing ball pattern to ${host}:${port} with ${pixels} pixels (interval=${intervalMs}ms)`);
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
addHexColor,
|
||||
createFrame,
|
||||
fadeFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 50;
|
||||
const PATH_FADE = 0.85;
|
||||
const PULSE_LENGTH = 6;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('020209') },
|
||||
{ stop: 0.3, color: hexToRgb('023047') },
|
||||
{ stop: 0.6, color: hexToRgb('115173') },
|
||||
{ stop: 0.8, color: hexToRgb('1ca78f') },
|
||||
{ stop: 1.0, color: hexToRgb('94fdf3') },
|
||||
];
|
||||
|
||||
const accentColors = ['14f5ff', 'a7ff4d', 'ffcc3f'];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node circuit-pulse.js <device-ip> [port] [width] [height] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
const paths = createPaths(width, height);
|
||||
const pulses = createPulses(paths.length);
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function createPaths(matrixWidth, matrixHeight) {
|
||||
const horizontalStep = Math.max(2, Math.floor(matrixHeight / 4));
|
||||
const verticalStep = Math.max(2, Math.floor(matrixWidth / 4));
|
||||
const generatedPaths = [];
|
||||
|
||||
for (let y = 1; y < matrixHeight; y += horizontalStep) {
|
||||
const path = [];
|
||||
for (let x = 0; x < matrixWidth; ++x) {
|
||||
path.push({ x, y });
|
||||
}
|
||||
generatedPaths.push(path);
|
||||
}
|
||||
|
||||
for (let x = 2; x < matrixWidth; x += verticalStep) {
|
||||
const path = [];
|
||||
for (let y = 0; y < matrixHeight; ++y) {
|
||||
path.push({ x, y });
|
||||
}
|
||||
generatedPaths.push(path);
|
||||
}
|
||||
|
||||
return generatedPaths;
|
||||
}
|
||||
|
||||
function createPulses(count) {
|
||||
const pulseList = [];
|
||||
for (let index = 0; index < count; ++index) {
|
||||
pulseList.push(spawnPulse(index));
|
||||
}
|
||||
return pulseList;
|
||||
}
|
||||
|
||||
function spawnPulse(pathIndex) {
|
||||
const color = accentColors[pathIndex % accentColors.length];
|
||||
return {
|
||||
pathIndex,
|
||||
position: 0,
|
||||
speed: 3 + Math.random() * 2,
|
||||
color,
|
||||
};
|
||||
}
|
||||
|
||||
function updatePulse(pulse, deltaSeconds) {
|
||||
pulse.position += pulse.speed * deltaSeconds;
|
||||
const path = paths[pulse.pathIndex];
|
||||
|
||||
if (!path || path.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pulse.position >= path.length + PULSE_LENGTH) {
|
||||
Object.assign(pulse, spawnPulse(pulse.pathIndex));
|
||||
pulse.position = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPulse(pulse) {
|
||||
const path = paths[pulse.pathIndex];
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let offset = 0; offset < PULSE_LENGTH; ++offset) {
|
||||
const index = Math.floor(pulse.position) - offset;
|
||||
if (index < 0 || index >= path.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { x, y } = path[index];
|
||||
const intensity = Math.max(0, 1 - offset / PULSE_LENGTH);
|
||||
const baseColor = samplePalette(paletteStops, intensity);
|
||||
frame[toIndex(x, y, width)] = baseColor;
|
||||
addHexColor(frame, toIndex(x, y, width), pulse.color, intensity * 1.4);
|
||||
}
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
fadeFrame(frame, PATH_FADE);
|
||||
|
||||
pulses.forEach((pulse) => {
|
||||
updatePulse(pulse, frameTimeSeconds);
|
||||
renderPulse(pulse);
|
||||
});
|
||||
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming circuit pulse to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms, paths=${paths.length})`
|
||||
);
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || '4210', 10);
|
||||
const pixels = parseInt(process.argv[4] || '64', 10);
|
||||
const speed = parseFloat(process.argv[5] || '0.5'); // cycles per second
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node fade-green-blue.js <device-ip> [port] [pixels] [speed-hz]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const intervalMs = 50;
|
||||
let tick = 0;
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
|
||||
function generateFrame() {
|
||||
const timeSeconds = (tick * intervalMs) / 1000;
|
||||
const phase = timeSeconds * speed * Math.PI * 2;
|
||||
const blend = (Math.sin(phase) + 1) * 0.5; // 0..1
|
||||
|
||||
const green = Math.round(255 * (1 - blend));
|
||||
const blue = Math.round(255 * blend);
|
||||
|
||||
let payload = 'RAW:';
|
||||
const gHex = green.toString(16).padStart(2, '0');
|
||||
const bHex = blue.toString(16).padStart(2, '0');
|
||||
|
||||
for (let i = 0; i < pixels; i++) {
|
||||
payload += '00';
|
||||
payload += gHex;
|
||||
payload += bHex;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
tick += 1;
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Streaming green/blue fade to ${host}:${port} with ${pixels} pixels (speed=${speed}Hz)`);
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
clamp,
|
||||
hexToRgb,
|
||||
samplePalette: samplePaletteFromStops,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 60;
|
||||
const DEFAULT_BLOB_COUNT = 6;
|
||||
const BASE_BLOB_SPEED = 0.18;
|
||||
const PHASE_SPEED_MIN = 0.6;
|
||||
const PHASE_SPEED_MAX = 1.2;
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
const blobCount = parseInt(process.argv[7] || String(DEFAULT_BLOB_COUNT), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node lava-lamp.js <device-ip> [port] [width] [height] [interval-ms] [blob-count]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs) || Number.isNaN(blobCount)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, interval-ms, and blob-count.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (blobCount <= 0) {
|
||||
console.error('Blob count must be a positive integer.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const totalPixels = width * height;
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
|
||||
const maxAxis = Math.max(width, height);
|
||||
const minBlobRadius = Math.max(3, maxAxis * 0.18);
|
||||
const maxBlobRadius = Math.max(minBlobRadius + 1, maxAxis * 0.38);
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('050319') },
|
||||
{ stop: 0.28, color: hexToRgb('2a0c4f') },
|
||||
{ stop: 0.55, color: hexToRgb('8f1f73') },
|
||||
{ stop: 0.75, color: hexToRgb('ff4a22') },
|
||||
{ stop: 0.9, color: hexToRgb('ff9333') },
|
||||
{ stop: 1.0, color: hexToRgb('fff7b0') },
|
||||
];
|
||||
|
||||
const blobs = createBlobs(blobCount);
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function createBlobs(count) {
|
||||
const blobList = [];
|
||||
for (let index = 0; index < count; ++index) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const speed = BASE_BLOB_SPEED * (0.6 + Math.random() * 0.8);
|
||||
blobList.push({
|
||||
x: Math.random() * Math.max(1, width - 1),
|
||||
y: Math.random() * Math.max(1, height - 1),
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
minRadius: minBlobRadius * (0.6 + Math.random() * 0.3),
|
||||
maxRadius: maxBlobRadius * (0.8 + Math.random() * 0.4),
|
||||
intensity: 0.8 + Math.random() * 0.7,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
phaseVelocity: PHASE_SPEED_MIN + Math.random() * (PHASE_SPEED_MAX - PHASE_SPEED_MIN),
|
||||
});
|
||||
}
|
||||
return blobList;
|
||||
}
|
||||
|
||||
function updateBlobs(deltaSeconds) {
|
||||
const maxX = Math.max(0, width - 1);
|
||||
const maxY = Math.max(0, height - 1);
|
||||
|
||||
blobs.forEach((blob) => {
|
||||
blob.x += blob.vx * deltaSeconds;
|
||||
blob.y += blob.vy * deltaSeconds;
|
||||
|
||||
if (blob.x < 0) {
|
||||
blob.x = -blob.x;
|
||||
blob.vx = Math.abs(blob.vx);
|
||||
} else if (blob.x > maxX) {
|
||||
blob.x = 2 * maxX - blob.x;
|
||||
blob.vx = -Math.abs(blob.vx);
|
||||
}
|
||||
|
||||
if (blob.y < 0) {
|
||||
blob.y = -blob.y;
|
||||
blob.vy = Math.abs(blob.vy);
|
||||
} else if (blob.y > maxY) {
|
||||
blob.y = 2 * maxY - blob.y;
|
||||
blob.vy = -Math.abs(blob.vy);
|
||||
}
|
||||
|
||||
blob.phase += blob.phaseVelocity * deltaSeconds;
|
||||
});
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
updateBlobs(frameTimeSeconds);
|
||||
|
||||
const frame = new Array(totalPixels);
|
||||
|
||||
for (let row = 0; row < height; ++row) {
|
||||
for (let col = 0; col < width; ++col) {
|
||||
const energy = calculateEnergyAt(col, row);
|
||||
const color = samplePaletteFromStops(paletteStops, energy);
|
||||
frame[toIndex(col, row, width)] = color;
|
||||
}
|
||||
}
|
||||
|
||||
return 'RAW:' + frame.join('');
|
||||
}
|
||||
|
||||
function calculateEnergyAt(col, row) {
|
||||
let energy = 0;
|
||||
|
||||
blobs.forEach((blob) => {
|
||||
const radius = getBlobRadius(blob);
|
||||
const dx = col - blob.x;
|
||||
const dy = row - blob.y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
const falloff = Math.max(0, 1 - distance / radius);
|
||||
energy += blob.intensity * falloff * falloff;
|
||||
});
|
||||
|
||||
return clamp(energy / blobs.length, 0, 1);
|
||||
}
|
||||
|
||||
function getBlobRadius(blob) {
|
||||
const oscillation = (Math.sin(blob.phase) + 1) * 0.5;
|
||||
return blob.minRadius + (blob.maxRadius - blob.minRadius) * oscillation;
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming lava lamp to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms, blobs=${blobCount})`,
|
||||
);
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
clamp,
|
||||
createFrame,
|
||||
fadeFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 45;
|
||||
const DEFAULT_METEOR_COUNT = 12;
|
||||
const BASE_SPEED_MIN = 4;
|
||||
const BASE_SPEED_MAX = 10;
|
||||
const TRAIL_DECAY = 0.76;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('0a0126') },
|
||||
{ stop: 0.3, color: hexToRgb('123d8b') },
|
||||
{ stop: 0.7, color: hexToRgb('21c7d9') },
|
||||
{ stop: 1.0, color: hexToRgb('f7ffff') },
|
||||
];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
const meteorCount = parseInt(process.argv[7] || String(DEFAULT_METEOR_COUNT), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node meteor-rain.js <device-ip> [port] [width] [height] [interval-ms] [meteor-count]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs) || Number.isNaN(meteorCount)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, interval-ms, and meteor-count.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (meteorCount <= 0) {
|
||||
console.error('Meteor count must be a positive integer.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
const meteors = createMeteors(meteorCount, width, height);
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function createMeteors(count, matrixWidth, matrixHeight) {
|
||||
const meteorList = [];
|
||||
for (let index = 0; index < count; ++index) {
|
||||
meteorList.push(spawnMeteor(matrixWidth, matrixHeight));
|
||||
}
|
||||
return meteorList;
|
||||
}
|
||||
|
||||
function spawnMeteor(matrixWidth, matrixHeight) {
|
||||
const angle = (Math.PI / 4) * (0.6 + Math.random() * 0.8);
|
||||
const speed = BASE_SPEED_MIN + Math.random() * (BASE_SPEED_MAX - BASE_SPEED_MIN);
|
||||
return {
|
||||
x: Math.random() * matrixWidth,
|
||||
y: -Math.random() * matrixHeight,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
};
|
||||
}
|
||||
|
||||
function drawMeteor(meteor) {
|
||||
const col = Math.round(meteor.x);
|
||||
const row = Math.round(meteor.y);
|
||||
if (col < 0 || col >= width || row < 0 || row >= height) {
|
||||
return;
|
||||
}
|
||||
|
||||
const energy = clamp(1.2 - Math.random() * 0.2, 0, 1);
|
||||
frame[toIndex(col, row, width)] = samplePalette(paletteStops, energy);
|
||||
}
|
||||
|
||||
function updateMeteors(deltaSeconds) {
|
||||
meteors.forEach((meteor, index) => {
|
||||
meteor.x += meteor.vx * deltaSeconds;
|
||||
meteor.y += meteor.vy * deltaSeconds;
|
||||
|
||||
drawMeteor(meteor);
|
||||
|
||||
if (meteor.x > width + 1 || meteor.y > height + 1) {
|
||||
meteors[index] = spawnMeteor(width, height);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
fadeFrame(frame, TRAIL_DECAY);
|
||||
updateMeteors(frameTimeSeconds);
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming meteor rain to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms, meteors=${meteorCount})`,
|
||||
);
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
clamp,
|
||||
createFrame,
|
||||
fadeFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 70;
|
||||
const PRIMARY_SPEED = 0.15;
|
||||
const SECONDARY_SPEED = 0.32;
|
||||
const WAVE_SCALE = 0.75;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('100406') },
|
||||
{ stop: 0.25, color: hexToRgb('2e0f1f') },
|
||||
{ stop: 0.5, color: hexToRgb('6a1731') },
|
||||
{ stop: 0.7, color: hexToRgb('b63b32') },
|
||||
{ stop: 0.85, color: hexToRgb('f48b2a') },
|
||||
{ stop: 1.0, color: hexToRgb('ffe9b0') },
|
||||
];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node nebula-drift.js <device-ip> [port] [width] [height] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
let timeSeconds = 0;
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function layeredWave(u, v, speed, offset) {
|
||||
return Math.sin((u * 3 + v * 2) * Math.PI * WAVE_SCALE + timeSeconds * speed + offset);
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
timeSeconds += frameTimeSeconds;
|
||||
|
||||
for (let row = 0; row < height; ++row) {
|
||||
const v = row / Math.max(1, height - 1);
|
||||
for (let col = 0; col < width; ++col) {
|
||||
const u = col / Math.max(1, width - 1);
|
||||
const primary = layeredWave(u, v, PRIMARY_SPEED, 0);
|
||||
const secondary = layeredWave(v, u, SECONDARY_SPEED, Math.PI / 4);
|
||||
const tertiary = Math.sin((u + v) * Math.PI * 1.5 + timeSeconds * 0.18);
|
||||
|
||||
const combined = 0.45 * primary + 0.35 * secondary + 0.2 * tertiary;
|
||||
const envelope = Math.sin((u * v) * Math.PI * 2 + timeSeconds * 0.1) * 0.25 + 0.75;
|
||||
const value = clamp((combined * 0.5 + 0.5) * envelope, 0, 1);
|
||||
|
||||
frame[toIndex(col, row, width)] = samplePalette(paletteStops, value);
|
||||
}
|
||||
}
|
||||
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming nebula drift to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms)`
|
||||
);
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
clamp,
|
||||
createFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 50;
|
||||
const SHIMMER = 0.08;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('031521') },
|
||||
{ stop: 0.35, color: hexToRgb('024f6d') },
|
||||
{ stop: 0.65, color: hexToRgb('13a4a1') },
|
||||
{ stop: 0.85, color: hexToRgb('67dcd0') },
|
||||
{ stop: 1.0, color: hexToRgb('fcdba4') },
|
||||
];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node ocean-glimmer.js <device-ip> [port] [width] [height] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
let timeSeconds = 0;
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function generateFrame() {
|
||||
timeSeconds += frameTimeSeconds;
|
||||
|
||||
for (let row = 0; row < height; ++row) {
|
||||
const v = row / Math.max(1, height - 1);
|
||||
for (let col = 0; col < width; ++col) {
|
||||
const u = col / Math.max(1, width - 1);
|
||||
const base =
|
||||
0.33 +
|
||||
0.26 * Math.sin(u * Math.PI * 2 + timeSeconds * 1.2) +
|
||||
0.26 * Math.sin(v * Math.PI * 2 - timeSeconds * 0.9) +
|
||||
0.26 * Math.sin((u + v) * Math.PI * 2 + timeSeconds * 0.5);
|
||||
const noise = (Math.random() - 0.5) * SHIMMER;
|
||||
const value = clamp(base + noise, 0, 1);
|
||||
frame[toIndex(col, row, width)] = samplePalette(paletteStops, value);
|
||||
}
|
||||
}
|
||||
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming ocean glimmer to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms)`,
|
||||
);
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || '4210', 10);
|
||||
const pixels = parseInt(process.argv[4] || '64', 10);
|
||||
const intervalMs = parseInt(process.argv[5] || '30', 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node rainbow.js <device-ip> [port] [pixels] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
let offset = 0;
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
|
||||
function wheel(pos) {
|
||||
pos = 255 - pos;
|
||||
if (pos < 85) {
|
||||
return [255 - pos * 3, 0, pos * 3];
|
||||
}
|
||||
if (pos < 170) {
|
||||
pos -= 85;
|
||||
return [0, pos * 3, 255 - pos * 3];
|
||||
}
|
||||
pos -= 170;
|
||||
return [pos * 3, 255 - pos * 3, 0];
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
let payload = 'RAW:';
|
||||
for (let i = 0; i < pixels; i++) {
|
||||
const colorIndex = (i * 256 / pixels + offset) & 255;
|
||||
const [r, g, b] = wheel(colorIndex);
|
||||
payload += r.toString(16).padStart(2, '0');
|
||||
payload += g.toString(16).padStart(2, '0');
|
||||
payload += b.toString(16).padStart(2, '0');
|
||||
}
|
||||
|
||||
offset = (offset + 1) & 255;
|
||||
return payload;
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Streaming rainbow pattern to ${host}:${port} with ${pixels} pixels (interval=${intervalMs}ms)`);
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
const SERPENTINE_WIRING = true;
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const normalizedHex = hex.trim().toLowerCase();
|
||||
const value = normalizedHex.startsWith('#') ? normalizedHex.slice(1) : normalizedHex;
|
||||
return {
|
||||
r: parseInt(value.slice(0, 2), 16),
|
||||
g: parseInt(value.slice(2, 4), 16),
|
||||
b: parseInt(value.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function rgbToHex(rgb) {
|
||||
return toHex(rgb.r) + toHex(rgb.g) + toHex(rgb.b);
|
||||
}
|
||||
|
||||
function toHex(value) {
|
||||
const boundedValue = clamp(Math.round(value), 0, 255);
|
||||
const hex = boundedValue.toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
}
|
||||
|
||||
function lerpRgb(lhs, rhs, t) {
|
||||
return {
|
||||
r: Math.round(lhs.r + (rhs.r - lhs.r) * t),
|
||||
g: Math.round(lhs.g + (rhs.g - lhs.g) * t),
|
||||
b: Math.round(lhs.b + (rhs.b - lhs.b) * t),
|
||||
};
|
||||
}
|
||||
|
||||
function samplePalette(paletteStops, value) {
|
||||
const clampedValue = clamp(value, 0, 1);
|
||||
|
||||
for (let index = 0; index < paletteStops.length - 1; ++index) {
|
||||
const left = paletteStops[index];
|
||||
const right = paletteStops[index + 1];
|
||||
if (clampedValue <= right.stop) {
|
||||
const span = right.stop - left.stop || 1;
|
||||
const t = clamp((clampedValue - left.stop) / span, 0, 1);
|
||||
const interpolatedColor = lerpRgb(left.color, right.color, t);
|
||||
return rgbToHex(interpolatedColor);
|
||||
}
|
||||
}
|
||||
|
||||
return rgbToHex(paletteStops[paletteStops.length - 1].color);
|
||||
}
|
||||
|
||||
function toIndex(col, row, width, serpentine = SERPENTINE_WIRING) {
|
||||
if (!serpentine || row % 2 === 0) {
|
||||
return row * width + col;
|
||||
}
|
||||
return row * width + (width - 1 - col);
|
||||
}
|
||||
|
||||
function createFrame(width, height, fill = '000000') {
|
||||
return new Array(width * height).fill(fill);
|
||||
}
|
||||
|
||||
function frameToPayload(frame) {
|
||||
return 'RAW:' + frame.join('');
|
||||
}
|
||||
|
||||
function fadeFrame(frame, factor) {
|
||||
for (let index = 0; index < frame.length; ++index) {
|
||||
const hex = frame[index];
|
||||
const r = parseInt(hex.slice(0, 2), 16) * factor;
|
||||
const g = parseInt(hex.slice(2, 4), 16) * factor;
|
||||
const b = parseInt(hex.slice(4, 6), 16) * factor;
|
||||
frame[index] = toHex(r) + toHex(g) + toHex(b);
|
||||
}
|
||||
}
|
||||
|
||||
function addRgbToFrame(frame, index, rgb) {
|
||||
if (index < 0 || index >= frame.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = hexToRgb(frame[index]);
|
||||
const updated = {
|
||||
r: clamp(current.r + rgb.r, 0, 255),
|
||||
g: clamp(current.g + rgb.g, 0, 255),
|
||||
b: clamp(current.b + rgb.b, 0, 255),
|
||||
};
|
||||
frame[index] = rgbToHex(updated);
|
||||
}
|
||||
|
||||
function addHexColor(frame, index, hexColor, intensity = 1) {
|
||||
if (intensity <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const base = hexToRgb(hexColor);
|
||||
addRgbToFrame(frame, index, {
|
||||
r: base.r * intensity,
|
||||
g: base.g * intensity,
|
||||
b: base.b * intensity,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SERPENTINE_WIRING,
|
||||
clamp,
|
||||
hexToRgb,
|
||||
rgbToHex,
|
||||
lerpRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
createFrame,
|
||||
frameToPayload,
|
||||
fadeFrame,
|
||||
addRgbToFrame,
|
||||
addHexColor,
|
||||
};
|
||||
|
||||
1319
test/pixelstream/snek/package-lock.json
generated
1319
test/pixelstream/snek/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "snek",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
(() => {
|
||||
const GRID_SIZE = 16;
|
||||
const TICK_MS = 120; // game speed
|
||||
|
||||
const canvas = document.getElementById('board');
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
const scoreEl = document.getElementById('score');
|
||||
const wsDot = document.getElementById('ws-dot');
|
||||
const wsLabel = document.getElementById('ws-label');
|
||||
|
||||
// NeoPixel matrix transmission config
|
||||
// Most WS2812(B) LEDs expect GRB color order. Many matrices are wired serpentine.
|
||||
const MATRIX_WIDTH = canvas.width;
|
||||
const MATRIX_HEIGHT = canvas.height;
|
||||
const COLOR_ORDER = 'RGB'; // one of: RGB, GRB, BRG, BGR, RBG, GBR
|
||||
const BRIGHTNESS = 1.0; // 0.0 .. 1.0 scalar
|
||||
const SERPENTINE = true; // true if every other row is reversed
|
||||
const FLIP_X = true; // set true if physical matrix is mirrored horizontally
|
||||
const FLIP_Y = false; // set true if physical matrix is flipped vertically
|
||||
|
||||
/** Game state */
|
||||
let snakeSegments = [];
|
||||
let currentDirection = { x: 1, y: 0 };
|
||||
let pendingDirection = { x: 1, y: 0 };
|
||||
let appleCell = null;
|
||||
let score = 0;
|
||||
let isGameOver = false;
|
||||
let sendingFrame = false;
|
||||
let ws;
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const url = `${protocol}://${location.host}/ws`;
|
||||
ws = new WebSocket(url);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
setWsStatus('connecting');
|
||||
|
||||
ws.addEventListener('open', () => setWsStatus('open'));
|
||||
ws.addEventListener('close', () => setWsStatus('closed'));
|
||||
ws.addEventListener('error', () => setWsStatus('error'));
|
||||
}
|
||||
|
||||
function clampToByte(value) {
|
||||
if (value < 0) return 0;
|
||||
if (value > 255) return 255;
|
||||
return value | 0;
|
||||
}
|
||||
|
||||
function applyBrightness(value) {
|
||||
// value is 0..255, BRIGHTNESS is 0..1
|
||||
return clampToByte(Math.round(value * BRIGHTNESS));
|
||||
}
|
||||
|
||||
function mapXYToLinearIndex(x, y, width, height) {
|
||||
// Apply optional flips to align with physical orientation
|
||||
const mappedX = FLIP_X ? (width - 1 - x) : x;
|
||||
const mappedY = FLIP_Y ? (height - 1 - y) : y;
|
||||
|
||||
// Serpentine wiring reverses every other row (commonly odd rows)
|
||||
const isOddRow = (mappedY % 2) === 1;
|
||||
const columnInRow = (SERPENTINE && isOddRow) ? (width - 1 - mappedX) : mappedX;
|
||||
return (mappedY * width) + columnInRow;
|
||||
}
|
||||
|
||||
function writePixelWithColorOrder(target, baseIndex, r, g, b) {
|
||||
// target is a Uint8Array, baseIndex is pixelIndex * 3
|
||||
switch (COLOR_ORDER) {
|
||||
case 'RGB':
|
||||
target[baseIndex + 0] = r;
|
||||
target[baseIndex + 1] = g;
|
||||
target[baseIndex + 2] = b;
|
||||
break;
|
||||
case 'GRB':
|
||||
target[baseIndex + 0] = g;
|
||||
target[baseIndex + 1] = r;
|
||||
target[baseIndex + 2] = b;
|
||||
break;
|
||||
case 'BRG':
|
||||
target[baseIndex + 0] = b;
|
||||
target[baseIndex + 1] = r;
|
||||
target[baseIndex + 2] = g;
|
||||
break;
|
||||
case 'BGR':
|
||||
target[baseIndex + 0] = b;
|
||||
target[baseIndex + 1] = g;
|
||||
target[baseIndex + 2] = r;
|
||||
break;
|
||||
case 'RBG':
|
||||
target[baseIndex + 0] = r;
|
||||
target[baseIndex + 1] = b;
|
||||
target[baseIndex + 2] = g;
|
||||
break;
|
||||
case 'GBR':
|
||||
target[baseIndex + 0] = g;
|
||||
target[baseIndex + 1] = b;
|
||||
target[baseIndex + 2] = r;
|
||||
break;
|
||||
default:
|
||||
// Fallback to GRB if misconfigured
|
||||
target[baseIndex + 0] = g;
|
||||
target[baseIndex + 1] = r;
|
||||
target[baseIndex + 2] = b;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeCanvasToNeoPixelFrame() {
|
||||
// Produces headerless frame: width*height pixels, 3 bytes per pixel in COLOR_ORDER
|
||||
const width = MATRIX_WIDTH;
|
||||
const height = MATRIX_HEIGHT;
|
||||
const out = new Uint8Array(width * height * 3);
|
||||
const src = ctx.getImageData(0, 0, width, height).data; // RGBA
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const srcIndex = ((y * width) + x) * 4;
|
||||
const r = applyBrightness(src[srcIndex + 0]);
|
||||
const g = applyBrightness(src[srcIndex + 1]);
|
||||
const b = applyBrightness(src[srcIndex + 2]);
|
||||
|
||||
const pixelIndex = mapXYToLinearIndex(x, y, width, height);
|
||||
const base = pixelIndex * 3;
|
||||
writePixelWithColorOrder(out, base, r, g, b);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function setWsStatus(state) {
|
||||
if (state === 'open') {
|
||||
wsDot.classList.add('ok');
|
||||
wsLabel.textContent = 'WS: connected';
|
||||
} else if (state === 'connecting') {
|
||||
wsDot.classList.remove('ok');
|
||||
wsLabel.textContent = 'WS: connecting…';
|
||||
} else if (state === 'closed') {
|
||||
wsDot.classList.remove('ok');
|
||||
wsLabel.textContent = 'WS: disconnected';
|
||||
// try to reconnect after a delay
|
||||
setTimeout(connectWebSocket, 1000);
|
||||
} else {
|
||||
wsDot.classList.remove('ok');
|
||||
wsLabel.textContent = 'WS: error';
|
||||
}
|
||||
}
|
||||
|
||||
function resetGame() {
|
||||
snakeSegments = [ { x: 4, y: 8 }, { x: 3, y: 8 }, { x: 2, y: 8 } ];
|
||||
currentDirection = { x: 1, y: 0 };
|
||||
pendingDirection = { x: 1, y: 0 };
|
||||
score = 0;
|
||||
scoreEl.textContent = String(score);
|
||||
isGameOver = false;
|
||||
placeApple();
|
||||
}
|
||||
|
||||
function placeApple() {
|
||||
const occupied = new Set(snakeSegments.map(c => `${c.x},${c.y}`));
|
||||
let x, y;
|
||||
do {
|
||||
x = Math.floor(Math.random() * GRID_SIZE);
|
||||
y = Math.floor(Math.random() * GRID_SIZE);
|
||||
} while (occupied.has(`${x},${y}`));
|
||||
appleCell = { x, y };
|
||||
}
|
||||
|
||||
function stepGame() {
|
||||
if (isGameOver) return;
|
||||
|
||||
// Commit pending direction (prevents double-turning in one tick)
|
||||
if ((pendingDirection.x !== -currentDirection.x) || (pendingDirection.y !== -currentDirection.y)) {
|
||||
currentDirection = pendingDirection;
|
||||
}
|
||||
|
||||
const head = snakeSegments[0];
|
||||
const newHead = { x: head.x + currentDirection.x, y: head.y + currentDirection.y };
|
||||
|
||||
// Wall collision
|
||||
if (newHead.x < 0 || newHead.x >= GRID_SIZE || newHead.y < 0 || newHead.y >= GRID_SIZE) {
|
||||
isGameOver = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Self collision
|
||||
for (let i = 0; i < snakeSegments.length; i++) {
|
||||
const seg = snakeSegments[i];
|
||||
if (seg.x === newHead.x && seg.y === newHead.y) {
|
||||
isGameOver = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Move snake
|
||||
snakeSegments.unshift(newHead);
|
||||
const ateApple = (newHead.x === appleCell.x && newHead.y === appleCell.y);
|
||||
if (ateApple) {
|
||||
score += 1;
|
||||
scoreEl.textContent = String(score);
|
||||
placeApple();
|
||||
} else {
|
||||
snakeSegments.pop();
|
||||
}
|
||||
}
|
||||
|
||||
function renderGame() {
|
||||
// Background
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Apple
|
||||
ctx.fillStyle = '#d23';
|
||||
ctx.fillRect(appleCell.x, appleCell.y, 1, 1);
|
||||
|
||||
// Snake
|
||||
ctx.fillStyle = '#3bd16f';
|
||||
for (const cell of snakeSegments) {
|
||||
ctx.fillRect(cell.x, cell.y, 1, 1);
|
||||
}
|
||||
|
||||
if (isGameOver) {
|
||||
// Simple overlay pixel art for game over (draw a cross)
|
||||
ctx.fillStyle = '#f33';
|
||||
for (let i = 0; i < GRID_SIZE; i++) {
|
||||
ctx.fillRect(i, i, 1, 1);
|
||||
ctx.fillRect(GRID_SIZE - 1 - i, i, 1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
if (sendingFrame) return;
|
||||
sendingFrame = true;
|
||||
try {
|
||||
const frame = encodeCanvasToNeoPixelFrame();
|
||||
// Send ArrayBuffer view; receivers expect raw 3-byte-per-pixel stream
|
||||
ws.send(frame.buffer);
|
||||
} catch (_) {
|
||||
// ignore
|
||||
} finally {
|
||||
sendingFrame = false;
|
||||
}
|
||||
}
|
||||
|
||||
function gameLoop() {
|
||||
stepGame();
|
||||
renderGame();
|
||||
sendFrame();
|
||||
}
|
||||
|
||||
// Controls
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === ' ') {
|
||||
resetGame();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp' && currentDirection.y !== 1) {
|
||||
pendingDirection = { x: 0, y: -1 };
|
||||
} else if (e.key === 'ArrowDown' && currentDirection.y !== -1) {
|
||||
pendingDirection = { x: 0, y: 1 };
|
||||
} else if (e.key === 'ArrowLeft' && currentDirection.x !== 1) {
|
||||
pendingDirection = { x: -1, y: 0 };
|
||||
} else if (e.key === 'ArrowRight' && currentDirection.x !== -1) {
|
||||
pendingDirection = { x: 1, y: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
// Start
|
||||
resetGame();
|
||||
connectWebSocket();
|
||||
setInterval(gameLoop, TICK_MS);
|
||||
})();
|
||||
@@ -1,59 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Snek 16x16</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
background: #0b0b0b;
|
||||
color: #e8e8e8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 24px 12px;
|
||||
}
|
||||
h2 { margin: 0 0 4px 0; font-weight: 600; }
|
||||
#board {
|
||||
width: 640px; /* make it big */
|
||||
height: 640px; /* make it big */
|
||||
image-rendering: pixelated;
|
||||
border: 1px solid #333;
|
||||
background: #000;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
}
|
||||
.hud {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.status {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; background: #a33; }
|
||||
.dot.ok { background: #3a3; }
|
||||
.pill { padding: 2px 8px; border: 1px solid #333; border-radius: 999px; font-size: 12px; }
|
||||
.hint { color: #aaa; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>16×16 Snek</h2>
|
||||
<div class="hud">
|
||||
<div>Score: <span id="score">0</span></div>
|
||||
<div class="status"><span class="dot" id="ws-dot"></span><span class="pill" id="ws-label">WS: connecting…</span></div>
|
||||
</div>
|
||||
<canvas id="board" width="16" height="16"></canvas>
|
||||
<div class="hint">Arrow keys to move. Space to restart.</div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,83 +0,0 @@
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
const dgram = require('dgram');
|
||||
const { WebSocketServer } = require('ws');
|
||||
|
||||
const HTTP_PORT = process.env.PORT || 3000;
|
||||
// UDP settings
|
||||
// Default broadcast; override with unicast address via UDP_ADDR if you have a single receiver
|
||||
const UDP_BROADCAST_PORT = Number(process.env.UDP_PORT) || 4210;
|
||||
const UDP_BROADCAST_ADDR = process.env.UDP_ADDR || '255.255.255.255';
|
||||
|
||||
// NeoPixel frame properties for basic validation/logging (no transformation here)
|
||||
const MATRIX_WIDTH = Number(process.env.MATRIX_WIDTH) || 16;
|
||||
const MATRIX_HEIGHT = Number(process.env.MATRIX_HEIGHT) || 16;
|
||||
const BYTES_PER_PIXEL = 3; // GRB without alpha
|
||||
|
||||
const app = express();
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
const udpSocket = dgram.createSocket('udp4');
|
||||
udpSocket.on('error', (err) => {
|
||||
console.error('[UDP] error:', err);
|
||||
});
|
||||
udpSocket.bind(() => {
|
||||
try {
|
||||
udpSocket.setBroadcast(true);
|
||||
console.log(`[UDP] Ready to broadcast on ${UDP_BROADCAST_ADDR}:${UDP_BROADCAST_PORT}`);
|
||||
} catch (e) {
|
||||
console.error('[UDP] setBroadcast failed:', e);
|
||||
}
|
||||
});
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const clientAddress = req?.socket?.remoteAddress || 'unknown';
|
||||
console.log(`[WS] Client connected: ${clientAddress}`);
|
||||
|
||||
ws.on('message', (data) => {
|
||||
const bufferToSend = Buffer.isBuffer(data)
|
||||
? data
|
||||
: (data instanceof ArrayBuffer ? Buffer.from(data) : Buffer.from(String(data)));
|
||||
|
||||
const expectedSize = MATRIX_WIDTH * MATRIX_HEIGHT * BYTES_PER_PIXEL;
|
||||
if (bufferToSend.length !== expectedSize) {
|
||||
console.warn(`[WS] Unexpected frame size: ${bufferToSend.length} bytes (expected ${expectedSize}).`);
|
||||
}
|
||||
|
||||
const hexPayload = bufferToSend.toString('hex');
|
||||
const udpPayload = Buffer.from(`RAW:${hexPayload}`, 'ascii');
|
||||
|
||||
udpSocket.send(
|
||||
udpPayload,
|
||||
UDP_BROADCAST_PORT,
|
||||
UDP_BROADCAST_ADDR,
|
||||
(err) => {
|
||||
if (err) {
|
||||
console.error('[UDP] send error:', err.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('[WS] Client disconnected');
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('[WS] error:', err.message);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(HTTP_PORT, () => {
|
||||
console.log(`Server listening on http://localhost:${HTTP_PORT}`);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('Shutting down...');
|
||||
try { udpSocket.close(); } catch {}
|
||||
try { server.close(() => process.exit(0)); } catch {}
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
createFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 55;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('051923') },
|
||||
{ stop: 0.2, color: hexToRgb('0c4057') },
|
||||
{ stop: 0.45, color: hexToRgb('1d7a70') },
|
||||
{ stop: 0.7, color: hexToRgb('39b15f') },
|
||||
{ stop: 0.88, color: hexToRgb('9dd54c') },
|
||||
{ stop: 1.0, color: hexToRgb('f7f5bc') },
|
||||
];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node spiral-bloom.js <device-ip> [port] [width] [height] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
let rotation = 0;
|
||||
let hueShift = 0;
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function generateFrame() {
|
||||
rotation += frameTimeSeconds * 0.7;
|
||||
hueShift += frameTimeSeconds * 0.2;
|
||||
|
||||
const cx = (width - 1) / 2;
|
||||
const cy = (height - 1) / 2;
|
||||
const radiusNorm = Math.hypot(cx, cy) || 1;
|
||||
|
||||
for (let row = 0; row < height; ++row) {
|
||||
for (let col = 0; col < width; ++col) {
|
||||
const dx = col - cx;
|
||||
const dy = row - cy;
|
||||
const radius = Math.hypot(dx, dy) / radiusNorm;
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const arm = 0.5 + 0.5 * Math.sin(5 * (angle + rotation) + hueShift * Math.PI * 2);
|
||||
const value = Math.min(1, radius * 0.8 + arm * 0.4);
|
||||
frame[toIndex(col, row, width)] = samplePalette(paletteStops, value);
|
||||
}
|
||||
}
|
||||
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming spiral bloom to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms)`,
|
||||
);
|
||||
|
||||
855
test/pixelstream/tetris/package-lock.json
generated
855
test/pixelstream/tetris/package-lock.json
generated
@@ -1,855 +0,0 @@
|
||||
{
|
||||
"name": "tetris",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "tetris",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.3",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"on-finished": "2.4.1",
|
||||
"qs": "6.13.0",
|
||||
"raw-body": "2.5.2",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.7.1",
|
||||
"cookie-signature": "1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "1.3.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "6.13.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "0.19.0",
|
||||
"serve-static": "1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
||||
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "2.0.1",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "2.0.0",
|
||||
"inherits": "2.0.4",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"toidentifier": "1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
|
||||
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"unpipe": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
|
||||
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~1.0.2",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/encodeurl": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
|
||||
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.2",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
||||
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "0.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "tetris",
|
||||
"version": "1.0.0",
|
||||
"description": "16x16 Tetris pixelstream demo",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,563 +0,0 @@
|
||||
(() => {
|
||||
const GRID_WIDTH = 16;
|
||||
const GRID_HEIGHT = 16;
|
||||
const FRAME_INTERVAL_MS = 50;
|
||||
const AUTO_DROP_MS = 600;
|
||||
const SOFT_DROP_MS = 60;
|
||||
|
||||
const canvas = document.getElementById('board');
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
const scoreEl = document.getElementById('score');
|
||||
const linesEl = document.getElementById('lines');
|
||||
const wsDot = document.getElementById('ws-dot');
|
||||
const wsLabel = document.getElementById('ws-label');
|
||||
|
||||
const MATRIX_WIDTH = canvas.width;
|
||||
const MATRIX_HEIGHT = canvas.height;
|
||||
const COLOR_ORDER = 'RGB';
|
||||
const BRIGHTNESS = 1.0;
|
||||
const SERPENTINE = true;
|
||||
const FLIP_X = true;
|
||||
const FLIP_Y = false;
|
||||
|
||||
const TETROMINOES = [
|
||||
{
|
||||
name: 'I',
|
||||
color: '#2dd9ff',
|
||||
rotations: [
|
||||
[ [0, 1], [1, 1], [2, 1], [3, 1] ],
|
||||
[ [2, 0], [2, 1], [2, 2], [2, 3] ],
|
||||
[ [0, 2], [1, 2], [2, 2], [3, 2] ],
|
||||
[ [1, 0], [1, 1], [1, 2], [1, 3] ],
|
||||
],
|
||||
spawnOffset: { x: 6, y: -1 },
|
||||
},
|
||||
{
|
||||
name: 'J',
|
||||
color: '#3b7ddd',
|
||||
rotations: [
|
||||
[ [0, 0], [0, 1], [1, 1], [2, 1] ],
|
||||
[ [1, 0], [2, 0], [1, 1], [1, 2] ],
|
||||
[ [0, 1], [1, 1], [2, 1], [2, 2] ],
|
||||
[ [1, 0], [1, 1], [0, 2], [1, 2] ],
|
||||
],
|
||||
spawnOffset: { x: 6, y: -1 },
|
||||
},
|
||||
{
|
||||
name: 'L',
|
||||
color: '#f7b733',
|
||||
rotations: [
|
||||
[ [2, 0], [0, 1], [1, 1], [2, 1] ],
|
||||
[ [1, 0], [1, 1], [1, 2], [2, 2] ],
|
||||
[ [0, 1], [1, 1], [2, 1], [0, 2] ],
|
||||
[ [0, 0], [1, 0], [1, 1], [1, 2] ],
|
||||
],
|
||||
spawnOffset: { x: 6, y: -1 },
|
||||
},
|
||||
{
|
||||
name: 'O',
|
||||
color: '#ffe66d',
|
||||
rotations: [
|
||||
[ [1, 0], [2, 0], [1, 1], [2, 1] ],
|
||||
[ [1, 0], [2, 0], [1, 1], [2, 1] ],
|
||||
[ [1, 0], [2, 0], [1, 1], [2, 1] ],
|
||||
[ [1, 0], [2, 0], [1, 1], [2, 1] ],
|
||||
],
|
||||
spawnOffset: { x: 6, y: -1 },
|
||||
},
|
||||
{
|
||||
name: 'S',
|
||||
color: '#30db6d',
|
||||
rotations: [
|
||||
[ [1, 0], [2, 0], [0, 1], [1, 1] ],
|
||||
[ [1, 0], [1, 1], [2, 1], [2, 2] ],
|
||||
[ [1, 1], [2, 1], [0, 2], [1, 2] ],
|
||||
[ [0, 0], [0, 1], [1, 1], [1, 2] ],
|
||||
],
|
||||
spawnOffset: { x: 6, y: -1 },
|
||||
},
|
||||
{
|
||||
name: 'T',
|
||||
color: '#c86bff',
|
||||
rotations: [
|
||||
[ [1, 0], [0, 1], [1, 1], [2, 1] ],
|
||||
[ [1, 0], [1, 1], [2, 1], [1, 2] ],
|
||||
[ [0, 1], [1, 1], [2, 1], [1, 2] ],
|
||||
[ [1, 0], [0, 1], [1, 1], [1, 2] ],
|
||||
],
|
||||
spawnOffset: { x: 6, y: -1 },
|
||||
},
|
||||
{
|
||||
name: 'Z',
|
||||
color: '#ff5f5f',
|
||||
rotations: [
|
||||
[ [0, 0], [1, 0], [1, 1], [2, 1] ],
|
||||
[ [2, 0], [1, 1], [2, 1], [1, 2] ],
|
||||
[ [0, 1], [1, 1], [1, 2], [2, 2] ],
|
||||
[ [1, 0], [0, 1], [1, 1], [0, 2] ],
|
||||
],
|
||||
spawnOffset: { x: 6, y: -1 },
|
||||
},
|
||||
];
|
||||
|
||||
const LINE_SCORE_TABLE = {
|
||||
1: 100,
|
||||
2: 250,
|
||||
3: 400,
|
||||
4: 800,
|
||||
};
|
||||
|
||||
let ws;
|
||||
let wsReady = false;
|
||||
let sendingFrame = false;
|
||||
|
||||
let board;
|
||||
let currentPiece;
|
||||
let holdQueue;
|
||||
let score;
|
||||
let clearedLines;
|
||||
let isGameOver;
|
||||
let dropTimer;
|
||||
let softDropActive;
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const url = `${protocol}://${location.host}/ws`;
|
||||
ws = new WebSocket(url);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
setWsStatus('connecting');
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
wsReady = true;
|
||||
setWsStatus('open');
|
||||
});
|
||||
ws.addEventListener('close', () => {
|
||||
wsReady = false;
|
||||
setWsStatus('closed');
|
||||
});
|
||||
ws.addEventListener('error', () => {
|
||||
wsReady = false;
|
||||
setWsStatus('error');
|
||||
});
|
||||
}
|
||||
|
||||
function setWsStatus(state) {
|
||||
if (state === 'open') {
|
||||
wsDot.classList.add('ok');
|
||||
wsLabel.textContent = 'WS: connected';
|
||||
} else if (state === 'connecting') {
|
||||
wsDot.classList.remove('ok');
|
||||
wsLabel.textContent = 'WS: connecting…';
|
||||
} else if (state === 'closed') {
|
||||
wsDot.classList.remove('ok');
|
||||
wsLabel.textContent = 'WS: disconnected';
|
||||
setTimeout(connectWebSocket, 1000);
|
||||
} else {
|
||||
wsDot.classList.remove('ok');
|
||||
wsLabel.textContent = 'WS: error';
|
||||
}
|
||||
}
|
||||
|
||||
function clampToByte(value) {
|
||||
if (value < 0) return 0;
|
||||
if (value > 255) return 255;
|
||||
return value | 0;
|
||||
}
|
||||
|
||||
function applyBrightness(value) {
|
||||
return clampToByte(Math.round(value * BRIGHTNESS));
|
||||
}
|
||||
|
||||
function mapXYToLinearIndex(x, y, width, height) {
|
||||
const mappedX = FLIP_X ? (width - 1 - x) : x;
|
||||
const mappedY = FLIP_Y ? (height - 1 - y) : y;
|
||||
const isOddRow = (mappedY % 2) === 1;
|
||||
const columnInRow = (SERPENTINE && isOddRow) ? (width - 1 - mappedX) : mappedX;
|
||||
return (mappedY * width) + columnInRow;
|
||||
}
|
||||
|
||||
function writePixelWithColorOrder(target, baseIndex, r, g, b) {
|
||||
switch (COLOR_ORDER) {
|
||||
case 'RGB':
|
||||
target[baseIndex + 0] = r;
|
||||
target[baseIndex + 1] = g;
|
||||
target[baseIndex + 2] = b;
|
||||
break;
|
||||
case 'GRB':
|
||||
target[baseIndex + 0] = g;
|
||||
target[baseIndex + 1] = r;
|
||||
target[baseIndex + 2] = b;
|
||||
break;
|
||||
case 'BRG':
|
||||
target[baseIndex + 0] = b;
|
||||
target[baseIndex + 1] = r;
|
||||
target[baseIndex + 2] = g;
|
||||
break;
|
||||
case 'BGR':
|
||||
target[baseIndex + 0] = b;
|
||||
target[baseIndex + 1] = g;
|
||||
target[baseIndex + 2] = r;
|
||||
break;
|
||||
case 'RBG':
|
||||
target[baseIndex + 0] = r;
|
||||
target[baseIndex + 1] = b;
|
||||
target[baseIndex + 2] = g;
|
||||
break;
|
||||
case 'GBR':
|
||||
target[baseIndex + 0] = g;
|
||||
target[baseIndex + 1] = b;
|
||||
target[baseIndex + 2] = r;
|
||||
break;
|
||||
default:
|
||||
target[baseIndex + 0] = g;
|
||||
target[baseIndex + 1] = r;
|
||||
target[baseIndex + 2] = b;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeCanvasToNeoPixelFrame() {
|
||||
const width = MATRIX_WIDTH;
|
||||
const height = MATRIX_HEIGHT;
|
||||
const out = new Uint8Array(width * height * 3);
|
||||
const src = ctx.getImageData(0, 0, width, height).data;
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const srcIndex = ((y * width) + x) * 4;
|
||||
const r = applyBrightness(src[srcIndex + 0]);
|
||||
const g = applyBrightness(src[srcIndex + 1]);
|
||||
const b = applyBrightness(src[srcIndex + 2]);
|
||||
|
||||
const pixelIndex = mapXYToLinearIndex(x, y, width, height);
|
||||
const base = pixelIndex * 3;
|
||||
writePixelWithColorOrder(out, base, r, g, b);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function createEmptyBoard() {
|
||||
const rows = [];
|
||||
for (let y = 0; y < GRID_HEIGHT; y++) {
|
||||
rows.push(new Array(GRID_WIDTH).fill(null));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function makePiece(template) {
|
||||
return {
|
||||
name: template.name,
|
||||
color: template.color,
|
||||
rotations: template.rotations,
|
||||
rotationIndex: 0,
|
||||
x: template.spawnOffset.x,
|
||||
y: template.spawnOffset.y,
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentShape(piece) {
|
||||
return piece.rotations[piece.rotationIndex];
|
||||
}
|
||||
|
||||
function forEachBlock(piece, callback) {
|
||||
const shape = getCurrentShape(piece);
|
||||
for (let i = 0; i < shape.length; i++) {
|
||||
const [dx, dy] = shape[i];
|
||||
callback(piece.x + dx, piece.y + dy);
|
||||
}
|
||||
}
|
||||
|
||||
function isInside(x, y) {
|
||||
return x >= 0 && x < GRID_WIDTH && y < GRID_HEIGHT;
|
||||
}
|
||||
|
||||
function isCellFree(x, y) {
|
||||
if (y < 0) {
|
||||
return true;
|
||||
}
|
||||
if (!isInside(x, y)) {
|
||||
return false;
|
||||
}
|
||||
return board[y][x] === null;
|
||||
}
|
||||
|
||||
function canPlace(piece, offsetX, offsetY, rotationDelta) {
|
||||
const nextRotation = (piece.rotationIndex + rotationDelta + piece.rotations.length) % piece.rotations.length;
|
||||
const shape = piece.rotations[nextRotation];
|
||||
for (let i = 0; i < shape.length; i++) {
|
||||
const [dx, dy] = shape[i];
|
||||
const x = piece.x + offsetX + dx;
|
||||
const y = piece.y + offsetY + dy;
|
||||
if (!isCellFree(x, y)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function commitPieceToBoard(piece) {
|
||||
const shape = getCurrentShape(piece);
|
||||
for (let i = 0; i < shape.length; i++) {
|
||||
const [dx, dy] = shape[i];
|
||||
const x = piece.x + dx;
|
||||
const y = piece.y + dy;
|
||||
if (y >= 0 && y < GRID_HEIGHT && x >= 0 && x < GRID_WIDTH) {
|
||||
board[y][x] = piece.color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearLines() {
|
||||
let linesCleared = 0;
|
||||
const newRows = [];
|
||||
for (let y = 0; y < GRID_HEIGHT; y++) {
|
||||
if (board[y].every(cell => cell !== null)) {
|
||||
linesCleared += 1;
|
||||
} else {
|
||||
newRows.push(board[y]);
|
||||
}
|
||||
}
|
||||
|
||||
while (newRows.length < GRID_HEIGHT) {
|
||||
newRows.unshift(new Array(GRID_WIDTH).fill(null));
|
||||
}
|
||||
|
||||
board = newRows;
|
||||
|
||||
if (linesCleared > 0) {
|
||||
clearedLines += linesCleared;
|
||||
const awarded = LINE_SCORE_TABLE[linesCleared] || (linesCleared * 200);
|
||||
score += awarded;
|
||||
}
|
||||
}
|
||||
|
||||
function spawnNextPiece() {
|
||||
if (holdQueue.length === 0) {
|
||||
refillBag();
|
||||
}
|
||||
currentPiece = makePiece(holdQueue.shift());
|
||||
if (!canPlace(currentPiece, 0, 0, 0)) {
|
||||
isGameOver = true;
|
||||
}
|
||||
}
|
||||
|
||||
function refillBag() {
|
||||
const bag = [...TETROMINOES];
|
||||
for (let i = bag.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[bag[i], bag[j]] = [bag[j], bag[i]];
|
||||
}
|
||||
holdQueue.push(...bag);
|
||||
}
|
||||
|
||||
function tryMove(offsetX, offsetY) {
|
||||
if (!currentPiece || !canPlace(currentPiece, offsetX, offsetY, 0)) {
|
||||
return false;
|
||||
}
|
||||
currentPiece.x += offsetX;
|
||||
currentPiece.y += offsetY;
|
||||
return true;
|
||||
}
|
||||
|
||||
function tryRotate(clockwise = true) {
|
||||
if (!currentPiece) {
|
||||
return;
|
||||
}
|
||||
const delta = clockwise ? 1 : -1;
|
||||
const originalRotation = currentPiece.rotationIndex;
|
||||
const targetRotation = (originalRotation + delta + currentPiece.rotations.length) % currentPiece.rotations.length;
|
||||
|
||||
const kicks = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: -1, y: 0 },
|
||||
{ x: 1, y: 0 },
|
||||
{ x: -2, y: 0 },
|
||||
{ x: 2, y: 0 },
|
||||
{ x: 0, y: -1 },
|
||||
];
|
||||
|
||||
for (const kick of kicks) {
|
||||
if (canPlace(currentPiece, kick.x, kick.y, delta)) {
|
||||
currentPiece.rotationIndex = targetRotation;
|
||||
currentPiece.x += kick.x;
|
||||
currentPiece.y += kick.y;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hardDrop() {
|
||||
if (!currentPiece) {
|
||||
return;
|
||||
}
|
||||
while (tryMove(0, 1)) {
|
||||
score += 2;
|
||||
}
|
||||
lockPiece();
|
||||
}
|
||||
|
||||
function lockPiece() {
|
||||
commitPieceToBoard(currentPiece);
|
||||
clearLines();
|
||||
scoreEl.textContent = String(score);
|
||||
linesEl.textContent = String(clearedLines);
|
||||
spawnNextPiece();
|
||||
dropTimer = 0;
|
||||
}
|
||||
|
||||
function updateGame(deltaMs) {
|
||||
if (isGameOver) {
|
||||
return;
|
||||
}
|
||||
|
||||
dropTimer += deltaMs;
|
||||
const targetDrop = softDropActive ? SOFT_DROP_MS : AUTO_DROP_MS;
|
||||
|
||||
if (dropTimer >= targetDrop) {
|
||||
dropTimer = 0;
|
||||
if (!tryMove(0, 1)) {
|
||||
lockPiece();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawBoard() {
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
for (let y = 0; y < GRID_HEIGHT; y++) {
|
||||
for (let x = 0; x < GRID_WIDTH; x++) {
|
||||
const cell = board[y][x];
|
||||
if (cell) {
|
||||
ctx.fillStyle = cell;
|
||||
ctx.fillRect(x, y, 1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentPiece) {
|
||||
ctx.fillStyle = currentPiece.color;
|
||||
forEachBlock(currentPiece, (x, y) => {
|
||||
if (y >= 0 && y < GRID_HEIGHT && x >= 0 && x < GRID_WIDTH) {
|
||||
ctx.fillRect(x, y, 1, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (isGameOver) {
|
||||
ctx.fillStyle = '#ff3b30';
|
||||
for (let x = 3; x < GRID_WIDTH - 3; x++) {
|
||||
ctx.fillRect(x, 6, 1, 1);
|
||||
ctx.fillRect(x, 9, 1, 1);
|
||||
}
|
||||
for (let y = 6; y <= 9; y++) {
|
||||
ctx.fillRect(3, y, 1, 1);
|
||||
ctx.fillRect(GRID_WIDTH - 4, y, 1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toHex(value) {
|
||||
const bounded = Math.max(0, Math.min(255, value));
|
||||
const hex = bounded.toString(16);
|
||||
return hex.length === 1 ? `0${hex}` : hex;
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
if (!wsReady || !ws || ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
if (sendingFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendingFrame = true;
|
||||
try {
|
||||
const frame = encodeCanvasToNeoPixelFrame();
|
||||
ws.send(frame.buffer);
|
||||
} catch (_) {
|
||||
// ignore
|
||||
} finally {
|
||||
sendingFrame = false;
|
||||
}
|
||||
}
|
||||
|
||||
let previousTimestamp = performance.now();
|
||||
|
||||
function gameLoop() {
|
||||
const now = performance.now();
|
||||
const delta = now - previousTimestamp;
|
||||
previousTimestamp = now;
|
||||
|
||||
updateGame(delta);
|
||||
drawBoard();
|
||||
sendFrame();
|
||||
|
||||
setTimeout(gameLoop, FRAME_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function resetGame() {
|
||||
board = createEmptyBoard();
|
||||
holdQueue = [];
|
||||
score = 0;
|
||||
clearedLines = 0;
|
||||
isGameOver = false;
|
||||
dropTimer = 0;
|
||||
softDropActive = false;
|
||||
scoreEl.textContent = '0';
|
||||
linesEl.textContent = '0';
|
||||
refillBag();
|
||||
spawnNextPiece();
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (isGameOver && event.key.toLowerCase() === 'r') {
|
||||
resetGame();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault();
|
||||
tryMove(-1, 0);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
event.preventDefault();
|
||||
tryMove(1, 0);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
softDropActive = true;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
tryRotate(true);
|
||||
break;
|
||||
case ' ': // Space hard drop
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
hardDrop();
|
||||
break;
|
||||
case 'r':
|
||||
case 'R':
|
||||
event.preventDefault();
|
||||
resetGame();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('keyup', (event) => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
softDropActive = false;
|
||||
}
|
||||
});
|
||||
|
||||
resetGame();
|
||||
connectWebSocket();
|
||||
gameLoop();
|
||||
})();
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>16×16 Tetris</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
background: #0b0b0b;
|
||||
color: #e8e8e8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 24px 12px;
|
||||
}
|
||||
h2 { margin: 0 0 4px 0; font-weight: 600; }
|
||||
#board {
|
||||
width: 640px;
|
||||
height: 640px;
|
||||
image-rendering: pixelated;
|
||||
border: 1px solid #333;
|
||||
background: #000;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.hud {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.status {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; background: #a33; }
|
||||
.dot.ok { background: #3a3; }
|
||||
.pill { padding: 2px 8px; border: 1px solid #333; border-radius: 999px; font-size: 12px; }
|
||||
.hint { color: #aaa; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>16×16 Tetris</h2>
|
||||
<div class="hud">
|
||||
<div>Score: <span id="score">0</span></div>
|
||||
<div>Lines: <span id="lines">0</span></div>
|
||||
<div class="status"><span class="dot" id="ws-dot"></span><span class="pill" id="ws-label">WS: connecting…</span></div>
|
||||
</div>
|
||||
<canvas id="board" width="16" height="16"></canvas>
|
||||
<div class="hint">Arrow keys to move, Up to rotate, Space or Enter to hard drop, R to restart.</div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
const dgram = require('dgram');
|
||||
const { WebSocketServer } = require('ws');
|
||||
|
||||
const HTTP_PORT = process.env.PORT || 3000;
|
||||
const UDP_BROADCAST_PORT = Number(process.env.UDP_PORT) || 4210;
|
||||
const UDP_BROADCAST_ADDR = process.env.UDP_ADDR || '255.255.255.255';
|
||||
|
||||
const MATRIX_WIDTH = Number(process.env.MATRIX_WIDTH) || 16;
|
||||
const MATRIX_HEIGHT = Number(process.env.MATRIX_HEIGHT) || 16;
|
||||
const BYTES_PER_PIXEL = 3;
|
||||
|
||||
const app = express();
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
const udpSocket = dgram.createSocket('udp4');
|
||||
udpSocket.on('error', (err) => {
|
||||
console.error('[UDP] error:', err);
|
||||
});
|
||||
udpSocket.bind(() => {
|
||||
try {
|
||||
udpSocket.setBroadcast(true);
|
||||
console.log(`[UDP] Ready to broadcast on ${UDP_BROADCAST_ADDR}:${UDP_BROADCAST_PORT}`);
|
||||
} catch (e) {
|
||||
console.error('[UDP] setBroadcast failed:', e);
|
||||
}
|
||||
});
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const clientAddress = req?.socket?.remoteAddress || 'unknown';
|
||||
console.log(`[WS] Client connected: ${clientAddress}`);
|
||||
|
||||
ws.on('message', (data) => {
|
||||
const bufferToSend = Buffer.isBuffer(data)
|
||||
? data
|
||||
: (data instanceof ArrayBuffer ? Buffer.from(data) : Buffer.from(String(data)));
|
||||
|
||||
const expectedSize = MATRIX_WIDTH * MATRIX_HEIGHT * BYTES_PER_PIXEL;
|
||||
if (bufferToSend.length !== expectedSize) {
|
||||
console.warn(`[WS] Unexpected frame size: ${bufferToSend.length} bytes (expected ${expectedSize}).`);
|
||||
}
|
||||
|
||||
const hexPayload = bufferToSend.toString('hex');
|
||||
const udpPayload = Buffer.from(`RAW:${hexPayload}`, 'ascii');
|
||||
|
||||
udpSocket.send(
|
||||
udpPayload,
|
||||
UDP_BROADCAST_PORT,
|
||||
UDP_BROADCAST_ADDR,
|
||||
(err) => {
|
||||
if (err) {
|
||||
console.error('[UDP] send error:', err.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('[WS] Client disconnected');
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('[WS] error:', err.message);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(HTTP_PORT, () => {
|
||||
console.log(`Server listening on http://localhost:${HTTP_PORT}`);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('Shutting down...');
|
||||
try { udpSocket.close(); } catch {}
|
||||
try { server.close(() => process.exit(0)); } catch {}
|
||||
});
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
addHexColor,
|
||||
clamp,
|
||||
createFrame,
|
||||
fadeFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 55;
|
||||
const DEFAULT_FIREFLY_COUNT = 18;
|
||||
const HOVER_SPEED = 0.6;
|
||||
const GLOW_SPEED = 1.8;
|
||||
const TRAIL_DECAY = 0.8;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('02030a') },
|
||||
{ stop: 0.2, color: hexToRgb('031c2d') },
|
||||
{ stop: 0.4, color: hexToRgb('053d4a') },
|
||||
{ stop: 0.6, color: hexToRgb('107b68') },
|
||||
{ stop: 0.8, color: hexToRgb('14c491') },
|
||||
{ stop: 1.0, color: hexToRgb('f2ffd2') },
|
||||
];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
const fireflyCount = parseInt(process.argv[7] || String(DEFAULT_FIREFLY_COUNT), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node voxel-fireflies.js <device-ip> [port] [width] [height] [interval-ms] [firefly-count]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs) || Number.isNaN(fireflyCount)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, interval-ms, and firefly-count.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (fireflyCount <= 0) {
|
||||
console.error('Firefly count must be a positive integer.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
const fireflies = createFireflies(fireflyCount, width, height);
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function createFireflies(count, matrixWidth, matrixHeight) {
|
||||
const list = [];
|
||||
for (let index = 0; index < count; ++index) {
|
||||
list.push(spawnFirefly(matrixWidth, matrixHeight));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function spawnFirefly(matrixWidth, matrixHeight) {
|
||||
return {
|
||||
x: Math.random() * (matrixWidth - 1),
|
||||
y: Math.random() * (matrixHeight - 1),
|
||||
targetX: Math.random() * (matrixWidth - 1),
|
||||
targetY: Math.random() * (matrixHeight - 1),
|
||||
glowPhase: Math.random() * Math.PI * 2,
|
||||
dwell: 1 + Math.random() * 2,
|
||||
};
|
||||
}
|
||||
|
||||
function updateFirefly(firefly, deltaSeconds) {
|
||||
const dx = firefly.targetX - firefly.x;
|
||||
const dy = firefly.targetY - firefly.y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
|
||||
if (distance < 0.2) {
|
||||
firefly.dwell -= deltaSeconds;
|
||||
if (firefly.dwell <= 0) {
|
||||
firefly.targetX = Math.random() * (width - 1);
|
||||
firefly.targetY = Math.random() * (height - 1);
|
||||
firefly.dwell = 1 + Math.random() * 2;
|
||||
}
|
||||
} else {
|
||||
const speed = HOVER_SPEED * (0.8 + Math.random() * 0.4);
|
||||
firefly.x += (dx / distance) * speed * deltaSeconds;
|
||||
firefly.y += (dy / distance) * speed * deltaSeconds;
|
||||
}
|
||||
|
||||
firefly.glowPhase += deltaSeconds * GLOW_SPEED * (0.7 + Math.random() * 0.6);
|
||||
}
|
||||
|
||||
function drawFirefly(firefly) {
|
||||
const baseGlow = (Math.sin(firefly.glowPhase) + 1) * 0.5;
|
||||
const col = Math.round(firefly.x);
|
||||
const row = Math.round(firefly.y);
|
||||
|
||||
for (let dy = -1; dy <= 1; ++dy) {
|
||||
for (let dx = -1; dx <= 1; ++dx) {
|
||||
const sampleX = col + dx;
|
||||
const sampleY = row + dy;
|
||||
if (sampleX < 0 || sampleX >= width || sampleY < 0 || sampleY >= height) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const distance = Math.hypot(dx, dy);
|
||||
const falloff = clamp(1 - distance * 0.7, 0, 1);
|
||||
const intensity = baseGlow * falloff;
|
||||
if (intensity <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
frame[toIndex(sampleX, sampleY, width)] = samplePalette(paletteStops, intensity);
|
||||
if (distance === 0) {
|
||||
addHexColor(frame, toIndex(sampleX, sampleY, width), 'ffd966', intensity * 1.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
fadeFrame(frame, TRAIL_DECAY);
|
||||
|
||||
fireflies.forEach((firefly) => {
|
||||
updateFirefly(firefly, frameTimeSeconds);
|
||||
drawFirefly(firefly);
|
||||
});
|
||||
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming voxel fireflies to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms, fireflies=${fireflyCount})`
|
||||
);
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
clamp,
|
||||
createFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 60;
|
||||
const RING_DENSITY = 8;
|
||||
const RING_SPEED = 1.4;
|
||||
const RING_SHARPNESS = 7.5;
|
||||
const TWIST_INTENSITY = 2.2;
|
||||
const TWIST_SPEED = 0.9;
|
||||
const CORE_EXPONENT = 1.6;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('010005') },
|
||||
{ stop: 0.2, color: hexToRgb('07204f') },
|
||||
{ stop: 0.45, color: hexToRgb('124aa0') },
|
||||
{ stop: 0.7, color: hexToRgb('36a5ff') },
|
||||
{ stop: 0.87, color: hexToRgb('99e6ff') },
|
||||
{ stop: 1.0, color: hexToRgb('f1fbff') },
|
||||
];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node wormhole-tunnel.js <device-ip> [port] [width] [height] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
let timeSeconds = 0;
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function generateFrame() {
|
||||
timeSeconds += frameTimeSeconds;
|
||||
|
||||
const cx = (width - 1) / 2;
|
||||
const cy = (height - 1) / 2;
|
||||
const radiusNorm = Math.hypot(cx, cy) || 1;
|
||||
|
||||
for (let row = 0; row < height; ++row) {
|
||||
for (let col = 0; col < width; ++col) {
|
||||
const dx = col - cx;
|
||||
const dy = row - cy;
|
||||
const radius = Math.hypot(dx, dy) / radiusNorm;
|
||||
const angle = Math.atan2(dy, dx);
|
||||
|
||||
const radialPhase = radius * RING_DENSITY - timeSeconds * RING_SPEED;
|
||||
const ring = Math.exp(-Math.pow(Math.sin(radialPhase * Math.PI), 2) * RING_SHARPNESS);
|
||||
|
||||
const twist = Math.sin(angle * TWIST_INTENSITY + timeSeconds * TWIST_SPEED) * 0.35 + 0.65;
|
||||
const depth = Math.pow(clamp(1 - radius, 0, 1), CORE_EXPONENT);
|
||||
|
||||
const value = clamp(ring * 0.6 + depth * 0.3 + twist * 0.1, 0, 1);
|
||||
frame[toIndex(col, row, width)] = samplePalette(paletteStops, value);
|
||||
}
|
||||
}
|
||||
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming wormhole tunnel to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms)`
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user