Compare commits

..

3 Commits

Author SHA1 Message Date
025ac7b810 fix: endpoints result 2025-09-23 21:29:18 +02:00
a9f56c1279 fix: tasks endpoint response 2025-09-23 21:03:13 +02:00
594b5e3af6 feat: implement complete JSON serialization system with response classes
- Add abstract JsonSerializable base class with toJson/fromJson methods
- Create comprehensive response classes for complete JSON document handling:
  * ClusterMembersResponse for cluster member data
  * TaskStatusResponse and TaskControlResponse for task operations
  * NodeStatusResponse, NodeEndpointsResponse, NodeOperationResponse for node data
- Implement concrete serializable classes for all data types:
  * NodeInfoSerializable, TaskInfoSerializable, SystemInfoSerializable
  * TaskSummarySerializable, EndpointInfoSerializable
- Refactor all service classes to use new serialization system
- Reduce service method complexity from 20-30 lines to 2-3 lines
- Eliminate manual JsonDocument creation and field mapping
- Ensure type safety and compile-time validation
- Maintain backward compatibility while improving maintainability

Breaking change: Service classes now use response objects instead of manual JSON creation
2025-09-22 21:55:25 +02:00
30 changed files with 1103 additions and 901 deletions

View File

@@ -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

View File

@@ -25,9 +25,9 @@ The system architecture consists of several key components working together:
- **Service Registry**: Track available services across the cluster
### Task Scheduler
- **Cooperative Multitasking**: Background task management system (`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
@@ -40,75 +40,27 @@ The cluster uses a UDP-based discovery protocol for automatic node detection:
### Discovery Process
1. **Discovery Broadcast**: Nodes periodically send UDP packets on port `udp_port` (default 4210)
2. **Response Handling**: Nodes respond with `CLUSTER_RESPONSE:<hostname>`
3. **Member Management**: Discovered nodes are added/updated in the cluster
4. **Node Info via UDP**: Heartbeat triggers peers to send `CLUSTER_NODE_INFO:<hostname>:<json>`
1. **Discovery Broadcast**: Nodes periodically send 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`)
- **UDP Port**: 4210 (configurable)
- **Discovery Message**: `CLUSTER_DISCOVERY`
- **Response Message**: `CLUSTER_RESPONSE`
- **Heartbeat Message**: `CLUSTER_HEARTBEAT`
- **Node Info Message**: `CLUSTER_NODE_INFO:<hostname>:<json>`
- **Broadcast Address**: 255.255.255.255
- **Discovery Interval**: `Config.discovery_interval_ms` (default 1000 ms)
- **Listen Interval**: `Config.cluster_listen_interval_ms` (default 10 ms)
- **Heartbeat Interval**: `Config.heartbeat_interval_ms` (default 5000 ms)
### Message Formats
- **Discovery**: `CLUSTER_DISCOVERY`
- Sender: any node, broadcast to 255.255.255.255:`udp_port`
- Purpose: announce presence and solicit peer identification
- **Response**: `CLUSTER_RESPONSE:<hostname>`
- Sender: node receiving a discovery; unicast to requester IP
- Purpose: provide hostname so requester can register/update member
- **Heartbeat**: `CLUSTER_HEARTBEAT:<hostname>`
- Sender: each node, broadcast to 255.255.255.255:`udp_port` on interval
- Purpose: prompt peers to reply with their node info and keep liveness
- **Node Info**: `CLUSTER_NODE_INFO:<hostname>:<json>`
- Sender: node receiving a heartbeat; unicast to heartbeat sender IP
- JSON fields: freeHeap, chipId, sdkVersion, cpuFreqMHz, flashChipSize, optional labels
### Discovery Flow
1. **Sender broadcasts** `CLUSTER_DISCOVERY`
2. **Each receiver responds** with `CLUSTER_RESPONSE:<hostname>` to the sender IP
3. **Sender registers/updates** the node using hostname and source IP
### Heartbeat Flow
1. **A node broadcasts** `CLUSTER_HEARTBEAT:<hostname>`
2. **Each receiver replies** with `CLUSTER_NODE_INFO:<hostname>:<json>` to the heartbeat sender IP
3. **The sender**:
- Ensures the node exists or creates it with `hostname` and sender IP
- Parses JSON and updates resources, labels, `status = ACTIVE`, `lastSeen = now`
- Sets `latency = now - lastHeartbeatSentAt` (per-node, measured at heartbeat origin)
### Listener Behavior
The `cluster_listen` task parses one UDP packet per run and dispatches by prefix to:
- **Discovery** → send `CLUSTER_RESPONSE`
- **Heartbeat** → send `CLUSTER_NODE_INFO` JSON
- **Response** → add/update node using provided hostname and source IP
- **Node Info** → update resources/status/labels and record latency
### Timing and Intervals
- **UDP Port**: `Config.udp_port` (default 4210)
- **Discovery Interval**: `Config.discovery_interval_ms` (default 1000 ms)
- **Listen Interval**: `Config.cluster_listen_interval_ms` (default 10 ms)
- **Heartbeat Interval**: `Config.heartbeat_interval_ms` (default 5000 ms)
- **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
@@ -116,14 +68,14 @@ The system runs several background tasks at different intervals:
### Core System Tasks
| Task | Interval (default) | Purpose |
|------|--------------------|---------|
| `cluster_discovery` | 1000 ms | Send UDP discovery packets |
| `cluster_listen` | 10 ms | Listen for discovery/heartbeat/node-info |
| `status_update` | 1000 ms | Update node status categories, purge dead |
| `heartbeat` | 5000 ms | Broadcast heartbeat and update local resources |
| `cluster_update_members_info` | 10000 ms | Reserved; no-op (info via UDP) |
| `print_members` | 5000 ms | Log current member list |
| Task | 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
@@ -160,7 +112,10 @@ 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
@@ -200,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
@@ -213,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
@@ -260,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

