Compare commits

..

2 Commits

Author SHA1 Message Date
921eec3848 docs: update 2025-09-24 21:12:22 +02:00
921e2c7152 feat(cluster): move member info sync to UDP heartbeat; remove HTTP polling
Broadcast CLUSTER_HEARTBEAT every 5s; peers reply with CLUSTER_NODE_INFO containing resources and labels. Update memberList from received node info and set status/lastSeen; keep UDP discovery responses.

Disable HTTP-based updateAllMembersInfoTaskCallback loop to reduce network and memory overhead.

Add protocol constants HEARTBEAT_MSG and NODE_INFO_MSG; increase UDP buffer to 512 bytes.

Set default heartbeat_interval_ms to 5000 ms.

Fix sdkVersion JSON fallback by converting to const char* before assigning to String.
2025-09-23 22:11:49 +02:00
23 changed files with 540 additions and 1019 deletions

View File

@@ -15,12 +15,18 @@ The SPORE system provides a comprehensive RESTful API for monitoring and control
| Endpoint | Method | Description | Response |
|----------|--------|-------------|----------|
| `/api/node/status` | GET | System resource information and API endpoint registry | System metrics and API catalog |
| `/api/node/status` | GET | System resource information | System metrics |
| `/api/node/endpoints` | GET | API endpoints and parameters | Detailed endpoint specifications |
| `/api/cluster/members` | GET | Cluster membership and node health information | Cluster topology and health status |
| `/api/node/update` | POST | Handle firmware updates via OTA | Update progress and status |
| `/api/node/restart` | POST | Trigger system restart | Restart confirmation |
### Monitoring API
| Endpoint | Method | Description | Response |
|----------|--------|-------------|----------|
| `/api/monitoring/resources` | GET | CPU, memory, filesystem, and uptime | System resource metrics |
### Network Management API
| Endpoint | Method | Description | Response |
@@ -140,7 +146,7 @@ Controls the execution state of individual tasks. Supports enabling, disabling,
#### GET /api/node/status
Returns comprehensive system resource information including memory usage, chip details, and a registry of all available API endpoints.
Returns comprehensive system resource information including memory usage and chip details. For a list of available API endpoints, use `/api/node/endpoints`.
**Response Fields:**
- `freeHeap`: Available RAM in bytes
@@ -168,7 +174,7 @@ Returns comprehensive system resource information including memory usage, chip d
#### GET /api/node/endpoints
Returns detailed information about all available API endpoints, including their parameters, types, and validation rules.
Returns detailed information about all available API endpoints, including their parameters, types, and validation rules. Methods are returned as strings (e.g., "GET", "POST").
**Response Fields:**
- `endpoints[]`: Array of endpoint capability objects
@@ -236,6 +242,54 @@ Initiates an over-the-air firmware update. The firmware file should be uploaded
Triggers a system restart. The response will be sent before the restart occurs.
### Monitoring
#### GET /api/monitoring/resources
Returns real-time system resource metrics.
Response Fields:
- `cpu.current_usage`: Current CPU usage percent
- `cpu.average_usage`: Average CPU usage percent
- `cpu.max_usage`: Max observed CPU usage
- `cpu.min_usage`: Min observed CPU usage
- `cpu.measurement_count`: Number of measurements
- `cpu.is_measuring`: Whether measurement is active
- `memory.free_heap`: Free heap bytes
- `memory.total_heap`: Total heap bytes (approximate)
- `memory.heap_fragmentation`: Fragmentation percent (0 on ESP8266)
- `filesystem.total_bytes`: LittleFS total bytes
- `filesystem.used_bytes`: Used bytes
- `filesystem.free_bytes`: Free bytes
- `filesystem.usage_percent`: Usage percent
- `system.uptime_ms`: Uptime in milliseconds
Example Response:
```json
{
"cpu": {
"current_usage": 3.5,
"average_usage": 2.1,
"max_usage": 15.2,
"min_usage": 0.0,
"measurement_count": 120,
"is_measuring": true
},
"memory": {
"free_heap": 48748,
"total_heap": 81920,
"heap_fragmentation": 0
},
"filesystem": {
"total_bytes": 65536,
"used_bytes": 10240,
"free_bytes": 55296,
"usage_percent": 15.6
},
"system": {
"uptime_ms": 123456
}
}
```
### Network Management
#### GET /api/network/status

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
- **Task Lifecycle Management**: Automatic task execution and monitoring
- **Resource Optimization**: Efficient task scheduling and execution
- **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
### Node Context
- **Central Context**: Shared resources and configuration
@@ -40,27 +40,30 @@ The cluster uses a UDP-based discovery protocol for automatic node detection:
### Discovery Process
1. **Discovery Broadcast**: Nodes periodically send UDP packets on port 4210
2. **Response Handling**: Nodes respond with their hostname and IP address
3. **Member Management**: Discovered nodes are automatically added to the cluster
4. **Health Monitoring**: Continuous status checking via HTTP API calls
1. **Discovery Broadcast**: Nodes periodically send UDP packets on port `udp_port` (default 4210)
2. **Response Handling**: Nodes respond with `CLUSTER_RESPONSE:<hostname>`
3. **Member Management**: Discovered nodes are added/updated in the cluster
4. **Node Info via UDP**: Heartbeat triggers peers to send `CLUSTER_NODE_INFO:<hostname>:<json>`
### Protocol Details
- **UDP Port**: 4210 (configurable)
- **UDP Port**: 4210 (configurable via `Config.udp_port`)
- **Discovery Message**: `CLUSTER_DISCOVERY`
- **Response Message**: `CLUSTER_RESPONSE`
- **Heartbeat Message**: `CLUSTER_HEARTBEAT`
- **Node Info Message**: `CLUSTER_NODE_INFO:<hostname>:<json>`
- **Broadcast Address**: 255.255.255.255
- **Discovery Interval**: 1 second (configurable)
- **Listen Interval**: 100ms (configurable)
- **Discovery Interval**: `Config.discovery_interval_ms` (default 1000 ms)
- **Listen Interval**: `Config.discovery_interval_ms / 10` (default 100 ms)
- **Heartbeat Interval**: `Config.heartbeat_interval_ms` (default 5000 ms)
### Node Status Categories
Nodes are automatically categorized by their activity:
- **ACTIVE**: Responding within 10 seconds
- **INACTIVE**: No response for 10-60 seconds
- **DEAD**: No response for over 60 seconds
- **ACTIVE**: lastSeen < `node_inactive_threshold_ms` (default 10s)
- **INACTIVE**: < `node_dead_threshold_ms` (default 120s)
- **DEAD**: `node_dead_threshold_ms`
## Task Scheduling System
@@ -68,14 +71,14 @@ The system runs several background tasks at different intervals:
### Core System Tasks
| Task | Interval | Purpose |
|------|----------|---------|
| **Discovery Send** | 1 second | Send UDP discovery packets |
| **Discovery Listen** | 100ms | Listen for discovery responses |
| **Status Updates** | 1 second | Monitor cluster member health |
| **Heartbeat** | 2 seconds | Maintain cluster connectivity |
| **Member Info** | 10 seconds | Update detailed node information |
| **Debug Output** | 5 seconds | Print cluster status |
| Task | Interval (default) | Purpose |
|------|--------------------|---------|
| `discovery_send` | 1000 ms | Send UDP discovery packets |
| `discovery_listen` | 100 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 |
| `update_members_info` | 10000 ms | Reserved; no-op (info via UDP) |
| `print_members` | 5000 ms | Log current member list |
### Task Management Features
@@ -112,10 +115,7 @@ ctx.fire("cluster_updated", &clusterData);
### Available Events
- **`node_discovered`**: New node added to cluster
- **`cluster_updated`**: Cluster membership changed
- **`resource_update`**: Node resources updated
- **`health_check`**: Node health status changed
- **`node_discovered`**: New node added or local node refreshed
## Resource Monitoring
@@ -155,10 +155,8 @@ The system includes automatic WiFi fallback for robust operation:
### Configuration
- **SSID Format**: `SPORE_<MAC_LAST_4>`
- **Password**: Configurable fallback password
- **IP Range**: 192.168.4.x subnet
- **Gateway**: 192.168.4.1
- **Hostname**: Derived from MAC (`esp-<mac>`) and assigned to `ctx.hostname`
- **AP Mode**: If STA connection fails, device switches to AP mode with configured SSID/password
## Cluster Topology
@@ -170,32 +168,30 @@ The system includes automatic WiFi fallback for robust operation:
### Network Architecture
- **Mesh-like Structure**: Nodes can communicate with each other
- **Dynamic Routing**: Automatic path discovery between nodes
- **Load Distribution**: Tasks distributed across available nodes
- **Fault Tolerance**: Automatic failover and recovery
- UDP broadcast-based discovery and heartbeats on local subnet
- Optional HTTP polling (disabled by default; node info exchanged via UDP)
## Data Flow
### Node Discovery
1. **UDP Broadcast**: Nodes broadcast discovery packets on port 4210
2. **UDP Response**: Receiving nodes responds with hostname
2. **UDP Response**: Receiving nodes respond with hostname
3. **Registration**: Discovered nodes are added to local cluster member list
### Health Monitoring
1. **Periodic Checks**: Cluster manager polls member nodes every 1 second
2. **Status Collection**: Each node returns resource usage and health metrics
1. **Periodic Checks**: Cluster manager updates node status categories
2. **Status Collection**: Each node updates resources via UDP node-info messages
### Task Management
1. **Scheduling**: TaskScheduler executes registered tasks at configured intervals
2. **Execution**: Tasks run cooperatively, yielding control to other tasks
3. **Monitoring**: Task status and results are exposed via REST API endpoints
1. **Scheduling**: `TaskManager` executes registered tasks at configured intervals
2. **Execution**: Tasks run cooperatively in the main loop without preemption
3. **Monitoring**: Task status is exposed via REST (`/api/tasks/status`)
## Performance Characteristics
### Memory Usage
- **Base System**: ~15-20KB RAM
- **Base System**: ~15-20KB RAM (device dependent)
- **Per Task**: ~100-200 bytes per task
- **Cluster Members**: ~50-100 bytes per member
- **API Endpoints**: ~20-30 bytes per endpoint
@@ -219,7 +215,7 @@ The system includes automatic WiFi fallback for robust operation:
### Current Implementation
- **Network Access**: Local network only (no internet exposure)
- **Authentication**: None currently implemented
- **Authentication**: None currently implemented; LAN-only access assumed
- **Data Validation**: Basic input validation
- **Resource Limits**: Memory and processing constraints