View File

@@ -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
│ │ ├── NodeService.cpp
│ │ ├── NetworkService.cpp
│ │ ├── ClusterService.cpp
│ │ ├── TaskService.cpp
│ │ ├── StaticFileService.cpp
│ │ └── MonitoringService.cpp
│ └── 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

View File

@@ -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.

View File

@@ -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.

View File

@@ -319,18 +319,18 @@ curl -X POST http://192.168.1.100/api/tasks/control \
### 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

View File

@@ -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),
@@ -35,7 +32,6 @@ NeoPatternService::NeoPatternService(NodeContext& ctx, TaskManager& taskMgr, con
registerPatterns();
registerTasks();
registerEventHandlers();
initialized = true;
LOG_INFO("NeoPattern", "Service initialized");
@@ -68,8 +64,7 @@ void NeoPatternService::registerEndpoints(ApiServer& api) {
ParamSpec{String("brightness"), false, String("body"), String("numberRange"), {}, String("80")},
ParamSpec{String("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
@@ -124,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
@@ -185,101 +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");
}
});
}
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") {

View File

@@ -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,7 +25,7 @@ public:
REVERSE
};
NeoPatternService(NodeContext& ctx, TaskManager& taskMgr, const NeoPixelConfig& config);
NeoPatternService(TaskManager& taskMgr, const NeoPixelConfig& config);
~NeoPatternService();
void registerEndpoints(ApiServer& api) override;
@@ -50,8 +49,6 @@ 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;

View File

@@ -45,7 +45,7 @@ void setup() {
);
// Create and add custom service
neoPatternService = new NeoPatternService(spore.getContext(), spore.getTaskManager(), config);
neoPatternService = new NeoPatternService(spore.getTaskManager(), config);
spore.addService(neoPatternService);
// Start the API server and complete initialization

View File

@@ -11,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,15 +20,17 @@ public:
ApiServer(NodeContext& ctx, TaskManager& taskMgr, uint16_t port = 80);
void begin();
void addService(Service& service);
void addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler);
void addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
std::function<void(AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool)> uploadHandler);
void addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
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");
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 = "");

View File

@@ -7,15 +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 sendDiscovery();
void listen();
void listenForDiscovery();
void addOrUpdateNode(const String& nodeHost, IPAddress nodeIP);
void updateAllNodeStatuses();
void removeDeadNodes();
@@ -28,23 +26,4 @@ public:
private:
NodeContext& ctx;
TaskManager& taskManager;
struct MessageHandler {
bool (*predicate)(const char*);
std::function<void(const char*)> handle;
const char* name;
};
void initMessageHandlers();
void handleIncomingMessage(const char* incoming);
static bool isDiscoveryMsg(const char* msg);
static bool isHeartbeatMsg(const char* msg);
static bool isResponseMsg(const char* msg);
static bool isNodeInfoMsg(const char* msg);
static bool isClusterEventMsg(const char* msg);
void onDiscovery(const char* msg);
void onHeartbeat(const char* msg);
void onResponse(const char* msg);
void onNodeInfo(const char* msg);
void onClusterEvent(const char* msg);
unsigned long lastHeartbeatSentAt = 0;
std::vector<MessageHandler> messageHandlers;
};

View File

@@ -7,12 +7,8 @@
namespace ClusterProtocol {
constexpr const char* DISCOVERY_MSG = "CLUSTER_DISCOVERY";
constexpr const char* RESPONSE_MSG = "CLUSTER_RESPONSE";
constexpr const char* HEARTBEAT_MSG = "CLUSTER_HEARTBEAT";
constexpr const char* NODE_INFO_MSG = "CLUSTER_NODE_INFO";
constexpr const char* CLUSTER_EVENT_MSG = "CLUSTER_EVENT";
constexpr uint16_t UDP_PORT = 4210;
// Increased buffer to accommodate node info JSON over UDP
constexpr size_t UDP_BUF_SIZE = 512;
constexpr size_t UDP_BUF_SIZE = 64;
constexpr const char* API_NODE_STATUS = "/api/node/status";
}

View 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

View 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

View File

@@ -15,7 +15,6 @@ public:
// Cluster Configuration
unsigned long discovery_interval_ms;
unsigned long heartbeat_interval_ms;
unsigned long cluster_listen_interval_ms;
unsigned long status_update_interval_ms;
unsigned long member_info_update_interval_ms;
unsigned long print_interval_ms;

View 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

View File

@@ -17,7 +17,7 @@ struct NodeInfo {
uint32_t cpuFreqMHz = 0;
uint32_t flashChipSize = 0;
} resources;
unsigned long latency = 0; // ms from heartbeat broadcast to NODE_INFO receipt
unsigned long latency = 0; // ms since lastSeen
std::vector<EndpointInfo> endpoints; // List of registered endpoints
std::map<String, String> labels; // Arbitrary node labels (key -> value)
};

View 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

View 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

View 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

View 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

View 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

View File

@@ -21,57 +21,31 @@ 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::addEndpoint(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::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) {
// Get current service name if available
String serviceName = "unknown";
if (!services.empty()) {
serviceName = services.back().get().getName();
}
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::addEndpoint(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();
}
const std::vector<ParamSpec>& params, const String& serviceName) {
registerEndpoint(uri, method, params, serviceName);
server.on(uri.c_str(), method, 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);
}

View File