View File

@@ -20,57 +20,99 @@
```
spore/
├── src/ # Source code
── main.cpp # Main application entry point
├── ApiServer.cpp # HTTP API server implementation
├── ClusterManager.cpp # Cluster management logic
├── NetworkManager.cpp # WiFi and network handling
│ ├── TaskManager.cpp # Background task management
└── NodeContext.cpp # Central context and events
├── src/ # Source code (framework under src/spore)
── spore/
├── Spore.cpp # Framework lifecycle (setup/begin/loop)
├── core/ # Core components
│ ├── ApiServer.cpp # HTTP API server implementation
│ ├── ClusterManager.cpp # Cluster management logic
│ ├── NetworkManager.cpp # WiFi and network handling
│ │ ├── TaskManager.cpp # Background task management
│ │ └── NodeContext.cpp # Central context and events
│ ├── services/ # Built-in services
│ │ ├── NodeService.cpp
│ │ ├── NetworkService.cpp
│ │ ├── ClusterService.cpp
│ │ ├── TaskService.cpp
│ │ ├── StaticFileService.cpp
│ │ └── MonitoringService.cpp
│ └── types/ # Shared types
├── include/ # Header files
├── lib/ # Library files
├── examples/ # Example apps per env (base, relay, neopattern)
├── docs/ # Documentation
├── api/ # OpenAPI specification
├── examples/ # Example code
── test/ # Test files
├── platformio.ini # PlatformIO configuration
└── ctl.sh # Build and deployment scripts
├── platformio.ini # PlatformIO configuration
── ctl.sh # Build and deployment scripts
```
## PlatformIO Configuration
### Framework and Board
The project uses PlatformIO with the following configuration:
The project uses PlatformIO with the following configuration (excerpt):
```ini
[env:esp01_1m]
[platformio]
default_envs = base
src_dir = .
data_dir = ${PROJECT_DIR}/examples/${PIOENV}/data
[common]
monitor_speed = 115200
lib_deps =
esp32async/ESPAsyncWebServer@^3.8.0
bblanchon/ArduinoJson@^7.4.2
[env:base]
platform = platformio/espressif8266@^4.2.1
board = esp01_1m
framework = arduino
upload_speed = 115200
flash_mode = dout
monitor_speed = 115200
board_build.f_cpu = 80000000L
board_build.flash_mode = qio
board_build.filesystem = littlefs
; note: somehow partition table is not working, so we need to use the ldscript
board_build.ldscript = eagle.flash.1m64.ld
lib_deps = ${common.lib_deps}
build_src_filter =
+<examples/base/*.cpp>
+<src/spore/*.cpp>
+<src/spore/core/*.cpp>
+<src/spore/services/*.cpp>
+<src/spore/types/*.cpp>
+<src/spore/util/*.cpp>
+<src/internal/*.cpp>
[env:d1_mini]
platform = platformio/espressif8266@^4.2.1
board = d1_mini
framework = arduino
upload_speed = 115200
monitor_speed = 115200
board_build.filesystem = littlefs
board_build.flash_mode = dio ; D1 Mini uses DIO on 4 Mbit flash
board_build.flash_size = 4M
board_build.ldscript = eagle.flash.4m1m.ld
lib_deps = ${common.lib_deps}
build_src_filter =
+<examples/base/*.cpp>
+<src/spore/*.cpp>
+<src/spore/core/*.cpp>
+<src/spore/services/*.cpp>
+<src/spore/types/*.cpp>
+<src/spore/util/*.cpp>
+<src/internal/*.cpp>
```
### Key Configuration Details
- **Framework**: Arduino
- **Board**: ESP-01 with 1MB flash
- **Upload Speed**: 115200 baud
- **Flash Mode**: DOUT (required for ESP-01S)
- **Build Type**: Release (optimized for production)
### Dependencies
The project requires the following libraries:
The project requires the following libraries (resolved via PlatformIO):
```ini
lib_deps =
esp32async/ESPAsyncWebServer@^3.8.0
bblanchon/ArduinoJson@^7.4.2
arkhipenko/TaskScheduler@^3.8.5
ESP8266HTTPClient@1.2
ESP8266WiFi@1.0
```
### Filesystem, Linker Scripts, and Flash Layout
@@ -103,7 +145,6 @@ Notes:
- If you need a different FS size, select an appropriate ldscript variant and keep `board_build.filesystem = littlefs`.
- On ESP8266, custom partition CSVs are not used for layout; the linker script defines the flash map. This project removed prior `board_build.partitions` usage in favor of explicit `board_build.ldscript` entries per environment.
## Building
### Basic Build Commands
@@ -308,7 +349,7 @@ export API_NODE=192.168.1.100
Key configuration files:
- **`platformio.ini`**: Build and upload configuration
- **`src/Config.cpp`**: Application configuration
- **`src/spore/types/Config.cpp`**: Default runtime configuration
- **`.env`**: Environment variables
- **`ctl.sh`**: Build and deployment scripts

79
docs/MonitoringService.md Normal file
View File

@@ -0,0 +1,79 @@
# Monitoring Service
Exposes system resource metrics via HTTP for observability.
## Overview
- **Service name**: `MonitoringService`
- **Endpoint**: `GET /api/monitoring/resources`
- **Metrics**: CPU usage, memory, filesystem, uptime
## Endpoint
### GET /api/monitoring/resources
Returns real-time system resource metrics.
Response fields:
- `cpu.current_usage`: Current CPU usage percent
- `cpu.average_usage`: Average CPU usage percent
- `cpu.max_usage`: Max observed CPU usage
- `cpu.min_usage`: Min observed CPU usage
- `cpu.measurement_count`: Number of measurements
- `cpu.is_measuring`: Whether measurement is active
- `memory.free_heap`: Free heap bytes
- `memory.total_heap`: Total heap bytes (approximate)
- `memory.min_free_heap`: Minimum free heap (0 on ESP8266)
- `memory.max_alloc_heap`: Max allocatable heap (0 on ESP8266)
- `memory.heap_fragmentation`: Fragmentation percent (0 on ESP8266)
- `filesystem.total_bytes`: LittleFS total bytes
- `filesystem.used_bytes`: Used bytes
- `filesystem.free_bytes`: Free bytes
- `filesystem.usage_percent`: Usage percent
- `system.uptime_ms`: Uptime in milliseconds
- `system.uptime_seconds`: Uptime in seconds
- `system.uptime_formatted`: Human-readable uptime
Example:
```json
{
"cpu": {
"current_usage": 3.5,
"average_usage": 2.1,
"max_usage": 15.2,
"min_usage": 0.0,
"measurement_count": 120,
"is_measuring": true
},
"memory": {
"free_heap": 48748,
"total_heap": 81920,
"min_free_heap": 0,
"max_alloc_heap": 0,
"heap_fragmentation": 0,
"heap_usage_percent": 40.4
},
"filesystem": {
"total_bytes": 65536,
"used_bytes": 10240,
"free_bytes": 55296,
"usage_percent": 15.6
},
"system": {
"uptime_ms": 123456,
"uptime_seconds": 123,
"uptime_formatted": "0h 2m 3s"
}
}
```
## Implementation Notes
- `MonitoringService` reads from `CpuUsage` and ESP8266 SDK APIs.
- Filesystem metrics are gathered from LittleFS.
- CPU measurement is bracketed by `Spore::loop()` calling `cpuUsage.startMeasurement()` and `cpuUsage.endMeasurement()`.
## Troubleshooting
- If `filesystem.total_bytes` is zero, ensure LittleFS is enabled in `platformio.ini` and an FS image is uploaded.
- CPU usage values remain zero until the main loop runs and CPU measurement is started.