@@ -8,35 +8,17 @@ ClusterManager::ClusterManager(NodeContext& ctx, TaskManager& taskMgr) : ctx(ctx
NodeInfo* node = static_cast<NodeInfo*>(data);
this->addOrUpdateNode(node->hostname, node->ip);
});
// Centralized broadcast handler: services fire 'cluster/broadcast' with CLUSTER_EVENT JSON payload
ctx.on("cluster/broadcast", [this](void* data) {
String* jsonStr = static_cast<String*>(data);
if (!jsonStr) {
LOG_WARN("Cluster", "cluster/broadcast called with null data");
return;
}
// Subnet-directed broadcast (more reliable than 255.255.255.255 on some networks)
IPAddress ip = WiFi.localIP();
IPAddress mask = WiFi.subnetMask();
IPAddress bcast(ip[0] | ~mask[0], ip[1] | ~mask[1], ip[2] | ~mask[2], ip[3] | ~mask[3]);
LOG_INFO("Cluster", String("Broadcasting CLUSTER_EVENT to ") + bcast.toString() + " len=" + String(jsonStr->length()));
this->ctx.udp->beginPacket(bcast, this->ctx.config.udp_port);
String msg = String(ClusterProtocol::CLUSTER_EVENT_MSG) + ":" + *jsonStr;
this->ctx.udp->write(msg.c_str());
this->ctx.udp->endPacket();
});
// Register tasks
registerTasks();
initMessageHandlers();
}
void ClusterManager::registerTasks() {
taskManager.registerTask("cluster_discovery", ctx.config.discovery_interval_ms, [this]() { sendDiscovery(); });
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("cluster_update_members_info", ctx.config.member_info_update_interval_ms, [this]() { updateAllMembersInfoTaskCallback(); });
taskManager.registerTask("update_members_info", ctx.config.member_info_update_interval_ms, [this]() { updateAllMembersInfoTaskCallback(); });
LOG_INFO("ClusterManager", "Registered all cluster tasks");
}
@@ -47,210 +29,28 @@ void ClusterManager::sendDiscovery() {
ctx.udp->endPacket();
}
void ClusterManager::listen() {
void ClusterManager::listenForDiscovery() {
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::isDiscoveryMsg, [this](const char* msg){ this->onDiscovery(msg); }, "DISCOVERY" });
messageHandlers.push_back({ &ClusterManager::isHeartbeatMsg, [this](const char* msg){ this->onHeartbeat(msg); }, "HEARTBEAT" });
messageHandlers.push_back({ &ClusterManager::isResponseMsg, [this](const char* msg){ this->onResponse(msg); }, "RESPONSE" });
messageHandlers.push_back({ &ClusterManager::isNodeInfoMsg, [this](const char* msg){ this->onNodeInfo(msg); }, "NODE_INFO" });
messageHandlers.push_back({ &ClusterManager::isClusterEventMsg, [this](const char* msg){ this->onClusterEvent(msg); }, "CLUSTER_EVENT" });
}
void ClusterManager::handleIncomingMessage(const char* incoming) {
for (const auto& h : messageHandlers) {
if (h.predicate(incoming)) {
h.handle(incoming);
return;
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());
}
}
// Unknown message - log first token
const char* colon = strchr(incoming, ':');
String head;
if (colon) {
head = String(incoming).substring(0, colon - incoming);
} else {
head = String(incoming);
}
LOG_DEBUG("Cluster", String("Unknown cluster message: ") + head);
}
bool ClusterManager::isDiscoveryMsg(const char* msg) {
return strcmp(msg, ClusterProtocol::DISCOVERY_MSG) == 0;
}
bool ClusterManager::isHeartbeatMsg(const char* msg) {
return strncmp(msg, ClusterProtocol::HEARTBEAT_MSG, strlen(ClusterProtocol::HEARTBEAT_MSG)) == 0;
}
bool ClusterManager::isResponseMsg(const char* msg) {
return strncmp(msg, ClusterProtocol::RESPONSE_MSG, strlen(ClusterProtocol::RESPONSE_MSG)) == 0;
}
bool ClusterManager::isNodeInfoMsg(const char* msg) {
return strncmp(msg, ClusterProtocol::NODE_INFO_MSG, strlen(ClusterProtocol::NODE_INFO_MSG)) == 0;
}
bool ClusterManager::isClusterEventMsg(const char* msg) {
return strncmp(msg, ClusterProtocol::CLUSTER_EVENT_MSG, strlen(ClusterProtocol::CLUSTER_EVENT_MSG)) == 0;
}
void ClusterManager::onDiscovery(const char* /*msg*/) {
ctx.udp->beginPacket(ctx.udp->remoteIP(), ctx.config.udp_port);
String response = String(ClusterProtocol::RESPONSE_MSG) + ":" + ctx.hostname;
ctx.udp->write(response.c_str());
ctx.udp->endPacket();
}
void ClusterManager::onHeartbeat(const char* /*msg*/) {
JsonDocument doc;
doc["freeHeap"] = ESP.getFreeHeap();
doc["chipId"] = ESP.getChipId();
doc["sdkVersion"] = ESP.getSdkVersion();
doc["cpuFreqMHz"] = ESP.getCpuFreqMHz();
doc["flashChipSize"] = ESP.getFlashChipSize();
if (ctx.memberList) {
auto it = ctx.memberList->find(ctx.hostname);
if (it != ctx.memberList->end()) {
JsonObject labelsObj = doc["labels"].to<JsonObject>();
for (const auto& kv : it->second.labels) {
labelsObj[kv.first.c_str()] = kv.second;
}
} else if (!ctx.self.labels.empty()) {
JsonObject labelsObj = doc["labels"].to<JsonObject>();
for (const auto& kv : ctx.self.labels) {
labelsObj[kv.first.c_str()] = kv.second;
}
}
}
String json;
serializeJson(doc, json);
ctx.udp->beginPacket(ctx.udp->remoteIP(), ctx.config.udp_port);
String msg = String(ClusterProtocol::NODE_INFO_MSG) + ":" + ctx.hostname + ":" + json;
ctx.udp->write(msg.c_str());
ctx.udp->endPacket();
}
void ClusterManager::onResponse(const char* msg) {
char* hostPtr = const_cast<char*>(msg) + strlen(ClusterProtocol::RESPONSE_MSG) + 1;
String nodeHost = String(hostPtr);
addOrUpdateNode(nodeHost, ctx.udp->remoteIP());
}
void ClusterManager::onNodeInfo(const char* msg) {
char* p = const_cast<char*>(msg) + strlen(ClusterProtocol::NODE_INFO_MSG) + 1;
char* hostEnd = strchr(p, ':');
if (hostEnd) {
*hostEnd = '\0';
const char* hostCStr = p;
const char* jsonCStr = hostEnd + 1;
String nodeHost = String(hostCStr);
IPAddress senderIP = ctx.udp->remoteIP();
addOrUpdateNode(nodeHost, senderIP);
JsonDocument doc;
DeserializationError err = deserializeJson(doc, jsonCStr);
if (!err) {
auto& memberList = *ctx.memberList;
auto it = memberList.find(nodeHost);
if (it != memberList.end()) {
NodeInfo& node = it->second;
node.resources.freeHeap = doc["freeHeap"] | node.resources.freeHeap;
node.resources.chipId = doc["chipId"] | node.resources.chipId;
{
const char* sdk = doc["sdkVersion"] | node.resources.sdkVersion.c_str();
node.resources.sdkVersion = sdk ? String(sdk) : node.resources.sdkVersion;
}
node.resources.cpuFreqMHz = doc["cpuFreqMHz"] | node.resources.cpuFreqMHz;
node.resources.flashChipSize = doc["flashChipSize"] | node.resources.flashChipSize;
node.status = NodeInfo::ACTIVE;
unsigned long now = millis();
node.lastSeen = now;
if (lastHeartbeatSentAt != 0) {
node.latency = now - lastHeartbeatSentAt;
}
node.labels.clear();
if (doc["labels"].is<JsonObject>()) {
JsonObject labelsObj = doc["labels"].as<JsonObject>();
for (JsonPair kvp : labelsObj) {
const char* key = kvp.key().c_str();
const char* value = labelsObj[kvp.key()];
node.labels[key] = value;
}
}
}
} else {
LOG_DEBUG("Cluster", String("Failed to parse NODE_INFO JSON from ") + senderIP.toString());
}
}
}
void ClusterManager::onClusterEvent(const char* msg) {
// Message format: CLUSTER_EVENT:{"event":"...","data":"<json string>"}
const char* jsonStart = msg + strlen(ClusterProtocol::CLUSTER_EVENT_MSG) + 1; // skip prefix and ':'
if (*jsonStart == '\0') {
LOG_DEBUG("Cluster", "CLUSTER_EVENT received with empty payload");
return;
}
LOG_INFO("Cluster", String("CLUSTER_EVENT raw from ") + ctx.udp->remoteIP().toString() + " len=" + String(strlen(jsonStart)));
JsonDocument doc;
DeserializationError err = deserializeJson(doc, jsonStart);
if (err) {
LOG_DEBUG("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_INFO("Cluster", String("Firing event '") + eventStr + "' with dataLen=" + String(data.length()));
ctx.fire(eventKey, &data);
}
void ClusterManager::addOrUpdateNode(const String& nodeHost, IPAddress nodeIP) {
@@ -271,7 +71,7 @@ void ClusterManager::addOrUpdateNode(const String& nodeHost, IPAddress nodeIP) {
newNode.hostname = nodeHost;
newNode.ip = nodeIP;
newNode.lastSeen = millis();
updateNodeStatus(newNode, newNode.lastSeen, ctx.config.node_inactive_threshold_ms, ctx.config.node_dead_threshold_ms);
updateNodeStatus(newNode, newNode.lastSeen, ctx.config.node_inactive_threshold_ms, ctx.config.node_dead_threshold_ms);
memberList[nodeHost] = newNode;
LOG_INFO("Cluster", "Added node: " + nodeHost + " @ " + newNode.ip.toString() + " | Status: " + statusToStr(newNode.status) + " | last update: 0");
//fetchNodeInfo(nodeIP); // Do not fetch here, handled by periodic task
@@ -394,18 +194,31 @@ void ClusterManager::heartbeatTaskCallback() {
updateLocalNodeResources();
ctx.fire("node_discovered", &node);
}
// Broadcast heartbeat so peers can respond with their node info
lastHeartbeatSentAt = millis();
ctx.udp->beginPacket("255.255.255.255", ctx.config.udp_port);
String hb = String(ClusterProtocol::HEARTBEAT_MSG) + ":" + ctx.hostname;
ctx.udp->write(hb.c_str());
ctx.udp->endPacket();
}
void ClusterManager::updateAllMembersInfoTaskCallback() {
// HTTP-based member info fetching disabled; node info is provided via UDP responses to heartbeats
// No-op to reduce network and memory usage
auto& memberList = *ctx.memberList;
// Limit concurrent HTTP requests to prevent memory pressure
const size_t maxConcurrentRequests = ctx.config.max_concurrent_http_requests;
size_t requestCount = 0;
for (auto& pair : memberList) {
const NodeInfo& node = pair.second;
if (node.ip != ctx.localIP) {
// Only process a limited number of requests per cycle
if (requestCount >= maxConcurrentRequests) {
LOG_DEBUG("Cluster", "Limiting concurrent HTTP requests to prevent memory pressure");
break;
}
fetchNodeInfo(node.ip);
requestCount++;
// Add small delay between requests to prevent overwhelming the system
delay(100);
}
}
}
void ClusterManager::updateAllNodeStatuses() {

View File

@@ -1,42 +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.addEndpoint("/api/cluster/members", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleMembersRequest(request); },
std::vector<ParamSpec>{});
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);
obj["resources"]["freeHeap"] = node.resources.freeHeap;
obj["resources"]["chipId"] = node.resources.chipId;
obj["resources"]["sdkVersion"] = node.resources.sdkVersion;
obj["resources"]["cpuFreqMHz"] = node.resources.cpuFreqMHz;
obj["resources"]["flashChipSize"] = node.resources.flashChipSize;
// Add labels if present
if (!node.labels.empty()) {
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());
}

View File

@@ -12,7 +12,7 @@ MonitoringService::MonitoringService(CpuUsage& cpuUsage)
void MonitoringService::registerEndpoints(ApiServer& api) {
api.addEndpoint("/api/monitoring/resources", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleResourcesRequest(request); },
std::vector<ParamSpec>{});
std::vector<ParamSpec>{}, "MonitoringService");
}
MonitoringService::SystemResources MonitoringService::getSystemResources() const {

View File

@@ -8,16 +8,16 @@ void NetworkService::registerEndpoints(ApiServer& api) {
// WiFi scanning endpoints
api.addEndpoint("/api/network/wifi/scan", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleWifiScanRequest(request); },
std::vector<ParamSpec>{});
std::vector<ParamSpec>{}, "NetworkService");
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.addEndpoint("/api/network/status", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleNetworkStatus(request); },
std::vector<ParamSpec>{});
std::vector<ParamSpec>{}, "NetworkService");
api.addEndpoint("/api/network/wifi/config", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleSetWifiConfig(request); },
@@ -26,7 +26,7 @@ void NetworkService::registerEndpoints(ApiServer& api) {
ParamSpec{String("password"), true, String("body"), String("string"), {}, String("")},
ParamSpec{String("connect_timeout_ms"), false, String("body"), String("number"), {}, String("10000")},
ParamSpec{String("retry_delay_ms"), false, String("body"), String("number"), {}, String("500")}
});
}, "NetworkService");
}
void NetworkService::handleWifiScanRequest(AsyncWebServerRequest* request) {

View File

@@ -1,6 +1,11 @@
#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) {}
@@ -8,7 +13,7 @@ void NodeService::registerEndpoints(ApiServer& api) {
// Status endpoint
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.addEndpoint("/api/node/update", HTTP_POST,
@@ -18,54 +23,45 @@ void NodeService::registerEndpoints(ApiServer& api) {
},
std::vector<ParamSpec>{
ParamSpec{String("firmware"), true, String("body"), String("file"), {}, String("")}
});
}, "NodeService");
// Restart endpoint
api.addEndpoint("/api/node/restart", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleRestartRequest(request); },
std::vector<ParamSpec>{});
std::vector<ParamSpec>{}, "NodeService");
// Endpoints endpoint
api.addEndpoint("/api/node/endpoints", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleEndpointsRequest(request); },
std::vector<ParamSpec>{});
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);
@@ -108,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);
@@ -120,36 +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);
NodeEndpointsResponse response;
response.addEndpoints(apiServer.getEndpoints());
request->send(200, "application/json", response.toJsonString());
}

View File

@@ -1,13 +1,17 @@
#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.addEndpoint("/api/tasks/status", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
std::vector<ParamSpec>{});
std::vector<ParamSpec>{}, "TaskService");
api.addEndpoint("/api/tasks/control", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleControlRequest(request); },
@@ -28,36 +32,19 @@ void TaskService::registerEndpoints(ApiServer& api) {
{String("enable"), String("disable"), String("start"), String("stop"), String("status")},
String("")
}
});
}, "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) {
@@ -88,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());
}
}

View File

@@ -10,11 +10,10 @@ Config::Config() {
api_server_port = 80;
// Cluster Configuration
discovery_interval_ms = 1000; // TODO retire this in favor of heartbeat_interval_ms
cluster_listen_interval_ms = 10;
heartbeat_interval_ms = 5000;
discovery_interval_ms = 1000;
heartbeat_interval_ms = 2000;
status_update_interval_ms = 1000;
member_info_update_interval_ms = 10000; // TODO retire this in favor of heartbeat_interval_ms
member_info_update_interval_ms = 10000;
print_interval_ms = 5000;
// Node Status Thresholds