View File

@@ -15,15 +15,8 @@ Complete API reference with detailed endpoint documentation, examples, and integ
- Task management workflows
- Cluster monitoring examples
### 📖 [TaskManager.md](./TaskManager.md)
Comprehensive guide to the TaskManager system for background task management.
**Includes:**
- Basic usage examples
- Advanced binding techniques
- Task status monitoring
- API integration details
- Performance considerations
### 📖 [MonitoringService.md](./MonitoringService.md)
System resource monitoring API for CPU, memory, filesystem, and uptime.
### 📖 [TaskManagement.md](./TaskManagement.md)
Complete guide to the task management system with examples and best practices.

View File

@@ -11,7 +11,6 @@
#include "spore/types/NodeInfo.h"
#include "spore/core/TaskManager.h"
#include "spore/types/ApiTypes.h"
#include "spore/util/Logging.h"
class Service; // Forward declaration
@@ -20,17 +19,15 @@ 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,
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 String& serviceName = "unknown");
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");
const std::vector<ParamSpec>& params);
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");
const std::vector<ParamSpec>& params);
// Static file serving
void serveStatic(const String& uri, fs::FS& fs, const String& path, const String& cache_header = "");

View File

@@ -7,8 +7,11 @@
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 uint16_t UDP_PORT = 4210;
constexpr size_t UDP_BUF_SIZE = 64;
// Increased buffer to accommodate node info JSON over UDP
constexpr size_t UDP_BUF_SIZE = 512;
constexpr const char* API_NODE_STATUS = "/api/node/status";
}

View File

@@ -1,114 +0,0 @@
#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

@@ -1,47 +0,0 @@
#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

@@ -1,76 +0,0 @@
#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

@@ -1,141 +0,0 @@
#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

@@ -1,102 +0,0 @@
#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

@@ -1,99 +0,0 @@
#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

@@ -1,194 +0,0 @@
#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

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

View File

@@ -29,6 +29,9 @@ void ClusterManager::sendDiscovery() {
ctx.udp->endPacket();
}
// TODO the various if statements here are a mess, we need to clean them up
// TODO we should use a state machine to handle the different types of messages
// TODO we should use a class to handle the different types of messages using predicate functions
void ClusterManager::listenForDiscovery() {
int packetSize = ctx.udp->parsePacket();
if (packetSize) {
@@ -45,10 +48,92 @@ void ClusterManager::listenForDiscovery() {
ctx.udp->write(response.c_str());
ctx.udp->endPacket();
//LOG_DEBUG(ctx, "UDP", "Sent response with hostname: " + ctx.hostname);
} else if (strncmp(incoming, ClusterProtocol::HEARTBEAT_MSG, strlen(ClusterProtocol::HEARTBEAT_MSG)) == 0) {
// Reply directly to heartbeat sender with our node info JSON
// Format: CLUSTER_NODE_INFO:<hostname>:<json>
JsonDocument doc;
doc["freeHeap"] = ESP.getFreeHeap();
doc["chipId"] = ESP.getChipId();
doc["sdkVersion"] = ESP.getSdkVersion();
doc["cpuFreqMHz"] = ESP.getCpuFreqMHz();
doc["flashChipSize"] = ESP.getFlashChipSize();
// Include labels if available
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();
} 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());
} else if (strncmp(incoming, ClusterProtocol::NODE_INFO_MSG, strlen(ClusterProtocol::NODE_INFO_MSG)) == 0) {
// Parse: CLUSTER_NODE_INFO:<hostname>:<json>
char* p = incoming + 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();
// Ensure node exists/updated basic info
addOrUpdateNode(nodeHost, senderIP);
// Parse JSON
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;
node.lastSeen = millis();
// Labels
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());
}
}
}
}
}
@@ -194,31 +279,17 @@ void ClusterManager::heartbeatTaskCallback() {
updateLocalNodeResources();
ctx.fire("node_discovered", &node);
}
// Broadcast heartbeat so peers can respond with their node info
ctx.udp->beginPacket("255.255.255.255", ctx.config.udp_port);
String hb = String(ClusterProtocol::HEARTBEAT_MSG) + ":" + ctx.hostname;
ctx.udp->write(hb.c_str());
ctx.udp->endPacket();
}
void ClusterManager::updateAllMembersInfoTaskCallback() {
auto& memberList = *ctx.memberList;
// Limit concurrent HTTP requests to prevent memory pressure
const size_t maxConcurrentRequests = ctx.config.max_concurrent_http_requests;
size_t requestCount = 0;
for (auto& pair : memberList) {
const NodeInfo& node = pair.second;
if (node.ip != ctx.localIP) {
// Only process a limited number of requests per cycle
if (requestCount >= maxConcurrentRequests) {
LOG_DEBUG("Cluster", "Limiting concurrent HTTP requests to prevent memory pressure");
break;
}
fetchNodeInfo(node.ip);
requestCount++;
// Add small delay between requests to prevent overwhelming the system
delay(100);
}
}
// HTTP-based member info fetching disabled; node info is provided via UDP responses to heartbeats
// No-op to reduce network and memory usage
}
void ClusterManager::updateAllNodeStatuses() {

View File

@@ -1,19 +1,42 @@
#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>{}, "ClusterService");
std::vector<ParamSpec>{});
}
void ClusterService::handleMembersRequest(AsyncWebServerRequest* request) {
ClusterMembersResponse response;
response.addNodes(ctx.memberList);
request->send(200, "application/json", response.toJsonString());
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);
}

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>{}, "MonitoringService");
std::vector<ParamSpec>{});
}
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>{}, "NetworkService");
std::vector<ParamSpec>{});
api.addEndpoint("/api/network/wifi/scan", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleGetWifiNetworks(request); },
std::vector<ParamSpec>{}, "NetworkService");
std::vector<ParamSpec>{});
// Network status and configuration endpoints
api.addEndpoint("/api/network/status", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleNetworkStatus(request); },
std::vector<ParamSpec>{}, "NetworkService");
std::vector<ParamSpec>{});
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,11 +1,6 @@
#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) {}
@@ -13,7 +8,7 @@ void NodeService::registerEndpoints(ApiServer& api) {
// Status endpoint
api.addEndpoint("/api/node/status", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
std::vector<ParamSpec>{}, "NodeService");
std::vector<ParamSpec>{});
// Update endpoint with file upload
api.addEndpoint("/api/node/update", HTTP_POST,
@@ -23,45 +18,54 @@ 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>{}, "NodeService");
std::vector<ParamSpec>{});
// Endpoints endpoint
api.addEndpoint("/api/node/endpoints", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleEndpointsRequest(request); },
std::vector<ParamSpec>{}, "NodeService");
std::vector<ParamSpec>{});
}
void NodeService::handleStatusRequest(AsyncWebServerRequest* request) {
NodeStatusResponse response;
// Get labels from member list or self
std::map<String, String> labels;
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
if (ctx.memberList) {
auto it = ctx.memberList->find(ctx.hostname);
if (it != ctx.memberList->end()) {
labels = it->second.labels;
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()) {
labels = ctx.self.labels;
JsonObject labelsObj = doc["labels"].to<JsonObject>();
for (const auto& kv : ctx.self.labels) {
labelsObj[kv.first.c_str()] = kv.second;
}
}
}
response.buildCompleteResponse(labels);
request->send(200, "application/json", response.toJsonString());
String json;
serializeJson(doc, json);
request->send(200, "application/json", json);
}
void NodeService::handleUpdateRequest(AsyncWebServerRequest* request) {
bool success = !Update.hasError();
NodeOperationResponse response;
response.setSuccess(success ? "OK" : "FAIL");
AsyncWebServerResponse* httpResponse = request->beginResponse(200, "application/json", response.toJsonString());
httpResponse->addHeader("Connection", "close");
request->send(httpResponse);
AsyncWebServerResponse* response = request->beginResponse(200, "application/json",
success ? "{\"status\": \"OK\"}" : "{\"status\": \"FAIL\"}");
response->addHeader("Connection", "close");
request->send(response);
request->onDisconnect([this]() {
LOG_INFO("API", "Restart device");
delay(10);
@@ -104,12 +108,10 @@ void NodeService::handleUpdateUpload(AsyncWebServerRequest* request, const Strin
}
void NodeService::handleRestartRequest(AsyncWebServerRequest* request) {
NodeOperationResponse response;
response.setSuccess("restarting");
AsyncWebServerResponse* httpResponse = request->beginResponse(200, "application/json", response.toJsonString());
httpResponse->addHeader("Connection", "close");
request->send(httpResponse);
AsyncWebServerResponse* response = request->beginResponse(200, "application/json",
"{\"status\": \"restarting\"}");
response->addHeader("Connection", "close");
request->send(response);
request->onDisconnect([this]() {
LOG_INFO("API", "Restart device");
delay(10);
@@ -118,7 +120,36 @@ void NodeService::handleRestartRequest(AsyncWebServerRequest* request) {
}
void NodeService::handleEndpointsRequest(AsyncWebServerRequest* request) {
NodeEndpointsResponse response;
response.addEndpoints(apiServer.getEndpoints());
request->send(200, "application/json", response.toJsonString());
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);
}

View File

@@ -1,17 +1,13 @@
#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>{}, "TaskService");
std::vector<ParamSpec>{});
api.addEndpoint("/api/tasks/control", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleControlRequest(request); },
@@ -32,19 +28,36 @@ 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);
// Build the complete response with the task data
response.buildCompleteResponse(taskStatuses);
request->send(200, "application/json", response.toJsonString());
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);
}
void TaskService::handleControlRequest(AsyncWebServerRequest* request) {
@@ -75,27 +88,50 @@ void TaskService::handleControlRequest(AsyncWebServerRequest* request) {
success = true;
message = "Task status retrieved";
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()));
JsonDocument statusDoc;
statusDoc["success"] = success;
statusDoc["message"] = message;
statusDoc["task"] = taskName;
statusDoc["action"] = action;
request->send(200, "application/json", response.toJsonString());
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);
return;
} else {
success = false;
message = "Invalid action. Use: enable, disable, start, stop, or status";
}
TaskControlResponse response;
response.setResponse(success, message, taskName, action);
request->send(success ? 200 : 400, "application/json", response.toJsonString());
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);
} else {
TaskControlResponse response;
response.setError("Missing parameters. Required: task, action",
"{\"task\": \"discovery_send\", \"action\": \"status\"}");
request->send(400, "application/json", response.toJsonString());
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);
}
}

View File

@@ -11,7 +11,7 @@ Config::Config() {
// Cluster Configuration
discovery_interval_ms = 1000;
heartbeat_interval_ms = 2000;
heartbeat_interval_ms = 5000;
status_update_interval_ms = 1000;
member_info_update_interval_ms = 10000;
print_interval_ms = 5000;