Compare commits
47 Commits
fd89c8e7eb
...
refactorin
| Author | SHA1 | Date | |
|---|---|---|---|
| 025ac7b810 | |||
| a9f56c1279 | |||
| 594b5e3af6 | |||
| c11652c123 | |||
| 51d4d4bc94 | |||
| e95eb09a11 | |||
| 4727405be1 | |||
| 93f09c3bb4 | |||
| 83d87159fc | |||
| f7f5918509 | |||
| 95a7e3167e | |||
| 702eec5a13 | |||
| 2d85f560bb | |||
| 0b63efece0 | |||
| 8a2988cb50 | |||
| 6a30dc0dc1 | |||
| 5229848600 | |||
| adde7a3676 | |||
| dd0c7cca78 | |||
| d160630f1d | |||
| 98fdc3e1ae | |||
| 089f938796 | |||
| 4d808938ee | |||
| 554c6ff38d | |||
| bf17684dc6 | |||
| 72b559e047 | |||
| 12caeb0be6 | |||
| fe045804cb | |||
| d3a9802ec9 | |||
| f8e5a9c66f | |||
| 4b63d1011f | |||
| d7e98a41fa | |||
| a9fb4252da | |||
| a085b3b8ee | |||
| f94d201b88 | |||
| c8d21a12ad | |||
| 07032b56ca | |||
| 5d6a7cf468 | |||
| f48ddcf9f7 | |||
| 17bdfeaaf0 | |||
| 30a5f8b8cb | |||
| d7d307e3ce | |||
| c0efba8667 | |||
| 5ccd3ec956 | |||
| 0d51816bd3 | |||
| f80b594d21 | |||
| 953768b681 |
56
.cursor/rules/cleancode.mdc
Normal file
56
.cursor/rules/cleancode.mdc
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
description: Guidelines for writing clean, maintainable, and human-readable code. Apply these rules when writing or reviewing code to ensure consistency and quality.
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
# Clean Code Guidelines
|
||||||
|
|
||||||
|
## Constants Over Magic Numbers
|
||||||
|
- Replace hard-coded values with named constants
|
||||||
|
- Use descriptive constant names that explain the value's purpose
|
||||||
|
- Keep constants at the top of the file or in a dedicated constants file
|
||||||
|
|
||||||
|
## Meaningful Names
|
||||||
|
- Variables, functions, and classes should reveal their purpose
|
||||||
|
- Names should explain why something exists and how it's used
|
||||||
|
- Avoid abbreviations unless they're universally understood
|
||||||
|
|
||||||
|
## Smart Comments
|
||||||
|
- Don't comment on what the code does - make the code self-documenting
|
||||||
|
- Use comments to explain why something is done a certain way
|
||||||
|
- Document APIs, complex algorithms, and non-obvious side effects
|
||||||
|
|
||||||
|
## Single Responsibility
|
||||||
|
- Each function should do exactly one thing
|
||||||
|
- Functions should be small and focused
|
||||||
|
- If a function needs a comment to explain what it does, it should be split
|
||||||
|
|
||||||
|
## DRY (Don't Repeat Yourself)
|
||||||
|
- Extract repeated code into reusable functions
|
||||||
|
- Share common logic through proper abstraction
|
||||||
|
- Maintain single sources of truth
|
||||||
|
|
||||||
|
## Clean Structure
|
||||||
|
- Keep related code together
|
||||||
|
- Organize code in a logical hierarchy
|
||||||
|
- Use consistent file and folder naming conventions
|
||||||
|
|
||||||
|
## Encapsulation
|
||||||
|
- Hide implementation details
|
||||||
|
- Expose clear interfaces
|
||||||
|
- Move nested conditionals into well-named functions
|
||||||
|
|
||||||
|
## Code Quality Maintenance
|
||||||
|
- Refactor continuously
|
||||||
|
- Fix technical debt early
|
||||||
|
- Leave code cleaner than you found it
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- Write tests before fixing bugs
|
||||||
|
- Keep tests readable and maintainable
|
||||||
|
- Test edge cases and error conditions
|
||||||
|
|
||||||
|
## Version Control
|
||||||
|
- Write clear commit messages
|
||||||
|
- Make small, focused commits
|
||||||
|
- Use meaningful branch names
|
||||||
68
.cursor/rules/cpp.mdc
Normal file
68
.cursor/rules/cpp.mdc
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
description: C++ Development Rules
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# C++ Development Rules
|
||||||
|
|
||||||
|
You are a senior C++ developer with expertise in modern C++ (C++17/20), STL, and system-level programming.
|
||||||
|
|
||||||
|
## Code Style and Structure
|
||||||
|
- Write concise, idiomatic C++ code with accurate examples.
|
||||||
|
- Follow modern C++ conventions and best practices.
|
||||||
|
- Use object-oriented, procedural, or functional programming patterns as appropriate.
|
||||||
|
- Leverage STL and standard algorithms for collection operations.
|
||||||
|
- Use descriptive variable and method names (e.g., 'isUserSignedIn', 'calculateTotal').
|
||||||
|
- Structure files into headers (*.hpp) and implementation files (*.cpp) with logical separation of concerns.
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
- Use PascalCase for class names.
|
||||||
|
- Use camelCase for variable names and methods.
|
||||||
|
- Use SCREAMING_SNAKE_CASE for constants and macros.
|
||||||
|
- Prefix member variables with an underscore or m_ (e.g., `_userId`, `m_userId`).
|
||||||
|
- Use namespaces to organize code logically.
|
||||||
|
## C++ Features Usage
|
||||||
|
|
||||||
|
- Prefer modern C++ features (e.g., auto, range-based loops, smart pointers).
|
||||||
|
- Use `std::unique_ptr` and `std::shared_ptr` for memory management.
|
||||||
|
- Prefer `std::optional`, `std::variant`, and `std::any` for type-safe alternatives.
|
||||||
|
- Use `constexpr` and `const` to optimize compile-time computations.
|
||||||
|
- Use `std::string_view` for read-only string operations to avoid unnecessary copies.
|
||||||
|
|
||||||
|
## Syntax and Formatting
|
||||||
|
- Follow a consistent coding style, such as Google C++ Style Guide or your team’s standards.
|
||||||
|
- Place braces on the same line for control structures and methods.
|
||||||
|
- Use clear and consistent commenting practices.
|
||||||
|
|
||||||
|
## Error Handling and Validation
|
||||||
|
- Use exceptions for error handling (e.g., `std::runtime_error`, `std::invalid_argument`).
|
||||||
|
- Use RAII for resource management to avoid memory leaks.
|
||||||
|
- Validate inputs at function boundaries.
|
||||||
|
- Log errors using a logging library (e.g., spdlog, Boost.Log).
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
- Avoid unnecessary heap allocations; prefer stack-based objects where possible.
|
||||||
|
- Use `std::move` to enable move semantics and avoid copies.
|
||||||
|
- Optimize loops with algorithms from `<algorithm>` (e.g., `std::sort`, `std::for_each`).
|
||||||
|
- Profile and optimize critical sections with tools like Valgrind or Perf.
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
- Use smart pointers over raw pointers for better memory safety.
|
||||||
|
- Avoid global variables; use singletons sparingly.
|
||||||
|
- Use `enum class` for strongly typed enumerations.
|
||||||
|
- Separate interface from implementation in classes.
|
||||||
|
- Use templates and metaprogramming judiciously for generic solutions.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
- Use secure coding practices to avoid vulnerabilities (e.g., buffer overflows, dangling pointers).
|
||||||
|
- Prefer `std::array` or `std::vector` over raw arrays.
|
||||||
|
- Avoid C-style casts; use `static_cast`, `dynamic_cast`, or `reinterpret_cast` when necessary.
|
||||||
|
- Enforce const-correctness in functions and member variables.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
- Write clear comments for classes, methods, and critical logic.
|
||||||
|
- Use Doxygen for generating API documentation.
|
||||||
|
- Document assumptions, constraints, and expected behavior of code.
|
||||||
|
|
||||||
|
Follow the official ISO C++ standards and guidelines for best practices in modern C++ development.
|
||||||
111
.cursor/rules/gitflow.mdc
Normal file
111
.cursor/rules/gitflow.mdc
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
---
|
||||||
|
description: Gitflow Workflow Rules. These rules should be applied when performing git operations.
|
||||||
|
---
|
||||||
|
# Gitflow Workflow Rules
|
||||||
|
|
||||||
|
## Main Branches
|
||||||
|
|
||||||
|
### main (or master)
|
||||||
|
- Contains production-ready code
|
||||||
|
- Never commit directly to main
|
||||||
|
- Only accepts merges from:
|
||||||
|
- hotfix/* branches
|
||||||
|
- release/* branches
|
||||||
|
- Must be tagged with version number after each merge
|
||||||
|
|
||||||
|
### develop
|
||||||
|
- Main development branch
|
||||||
|
- Contains latest delivered development changes
|
||||||
|
- Source branch for feature branches
|
||||||
|
- Never commit directly to develop
|
||||||
|
|
||||||
|
## Supporting Branches
|
||||||
|
|
||||||
|
### feature/*
|
||||||
|
- Branch from: develop
|
||||||
|
- Merge back into: develop
|
||||||
|
- Naming convention: feature/[issue-id]-descriptive-name
|
||||||
|
- Example: feature/123-user-authentication
|
||||||
|
- Must be up-to-date with develop before creating PR
|
||||||
|
- Delete after merge
|
||||||
|
|
||||||
|
### release/*
|
||||||
|
- Branch from: develop
|
||||||
|
- Merge back into:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
- Naming convention: release/vX.Y.Z
|
||||||
|
- Example: release/v1.2.0
|
||||||
|
- Only bug fixes, documentation, and release-oriented tasks
|
||||||
|
- No new features
|
||||||
|
- Delete after merge
|
||||||
|
|
||||||
|
### hotfix/*
|
||||||
|
- Branch from: main
|
||||||
|
- Merge back into:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
- Naming convention: hotfix/vX.Y.Z
|
||||||
|
- Example: hotfix/v1.2.1
|
||||||
|
- Only for urgent production fixes
|
||||||
|
- Delete after merge
|
||||||
|
|
||||||
|
## Commit Messages
|
||||||
|
|
||||||
|
- Format: `type(scope): description`
|
||||||
|
- Types:
|
||||||
|
- feat: New feature
|
||||||
|
- fix: Bug fix
|
||||||
|
- docs: Documentation changes
|
||||||
|
- style: Formatting, missing semicolons, etc.
|
||||||
|
- refactor: Code refactoring
|
||||||
|
- test: Adding tests
|
||||||
|
- chore: Maintenance tasks
|
||||||
|
|
||||||
|
## Version Control
|
||||||
|
|
||||||
|
### Semantic Versioning
|
||||||
|
- MAJOR version for incompatible API changes
|
||||||
|
- MINOR version for backwards-compatible functionality
|
||||||
|
- PATCH version for backwards-compatible bug fixes
|
||||||
|
|
||||||
|
## Pull Request Rules
|
||||||
|
|
||||||
|
1. All changes must go through Pull Requests
|
||||||
|
2. Required approvals: minimum 1
|
||||||
|
3. CI checks must pass
|
||||||
|
4. No direct commits to protected branches (main, develop)
|
||||||
|
5. Branch must be up to date before merging
|
||||||
|
6. Delete branch after merge
|
||||||
|
|
||||||
|
## Branch Protection Rules
|
||||||
|
|
||||||
|
### main & develop
|
||||||
|
- Require pull request reviews
|
||||||
|
- Require status checks to pass
|
||||||
|
- Require branches to be up to date
|
||||||
|
- Include administrators in restrictions
|
||||||
|
- No force pushes
|
||||||
|
- No deletions
|
||||||
|
|
||||||
|
## Release Process
|
||||||
|
|
||||||
|
1. Create release branch from develop
|
||||||
|
2. Bump version numbers
|
||||||
|
3. Fix any release-specific issues
|
||||||
|
4. Create PR to main
|
||||||
|
5. After merge to main:
|
||||||
|
- Tag release
|
||||||
|
- Merge back to develop
|
||||||
|
- Delete release branch
|
||||||
|
|
||||||
|
## Hotfix Process
|
||||||
|
|
||||||
|
1. Create hotfix branch from main
|
||||||
|
2. Fix the issue
|
||||||
|
3. Bump patch version
|
||||||
|
4. Create PR to main
|
||||||
|
5. After merge to main:
|
||||||
|
- Tag release
|
||||||
|
- Merge back to develop
|
||||||
|
- Delete hotfix branch
|
||||||
290
README.md
290
README.md
@@ -2,228 +2,184 @@
|
|||||||
|
|
||||||
> **S**Procket **OR**chestration **E**ngine
|
> **S**Procket **OR**chestration **E**ngine
|
||||||
|
|
||||||
SPORE is a basic cluster orchestration engine for ESP8266 microcontrollers that provides automatic node discovery, health monitoring, and over-the-air updates in a distributed network environment.
|
SPORE is a cluster engine for ESP8266 microcontrollers that provides automatic node discovery, health monitoring, and over-the-air updates in a distributed network environment.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Features](#features)
|
||||||
|
- [Supported Hardware](#supported-hardware)
|
||||||
|
- [Architecture](#architecture)
|
||||||
|
- [API Reference](#api-reference)
|
||||||
|
- [Configuration](#configuration)
|
||||||
|
- [Development](#development)
|
||||||
|
- [Current Limitations](#current-limitations)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
|
- [Documentation](#documentation)
|
||||||
|
- [Contributing](#contributing)
|
||||||
|
- [License](#license)
|
||||||
|
- [Acknowledgments](#acknowledgments)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **WiFi Management**: Automatic WiFi STA/AP configuration with hostname generation
|
- **WiFi Management**: Automatic WiFi STA/AP configuration with MAC-based hostname generation
|
||||||
- **Auto Discovery**: UDP-based node discovery with automatic cluster membership
|
- **Auto Discovery**: UDP-based node discovery with automatic cluster membership
|
||||||
- **Service Registry**: Dynamic API endpoint discovery and registration
|
- **Service Registry**: Dynamic API endpoint discovery and registration
|
||||||
- **Health Monitoring**: Real-time node status tracking with resource monitoring
|
- **Health Monitoring**: Real-time node status tracking with resource monitoring
|
||||||
- **Event System**: Local and cluster-wide event publishing/subscription
|
- **Event System**: Local and cluster-wide event publishing/subscription
|
||||||
- **Over-The-Air Updates**: Seamless firmware updates across the cluster
|
- **Over-The-Air Updates**: Seamless firmware updates across the cluster
|
||||||
- **RESTful API**: HTTP-based cluster management and monitoring
|
- **REST API**: HTTP-based cluster management and monitoring
|
||||||
|
- **Capability Discovery**: Automatic API endpoint and service capability detection
|
||||||
|
|
||||||
## Supported Hardware
|
## Supported Hardware
|
||||||
|
|
||||||
- **ESP-01** (1MB Flash)
|
- **ESP-01 / ESP-01S** (1MB Flash)
|
||||||
- **ESP-01S** (1MB Flash)
|
- **Wemos D1** (4MB Flash)
|
||||||
- Other ESP8266 boards with 1MB+ flash
|
- Other ESP8266 boards with 1MB+ flash
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Core Components
|
SPORE uses a modular architecture with automatic node discovery, health monitoring, and distributed task management.
|
||||||
|
|
||||||
The system architecture consists of several key components working together:
|
|
||||||
|
|
||||||
|
**Core Components:**
|
||||||
|
- **Spore Framework**: Main framework class that orchestrates all components
|
||||||
- **Network Manager**: WiFi connection handling and hostname configuration
|
- **Network Manager**: WiFi connection handling and hostname configuration
|
||||||
- **Cluster Manager**: Node discovery, member list management, and health monitoring
|
- **Cluster Manager**: Node discovery, member list management, and health monitoring
|
||||||
- **API Server**: HTTP API server with dynamic endpoint registration
|
- **API Server**: HTTP API server with dynamic endpoint registration
|
||||||
- **Task Scheduler**: Cooperative multitasking system for background operations
|
- **Task Scheduler**: Cooperative multitasking system for background operations
|
||||||
- **Node Context**: Central context providing event system and shared resources
|
- **Node Context**: Central context providing event system and shared resources
|
||||||
|
|
||||||
### Auto Discovery Protocol
|
**Key Features:**
|
||||||
|
- **Auto Discovery**: UDP-based node detection on port 4210
|
||||||
|
- **Health Monitoring**: Continuous status checking via HTTP API
|
||||||
|
- **Task Scheduling**: Background tasks at configurable intervals
|
||||||
|
- **Event System**: Local and cluster-wide event publishing/subscription
|
||||||
|
- **Status Tracking**: Automatic node categorization (ACTIVE, INACTIVE, DEAD)
|
||||||
|
- **Resource Monitoring**: Memory, CPU, flash, and API endpoint tracking
|
||||||
|
- **WiFi Fallback**: Automatic access point creation if connection fails
|
||||||
|
|
||||||
The cluster uses a UDP-based discovery protocol for automatic node detection:
|
📖 **Detailed Architecture:** See [`docs/Architecture.md`](./docs/Architecture.md) for comprehensive system design and implementation details.
|
||||||
|
|
||||||
1. **Discovery Broadcast**: Nodes periodically send UDP packets on port 4210
|
## Quick Start
|
||||||
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
|
|
||||||
|
|
||||||
### Task Scheduling
|
The Spore framework provides a simple, unified interface for all core functionality:
|
||||||
|
|
||||||
The system runs several background tasks at different intervals:
|
```cpp
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include "Spore.h"
|
||||||
|
|
||||||
- **Discovery Tasks**: Send/listen for discovery packets (1s/100ms)
|
// Create Spore instance with custom labels
|
||||||
- **Status Updates**: Monitor cluster member health (1s)
|
Spore spore({
|
||||||
- **Heartbeat**: Maintain cluster connectivity (2s)
|
{"app", "my_app"},
|
||||||
- **Member Info**: Update detailed node information (10s)
|
{"role", "controller"}
|
||||||
- **Debug Output**: Print cluster status (5s)
|
});
|
||||||
|
|
||||||
## API Endpoints
|
void setup() {
|
||||||
|
spore.setup();
|
||||||
|
spore.begin();
|
||||||
|
}
|
||||||
|
|
||||||
### Node Management
|
void loop() {
|
||||||
|
spore.loop();
|
||||||
| Endpoint | Method | Description |
|
|
||||||
|----------|--------|-------------|
|
|
||||||
| `/api/node/status` | GET | Get system resources and API endpoints |
|
|
||||||
| `/api/node/update` | POST | Upload and install firmware update |
|
|
||||||
| `/api/node/restart` | POST | Restart the node |
|
|
||||||
|
|
||||||
### Cluster Management
|
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
|
||||||
|----------|--------|-------------|
|
|
||||||
| `/api/cluster/members` | GET | Get cluster membership and status |
|
|
||||||
|
|
||||||
### Node Status Response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"freeHeap": 12345,
|
|
||||||
"chipId": 12345678,
|
|
||||||
"sdkVersion": "2.2.2-dev(38a443e)",
|
|
||||||
"cpuFreqMHz": 80,
|
|
||||||
"flashChipSize": 1048576,
|
|
||||||
"api": [
|
|
||||||
{
|
|
||||||
"uri": "/api/node/status",
|
|
||||||
"method": "GET"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Cluster Members Response
|
**Adding Custom Services:**
|
||||||
|
```cpp
|
||||||
```json
|
void setup() {
|
||||||
{
|
spore.setup();
|
||||||
"members": [
|
|
||||||
{
|
// Create and register custom services
|
||||||
"hostname": "esp_123456",
|
RelayService* relayService = new RelayService(spore.getTaskManager(), 2);
|
||||||
"ip": "192.168.1.100",
|
spore.addService(relayService);
|
||||||
"lastSeen": 1234567890,
|
|
||||||
"latency": 5,
|
// Or using smart pointers
|
||||||
"status": "ACTIVE",
|
auto sensorService = std::make_shared<SensorService>();
|
||||||
"resources": {
|
spore.addService(sensorService);
|
||||||
"freeHeap": 12345,
|
|
||||||
"chipId": 12345678,
|
// Start the API server and complete initialization
|
||||||
"sdkVersion": "2.2.2-dev(38a443e)",
|
spore.begin();
|
||||||
"cpuFreqMHz": 80,
|
|
||||||
"flashChipSize": 1048576
|
|
||||||
},
|
|
||||||
"api": [
|
|
||||||
{
|
|
||||||
"uri": "/api/node/status",
|
|
||||||
"method": "GET"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Examples:** See [`examples/base/`](./examples/base/) for basic usage and [`examples/relay/`](./examples/relay/) for custom service integration.
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
The system provides a comprehensive RESTful API for monitoring and controlling the embedded device. All endpoints return JSON responses and support standard HTTP status codes.
|
||||||
|
|
||||||
|
**Core Endpoints:**
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/api/node/status` | GET | System resources and API endpoint registry |
|
||||||
|
| `/api/node/endpoints` | GET | API endpoints and parameters |
|
||||||
|
| `/api/cluster/members` | GET | Cluster membership and health status |
|
||||||
|
| `/api/node/update` | POST | OTA firmware updates |
|
||||||
|
| `/api/node/restart` | POST | System restart |
|
||||||
|
| `/api/tasks/status` | GET | Task management and monitoring |
|
||||||
|
| `/api/tasks/control` | POST | Task control operations |
|
||||||
|
| `/api/network/status` | GET | WiFi and network status information |
|
||||||
|
| `/api/network/wifi/scan` | GET/POST | WiFi network scanning and discovery |
|
||||||
|
| `/api/network/wifi/config` | POST | WiFi configuration management |
|
||||||
|
|
||||||
|
**Response Format:** All endpoints return JSON with standardized error handling and HTTP status codes.
|
||||||
|
|
||||||
|
📖 **Complete API Documentation:** See [`docs/API.md`](./docs/API.md) for detailed endpoint documentation, examples, and integration guides.
|
||||||
|
|
||||||
|
🔧 **OpenAPI Specification:** Machine-readable API spec available in [`api/`](./api/) folder for code generation and tooling integration.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Environment Setup
|
The project uses PlatformIO with Arduino framework and supports multiple ESP8266 boards.
|
||||||
|
|
||||||
Create a `.env` file in your project root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# API node IP for cluster management
|
|
||||||
export API_NODE=192.168.1.100
|
|
||||||
|
|
||||||
# WiFi credentials (optional, can be configured in code)
|
|
||||||
export WIFI_SSID=your_network
|
|
||||||
export WIFI_PASSWORD=your_password
|
|
||||||
```
|
|
||||||
|
|
||||||
### PlatformIO Configuration
|
|
||||||
|
|
||||||
The project uses PlatformIO with the following configuration:
|
|
||||||
|
|
||||||
|
**Key Settings:**
|
||||||
- **Framework**: Arduino
|
- **Framework**: Arduino
|
||||||
- **Board**: ESP-01 with 1MB flash
|
- **Board**: ESP-01 with 1MB flash (default)
|
||||||
- **Upload Speed**: 115200 baud
|
- **Upload Speed**: 115200 baud
|
||||||
- **Flash Mode**: DOUT (required for ESP-01S)
|
- **Flash Mode**: DOUT (required for ESP-01S)
|
||||||
|
|
||||||
|
**Dependencies:**
|
||||||
|
- ESPAsyncWebServer, ArduinoJson, TaskScheduler
|
||||||
|
- ESP8266WiFi and HTTPClient libraries
|
||||||
|
|
||||||
|
**Environment Setup:** Create `.env` file for cluster configuration and API node settings.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Prerequisites
|
**Quick Commands:**
|
||||||
|
|
||||||
- PlatformIO Core or PlatformIO IDE
|
|
||||||
- ESP8266 development tools
|
|
||||||
- `jq` for JSON processing in scripts
|
|
||||||
|
|
||||||
### Building
|
|
||||||
|
|
||||||
Build the firmware:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./ctl.sh build
|
# Build firmware
|
||||||
```
|
./ctl.sh build target esp01_1m
|
||||||
|
|
||||||
### Flashing
|
# Flash device
|
||||||
|
./ctl.sh flash target esp01_1m
|
||||||
|
|
||||||
Flash firmware to a connected device:
|
# OTA update
|
||||||
|
./ctl.sh ota update 192.168.1.100 esp01_1m
|
||||||
|
|
||||||
```bash
|
# Cluster management
|
||||||
./ctl.sh flash
|
|
||||||
```
|
|
||||||
|
|
||||||
### Over-The-Air Updates
|
|
||||||
|
|
||||||
Update a specific node:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./ctl.sh ota update 192.168.1.100
|
|
||||||
```
|
|
||||||
|
|
||||||
Update all nodes in the cluster:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./ctl.sh ota all
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cluster Management
|
|
||||||
|
|
||||||
View cluster members:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./ctl.sh cluster members
|
./ctl.sh cluster members
|
||||||
```
|
```
|
||||||
|
|
||||||
## Implementation Details
|
**Prerequisites:** PlatformIO Core, ESP8266 tools, `jq` for JSON processing
|
||||||
|
|
||||||
### Event System
|
📖 **Complete Development Guide:** See [`docs/Development.md`](./docs/Development.md) for comprehensive build, deployment, and troubleshooting instructions.
|
||||||
|
|
||||||
The `NodeContext` provides an event-driven architecture:
|
## Current Limitations
|
||||||
|
|
||||||
```cpp
|
- WiFi credentials are hardcoded in `Config.cpp` (should be configurable)
|
||||||
// Subscribe to events
|
- Limited error handling for network failures
|
||||||
ctx.on("node_discovered", [](void* data) {
|
- No persistent storage for configuration
|
||||||
NodeInfo* node = static_cast<NodeInfo*>(data);
|
- Task monitoring and system health metrics
|
||||||
// Handle new node discovery
|
- Task execution history and performance analytics not yet implemented
|
||||||
});
|
- No authentication or security features implemented
|
||||||
|
|
||||||
// Publish events
|
|
||||||
ctx.fire("node_discovered", &newNode);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Node Status Tracking
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
### Resource Monitoring
|
|
||||||
|
|
||||||
Each node tracks:
|
|
||||||
- Free heap memory
|
|
||||||
- Chip ID and SDK version
|
|
||||||
- CPU frequency
|
|
||||||
- Flash chip size
|
|
||||||
- API endpoint registry
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Common Issues
|
### Common Issues
|
||||||
|
|
||||||
1. **Discovery Failures**: Check UDP port 4210 is not blocked
|
1. **Discovery Failures**: Check UDP port 4210 is not blocked
|
||||||
2. **WiFi Connection**: Verify SSID/password in NetworkManager
|
2. **WiFi Connection**: Verify SSID/password in Config.cpp
|
||||||
3. **OTA Updates**: Ensure sufficient flash space (1MB minimum)
|
3. **OTA Updates**: Ensure sufficient flash space (1MB minimum)
|
||||||
4. **Cluster Split**: Check network connectivity between nodes
|
4. **Cluster Split**: Check network connectivity between nodes
|
||||||
|
|
||||||
@@ -235,6 +191,16 @@ Enable serial monitoring to see cluster activity:
|
|||||||
pio device monitor
|
pio device monitor
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
📚 **Comprehensive documentation is available in the [`docs/`](./docs/) folder:**
|
||||||
|
|
||||||
|
- **[API Reference](./docs/API.md)** - Complete API documentation with examples
|
||||||
|
- **[Architecture Guide](./docs/Architecture.md)** - System design and implementation details
|
||||||
|
- **[Development Guide](./docs/Development.md)** - Build, deployment, and configuration
|
||||||
|
- **[Task Management Guide](./docs/TaskManagement.md)** - Background task management system
|
||||||
|
- **[OpenAPI Spec](./api/)** - Machine-readable API specification
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
1. Fork the repository
|
1. Fork the repository
|
||||||
|
|||||||
201
api/README.md
Normal file
201
api/README.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
# SPORE API Documentation
|
||||||
|
|
||||||
|
This folder contains the OpenAPI specification for the SPORE embedded system API.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- **`openapi.yaml`** - OpenAPI 3.0 specification file
|
||||||
|
- **`README.md`** - This documentation file
|
||||||
|
|
||||||
|
## OpenAPI Specification
|
||||||
|
|
||||||
|
The `openapi.yaml` file provides a complete, machine-readable specification of the SPORE API. It can be used with various tools to:
|
||||||
|
|
||||||
|
- Generate client libraries in multiple programming languages
|
||||||
|
- Create interactive API documentation
|
||||||
|
- Validate API requests and responses
|
||||||
|
- Generate mock servers for testing
|
||||||
|
- Integrate with API management platforms
|
||||||
|
|
||||||
|
## Using the OpenAPI Specification
|
||||||
|
|
||||||
|
### 1. View Interactive Documentation
|
||||||
|
|
||||||
|
#### Option A: Swagger UI Online
|
||||||
|
1. Go to [Swagger Editor](https://editor.swagger.io/)
|
||||||
|
2. Copy and paste the contents of `openapi.yaml`
|
||||||
|
3. View the interactive documentation
|
||||||
|
|
||||||
|
#### Option B: Local Swagger UI
|
||||||
|
```bash
|
||||||
|
# Install Swagger UI locally
|
||||||
|
npm install -g swagger-ui-express
|
||||||
|
|
||||||
|
# Or use Docker
|
||||||
|
docker run -p 8080:8080 -e SWAGGER_JSON=/openapi.yaml -v $(pwd):/swagger swaggerapi/swagger-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Generate Client Libraries
|
||||||
|
|
||||||
|
#### Using OpenAPI Generator
|
||||||
|
```bash
|
||||||
|
# Install OpenAPI Generator
|
||||||
|
npm install @openapitools/openapi-generator-cli -g
|
||||||
|
|
||||||
|
# Generate Python client
|
||||||
|
openapi-generator generate -i openapi.yaml -g python -o python-client
|
||||||
|
|
||||||
|
# Generate JavaScript client
|
||||||
|
openapi-generator generate -i openapi.yaml -g javascript -o js-client
|
||||||
|
|
||||||
|
# Generate C# client
|
||||||
|
openapi-generator generate -i openapi.yaml -g csharp -o csharp-client
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Using Swagger Codegen
|
||||||
|
```bash
|
||||||
|
# Install Swagger Codegen
|
||||||
|
npm install -g swagger-codegen
|
||||||
|
|
||||||
|
# Generate Python client
|
||||||
|
swagger-codegen generate -i openapi.yaml -l python -o python-client
|
||||||
|
|
||||||
|
# Generate Java client
|
||||||
|
swagger-codegen generate -i openapi.yaml -l java -o java-client
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Validate API Responses
|
||||||
|
|
||||||
|
#### Using OpenAPI Validator
|
||||||
|
```bash
|
||||||
|
# Install OpenAPI validator
|
||||||
|
npm install -g @apidevtools/swagger-parser
|
||||||
|
|
||||||
|
# Validate the specification
|
||||||
|
swagger-parser validate openapi.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Generate Mock Server
|
||||||
|
|
||||||
|
#### Using Prism (Stoplight)
|
||||||
|
```bash
|
||||||
|
# Install Prism
|
||||||
|
npm install -g @stoplight/prism-cli
|
||||||
|
|
||||||
|
# Start mock server
|
||||||
|
prism mock openapi.yaml
|
||||||
|
|
||||||
|
# The mock server will be available at http://localhost:4010
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints Overview
|
||||||
|
|
||||||
|
The SPORE API provides the following main categories of endpoints:
|
||||||
|
|
||||||
|
### Task Management
|
||||||
|
- **`GET /api/tasks/status`** - Get comprehensive task status
|
||||||
|
- **`POST /api/tasks/control`** - Control individual tasks
|
||||||
|
|
||||||
|
### System Status
|
||||||
|
- **`GET /api/node/status`** - Get system resource information
|
||||||
|
- **`GET /api/cluster/members`** - Get cluster membership
|
||||||
|
|
||||||
|
### System Management
|
||||||
|
- **`POST /api/node/update`** - Handle OTA firmware updates
|
||||||
|
- **`POST /api/node/restart`** - Trigger system restart
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
The OpenAPI specification includes comprehensive schemas for:
|
||||||
|
|
||||||
|
- **Task Information**: Task status, configuration, and execution details
|
||||||
|
- **System Resources**: Memory usage, chip information, and performance metrics
|
||||||
|
- **Cluster Management**: Node health, network topology, and resource sharing
|
||||||
|
- **API Responses**: Standardized response formats and error handling
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Basic Task Status Check
|
||||||
|
```bash
|
||||||
|
curl -s http://192.168.1.100/api/tasks/status | jq '.'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task Control
|
||||||
|
```bash
|
||||||
|
# Disable a task
|
||||||
|
curl -X POST http://192.168.1.100/api/tasks/control \
|
||||||
|
-d "task=heartbeat&action=disable"
|
||||||
|
|
||||||
|
# Get detailed status
|
||||||
|
curl -X POST http://192.168.1.100/api/tasks/control \
|
||||||
|
-d "task=discovery_send&action=status"
|
||||||
|
```
|
||||||
|
|
||||||
|
### System Monitoring
|
||||||
|
```bash
|
||||||
|
# Check system resources
|
||||||
|
curl -s http://192.168.1.100/api/node/status | jq '.freeHeap'
|
||||||
|
|
||||||
|
# Monitor cluster health
|
||||||
|
curl -s http://192.168.1.100/api/cluster/members | jq '.members[].status'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Examples
|
||||||
|
|
||||||
|
### Python Client
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Get task status
|
||||||
|
response = requests.get('http://192.168.1.100/api/tasks/status')
|
||||||
|
tasks = response.json()
|
||||||
|
|
||||||
|
# Check active tasks
|
||||||
|
active_count = tasks['summary']['activeTasks']
|
||||||
|
print(f"Active tasks: {active_count}")
|
||||||
|
|
||||||
|
# Control a task
|
||||||
|
control_data = {'task': 'heartbeat', 'action': 'disable'}
|
||||||
|
response = requests.post('http://192.168.1.100/api/tasks/control', data=control_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript Client
|
||||||
|
```javascript
|
||||||
|
// Get task status
|
||||||
|
fetch('http://192.168.1.100/api/tasks/status')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
console.log(`Total tasks: ${data.summary.totalTasks}`);
|
||||||
|
console.log(`Active tasks: ${data.summary.activeTasks}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Control a task
|
||||||
|
fetch('http://192.168.1.100/api/tasks/control', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: 'task=heartbeat&action=disable'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
When adding new API endpoints or modifying existing ones:
|
||||||
|
|
||||||
|
1. Update the `openapi.yaml` file
|
||||||
|
2. Add appropriate schemas for new data models
|
||||||
|
3. Include example responses
|
||||||
|
4. Update this README if needed
|
||||||
|
5. Test the specification with validation tools
|
||||||
|
|
||||||
|
## Tools and Resources
|
||||||
|
|
||||||
|
- [OpenAPI Specification](https://swagger.io/specification/)
|
||||||
|
- [OpenAPI Generator](https://openapi-generator.tech/)
|
||||||
|
- [Swagger Codegen](https://github.com/swagger-api/swagger-codegen)
|
||||||
|
- [Swagger UI](https://swagger.io/tools/swagger-ui/)
|
||||||
|
- [Prism Mock Server](https://stoplight.io/open-source/prism/)
|
||||||
|
- [OpenAPI Validator](https://apitools.dev/swagger-parser/)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This API specification is part of the SPORE project and follows the same license terms.
|
||||||
1512
api/openapi.yaml
Normal file
1512
api/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
46
ctl.sh
46
ctl.sh
@@ -9,26 +9,54 @@ function info {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function build {
|
function build {
|
||||||
echo "Building project..."
|
function target {
|
||||||
pio run
|
echo "Building project for $1..."
|
||||||
|
pio run -e $1
|
||||||
|
}
|
||||||
|
function all {
|
||||||
|
pio run
|
||||||
|
}
|
||||||
|
${@:-all}
|
||||||
}
|
}
|
||||||
|
|
||||||
function flash {
|
function flash {
|
||||||
echo "Flashing firmware..."
|
function target {
|
||||||
pio run --target upload
|
echo "Flashing firmware for $1..."
|
||||||
|
pio run --target upload -e $1
|
||||||
|
}
|
||||||
|
${@:-info}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mkfs {
|
||||||
|
~/bin/mklittlefs -c data \
|
||||||
|
-s 0x9000 \
|
||||||
|
-b 4096 \
|
||||||
|
-p 256 \
|
||||||
|
littlefs.bin
|
||||||
|
}
|
||||||
|
|
||||||
|
function flashfs {
|
||||||
|
esptool.py --port /dev/ttyUSB0 \
|
||||||
|
--baud 115200 \
|
||||||
|
write_flash 0xbb000 littlefs.bin
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadfs {
|
||||||
|
echo "Uploading files to LittleFS..."
|
||||||
|
pio run -e $1 -t uploadfs
|
||||||
}
|
}
|
||||||
|
|
||||||
function ota {
|
function ota {
|
||||||
function update {
|
function update {
|
||||||
echo "Updating node at $1"
|
echo "Updating node at $1 with $2... "
|
||||||
curl -X POST \
|
curl -X POST \
|
||||||
-F "file=@.pio/build/esp01_1m/firmware.bin" \
|
-F "file=@.pio/build/$2/firmware.bin" \
|
||||||
http://$1/api/node/update | jq -r '.status'
|
http://$1/api/node/update | jq -r '.status'
|
||||||
}
|
}
|
||||||
function all {
|
function all {
|
||||||
echo "Updating all nodes..."
|
echo "Updating all nodes..."
|
||||||
curl -s http://$API_NODE/api/cluster/members | jq -r '.members.[].ip' | while read -r ip; do
|
curl -s http://$API_NODE/api/cluster/members | jq -r '.members.[].ip' | while read -r ip; do
|
||||||
ota update $ip
|
ota update $ip $1
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
${@:-info}
|
${@:-info}
|
||||||
@@ -41,4 +69,8 @@ function cluster {
|
|||||||
${@:-info}
|
${@:-info}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function monitor {
|
||||||
|
pio run --target monitor
|
||||||
|
}
|
||||||
|
|
||||||
${@:-info}
|
${@:-info}
|
||||||
|
|||||||
580
docs/API.md
Normal file
580
docs/API.md
Normal file
@@ -0,0 +1,580 @@
|
|||||||
|
# SPORE API Documentation
|
||||||
|
|
||||||
|
The SPORE system provides a comprehensive RESTful API for monitoring and controlling the embedded device. All endpoints return JSON responses and support standard HTTP status codes.
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### Task Management API
|
||||||
|
|
||||||
|
| Endpoint | Method | Description | Parameters | Response |
|
||||||
|
|----------|--------|-------------|------------|----------|
|
||||||
|
| `/api/tasks/status` | GET | Get comprehensive status of all tasks and system information | None | Task status overview with system metrics |
|
||||||
|
| `/api/tasks/control` | POST | Control individual task operations | `task`, `action` | Operation result with task details |
|
||||||
|
|
||||||
|
### System Status API
|
||||||
|
|
||||||
|
| Endpoint | Method | Description | Response |
|
||||||
|
|----------|--------|-------------|----------|
|
||||||
|
| `/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 |
|
||||||
|
|
||||||
|
### Network Management API
|
||||||
|
|
||||||
|
| Endpoint | Method | Description | Response |
|
||||||
|
|----------|--------|-------------|----------|
|
||||||
|
| `/api/network/status` | GET | WiFi and network status information | Network configuration and status |
|
||||||
|
| `/api/network/wifi/scan` | GET | Get available WiFi networks | List of discovered networks |
|
||||||
|
| `/api/network/wifi/scan` | POST | Trigger WiFi network scan | Scan initiation confirmation |
|
||||||
|
| `/api/network/wifi/config` | POST | Configure WiFi connection | Connection status and result |
|
||||||
|
|
||||||
|
### Hardware Services API
|
||||||
|
|
||||||
|
| Endpoint | Method | Description | Response |
|
||||||
|
|----------|--------|-------------|----------|
|
||||||
|
| `/api/neopixel/status` | GET | NeoPixel LED strip status | LED configuration and state |
|
||||||
|
| `/api/neopixel` | POST | NeoPixel pattern and color control | Control confirmation |
|
||||||
|
| `/api/neopixel/patterns` | GET | Available LED patterns | List of supported patterns |
|
||||||
|
| `/api/relay/status` | GET | Relay state and configuration | Relay pin and state |
|
||||||
|
| `/api/relay` | POST | Relay control (on/off/toggle) | Control result |
|
||||||
|
| `/api/neopattern/status` | GET | NeoPattern service status | Pattern configuration |
|
||||||
|
| `/api/neopattern` | POST | Advanced LED pattern control | Control confirmation |
|
||||||
|
| `/api/neopattern/patterns` | GET | Available pattern types | List of supported patterns |
|
||||||
|
|
||||||
|
## Detailed API Reference
|
||||||
|
|
||||||
|
### Task Management
|
||||||
|
|
||||||
|
#### GET /api/tasks/status
|
||||||
|
|
||||||
|
Returns comprehensive status information for all registered tasks, including system resource metrics and task execution details.
|
||||||
|
|
||||||
|
**Response Fields:**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `summary.totalTasks` | integer | Total number of registered tasks |
|
||||||
|
| `summary.activeTasks` | integer | Number of currently enabled tasks |
|
||||||
|
| `tasks[].name` | string | Unique task identifier |
|
||||||
|
| `tasks[].interval` | integer | Execution frequency in milliseconds |
|
||||||
|
| `tasks[].enabled` | boolean | Whether task is currently enabled |
|
||||||
|
| `tasks[].running` | boolean | Whether task is actively executing |
|
||||||
|
| `tasks[].autoStart` | boolean | Whether task starts automatically |
|
||||||
|
| `system.freeHeap` | integer | Available RAM in bytes |
|
||||||
|
| `system.uptime` | integer | System uptime in milliseconds |
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": {
|
||||||
|
"totalTasks": 6,
|
||||||
|
"activeTasks": 5
|
||||||
|
},
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"name": "discovery_send",
|
||||||
|
"interval": 1000,
|
||||||
|
"enabled": true,
|
||||||
|
"running": true,
|
||||||
|
"autoStart": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"system": {
|
||||||
|
"freeHeap": 48748,
|
||||||
|
"uptime": 12345
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### POST /api/tasks/control
|
||||||
|
|
||||||
|
Controls the execution state of individual tasks. Supports enabling, disabling, starting, stopping, and getting detailed status for specific tasks.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `task` (required): Name of the task to control
|
||||||
|
- `action` (required): Action to perform
|
||||||
|
|
||||||
|
**Available Actions:**
|
||||||
|
|
||||||
|
| Action | Description | Use Case |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| `enable` | Enable a disabled task | Resume background operations |
|
||||||
|
| `disable` | Disable a running task | Pause resource-intensive tasks |
|
||||||
|
| `start` | Start a stopped task | Begin task execution |
|
||||||
|
| `stop` | Stop a running task | Halt task execution |
|
||||||
|
| `status` | Get detailed status for a specific task | Monitor individual task health |
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Task enabled",
|
||||||
|
"task": "heartbeat",
|
||||||
|
"action": "enable"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Task Status Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Task status retrieved",
|
||||||
|
"task": "discovery_send",
|
||||||
|
"action": "status",
|
||||||
|
"taskDetails": {
|
||||||
|
"name": "discovery_send",
|
||||||
|
"enabled": true,
|
||||||
|
"running": true,
|
||||||
|
"interval": 1000,
|
||||||
|
"system": {
|
||||||
|
"freeHeap": 48748,
|
||||||
|
"uptime": 12345
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### System Status
|
||||||
|
|
||||||
|
#### GET /api/node/status
|
||||||
|
|
||||||
|
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
|
||||||
|
- `chipId`: ESP8266 chip ID
|
||||||
|
- `sdkVersion`: ESP8266 SDK version
|
||||||
|
- `cpuFreqMHz`: CPU frequency in MHz
|
||||||
|
- `flashChipSize`: Flash chip size in bytes
|
||||||
|
- `labels`: Node labels and metadata (if available)
|
||||||
|
- `api`: Array of registered API endpoints
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"freeHeap": 48748,
|
||||||
|
"chipId": 12345678,
|
||||||
|
"sdkVersion": "3.1.2",
|
||||||
|
"cpuFreqMHz": 80,
|
||||||
|
"flashChipSize": 1048576,
|
||||||
|
"labels": {
|
||||||
|
"location": "kitchen",
|
||||||
|
"type": "sensor"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /api/node/endpoints
|
||||||
|
|
||||||
|
Returns detailed information about all available API endpoints, including their parameters, types, and validation rules.
|
||||||
|
|
||||||
|
**Response Fields:**
|
||||||
|
- `endpoints[]`: Array of endpoint capability objects
|
||||||
|
- `uri`: Endpoint URI path
|
||||||
|
- `method`: HTTP method (GET, POST, etc.)
|
||||||
|
- `params[]`: Parameter specifications (if applicable)
|
||||||
|
- `name`: Parameter name
|
||||||
|
- `location`: Parameter location (body, query, etc.)
|
||||||
|
- `required`: Whether parameter is required
|
||||||
|
- `type`: Parameter data type
|
||||||
|
- `values[]`: Allowed values (for enums)
|
||||||
|
- `default`: Default value
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"uri": "/api/tasks/control",
|
||||||
|
"method": "POST",
|
||||||
|
"params": [
|
||||||
|
{
|
||||||
|
"name": "task",
|
||||||
|
"location": "body",
|
||||||
|
"required": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "action",
|
||||||
|
"location": "body",
|
||||||
|
"required": true,
|
||||||
|
"type": "string",
|
||||||
|
"values": ["enable", "disable", "start", "stop", "status"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /api/cluster/members
|
||||||
|
|
||||||
|
Returns information about all nodes in the cluster, including their health status, resources, and API endpoints.
|
||||||
|
|
||||||
|
**Response Fields:**
|
||||||
|
- `members[]`: Array of cluster node information
|
||||||
|
- `hostname`: Node hostname
|
||||||
|
- `ip`: Node IP address
|
||||||
|
- `lastSeen`: Timestamp of last communication
|
||||||
|
- `latency`: Network latency in milliseconds
|
||||||
|
- `status`: Node health status (ACTIVE, INACTIVE, DEAD)
|
||||||
|
- `resources`: System resource information
|
||||||
|
- `api`: Available API endpoints
|
||||||
|
|
||||||
|
### System Management
|
||||||
|
|
||||||
|
#### POST /api/node/update
|
||||||
|
|
||||||
|
Initiates an over-the-air firmware update. The firmware file should be uploaded as multipart/form-data.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `firmware`: Firmware binary file (.bin)
|
||||||
|
|
||||||
|
#### POST /api/node/restart
|
||||||
|
|
||||||
|
Triggers a system restart. The response will be sent before the restart occurs.
|
||||||
|
|
||||||
|
### Network Management
|
||||||
|
|
||||||
|
#### GET /api/network/status
|
||||||
|
|
||||||
|
Returns comprehensive WiFi and network status information.
|
||||||
|
|
||||||
|
**Response Fields:**
|
||||||
|
- `wifi.connected`: Whether WiFi is connected
|
||||||
|
- `wifi.mode`: WiFi mode (STA or AP)
|
||||||
|
- `wifi.ssid`: Connected network SSID
|
||||||
|
- `wifi.ip`: Local IP address
|
||||||
|
- `wifi.mac`: MAC address
|
||||||
|
- `wifi.hostname`: Device hostname
|
||||||
|
- `wifi.rssi`: Signal strength
|
||||||
|
- `wifi.ap_ip`: Access point IP (if in AP mode)
|
||||||
|
- `wifi.ap_mac`: Access point MAC (if in AP mode)
|
||||||
|
- `wifi.stations_connected`: Number of connected stations (if in AP mode)
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"wifi": {
|
||||||
|
"connected": true,
|
||||||
|
"mode": "STA",
|
||||||
|
"ssid": "MyNetwork",
|
||||||
|
"ip": "192.168.1.100",
|
||||||
|
"mac": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"hostname": "spore-node-1",
|
||||||
|
"rssi": -45
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /api/network/wifi/scan
|
||||||
|
|
||||||
|
Returns a list of available WiFi networks discovered during the last scan.
|
||||||
|
|
||||||
|
**Response Fields:**
|
||||||
|
- `access_points[]`: Array of discovered networks
|
||||||
|
- `ssid`: Network name
|
||||||
|
- `rssi`: Signal strength
|
||||||
|
- `channel`: WiFi channel
|
||||||
|
- `encryption_type`: Security type
|
||||||
|
- `hidden`: Whether network is hidden
|
||||||
|
- `bssid`: Network MAC address
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_points": [
|
||||||
|
{
|
||||||
|
"ssid": "MyNetwork",
|
||||||
|
"rssi": -45,
|
||||||
|
"channel": 6,
|
||||||
|
"encryption_type": 4,
|
||||||
|
"hidden": false,
|
||||||
|
"bssid": "AA:BB:CC:DD:EE:FF"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### POST /api/network/wifi/scan
|
||||||
|
|
||||||
|
Initiates a new WiFi network scan.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "scanning",
|
||||||
|
"message": "WiFi scan started"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### POST /api/network/wifi/config
|
||||||
|
|
||||||
|
Configures WiFi connection with new credentials.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `ssid` (required): Network SSID
|
||||||
|
- `password` (required): Network password
|
||||||
|
- `connect_timeout_ms` (optional): Connection timeout in milliseconds (default: 10000)
|
||||||
|
- `retry_delay_ms` (optional): Retry delay in milliseconds (default: 500)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "WiFi configuration updated",
|
||||||
|
"connected": true,
|
||||||
|
"ip": "192.168.1.100"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hardware Services
|
||||||
|
|
||||||
|
#### NeoPixel LED Control
|
||||||
|
|
||||||
|
##### GET /api/neopixel/status
|
||||||
|
|
||||||
|
Returns current NeoPixel LED strip status and configuration.
|
||||||
|
|
||||||
|
**Response Fields:**
|
||||||
|
- `pin`: GPIO pin number
|
||||||
|
- `count`: Number of LEDs in strip
|
||||||
|
- `interval_ms`: Update interval in milliseconds
|
||||||
|
- `brightness`: Current brightness (0-255)
|
||||||
|
- `pattern`: Current pattern name
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pin": 2,
|
||||||
|
"count": 16,
|
||||||
|
"interval_ms": 100,
|
||||||
|
"brightness": 50,
|
||||||
|
"pattern": "rainbow"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### GET /api/neopixel/patterns
|
||||||
|
|
||||||
|
Returns list of available LED patterns.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
["off", "color_wipe", "rainbow", "rainbow_cycle", "theater_chase", "theater_chase_rainbow"]
|
||||||
|
```
|
||||||
|
|
||||||
|
##### POST /api/neopixel
|
||||||
|
|
||||||
|
Controls NeoPixel LED strip patterns and colors.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `pattern` (optional): Pattern name
|
||||||
|
- `interval_ms` (optional): Update interval in milliseconds
|
||||||
|
- `brightness` (optional): Brightness level (0-255)
|
||||||
|
- `color` (optional): Primary color (hex value)
|
||||||
|
- `r`, `g`, `b` (optional): RGB color values
|
||||||
|
- `color2` (optional): Secondary color (hex value)
|
||||||
|
- `r2`, `g2`, `b2` (optional): Secondary RGB values
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://192.168.1.100/api/neopixel \
|
||||||
|
-d "pattern=rainbow&brightness=100&interval_ms=50"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Relay Control
|
||||||
|
|
||||||
|
##### GET /api/relay/status
|
||||||
|
|
||||||
|
Returns current relay status and configuration.
|
||||||
|
|
||||||
|
**Response Fields:**
|
||||||
|
- `pin`: GPIO pin number
|
||||||
|
- `state`: Current state (on/off)
|
||||||
|
- `uptime`: System uptime in milliseconds
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pin": 5,
|
||||||
|
"state": "off",
|
||||||
|
"uptime": 12345
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### POST /api/relay
|
||||||
|
|
||||||
|
Controls relay state.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `state` (required): Desired state (on, off, toggle)
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://192.168.1.100/api/relay \
|
||||||
|
-d "state=on"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### NeoPattern Service
|
||||||
|
|
||||||
|
##### GET /api/neopattern/status
|
||||||
|
|
||||||
|
Returns NeoPattern service status and configuration.
|
||||||
|
|
||||||
|
**Response Fields:**
|
||||||
|
- `pin`: GPIO pin number
|
||||||
|
- `count`: Number of LEDs
|
||||||
|
- `interval_ms`: Update interval
|
||||||
|
- `brightness`: Current brightness
|
||||||
|
- `pattern`: Current pattern name
|
||||||
|
- `total_steps`: Pattern step count
|
||||||
|
- `color1`: Primary color
|
||||||
|
- `color2`: Secondary color
|
||||||
|
|
||||||
|
##### GET /api/neopattern/patterns
|
||||||
|
|
||||||
|
Returns available pattern types.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
["off", "rainbow_cycle", "theater_chase", "color_wipe", "scanner", "fade", "fire"]
|
||||||
|
```
|
||||||
|
|
||||||
|
##### POST /api/neopattern
|
||||||
|
|
||||||
|
Controls advanced LED patterns.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `pattern` (optional): Pattern name
|
||||||
|
- `interval_ms` (optional): Update interval
|
||||||
|
- `brightness` (optional): Brightness level
|
||||||
|
- `color`, `color2` (optional): Color values
|
||||||
|
- `r`, `g`, `b`, `r2`, `g2`, `b2` (optional): RGB values
|
||||||
|
- `total_steps` (optional): Pattern step count
|
||||||
|
- `direction` (optional): Pattern direction (forward/reverse)
|
||||||
|
|
||||||
|
## HTTP Status Codes
|
||||||
|
|
||||||
|
| Code | Description | Use Case |
|
||||||
|
|------|-------------|----------|
|
||||||
|
| 200 | Success | Operation completed successfully |
|
||||||
|
| 400 | Bad Request | Invalid parameters or action |
|
||||||
|
| 404 | Not Found | Task or endpoint not found |
|
||||||
|
| 500 | Internal Server Error | System error occurred |
|
||||||
|
|
||||||
|
## OpenAPI Specification
|
||||||
|
|
||||||
|
A complete OpenAPI 3.0 specification is available in the [`api/`](../api/) folder. This specification can be used to:
|
||||||
|
|
||||||
|
- Generate client libraries in multiple programming languages
|
||||||
|
- Create interactive API documentation
|
||||||
|
- Validate API requests and responses
|
||||||
|
- Generate mock servers for testing
|
||||||
|
- Integrate with API management platforms
|
||||||
|
|
||||||
|
See [`api/README.md`](../api/README.md) for detailed usage instructions.
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Basic Task Status Check
|
||||||
|
```bash
|
||||||
|
curl -s http://10.0.1.60/api/tasks/status | jq '.'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task Control
|
||||||
|
```bash
|
||||||
|
# Disable a task
|
||||||
|
curl -X POST http://10.0.1.60/api/tasks/control \
|
||||||
|
-d "task=heartbeat&action=disable"
|
||||||
|
|
||||||
|
# Get detailed status
|
||||||
|
curl -X POST http://10.0.1.60/api/tasks/control \
|
||||||
|
-d "task=discovery_send&action=status"
|
||||||
|
```
|
||||||
|
|
||||||
|
### System Monitoring
|
||||||
|
```bash
|
||||||
|
# Check system resources
|
||||||
|
curl -s http://10.0.1.60/api/node/status | jq '.freeHeap'
|
||||||
|
|
||||||
|
# Monitor cluster health
|
||||||
|
curl -s http://10.0.1.60/api/cluster/members | jq '.members[].status'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Examples
|
||||||
|
|
||||||
|
### Python Client
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Get task status
|
||||||
|
response = requests.get('http://10.0.1.60/api/tasks/status')
|
||||||
|
tasks = response.json()
|
||||||
|
|
||||||
|
# Check active tasks
|
||||||
|
active_count = tasks['summary']['activeTasks']
|
||||||
|
print(f"Active tasks: {active_count}")
|
||||||
|
|
||||||
|
# Control a task
|
||||||
|
control_data = {'task': 'heartbeat', 'action': 'disable'}
|
||||||
|
response = requests.post('http://10.0.1.60/api/tasks/control', data=control_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript Client
|
||||||
|
```javascript
|
||||||
|
// Get task status
|
||||||
|
fetch('http://10.0.1.60/api/tasks/status')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
console.log(`Total tasks: ${data.summary.totalTasks}`);
|
||||||
|
console.log(`Active tasks: ${data.summary.activeTasks}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Control a task
|
||||||
|
fetch('http://10.0.1.60/api/tasks/control', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: 'task=heartbeat&action=disable'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task Management Examples
|
||||||
|
|
||||||
|
### Monitoring Task Health
|
||||||
|
```bash
|
||||||
|
# Check overall task status
|
||||||
|
curl -s http://10.0.1.60/api/tasks/status | jq '.'
|
||||||
|
|
||||||
|
# Monitor specific task
|
||||||
|
curl -s -X POST http://10.0.1.60/api/tasks/control \
|
||||||
|
-d "task=heartbeat&action=status" | jq '.'
|
||||||
|
|
||||||
|
# Watch for low memory conditions
|
||||||
|
watch -n 5 'curl -s http://10.0.1.60/api/tasks/status | jq ".system.freeHeap"'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task Control Workflows
|
||||||
|
```bash
|
||||||
|
# Temporarily disable discovery to reduce network traffic
|
||||||
|
curl -X POST http://10.0.1.60/api/tasks/control \
|
||||||
|
-d "task=discovery_send&action=disable"
|
||||||
|
|
||||||
|
# Check if it's disabled
|
||||||
|
curl -s -X POST http://10.0.1.60/api/tasks/control \
|
||||||
|
-d "task=discovery_send&action=status" | jq '.taskDetails.enabled'
|
||||||
|
|
||||||
|
# Re-enable when needed
|
||||||
|
curl -X POST http://10.0.1.60/api/tasks/control \
|
||||||
|
-d "task=discovery_send&action=enable"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cluster Health Monitoring
|
||||||
|
```bash
|
||||||
|
# Monitor all nodes in cluster
|
||||||
|
for ip in 10.0.1.60 10.0.1.61 10.0.1.62; do
|
||||||
|
echo "=== Node $ip ==="
|
||||||
|
curl -s "http://$ip/api/tasks/status" | jq '.summary'
|
||||||
|
done
|
||||||
|
```
|
||||||
343
docs/Architecture.md
Normal file
343
docs/Architecture.md
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
# SPORE Architecture & Implementation
|
||||||
|
|
||||||
|
## System Overview
|
||||||
|
|
||||||
|
SPORE (SProcket ORchestration Engine) is a cluster engine for ESP8266 microcontrollers that provides automatic node discovery, health monitoring, and over-the-air updates in a distributed network environment.
|
||||||
|
|
||||||
|
## Core Components
|
||||||
|
|
||||||
|
The system architecture consists of several key components working together:
|
||||||
|
|
||||||
|
### Network Manager
|
||||||
|
- **WiFi Connection Handling**: Automatic WiFi STA/AP configuration
|
||||||
|
- **Hostname Configuration**: MAC-based hostname generation
|
||||||
|
- **Fallback Management**: Automatic access point creation if WiFi connection fails
|
||||||
|
|
||||||
|
### Cluster Manager
|
||||||
|
- **Node Discovery**: UDP-based automatic node detection
|
||||||
|
- **Member List Management**: Dynamic cluster membership tracking
|
||||||
|
- **Health Monitoring**: Continuous node status checking
|
||||||
|
- **Resource Tracking**: Monitor node resources and capabilities
|
||||||
|
|
||||||
|
### API Server
|
||||||
|
- **HTTP API Server**: RESTful API for cluster management
|
||||||
|
- **Dynamic Endpoint Registration**: Automatic API endpoint discovery
|
||||||
|
- **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
|
||||||
|
|
||||||
|
### Node Context
|
||||||
|
- **Central Context**: Shared resources and configuration
|
||||||
|
- **Event System**: Local and cluster-wide event publishing/subscription
|
||||||
|
- **Resource Management**: Centralized resource allocation and monitoring
|
||||||
|
|
||||||
|
## Auto Discovery Protocol
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
### Protocol Details
|
||||||
|
|
||||||
|
- **UDP Port**: 4210 (configurable)
|
||||||
|
- **Discovery Message**: `CLUSTER_DISCOVERY`
|
||||||
|
- **Response Message**: `CLUSTER_RESPONSE`
|
||||||
|
- **Broadcast Address**: 255.255.255.255
|
||||||
|
- **Discovery Interval**: 1 second (configurable)
|
||||||
|
- **Listen Interval**: 100ms (configurable)
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
## Task Scheduling System
|
||||||
|
|
||||||
|
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 Management Features
|
||||||
|
|
||||||
|
- **Dynamic Intervals**: Change execution frequency on-the-fly
|
||||||
|
- **Runtime Control**: Enable/disable tasks without restart
|
||||||
|
- **Status Monitoring**: Real-time task health tracking
|
||||||
|
- **Resource Integration**: View task status with system resources
|
||||||
|
|
||||||
|
## Event System
|
||||||
|
|
||||||
|
The `NodeContext` provides an event-driven architecture for system-wide communication:
|
||||||
|
|
||||||
|
### Event Subscription
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Subscribe to events
|
||||||
|
ctx.on("node_discovered", [](void* data) {
|
||||||
|
NodeInfo* node = static_cast<NodeInfo*>(data);
|
||||||
|
// Handle new node discovery
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.on("cluster_updated", [](void* data) {
|
||||||
|
// Handle cluster membership changes
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Publishing
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Publish events
|
||||||
|
ctx.fire("node_discovered", &newNode);
|
||||||
|
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
|
||||||
|
|
||||||
|
## Resource Monitoring
|
||||||
|
|
||||||
|
Each node tracks comprehensive system resources:
|
||||||
|
|
||||||
|
### System Resources
|
||||||
|
|
||||||
|
- **Free Heap Memory**: Available RAM in bytes
|
||||||
|
- **Chip ID**: Unique ESP8266 identifier
|
||||||
|
- **SDK Version**: ESP8266 firmware version
|
||||||
|
- **CPU Frequency**: Operating frequency in MHz
|
||||||
|
- **Flash Chip Size**: Total flash storage in bytes
|
||||||
|
|
||||||
|
### API Endpoint Registry
|
||||||
|
|
||||||
|
- **Dynamic Discovery**: Automatically detect available endpoints
|
||||||
|
- **Method Information**: HTTP method (GET, POST, etc.)
|
||||||
|
- **Service Catalog**: Complete service registry across cluster
|
||||||
|
|
||||||
|
### Health Metrics
|
||||||
|
|
||||||
|
- **Response Time**: API response latency
|
||||||
|
- **Uptime**: System uptime in milliseconds
|
||||||
|
- **Connection Status**: Network connectivity health
|
||||||
|
- **Resource Utilization**: Memory and CPU usage
|
||||||
|
|
||||||
|
## WiFi Fallback System
|
||||||
|
|
||||||
|
The system includes automatic WiFi fallback for robust operation:
|
||||||
|
|
||||||
|
### Fallback Process
|
||||||
|
|
||||||
|
1. **Primary Connection**: Attempts to connect to configured WiFi network
|
||||||
|
2. **Connection Failure**: If connection fails, creates an access point
|
||||||
|
3. **Hostname Generation**: Automatically generates hostname from MAC address
|
||||||
|
4. **Service Continuity**: Maintains cluster functionality in fallback mode
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
- **SSID Format**: `SPORE_<MAC_LAST_4>`
|
||||||
|
- **Password**: Configurable fallback password
|
||||||
|
- **IP Range**: 192.168.4.x subnet
|
||||||
|
- **Gateway**: 192.168.4.1
|
||||||
|
|
||||||
|
## Cluster Topology
|
||||||
|
|
||||||
|
### Node Types
|
||||||
|
|
||||||
|
- **Master Node**: Primary cluster coordinator (if applicable)
|
||||||
|
- **Worker Nodes**: Standard cluster members
|
||||||
|
- **Edge Nodes**: Network edge devices
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### Node Discovery
|
||||||
|
1. **UDP Broadcast**: Nodes broadcast discovery packets on port 4210
|
||||||
|
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 polls member nodes every 1 second
|
||||||
|
2. **Status Collection**: Each node returns resource usage and health metrics
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
## Performance Characteristics
|
||||||
|
|
||||||
|
### Memory Usage
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
|
### Network Overhead
|
||||||
|
|
||||||
|
- **Discovery Packets**: 64 bytes every 1 second
|
||||||
|
- **Health Checks**: ~200-500 bytes every 1 second
|
||||||
|
- **Status Updates**: ~1-2KB per node
|
||||||
|
- **API Responses**: Varies by endpoint (typically 100B-5KB)
|
||||||
|
|
||||||
|
### Processing Overhead
|
||||||
|
|
||||||
|
- **Task Execution**: Minimal overhead per task
|
||||||
|
- **Event Processing**: Fast event dispatch
|
||||||
|
- **JSON Parsing**: Efficient ArduinoJson usage
|
||||||
|
- **Network I/O**: Asynchronous operations
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Current Implementation
|
||||||
|
|
||||||
|
- **Network Access**: Local network only (no internet exposure)
|
||||||
|
- **Authentication**: None currently implemented
|
||||||
|
- **Data Validation**: Basic input validation
|
||||||
|
- **Resource Limits**: Memory and processing constraints
|
||||||
|
|
||||||
|
### Future Enhancements
|
||||||
|
|
||||||
|
- **TLS/SSL**: Encrypted communications
|
||||||
|
- **API Keys**: Authentication for API access
|
||||||
|
- **Access Control**: Role-based permissions
|
||||||
|
- **Audit Logging**: Security event tracking
|
||||||
|
|
||||||
|
## Scalability
|
||||||
|
|
||||||
|
### Cluster Size Limits
|
||||||
|
|
||||||
|
- **Theoretical**: Up to 255 nodes (IP subnet limit)
|
||||||
|
- **Practical**: 20-50 nodes for optimal performance
|
||||||
|
- **Memory Constraint**: ~8KB available for member tracking
|
||||||
|
- **Network Constraint**: UDP packet size limits
|
||||||
|
|
||||||
|
### Performance Scaling
|
||||||
|
|
||||||
|
- **Linear Scaling**: Most operations scale linearly with node count
|
||||||
|
- **Discovery Overhead**: Increases with cluster size
|
||||||
|
- **Health Monitoring**: Parallel HTTP requests
|
||||||
|
- **Task Management**: Independent per-node execution
|
||||||
|
|
||||||
|
## Configuration Management
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API node IP for cluster management
|
||||||
|
export API_NODE=192.168.1.100
|
||||||
|
```
|
||||||
|
|
||||||
|
### PlatformIO Configuration
|
||||||
|
|
||||||
|
The project uses PlatformIO with the following configuration:
|
||||||
|
|
||||||
|
- **Framework**: Arduino
|
||||||
|
- **Board**: ESP-01 with 1MB flash
|
||||||
|
- **Upload Speed**: 115200 baud
|
||||||
|
- **Flash Mode**: DOUT (required for ESP-01S)
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
The project requires the following libraries:
|
||||||
|
- `esp32async/ESPAsyncWebServer@^3.8.0` - HTTP API server
|
||||||
|
- `bblanchon/ArduinoJson@^7.4.2` - JSON processing
|
||||||
|
- `arkhipenko/TaskScheduler@^3.8.5` - Cooperative multitasking
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
Build the firmware for specific chip:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./ctl.sh build target esp01_1m
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flashing
|
||||||
|
|
||||||
|
Flash firmware to a connected device:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./ctl.sh flash target esp01_1m
|
||||||
|
```
|
||||||
|
|
||||||
|
### Over-The-Air Updates
|
||||||
|
|
||||||
|
Update a specific node:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./ctl.sh ota update 192.168.1.100 esp01_1m
|
||||||
|
```
|
||||||
|
|
||||||
|
Update all nodes in the cluster:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./ctl.sh ota all esp01_1m
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cluster Management
|
||||||
|
|
||||||
|
View cluster members:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./ctl.sh cluster members
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Discovery Failures**: Check UDP port 4210 is not blocked
|
||||||
|
2. **WiFi Connection**: Verify SSID/password in Config.cpp
|
||||||
|
3. **OTA Updates**: Ensure sufficient flash space (1MB minimum)
|
||||||
|
4. **Cluster Split**: Check network connectivity between nodes
|
||||||
|
|
||||||
|
### Debug Output
|
||||||
|
|
||||||
|
Enable serial monitoring to see cluster activity:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pio device monitor
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Monitoring
|
||||||
|
|
||||||
|
- **Memory Usage**: Monitor free heap with `/api/node/status`
|
||||||
|
- **Task Health**: Check task status with `/api/tasks/status`
|
||||||
|
- **Cluster Health**: Monitor member status with `/api/cluster/members`
|
||||||
|
- **Network Latency**: Track response times in cluster data
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- **[Task Management](./TaskManagement.md)** - Background task system
|
||||||
|
- **[API Reference](./API.md)** - REST API documentation
|
||||||
|
- **[TaskManager API](./TaskManager.md)** - TaskManager class reference
|
||||||
|
- **[OpenAPI Specification](../api/)** - Machine-readable API specification
|
||||||
468
docs/Development.md
Normal file
468
docs/Development.md
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
# Development & Deployment Guide
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
### Required Tools
|
||||||
|
|
||||||
|
- **PlatformIO Core** or **PlatformIO IDE**
|
||||||
|
- **ESP8266 development tools**
|
||||||
|
- **`jq`** for JSON processing in scripts
|
||||||
|
- **Git** for version control
|
||||||
|
|
||||||
|
### System Requirements
|
||||||
|
|
||||||
|
- **Operating System**: Linux, macOS, or Windows
|
||||||
|
- **Python**: 3.7+ (for PlatformIO)
|
||||||
|
- **Memory**: 4GB+ RAM recommended
|
||||||
|
- **Storage**: 2GB+ free space for development environment
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
├── include/ # Header files
|
||||||
|
├── lib/ # Library files
|
||||||
|
├── docs/ # Documentation
|
||||||
|
├── api/ # OpenAPI specification
|
||||||
|
├── 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:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[env:esp01_1m]
|
||||||
|
platform = platformio/espressif8266@^4.2.1
|
||||||
|
board = esp01_1m
|
||||||
|
framework = arduino
|
||||||
|
upload_speed = 115200
|
||||||
|
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:
|
||||||
|
|
||||||
|
```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
|
||||||
|
|
||||||
|
This project uses LittleFS as the filesystem on ESP8266. Flash layout is controlled by the linker script (ldscript) selected per environment in `platformio.ini`.
|
||||||
|
|
||||||
|
- **Filesystem**: LittleFS (replaces SPIFFS). Although ldscripts reference SPIFFS in their names, the reserved region is used by LittleFS transparently.
|
||||||
|
- **Why ldscript matters**: It defines maximum sketch size and the size of the filesystem area in flash.
|
||||||
|
|
||||||
|
| Board / Env | Flash size | ldscript | Filesystem | FS size | Notes |
|
||||||
|
|-------------|------------|---------|------------|---------|-------|
|
||||||
|
| esp01_1m | 1MB | `eagle.flash.1m64.ld` | LittleFS | 64KB | Prioritizes sketch size on 1MB modules. OTA is constrained on 1MB.
|
||||||
|
| d1_mini | 4MB | `eagle.flash.4m1m.ld` | LittleFS | 1MB | Standard 4M/1M layout; ample space for firmware and data.
|
||||||
|
|
||||||
|
Configured in `platformio.ini`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[env:esp01_1m]
|
||||||
|
board_build.filesystem = littlefs
|
||||||
|
board_build.ldscript = eagle.flash.1m64.ld
|
||||||
|
|
||||||
|
[env:d1_mini]
|
||||||
|
board_build.filesystem = littlefs
|
||||||
|
board_build.ldscript = eagle.flash.4m1m.ld
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The ldscript name indicates total flash and filesystem size (e.g., `4m1m` = 4MB flash with 1MB FS; `1m64` = 1MB flash with 64KB FS).
|
||||||
|
- LittleFS works within the filesystem region defined by the ldscript, despite SPIFFS naming.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
Build the firmware for specific chip:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build for ESP-01 1MB
|
||||||
|
./ctl.sh build target esp01_1m
|
||||||
|
|
||||||
|
# Build for D1 Mini
|
||||||
|
./ctl.sh build target d1_mini
|
||||||
|
|
||||||
|
# Build with verbose output
|
||||||
|
pio run -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Targets
|
||||||
|
|
||||||
|
Available build targets:
|
||||||
|
|
||||||
|
| Target | Description | Flash Size |
|
||||||
|
|--------|-------------|------------|
|
||||||
|
| `esp01_1m` | ESP-01 with 1MB flash | 1MB |
|
||||||
|
| `d1_mini` | D1 Mini with 4MB flash | 4MB |
|
||||||
|
|
||||||
|
### Build Artifacts
|
||||||
|
|
||||||
|
After successful build:
|
||||||
|
|
||||||
|
- **Firmware**: `.pio/build/{target}/firmware.bin`
|
||||||
|
- **ELF File**: `.pio/build/{target}/firmware.elf`
|
||||||
|
- **Map File**: `.pio/build/{target}/firmware.map`
|
||||||
|
|
||||||
|
## Flashing
|
||||||
|
|
||||||
|
### Direct USB Flashing
|
||||||
|
|
||||||
|
Flash firmware to a connected device:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Flash ESP-01
|
||||||
|
./ctl.sh flash target esp01_1m
|
||||||
|
|
||||||
|
# Flash D1 Mini
|
||||||
|
./ctl.sh flash target d1_mini
|
||||||
|
|
||||||
|
# Manual flash command
|
||||||
|
pio run --target upload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flash Settings
|
||||||
|
|
||||||
|
- **Upload Speed**: 115200 baud (optimal for ESP-01)
|
||||||
|
- **Flash Mode**: DOUT (required for ESP-01S)
|
||||||
|
- **Reset Method**: Hardware reset or manual reset
|
||||||
|
|
||||||
|
### Troubleshooting Flashing
|
||||||
|
|
||||||
|
Common flashing issues:
|
||||||
|
|
||||||
|
1. **Connection Failed**: Check USB cable and drivers
|
||||||
|
2. **Wrong Upload Speed**: Try lower speeds (9600, 57600)
|
||||||
|
3. **Flash Mode Error**: Ensure DOUT mode for ESP-01S
|
||||||
|
4. **Permission Denied**: Run with sudo or add user to dialout group
|
||||||
|
|
||||||
|
## Over-The-Air Updates
|
||||||
|
|
||||||
|
### Single Node Update
|
||||||
|
|
||||||
|
Update a specific node:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update specific node
|
||||||
|
./ctl.sh ota update 192.168.1.100 esp01_1m
|
||||||
|
|
||||||
|
# Update with custom firmware
|
||||||
|
./ctl.sh ota update 192.168.1.100 esp01_1m custom_firmware.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cluster-Wide Updates
|
||||||
|
|
||||||
|
Update all nodes in the cluster:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update all nodes
|
||||||
|
./ctl.sh ota all esp01_1m
|
||||||
|
```
|
||||||
|
|
||||||
|
### OTA Process
|
||||||
|
|
||||||
|
1. **Firmware Upload**: Send firmware to target node
|
||||||
|
2. **Verification**: Check firmware integrity
|
||||||
|
3. **Installation**: Install new firmware
|
||||||
|
4. **Restart**: Node restarts with new firmware
|
||||||
|
5. **Verification**: Confirm successful update
|
||||||
|
|
||||||
|
### OTA Requirements
|
||||||
|
|
||||||
|
- **Flash Space**: Minimum 1MB for OTA updates
|
||||||
|
- **Network**: Stable WiFi connection
|
||||||
|
- **Power**: Stable power supply during update
|
||||||
|
- **Memory**: Sufficient RAM for firmware processing
|
||||||
|
|
||||||
|
## Cluster Management
|
||||||
|
|
||||||
|
### View Cluster Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View all cluster members
|
||||||
|
./ctl.sh cluster members
|
||||||
|
|
||||||
|
# View specific node details
|
||||||
|
./ctl.sh cluster members --node 192.168.1.100
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cluster Commands
|
||||||
|
|
||||||
|
Available cluster management commands:
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `members` | List all cluster members |
|
||||||
|
| `status` | Show cluster health status |
|
||||||
|
| `discover` | Force discovery process |
|
||||||
|
| `health` | Check cluster member health |
|
||||||
|
|
||||||
|
### Cluster Monitoring
|
||||||
|
|
||||||
|
Monitor cluster health in real-time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Watch cluster status
|
||||||
|
watch -n 5 './ctl.sh cluster members'
|
||||||
|
|
||||||
|
# Monitor specific metrics
|
||||||
|
./ctl.sh cluster members | jq '.members[] | {hostname, status, latency}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
1. **Setup Environment**:
|
||||||
|
```bash
|
||||||
|
git clone <repository>
|
||||||
|
cd spore
|
||||||
|
pio run
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Make Changes**:
|
||||||
|
- Edit source files in `src/`
|
||||||
|
- Modify headers in `include/`
|
||||||
|
- Update configuration in `platformio.ini`
|
||||||
|
|
||||||
|
3. **Test Changes**:
|
||||||
|
```bash
|
||||||
|
pio run
|
||||||
|
pio check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
Run various tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Code quality check
|
||||||
|
pio check
|
||||||
|
|
||||||
|
# Unit tests (if available)
|
||||||
|
pio test
|
||||||
|
|
||||||
|
# Memory usage analysis
|
||||||
|
pio run --target size
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debugging
|
||||||
|
|
||||||
|
Enable debug output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Serial monitoring
|
||||||
|
pio device monitor
|
||||||
|
|
||||||
|
# Build with debug symbols
|
||||||
|
pio run --environment esp01_1m --build-flags -DDEBUG
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Management
|
||||||
|
|
||||||
|
### Environment Setup
|
||||||
|
|
||||||
|
Create a `.env` file in your project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API node IP for cluster management
|
||||||
|
export API_NODE=192.168.1.100
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Files
|
||||||
|
|
||||||
|
Key configuration files:
|
||||||
|
|
||||||
|
- **`platformio.ini`**: Build and upload configuration
|
||||||
|
- **`src/Config.cpp`**: Application configuration
|
||||||
|
- **`.env`**: Environment variables
|
||||||
|
- **`ctl.sh`**: Build and deployment scripts
|
||||||
|
|
||||||
|
### Configuration Options
|
||||||
|
|
||||||
|
Available configuration options:
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `CLUSTER_PORT` | 4210 | UDP discovery port |
|
||||||
|
| `DISCOVERY_INTERVAL` | 1000 | Discovery packet interval (ms) |
|
||||||
|
| `HEALTH_CHECK_INTERVAL` | 1000 | Health check interval (ms) |
|
||||||
|
| `API_SERVER_PORT` | 80 | HTTP API server port |
|
||||||
|
|
||||||
|
## Deployment Strategies
|
||||||
|
|
||||||
|
### Development Deployment
|
||||||
|
|
||||||
|
For development and testing:
|
||||||
|
|
||||||
|
1. **Build**: `pio run`
|
||||||
|
2. **Flash**: `pio run --target upload`
|
||||||
|
3. **Monitor**: `pio device monitor`
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
|
||||||
|
For production systems:
|
||||||
|
|
||||||
|
1. **Build Release**: `pio run --environment esp01_1m`
|
||||||
|
2. **OTA Update**: `./ctl.sh ota update <ip> esp01_1m`
|
||||||
|
3. **Verify**: Check node status via API
|
||||||
|
|
||||||
|
### Continuous Integration
|
||||||
|
|
||||||
|
Automated deployment pipeline:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Example GitHub Actions workflow
|
||||||
|
- name: Build Firmware
|
||||||
|
run: pio run --environment esp01_1m
|
||||||
|
|
||||||
|
- name: Deploy to Test Cluster
|
||||||
|
run: ./ctl.sh ota all esp01_1m --target test
|
||||||
|
|
||||||
|
- name: Deploy to Production
|
||||||
|
run: ./ctl.sh ota all esp01_1m --target production
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring and Debugging
|
||||||
|
|
||||||
|
### Serial Output
|
||||||
|
|
||||||
|
Enable serial monitoring:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic monitoring
|
||||||
|
pio device monitor
|
||||||
|
|
||||||
|
# With specific baud rate
|
||||||
|
pio device monitor --baud 115200
|
||||||
|
|
||||||
|
# Filter specific messages
|
||||||
|
pio device monitor | grep "Cluster"
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Monitoring
|
||||||
|
|
||||||
|
Monitor system via HTTP API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check system status
|
||||||
|
curl -s http://192.168.1.100/api/node/status | jq '.'
|
||||||
|
|
||||||
|
# Monitor tasks
|
||||||
|
curl -s http://192.168.1.100/api/tasks/status | jq '.'
|
||||||
|
|
||||||
|
# Check cluster health
|
||||||
|
curl -s http://192.168.1.100/api/cluster/members | jq '.'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Monitoring
|
||||||
|
|
||||||
|
Track system performance:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Memory usage over time
|
||||||
|
watch -n 5 'curl -s http://192.168.1.100/api/node/status | jq ".freeHeap"'
|
||||||
|
|
||||||
|
# Task execution status
|
||||||
|
watch -n 10 'curl -s http://192.168.1.100/api/tasks/status | jq ".summary"'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Discovery Failures**: Check UDP port 4210 is not blocked
|
||||||
|
2. **WiFi Connection**: Verify SSID/password in Config.cpp
|
||||||
|
3. **OTA Updates**: Ensure sufficient flash space (1MB minimum)
|
||||||
|
4. **Cluster Split**: Check network connectivity between nodes
|
||||||
|
|
||||||
|
### Debug Commands
|
||||||
|
|
||||||
|
Useful debugging commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check network connectivity
|
||||||
|
ping 192.168.1.100
|
||||||
|
|
||||||
|
# Test UDP port
|
||||||
|
nc -u 192.168.1.100 4210
|
||||||
|
|
||||||
|
# Check HTTP API
|
||||||
|
curl -v http://192.168.1.100/api/node/status
|
||||||
|
|
||||||
|
# Monitor system resources
|
||||||
|
./ctl.sh cluster members | jq '.members[] | {hostname, status, resources.freeHeap}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Issues
|
||||||
|
|
||||||
|
Common performance problems:
|
||||||
|
|
||||||
|
- **Memory Leaks**: Monitor free heap over time
|
||||||
|
- **Network Congestion**: Check discovery intervals
|
||||||
|
- **Task Overload**: Review task execution intervals
|
||||||
|
- **WiFi Interference**: Check channel and signal strength
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Code Organization
|
||||||
|
|
||||||
|
1. **Modular Design**: Keep components loosely coupled
|
||||||
|
2. **Clear Interfaces**: Define clear APIs between components
|
||||||
|
3. **Error Handling**: Implement proper error handling and logging
|
||||||
|
4. **Resource Management**: Efficient memory and resource usage
|
||||||
|
|
||||||
|
### Testing Strategy
|
||||||
|
|
||||||
|
1. **Unit Tests**: Test individual components
|
||||||
|
2. **Integration Tests**: Test component interactions
|
||||||
|
3. **System Tests**: Test complete system functionality
|
||||||
|
4. **Performance Tests**: Monitor resource usage and performance
|
||||||
|
|
||||||
|
### Deployment Strategy
|
||||||
|
|
||||||
|
1. **Staged Rollout**: Deploy to test cluster first
|
||||||
|
2. **Rollback Plan**: Maintain ability to rollback updates
|
||||||
|
3. **Monitoring**: Monitor system health during deployment
|
||||||
|
4. **Documentation**: Keep deployment procedures updated
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- **[Architecture Guide](./Architecture.md)** - System architecture overview
|
||||||
|
- **[Task Management](./TaskManagement.md)** - Background task system
|
||||||
|
- **[API Reference](./API.md)** - REST API documentation
|
||||||
|
- **[OpenAPI Specification](../api/)** - Machine-readable API specification
|
||||||
89
docs/README.md
Normal file
89
docs/README.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# SPORE Documentation
|
||||||
|
|
||||||
|
This folder contains comprehensive documentation for the SPORE embedded system.
|
||||||
|
|
||||||
|
## Available Documentation
|
||||||
|
|
||||||
|
### 📖 [API.md](./API.md)
|
||||||
|
Complete API reference with detailed endpoint documentation, examples, and integration guides.
|
||||||
|
|
||||||
|
**Includes:**
|
||||||
|
- API endpoint specifications
|
||||||
|
- Request/response examples
|
||||||
|
- HTTP status codes
|
||||||
|
- Integration examples (Python, JavaScript)
|
||||||
|
- 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
|
||||||
|
|
||||||
|
### 📖 [TaskManagement.md](./TaskManagement.md)
|
||||||
|
Complete guide to the task management system with examples and best practices.
|
||||||
|
|
||||||
|
**Includes:**
|
||||||
|
- Task registration methods (std::bind, lambdas, functions)
|
||||||
|
- Task control and lifecycle management
|
||||||
|
- Remote task management via API
|
||||||
|
- Performance considerations and best practices
|
||||||
|
- Migration guides and compatibility information
|
||||||
|
|
||||||
|
### 📖 [Architecture.md](./Architecture.md)
|
||||||
|
Comprehensive system architecture and implementation details.
|
||||||
|
|
||||||
|
**Includes:**
|
||||||
|
- Core component descriptions
|
||||||
|
- Auto discovery protocol details
|
||||||
|
- Task scheduling system
|
||||||
|
- Event system architecture
|
||||||
|
- Resource monitoring
|
||||||
|
- Performance characteristics
|
||||||
|
- Security and scalability considerations
|
||||||
|
|
||||||
|
### 📖 [Development.md](./Development.md)
|
||||||
|
Complete development and deployment guide.
|
||||||
|
|
||||||
|
**Includes:**
|
||||||
|
- PlatformIO configuration
|
||||||
|
- Filesystem, linker scripts, and flash layout mapping
|
||||||
|
- Build and flash instructions
|
||||||
|
- OTA update procedures
|
||||||
|
- Cluster management commands
|
||||||
|
- Development workflow
|
||||||
|
- Troubleshooting guide
|
||||||
|
- Best practices
|
||||||
|
|
||||||
|
### 📖 [StaticFileService.md](./StaticFileService.md)
|
||||||
|
Static file hosting over HTTP using LittleFS.
|
||||||
|
|
||||||
|
## Quick Links
|
||||||
|
|
||||||
|
- **Main Project**: [../README.md](../README.md)
|
||||||
|
- **OpenAPI Specification**: [../api/](../api/)
|
||||||
|
- **Source Code**: [../src/](../src/)
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
When adding new documentation:
|
||||||
|
|
||||||
|
1. Create a new `.md` file in this folder
|
||||||
|
2. Use clear, descriptive filenames
|
||||||
|
3. Include practical examples and code snippets
|
||||||
|
4. Update this README.md to reference new files
|
||||||
|
5. Follow the existing documentation style
|
||||||
|
|
||||||
|
## Documentation Style Guide
|
||||||
|
|
||||||
|
- Use clear, concise language
|
||||||
|
- Include practical examples
|
||||||
|
- Use code blocks with appropriate language tags
|
||||||
|
- Include links to related documentation
|
||||||
|
- Use emojis sparingly for visual organization
|
||||||
|
- Keep README.md files focused and scoped
|
||||||
84
docs/StaticFileService.md
Normal file
84
docs/StaticFileService.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# StaticFileService
|
||||||
|
|
||||||
|
Serves static files from the device's LittleFS filesystem over HTTP.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
- Service name: `StaticFileService`
|
||||||
|
- Mounts: LittleFS at startup
|
||||||
|
- Endpoints:
|
||||||
|
- GET / → serves /index.html
|
||||||
|
- GET /* → serves any file under LittleFS by path
|
||||||
|
|
||||||
|
Files are looked up relative to the filesystem root. If a requested path is empty or `/`, it falls back to `index.html`. Unknown paths return 404; a helper exists to fall back to `index.html` for SPA routing if desired.
|
||||||
|
|
||||||
|
## Content types
|
||||||
|
|
||||||
|
Determined by filename extension:
|
||||||
|
|
||||||
|
- .html, .htm → text/html
|
||||||
|
- .css → text/css
|
||||||
|
- .js → application/javascript
|
||||||
|
- .json → application/json
|
||||||
|
- .png → image/png
|
||||||
|
- .jpg, .jpeg → image/jpeg
|
||||||
|
- .gif → image/gif
|
||||||
|
- .svg → image/svg+xml
|
||||||
|
- .ico → image/x-icon
|
||||||
|
- default → application/octet-stream
|
||||||
|
|
||||||
|
## Building and uploading the filesystem
|
||||||
|
|
||||||
|
Place your web assets under the `data/` folder at the project root. The build system packages `data/` into a LittleFS image matching the environment's flash layout.
|
||||||
|
|
||||||
|
### Upload via PlatformIO
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and upload filesystem for esp01_1m
|
||||||
|
pio run -e esp01_1m -t uploadfs
|
||||||
|
|
||||||
|
# Build and upload filesystem for d1_mini
|
||||||
|
pio run -e d1_mini -t uploadfs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual image build (optional)
|
||||||
|
|
||||||
|
There are helper commands in `ctl.sh` if you need to build and flash the FS image manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build image from ./data into littlefs.bin
|
||||||
|
./ctl.sh mkfs
|
||||||
|
|
||||||
|
# Flash image at preconfigured offset
|
||||||
|
./ctl.sh flashfs
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Offsets and sizes must match the environment's ldscript. The project defaults are:
|
||||||
|
|
||||||
|
- esp01_1m: ldscript `eagle.flash.1m64.ld` (64KB FS)
|
||||||
|
- d1_mini: ldscript `eagle.flash.4m1m.ld` (1MB FS)
|
||||||
|
|
||||||
|
See `docs/Development.md` for details on filesystem and ldscript mapping.
|
||||||
|
|
||||||
|
## Enabling the service
|
||||||
|
|
||||||
|
`StaticFileService` is registered as a core service in the framework and enabled by default:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// part of Spore::registerCoreServices()
|
||||||
|
auto staticFileService = std::make_shared<StaticFileService>(ctx, apiServer);
|
||||||
|
services.push_back(staticFileService);
|
||||||
|
```
|
||||||
|
|
||||||
|
If you maintain a custom build without the default services, ensure you instantiate and add the service before starting the API server.
|
||||||
|
|
||||||
|
## Routing notes
|
||||||
|
|
||||||
|
- Place your SPA assets in `data/` and reference paths relatively.
|
||||||
|
- To support client-side routing, you can adapt `handleNotFound` to always serve `/index.html` for unknown paths.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- "LittleFS Mount Failed": Ensure `board_build.filesystem = littlefs` is set and the ldscript provides a filesystem region.
|
||||||
|
- 404 for known files: Confirm the file exists under `data/` and was uploaded (`pio run -t uploadfs`).
|
||||||
|
- Incorrect MIME type: Add the extension mapping to `getContentType()` if using custom extensions.
|
||||||
348
docs/TaskManagement.md
Normal file
348
docs/TaskManagement.md
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
# Task Management System
|
||||||
|
|
||||||
|
The SPORE system includes a comprehensive TaskManager that provides a clean interface for managing system tasks. This makes it easy to add, configure, and control background tasks without cluttering the main application code.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The TaskManager system provides:
|
||||||
|
- **Easy Task Registration**: Simple API for adding new tasks with configurable intervals
|
||||||
|
- **Dynamic Control**: Enable/disable tasks at runtime
|
||||||
|
- **Interval Management**: Change task execution frequency on the fly
|
||||||
|
- **Status Monitoring**: View task status and configuration
|
||||||
|
- **Automatic Lifecycle**: Tasks are automatically managed and executed
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include "TaskManager.h"
|
||||||
|
|
||||||
|
// Create task manager
|
||||||
|
TaskManager taskManager(ctx);
|
||||||
|
|
||||||
|
// Register tasks
|
||||||
|
taskManager.registerTask("heartbeat", 2000, heartbeatFunction);
|
||||||
|
taskManager.registerTask("maintenance", 30000, maintenanceFunction);
|
||||||
|
|
||||||
|
// Initialize and start all tasks
|
||||||
|
taskManager.initialize();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task Registration Methods
|
||||||
|
|
||||||
|
### Using std::bind with Member Functions (Recommended)
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include <functional>
|
||||||
|
#include "TaskManager.h"
|
||||||
|
|
||||||
|
class MyService {
|
||||||
|
public:
|
||||||
|
void sendHeartbeat() {
|
||||||
|
Serial.println("Service heartbeat");
|
||||||
|
}
|
||||||
|
|
||||||
|
void performMaintenance() {
|
||||||
|
Serial.println("Running maintenance");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
MyService service;
|
||||||
|
TaskManager taskManager(ctx);
|
||||||
|
|
||||||
|
// Register member functions using std::bind
|
||||||
|
taskManager.registerTask("heartbeat", 2000,
|
||||||
|
std::bind(&MyService::sendHeartbeat, &service));
|
||||||
|
taskManager.registerTask("maintenance", 30000,
|
||||||
|
std::bind(&MyService::performMaintenance, &service));
|
||||||
|
|
||||||
|
// Initialize and start all tasks
|
||||||
|
taskManager.initialize();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Lambda Functions
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Register lambda functions directly
|
||||||
|
taskManager.registerTask("counter", 1000, []() {
|
||||||
|
static int count = 0;
|
||||||
|
Serial.printf("Count: %d\n", ++count);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lambda with capture
|
||||||
|
int threshold = 100;
|
||||||
|
taskManager.registerTask("monitor", 5000, [&threshold]() {
|
||||||
|
if (ESP.getFreeHeap() < threshold) {
|
||||||
|
Serial.println("Low memory warning!");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complex Task Registration
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
class NetworkManager {
|
||||||
|
public:
|
||||||
|
void checkConnection() { /* ... */ }
|
||||||
|
void sendData(String data) { /* ... */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
NetworkManager network;
|
||||||
|
|
||||||
|
// Multiple operations in one task
|
||||||
|
taskManager.registerTask("network_ops", 3000,
|
||||||
|
std::bind([](NetworkManager* net) {
|
||||||
|
net->checkConnection();
|
||||||
|
net->sendData("status_update");
|
||||||
|
}, &network));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task Control API
|
||||||
|
|
||||||
|
### Basic Operations
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Enable/disable tasks
|
||||||
|
taskManager.enableTask("heartbeat");
|
||||||
|
taskManager.disableTask("maintenance");
|
||||||
|
|
||||||
|
// Change intervals
|
||||||
|
taskManager.setTaskInterval("heartbeat", 5000); // 5 seconds
|
||||||
|
|
||||||
|
// Check status
|
||||||
|
bool isRunning = taskManager.isTaskEnabled("heartbeat");
|
||||||
|
unsigned long interval = taskManager.getTaskInterval("heartbeat");
|
||||||
|
|
||||||
|
// Print all task statuses
|
||||||
|
taskManager.printTaskStatus();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task Lifecycle Management
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Start/stop tasks
|
||||||
|
taskManager.startTask("heartbeat");
|
||||||
|
taskManager.stopTask("discovery");
|
||||||
|
|
||||||
|
// Bulk operations
|
||||||
|
taskManager.enableAllTasks();
|
||||||
|
taskManager.disableAllTasks();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task Configuration Options
|
||||||
|
|
||||||
|
When registering tasks, you can specify:
|
||||||
|
|
||||||
|
- **Name**: Unique identifier for the task
|
||||||
|
- **Interval**: Execution frequency in milliseconds
|
||||||
|
- **Callback**: Function, bound method, or lambda to execute
|
||||||
|
- **Enabled**: Whether the task starts enabled (default: true)
|
||||||
|
- **AutoStart**: Whether to start automatically (default: true)
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Traditional function
|
||||||
|
taskManager.registerTask("delayed_task", 5000, taskFunction, true, false);
|
||||||
|
|
||||||
|
// Member function with std::bind
|
||||||
|
taskManager.registerTask("service_task", 3000,
|
||||||
|
std::bind(&Service::method, &instance), true, false);
|
||||||
|
|
||||||
|
// Lambda function
|
||||||
|
taskManager.registerTask("lambda_task", 2000,
|
||||||
|
[]() { Serial.println("Lambda!"); }, true, false);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding Custom Tasks
|
||||||
|
|
||||||
|
### Method 1: Using std::bind (Recommended)
|
||||||
|
|
||||||
|
1. **Create your service class**:
|
||||||
|
```cpp
|
||||||
|
class SensorService {
|
||||||
|
public:
|
||||||
|
void readTemperature() {
|
||||||
|
// Read sensor logic
|
||||||
|
Serial.println("Reading temperature");
|
||||||
|
}
|
||||||
|
|
||||||
|
void calibrateSensors() {
|
||||||
|
// Calibration logic
|
||||||
|
Serial.println("Calibrating sensors");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Register with TaskManager**:
|
||||||
|
```cpp
|
||||||
|
SensorService sensors;
|
||||||
|
|
||||||
|
taskManager.registerTask("temp_read", 1000,
|
||||||
|
std::bind(&SensorService::readTemperature, &sensors));
|
||||||
|
taskManager.registerTask("calibrate", 60000,
|
||||||
|
std::bind(&SensorService::calibrateSensors, &sensors));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method 2: Traditional Functions
|
||||||
|
|
||||||
|
1. **Define your task function**:
|
||||||
|
```cpp
|
||||||
|
void myCustomTask() {
|
||||||
|
// Your task logic here
|
||||||
|
Serial.println("Custom task executed");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Register with TaskManager**:
|
||||||
|
```cpp
|
||||||
|
taskManager.registerTask("my_task", 10000, myCustomTask);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enhanced TaskManager Capabilities
|
||||||
|
|
||||||
|
### Task Status Monitoring
|
||||||
|
- **Real-time Status**: Check enabled/disabled state and running status
|
||||||
|
- **Performance Metrics**: Monitor execution intervals and timing
|
||||||
|
- **System Integration**: View task status alongside system resources
|
||||||
|
- **Bulk Operations**: Get status of all tasks at once
|
||||||
|
|
||||||
|
### Task Control Features
|
||||||
|
- **Runtime Control**: Enable/disable tasks without restart
|
||||||
|
- **Dynamic Intervals**: Change task execution frequency on-the-fly
|
||||||
|
- **Individual Status**: Get detailed information about specific tasks
|
||||||
|
- **Health Monitoring**: Track task health and system resources
|
||||||
|
|
||||||
|
## Remote Task Management
|
||||||
|
|
||||||
|
The TaskManager integrates with the API server to provide comprehensive remote task control and monitoring.
|
||||||
|
|
||||||
|
### Task Status Overview
|
||||||
|
|
||||||
|
Get a complete overview of all tasks and system status:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get comprehensive task status
|
||||||
|
curl http://192.168.1.100/api/tasks/status
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response includes:**
|
||||||
|
- **Summary**: Total task count and active task count
|
||||||
|
- **Task Details**: Individual status for each task (name, interval, enabled, running, auto-start)
|
||||||
|
- **System Info**: Free heap memory and uptime
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": {
|
||||||
|
"totalTasks": 6,
|
||||||
|
"activeTasks": 5
|
||||||
|
},
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"name": "discovery_send",
|
||||||
|
"interval": 1000,
|
||||||
|
"enabled": true,
|
||||||
|
"running": true,
|
||||||
|
"autoStart": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "heartbeat",
|
||||||
|
"interval": 2000,
|
||||||
|
"enabled": true,
|
||||||
|
"running": true,
|
||||||
|
"autoStart": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"system": {
|
||||||
|
"freeHeap": 48748,
|
||||||
|
"uptime": 12345
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Individual Task Control
|
||||||
|
|
||||||
|
Control individual tasks with various actions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Control tasks
|
||||||
|
curl -X POST http://192.168.1.100/api/tasks/control \
|
||||||
|
-d "task=heartbeat&action=disable"
|
||||||
|
|
||||||
|
# Get detailed status for a specific task
|
||||||
|
curl -X POST http://192.168.1.100/api/tasks/control \
|
||||||
|
-d "task=discovery_send&action=status"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available Actions:**
|
||||||
|
- `enable` - Enable a task
|
||||||
|
- `disable` - Disable a task
|
||||||
|
- `start` - Start a task
|
||||||
|
- `stop` - Stop a task
|
||||||
|
- `status` - Get detailed status for a specific task
|
||||||
|
|
||||||
|
**Task Status Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Task status retrieved",
|
||||||
|
"task": "discovery_send",
|
||||||
|
"action": "status",
|
||||||
|
"taskDetails": {
|
||||||
|
"name": "discovery_send",
|
||||||
|
"enabled": true,
|
||||||
|
"running": true,
|
||||||
|
"interval": 1000,
|
||||||
|
"system": {
|
||||||
|
"freeHeap": 48748,
|
||||||
|
"uptime": 12345
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- `std::bind` creates a callable object that may have a small overhead compared to direct function pointers
|
||||||
|
- For high-frequency tasks, consider the performance impact
|
||||||
|
- The overhead is typically negligible for most embedded applications
|
||||||
|
- The TaskManager stores bound functions efficiently in a registry
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Use std::bind for member functions**: Cleaner than wrapper functions
|
||||||
|
2. **Group related tasks**: Register multiple related operations in a single task
|
||||||
|
3. **Monitor task health**: Use the status API to monitor task performance
|
||||||
|
4. **Plan intervals carefully**: Balance responsiveness with system resources
|
||||||
|
5. **Use descriptive names**: Make task names clear and meaningful
|
||||||
|
|
||||||
|
## Migration from Wrapper Functions
|
||||||
|
|
||||||
|
### Before (with wrapper functions):
|
||||||
|
```cpp
|
||||||
|
void discoverySendTask() { cluster.sendDiscovery(); }
|
||||||
|
void discoveryListenTask() { cluster.listenForDiscovery(); }
|
||||||
|
|
||||||
|
taskManager.registerTask("discovery_send", interval, discoverySendTask);
|
||||||
|
taskManager.registerTask("discovery_listen", interval, discoveryListenTask);
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (with std::bind):
|
||||||
|
```cpp
|
||||||
|
taskManager.registerTask("discovery_send", interval,
|
||||||
|
std::bind(&ClusterManager::sendDiscovery, &cluster));
|
||||||
|
taskManager.registerTask("discovery_listen", interval,
|
||||||
|
std::bind(&ClusterManager::listenForDiscovery, &cluster));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- The new `std::bind` support is fully backward compatible
|
||||||
|
- Existing code using function pointers will continue to work
|
||||||
|
- You can mix both approaches in the same project
|
||||||
|
- All existing TaskManager methods remain unchanged
|
||||||
|
- New status monitoring methods are additive and don't break existing functionality
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- **[TaskManager API Reference](./TaskManager.md)** - Detailed API documentation
|
||||||
|
- **[API Reference](./API.md)** - REST API for remote task management
|
||||||
|
- **[OpenAPI Specification](../api/)** - Machine-readable API specification
|
||||||
165
examples/base/data/public/index.html
Normal file
165
examples/base/data/public/index.html
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Spore</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-size: 2.5em;
|
||||||
|
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
.status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.status-card {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
.status-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
.status-value {
|
||||||
|
font-size: 2em;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.api-section {
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
.api-links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.api-link {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 25px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.api-link:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>🍄 Spore Node</h1>
|
||||||
|
|
||||||
|
<div class="status-grid">
|
||||||
|
<div class="status-card">
|
||||||
|
<h3>Node Status</h3>
|
||||||
|
<div class="status-value" id="nodeStatus">Loading...</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-card">
|
||||||
|
<h3>Network</h3>
|
||||||
|
<div class="status-value" id="networkStatus">Loading...</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-card">
|
||||||
|
<h3>Tasks</h3>
|
||||||
|
<div class="status-value" id="taskStatus">Loading...</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-card">
|
||||||
|
<h3>Cluster</h3>
|
||||||
|
<div class="status-value" id="clusterStatus">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="api-section">
|
||||||
|
<h2>API Endpoints</h2>
|
||||||
|
<div class="api-links">
|
||||||
|
<a href="/api/node/status" class="api-link">Node Status</a>
|
||||||
|
<a href="/api/network/status" class="api-link">Network Status</a>
|
||||||
|
<a href="/api/tasks/status" class="api-link">Tasks Status</a>
|
||||||
|
<a href="/api/cluster/members" class="api-link">Cluster Members</a>
|
||||||
|
<a href="/api/node/endpoints" class="api-link">All Endpoints</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Load initial data
|
||||||
|
async function loadStatus() {
|
||||||
|
try {
|
||||||
|
const [nodeResponse, networkResponse, taskResponse, clusterResponse] = await Promise.all([
|
||||||
|
fetch('/api/node/status').then(r => r.json()).catch(() => null),
|
||||||
|
fetch('/api/network/status').then(r => r.json()).catch(() => null),
|
||||||
|
fetch('/api/tasks/status').then(r => r.json()).catch(() => null),
|
||||||
|
fetch('/api/cluster/members').then(r => r.json()).catch(() => null)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update node status
|
||||||
|
if (nodeResponse) {
|
||||||
|
document.getElementById('nodeStatus').textContent =
|
||||||
|
nodeResponse.uptime ? `${Math.floor(nodeResponse.uptime / 1000)}s` : 'Online';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update network status
|
||||||
|
if (networkResponse) {
|
||||||
|
document.getElementById('networkStatus').textContent =
|
||||||
|
networkResponse.connected ? 'Connected' : 'Disconnected';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update task status
|
||||||
|
if (taskResponse) {
|
||||||
|
const activeTasks = taskResponse.tasks ?
|
||||||
|
taskResponse.tasks.filter(t => t.status === 'running').length : 0;
|
||||||
|
document.getElementById('taskStatus').textContent = `${activeTasks} active`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cluster status
|
||||||
|
if (clusterResponse) {
|
||||||
|
const memberCount = clusterResponse.members ?
|
||||||
|
Object.keys(clusterResponse.members).length : 0;
|
||||||
|
document.getElementById('clusterStatus').textContent = `${memberCount} nodes`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load status on page load
|
||||||
|
loadStatus();
|
||||||
|
|
||||||
|
// Refresh status every 5 seconds
|
||||||
|
setInterval(loadStatus, 5000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
examples/base/main.cpp
Normal file
21
examples/base/main.cpp
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#include <Arduino.h>
|
||||||
|
#include "spore/Spore.h"
|
||||||
|
|
||||||
|
// Create Spore instance with custom labels
|
||||||
|
Spore spore({
|
||||||
|
{"app", "base"},
|
||||||
|
{"role", "demo"}
|
||||||
|
});
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
// Initialize the Spore framework
|
||||||
|
spore.setup();
|
||||||
|
|
||||||
|
// Start the API server and complete initialization
|
||||||
|
spore.begin();
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
// Run the Spore framework loop
|
||||||
|
spore.loop();
|
||||||
|
}
|
||||||
376
examples/neopattern/NeoPattern.cpp
Normal file
376
examples/neopattern/NeoPattern.cpp
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
#include "NeoPattern.h"
|
||||||
|
|
||||||
|
// Constructor - calls base-class constructor to initialize strip
|
||||||
|
NeoPattern::NeoPattern(uint16_t pixels, uint8_t pin, uint8_t type, void (*callback)(int))
|
||||||
|
: Adafruit_NeoPixel(pixels, pin, type)
|
||||||
|
{
|
||||||
|
frameBuffer = (uint8_t *)malloc(768);
|
||||||
|
OnComplete = callback;
|
||||||
|
TotalSteps = numPixels();
|
||||||
|
begin();
|
||||||
|
}
|
||||||
|
|
||||||
|
NeoPattern::NeoPattern(uint16_t pixels, uint8_t pin, uint8_t type)
|
||||||
|
: Adafruit_NeoPixel(pixels, pin, type)
|
||||||
|
{
|
||||||
|
frameBuffer = (uint8_t *)malloc(768);
|
||||||
|
TotalSteps = numPixels();
|
||||||
|
begin();
|
||||||
|
}
|
||||||
|
|
||||||
|
NeoPattern::~NeoPattern() {
|
||||||
|
if (frameBuffer) {
|
||||||
|
free(frameBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPattern::handleStream(uint8_t *data, size_t len)
|
||||||
|
{
|
||||||
|
//const uint16_t *data16 = (uint16_t *)data;
|
||||||
|
bufferSize = len;
|
||||||
|
memcpy(frameBuffer, data, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPattern::drawFrameBuffer(int w, uint8_t *frame, int length)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < length; i++)
|
||||||
|
{
|
||||||
|
uint8_t r = frame[i];
|
||||||
|
uint8_t g = frame[i + 1];
|
||||||
|
uint8_t b = frame[i + 2];
|
||||||
|
setPixelColor(i, r, g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPattern::onCompleteDefault(int pixels)
|
||||||
|
{
|
||||||
|
//Serial.println("onCompleteDefault");
|
||||||
|
// FIXME no specific code
|
||||||
|
if (ActivePattern == THEATER_CHASE)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Reverse();
|
||||||
|
//Serial.println("pattern completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment the Index and reset at the end
|
||||||
|
void NeoPattern::Increment()
|
||||||
|
{
|
||||||
|
completed = 0;
|
||||||
|
if (Direction == FORWARD)
|
||||||
|
{
|
||||||
|
Index++;
|
||||||
|
if (Index >= TotalSteps)
|
||||||
|
{
|
||||||
|
Index = 0;
|
||||||
|
completed = 1;
|
||||||
|
if (OnComplete != NULL)
|
||||||
|
{
|
||||||
|
OnComplete(numPixels()); // call the comlpetion callback
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
onCompleteDefault(numPixels());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else // Direction == REVERSE
|
||||||
|
{
|
||||||
|
--Index;
|
||||||
|
if (Index <= 0)
|
||||||
|
{
|
||||||
|
Index = TotalSteps - 1;
|
||||||
|
completed = 1;
|
||||||
|
if (OnComplete != NULL)
|
||||||
|
{
|
||||||
|
OnComplete(numPixels()); // call the comlpetion callback
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
onCompleteDefault(numPixels());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse pattern direction
|
||||||
|
void NeoPattern::Reverse()
|
||||||
|
{
|
||||||
|
if (Direction == FORWARD)
|
||||||
|
{
|
||||||
|
Direction = REVERSE;
|
||||||
|
Index = TotalSteps - 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Direction = FORWARD;
|
||||||
|
Index = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize for a RainbowCycle
|
||||||
|
void NeoPattern::RainbowCycle(uint8_t interval, direction dir)
|
||||||
|
{
|
||||||
|
ActivePattern = RAINBOW_CYCLE;
|
||||||
|
Interval = interval;
|
||||||
|
TotalSteps = 255;
|
||||||
|
Index = 0;
|
||||||
|
Direction = dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the Rainbow Cycle Pattern
|
||||||
|
void NeoPattern::RainbowCycleUpdate()
|
||||||
|
{
|
||||||
|
for (int i = 0; i < numPixels(); i++)
|
||||||
|
{
|
||||||
|
setPixelColor(i, Wheel(((i * 256 / numPixels()) + Index) & 255));
|
||||||
|
}
|
||||||
|
show();
|
||||||
|
Increment();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize for a Theater Chase
|
||||||
|
void NeoPattern::TheaterChase(uint32_t color1, uint32_t color2, uint16_t interval, direction dir)
|
||||||
|
{
|
||||||
|
ActivePattern = THEATER_CHASE;
|
||||||
|
Interval = interval;
|
||||||
|
TotalSteps = numPixels();
|
||||||
|
Color1 = color1;
|
||||||
|
Color2 = color2;
|
||||||
|
Index = 0;
|
||||||
|
Direction = dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the Theater Chase Pattern
|
||||||
|
void NeoPattern::TheaterChaseUpdate()
|
||||||
|
{
|
||||||
|
for (int i = 0; i < numPixels(); i++)
|
||||||
|
{
|
||||||
|
if ((i + Index) % 3 == 0)
|
||||||
|
{
|
||||||
|
setPixelColor(i, Color1);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
setPixelColor(i, Color2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
show();
|
||||||
|
Increment();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize for a ColorWipe
|
||||||
|
void NeoPattern::ColorWipe(uint32_t color, uint8_t interval, direction dir)
|
||||||
|
{
|
||||||
|
ActivePattern = COLOR_WIPE;
|
||||||
|
Interval = interval;
|
||||||
|
TotalSteps = numPixels();
|
||||||
|
Color1 = color;
|
||||||
|
Index = 0;
|
||||||
|
Direction = dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the Color Wipe Pattern
|
||||||
|
void NeoPattern::ColorWipeUpdate()
|
||||||
|
{
|
||||||
|
setPixelColor(Index, Color1);
|
||||||
|
show();
|
||||||
|
Increment();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize for a SCANNNER
|
||||||
|
void NeoPattern::Scanner(uint32_t color1, uint8_t interval)
|
||||||
|
{
|
||||||
|
ActivePattern = SCANNER;
|
||||||
|
Interval = interval;
|
||||||
|
TotalSteps = (numPixels() - 1) * 2;
|
||||||
|
Color1 = color1;
|
||||||
|
Index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the Scanner Pattern
|
||||||
|
void NeoPattern::ScannerUpdate()
|
||||||
|
{
|
||||||
|
for (int i = 0; i < numPixels(); i++)
|
||||||
|
{
|
||||||
|
if (i == Index) // Scan Pixel to the right
|
||||||
|
{
|
||||||
|
setPixelColor(i, Color1);
|
||||||
|
}
|
||||||
|
else if (i == TotalSteps - Index) // Scan Pixel to the left
|
||||||
|
{
|
||||||
|
setPixelColor(i, Color1);
|
||||||
|
}
|
||||||
|
else // Fading tail
|
||||||
|
{
|
||||||
|
setPixelColor(i, DimColor(getPixelColor(i)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
show();
|
||||||
|
Increment();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize for a Fade
|
||||||
|
void NeoPattern::Fade(uint32_t color1, uint32_t color2, uint16_t steps, uint8_t interval, direction dir)
|
||||||
|
{
|
||||||
|
ActivePattern = FADE;
|
||||||
|
Interval = interval;
|
||||||
|
TotalSteps = steps;
|
||||||
|
Color1 = color1;
|
||||||
|
Color2 = color2;
|
||||||
|
Index = 0;
|
||||||
|
Direction = dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the Fade Pattern
|
||||||
|
void NeoPattern::FadeUpdate()
|
||||||
|
{
|
||||||
|
// Calculate linear interpolation between Color1 and Color2
|
||||||
|
// Optimise order of operations to minimize truncation error
|
||||||
|
uint8_t red = ((Red(Color1) * (TotalSteps - Index)) + (Red(Color2) * Index)) / TotalSteps;
|
||||||
|
uint8_t green = ((Green(Color1) * (TotalSteps - Index)) + (Green(Color2) * Index)) / TotalSteps;
|
||||||
|
uint8_t blue = ((Blue(Color1) * (TotalSteps - Index)) + (Blue(Color2) * Index)) / TotalSteps;
|
||||||
|
|
||||||
|
ColorSet(Color(red, green, blue));
|
||||||
|
show();
|
||||||
|
Increment();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate 50% dimmed version of a color (used by ScannerUpdate)
|
||||||
|
uint32_t NeoPattern::DimColor(uint32_t color)
|
||||||
|
{
|
||||||
|
// Shift R, G and B components one bit to the right
|
||||||
|
uint32_t dimColor = Color(Red(color) >> 1, Green(color) >> 1, Blue(color) >> 1);
|
||||||
|
return dimColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set all pixels to a color (synchronously)
|
||||||
|
void NeoPattern::ColorSet(uint32_t color)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < numPixels(); i++)
|
||||||
|
{
|
||||||
|
setPixelColor(i, color);
|
||||||
|
}
|
||||||
|
show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the Red component of a 32-bit color
|
||||||
|
uint8_t NeoPattern::Red(uint32_t color)
|
||||||
|
{
|
||||||
|
return (color >> 16) & 0xFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the Green component of a 32-bit color
|
||||||
|
uint8_t NeoPattern::Green(uint32_t color)
|
||||||
|
{
|
||||||
|
return (color >> 8) & 0xFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the Blue component of a 32-bit color
|
||||||
|
uint8_t NeoPattern::Blue(uint32_t color)
|
||||||
|
{
|
||||||
|
return color & 0xFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input a value 0 to 255 to get a color value.
|
||||||
|
// The colours are a transition r - g - b - back to r.
|
||||||
|
uint32_t NeoPattern::Wheel(uint8_t WheelPos)
|
||||||
|
{
|
||||||
|
//if(WheelPos == 0) return Color(0,0,0);
|
||||||
|
WheelPos = 255 - WheelPos;
|
||||||
|
if (WheelPos < 85)
|
||||||
|
{
|
||||||
|
return Color(255 - WheelPos * 3, 0, WheelPos * 3);
|
||||||
|
}
|
||||||
|
else if (WheelPos < 170)
|
||||||
|
{
|
||||||
|
WheelPos -= 85;
|
||||||
|
return Color(0, WheelPos * 3, 255 - WheelPos * 3);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
WheelPos -= 170;
|
||||||
|
return Color(WheelPos * 3, 255 - WheelPos * 3, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effects from https://www.tweaking4all.com/hardware/arduino/adruino-led-strip-effects/
|
||||||
|
*/
|
||||||
|
void NeoPattern::Fire(int Cooling, int Sparking)
|
||||||
|
{
|
||||||
|
uint8_t heat[numPixels()];
|
||||||
|
int cooldown;
|
||||||
|
|
||||||
|
// Step 1. Cool down every cell a little
|
||||||
|
for (int i = 0; i < numPixels(); i++)
|
||||||
|
{
|
||||||
|
cooldown = random(0, ((Cooling * 10) / numPixels()) + 2);
|
||||||
|
|
||||||
|
if (cooldown > heat[i])
|
||||||
|
{
|
||||||
|
heat[i] = 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
heat[i] = heat[i] - cooldown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2. Heat from each cell drifts 'up' and diffuses a little
|
||||||
|
for (int k = numPixels() - 1; k >= 2; k--)
|
||||||
|
{
|
||||||
|
heat[k] = (heat[k - 1] + heat[k - 2] + heat[k - 2]) / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3. Randomly ignite new 'sparks' near the bottom
|
||||||
|
if (random(255) < Sparking)
|
||||||
|
{
|
||||||
|
int y = random(7);
|
||||||
|
heat[y] = heat[y] + random(160, 255);
|
||||||
|
//heat[y] = random(160,255);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4. Convert heat to LED colors
|
||||||
|
for (int j = 0; j < numPixels(); j++)
|
||||||
|
{
|
||||||
|
setPixelHeatColor(j, heat[j]);
|
||||||
|
}
|
||||||
|
|
||||||
|
showStrip();
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPattern::setPixelHeatColor(int Pixel, uint8_t temperature)
|
||||||
|
{
|
||||||
|
// Scale 'heat' down from 0-255 to 0-191
|
||||||
|
uint8_t t192 = round((temperature / 255.0) * 191);
|
||||||
|
|
||||||
|
// calculate ramp up from
|
||||||
|
uint8_t heatramp = t192 & 0x3F; // 0..63
|
||||||
|
heatramp <<= 2; // scale up to 0..252
|
||||||
|
|
||||||
|
// figure out which third of the spectrum we're in:
|
||||||
|
if (t192 > 0x80)
|
||||||
|
{ // hottest
|
||||||
|
setPixel(Pixel, 255, 255, heatramp);
|
||||||
|
}
|
||||||
|
else if (t192 > 0x40)
|
||||||
|
{ // middle
|
||||||
|
setPixel(Pixel, 255, heatramp, 0);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{ // coolest
|
||||||
|
setPixel(Pixel, heatramp, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPattern::setPixel(int Pixel, uint8_t red, uint8_t green, uint8_t blue)
|
||||||
|
{
|
||||||
|
setPixelColor(Pixel, Color(red, green, blue));
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPattern::showStrip()
|
||||||
|
{
|
||||||
|
show();
|
||||||
|
}
|
||||||
109
examples/neopattern/NeoPattern.h
Normal file
109
examples/neopattern/NeoPattern.h
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Original NeoPattern code by Bill Earl
|
||||||
|
* https://learn.adafruit.com/multi-tasking-the-arduino-part-3/overview
|
||||||
|
*
|
||||||
|
* Custom modifications by 0x1d:
|
||||||
|
* - default OnComplete callback that sets pattern to reverse
|
||||||
|
* - separate animation update from timer; Update now updates directly, UpdateScheduled uses timer
|
||||||
|
*/
|
||||||
|
#ifndef __NeoPattern_INCLUDED__
|
||||||
|
#define __NeoPattern_INCLUDED__
|
||||||
|
|
||||||
|
#include <Adafruit_NeoPixel.h>
|
||||||
|
|
||||||
|
using namespace std;
|
||||||
|
|
||||||
|
// Pattern types supported:
|
||||||
|
enum pattern
|
||||||
|
{
|
||||||
|
NONE = 0,
|
||||||
|
RAINBOW_CYCLE = 1,
|
||||||
|
THEATER_CHASE = 2,
|
||||||
|
COLOR_WIPE = 3,
|
||||||
|
SCANNER = 4,
|
||||||
|
FADE = 5,
|
||||||
|
FIRE = 6
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pattern directions supported:
|
||||||
|
enum direction
|
||||||
|
{
|
||||||
|
FORWARD,
|
||||||
|
REVERSE
|
||||||
|
};
|
||||||
|
|
||||||
|
// NeoPattern Class - derived from the Adafruit_NeoPixel class
|
||||||
|
class NeoPattern : public Adafruit_NeoPixel
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
// Member Variables:
|
||||||
|
pattern ActivePattern = RAINBOW_CYCLE; // which pattern is running
|
||||||
|
direction Direction = FORWARD; // direction to run the pattern
|
||||||
|
|
||||||
|
unsigned long Interval = 150; // milliseconds between updates
|
||||||
|
unsigned long lastUpdate = 0; // last update of position
|
||||||
|
|
||||||
|
uint32_t Color1 = 0;
|
||||||
|
uint32_t Color2 = 0; // What colors are in use
|
||||||
|
uint16_t TotalSteps = 32; // total number of steps in the pattern
|
||||||
|
uint16_t Index; // current step within the pattern
|
||||||
|
uint16_t completed = 0;
|
||||||
|
|
||||||
|
// Callback on completion of pattern
|
||||||
|
void (*OnComplete)(int);
|
||||||
|
|
||||||
|
uint8_t *frameBuffer;
|
||||||
|
int bufferSize = 0;
|
||||||
|
|
||||||
|
// Constructor - calls base-class constructor to initialize strip
|
||||||
|
NeoPattern(uint16_t pixels, uint8_t pin, uint8_t type, void (*callback)(int));
|
||||||
|
NeoPattern(uint16_t pixels, uint8_t pin, uint8_t type);
|
||||||
|
~NeoPattern();
|
||||||
|
|
||||||
|
// Stream handling
|
||||||
|
void handleStream(uint8_t *data, size_t len);
|
||||||
|
void drawFrameBuffer(int w, uint8_t *frame, int length);
|
||||||
|
|
||||||
|
// Pattern completion
|
||||||
|
void onCompleteDefault(int pixels);
|
||||||
|
|
||||||
|
// Pattern control
|
||||||
|
void Increment();
|
||||||
|
void Reverse();
|
||||||
|
|
||||||
|
// Rainbow Cycle
|
||||||
|
void RainbowCycle(uint8_t interval, direction dir = FORWARD);
|
||||||
|
void RainbowCycleUpdate();
|
||||||
|
|
||||||
|
// Theater Chase
|
||||||
|
void TheaterChase(uint32_t color1, uint32_t color2, uint16_t interval, direction dir = FORWARD);
|
||||||
|
void TheaterChaseUpdate();
|
||||||
|
|
||||||
|
// Color Wipe
|
||||||
|
void ColorWipe(uint32_t color, uint8_t interval, direction dir = FORWARD);
|
||||||
|
void ColorWipeUpdate();
|
||||||
|
|
||||||
|
// Scanner
|
||||||
|
void Scanner(uint32_t color1, uint8_t interval);
|
||||||
|
void ScannerUpdate();
|
||||||
|
|
||||||
|
// Fade
|
||||||
|
void Fade(uint32_t color1, uint32_t color2, uint16_t steps, uint8_t interval, direction dir = FORWARD);
|
||||||
|
void FadeUpdate();
|
||||||
|
|
||||||
|
// Fire effect
|
||||||
|
void Fire(int Cooling, int Sparking);
|
||||||
|
void setPixelHeatColor(int Pixel, uint8_t temperature);
|
||||||
|
void setPixel(int Pixel, uint8_t red, uint8_t green, uint8_t blue);
|
||||||
|
void showStrip();
|
||||||
|
|
||||||
|
// Utility methods
|
||||||
|
uint32_t DimColor(uint32_t color);
|
||||||
|
void ColorSet(uint32_t color);
|
||||||
|
uint8_t Red(uint32_t color);
|
||||||
|
uint8_t Green(uint32_t color);
|
||||||
|
uint8_t Blue(uint32_t color);
|
||||||
|
uint32_t Wheel(uint8_t WheelPos);
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif
|
||||||
451
examples/neopattern/NeoPatternService.cpp
Normal file
451
examples/neopattern/NeoPatternService.cpp
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
#include "NeoPatternService.h"
|
||||||
|
#include "spore/core/ApiServer.h"
|
||||||
|
#include "spore/util/Logging.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
NeoPatternService::NeoPatternService(TaskManager& taskMgr, const NeoPixelConfig& config)
|
||||||
|
: taskManager(taskMgr),
|
||||||
|
config(config),
|
||||||
|
activePattern(NeoPatternType::RAINBOW_CYCLE),
|
||||||
|
direction(NeoDirection::FORWARD),
|
||||||
|
updateIntervalMs(config.updateInterval),
|
||||||
|
lastUpdateMs(0),
|
||||||
|
initialized(false) {
|
||||||
|
|
||||||
|
// Initialize NeoPattern
|
||||||
|
neoPattern = new NeoPattern(config.length, config.pin, NEO_GRB + NEO_KHZ800);
|
||||||
|
neoPattern->setBrightness(config.brightness);
|
||||||
|
|
||||||
|
// Initialize state
|
||||||
|
currentState.pattern = static_cast<uint>(activePattern);
|
||||||
|
currentState.color = 0xFF0000; // Red
|
||||||
|
currentState.color2 = 0x0000FF; // Blue
|
||||||
|
currentState.totalSteps = 16;
|
||||||
|
currentState.brightness = config.brightness;
|
||||||
|
|
||||||
|
// Set initial pattern
|
||||||
|
neoPattern->Color1 = currentState.color;
|
||||||
|
neoPattern->Color2 = currentState.color2;
|
||||||
|
neoPattern->TotalSteps = currentState.totalSteps;
|
||||||
|
neoPattern->ActivePattern = static_cast<::pattern>(activePattern);
|
||||||
|
neoPattern->Direction = static_cast<::direction>(direction);
|
||||||
|
|
||||||
|
registerPatterns();
|
||||||
|
registerTasks();
|
||||||
|
initialized = true;
|
||||||
|
|
||||||
|
LOG_INFO("NeoPattern", "Service initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
NeoPatternService::~NeoPatternService() {
|
||||||
|
if (neoPattern) {
|
||||||
|
delete neoPattern;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::registerEndpoints(ApiServer& api) {
|
||||||
|
// Status endpoint
|
||||||
|
api.addEndpoint("/api/neopattern/status", HTTP_GET,
|
||||||
|
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
|
||||||
|
std::vector<ParamSpec>{});
|
||||||
|
|
||||||
|
// Patterns list endpoint
|
||||||
|
api.addEndpoint("/api/neopattern/patterns", HTTP_GET,
|
||||||
|
[this](AsyncWebServerRequest* request) { handlePatternsRequest(request); },
|
||||||
|
std::vector<ParamSpec>{});
|
||||||
|
|
||||||
|
// Control endpoint
|
||||||
|
api.addEndpoint("/api/neopattern", HTTP_POST,
|
||||||
|
[this](AsyncWebServerRequest* request) { handleControlRequest(request); },
|
||||||
|
std::vector<ParamSpec>{
|
||||||
|
ParamSpec{String("pattern"), false, String("body"), String("string"), patternNamesVector()},
|
||||||
|
ParamSpec{String("color"), false, String("body"), String("color"), {}},
|
||||||
|
ParamSpec{String("color2"), false, String("body"), String("color"), {}},
|
||||||
|
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")}
|
||||||
|
});
|
||||||
|
|
||||||
|
// State endpoint for complex state updates
|
||||||
|
api.addEndpoint("/api/neopattern/state", HTTP_POST,
|
||||||
|
[this](AsyncWebServerRequest* request) { handleStateRequest(request); },
|
||||||
|
std::vector<ParamSpec>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::handleStatusRequest(AsyncWebServerRequest* request) {
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["pin"] = config.pin;
|
||||||
|
doc["length"] = config.length;
|
||||||
|
doc["brightness"] = currentState.brightness;
|
||||||
|
doc["pattern"] = currentPatternName();
|
||||||
|
doc["color"] = String(currentState.color, HEX);
|
||||||
|
doc["color2"] = String(currentState.color2, HEX);
|
||||||
|
doc["total_steps"] = currentState.totalSteps;
|
||||||
|
doc["direction"] = (direction == NeoDirection::FORWARD) ? "forward" : "reverse";
|
||||||
|
doc["interval"] = updateIntervalMs;
|
||||||
|
doc["active"] = initialized;
|
||||||
|
|
||||||
|
// Add pattern metadata
|
||||||
|
String currentPattern = currentPatternName();
|
||||||
|
doc["pattern_description"] = getPatternDescription(currentPattern);
|
||||||
|
doc["pattern_requires_color2"] = patternRequiresColor2(currentPattern);
|
||||||
|
doc["pattern_supports_direction"] = patternSupportsDirection(currentPattern);
|
||||||
|
|
||||||
|
String json;
|
||||||
|
serializeJson(doc, json);
|
||||||
|
request->send(200, "application/json", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::handlePatternsRequest(AsyncWebServerRequest* request) {
|
||||||
|
JsonDocument doc;
|
||||||
|
JsonArray arr = doc.to<JsonArray>();
|
||||||
|
|
||||||
|
// Get all patterns from registry and include metadata
|
||||||
|
auto patterns = patternRegistry.getAllPatterns();
|
||||||
|
for (const auto& pattern : patterns) {
|
||||||
|
JsonObject patternObj = arr.add<JsonObject>();
|
||||||
|
patternObj["name"] = pattern.name;
|
||||||
|
patternObj["type"] = pattern.type;
|
||||||
|
patternObj["description"] = pattern.description;
|
||||||
|
patternObj["requires_color2"] = pattern.requiresColor2;
|
||||||
|
patternObj["supports_direction"] = pattern.supportsDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
String json;
|
||||||
|
serializeJson(doc, json);
|
||||||
|
request->send(200, "application/json", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::handleControlRequest(AsyncWebServerRequest* request) {
|
||||||
|
bool updated = false;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request->hasParam("color", true)) {
|
||||||
|
String colorStr = request->getParam("color", true)->value();
|
||||||
|
uint32_t color = parseColor(colorStr);
|
||||||
|
setColor(color);
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request->hasParam("color2", true)) {
|
||||||
|
String colorStr = request->getParam("color2", true)->value();
|
||||||
|
uint32_t color = parseColor(colorStr);
|
||||||
|
setColor2(color);
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
JsonDocument resp;
|
||||||
|
resp["ok"] = true;
|
||||||
|
resp["pattern"] = currentPatternName();
|
||||||
|
resp["color"] = String(currentState.color, HEX);
|
||||||
|
resp["color2"] = String(currentState.color2, HEX);
|
||||||
|
resp["brightness"] = currentState.brightness;
|
||||||
|
resp["total_steps"] = currentState.totalSteps;
|
||||||
|
resp["direction"] = (direction == NeoDirection::FORWARD) ? "forward" : "reverse";
|
||||||
|
resp["interval"] = updateIntervalMs;
|
||||||
|
resp["updated"] = updated;
|
||||||
|
|
||||||
|
String json;
|
||||||
|
serializeJson(resp, json);
|
||||||
|
request->send(200, "application/json", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::handleStateRequest(AsyncWebServerRequest* request) {
|
||||||
|
if (request->contentType() != "application/json") {
|
||||||
|
request->send(400, "application/json", "{\"error\":\"Content-Type must be application/json\"}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: AsyncWebServerRequest doesn't have getBody() method
|
||||||
|
// For now, we'll skip JSON body parsing and use form parameters
|
||||||
|
request->send(400, "application/json", "{\"error\":\"JSON body parsing not implemented\"}");
|
||||||
|
return;
|
||||||
|
|
||||||
|
// JSON body parsing not implemented for AsyncWebServerRequest
|
||||||
|
// This would need to be implemented differently
|
||||||
|
request->send(501, "application/json", "{\"error\":\"Not implemented\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::setPattern(NeoPatternType pattern) {
|
||||||
|
activePattern = pattern;
|
||||||
|
currentState.pattern = static_cast<uint>(pattern);
|
||||||
|
neoPattern->ActivePattern = static_cast<::pattern>(pattern);
|
||||||
|
resetStateForPattern(pattern);
|
||||||
|
|
||||||
|
// Initialize the pattern using the registry
|
||||||
|
patternRegistry.initializePattern(static_cast<uint8_t>(pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::setPatternByName(const String& name) {
|
||||||
|
NeoPatternType pattern = nameToPattern(name);
|
||||||
|
setPattern(pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::setColor(uint32_t color) {
|
||||||
|
currentState.color = color;
|
||||||
|
neoPattern->Color1 = color;
|
||||||
|
if (activePattern == NeoPatternType::NONE) {
|
||||||
|
neoPattern->ColorSet(color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::setColor2(uint32_t color) {
|
||||||
|
currentState.color2 = color;
|
||||||
|
neoPattern->Color2 = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::setBrightness(uint8_t brightness) {
|
||||||
|
currentState.brightness = brightness;
|
||||||
|
neoPattern->setBrightness(brightness);
|
||||||
|
neoPattern->show();
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::setTotalSteps(uint16_t steps) {
|
||||||
|
currentState.totalSteps = steps;
|
||||||
|
neoPattern->TotalSteps = steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::setDirection(NeoDirection dir) {
|
||||||
|
direction = dir;
|
||||||
|
neoPattern->Direction = static_cast<::direction>(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::setUpdateInterval(unsigned long interval) {
|
||||||
|
updateIntervalMs = interval;
|
||||||
|
taskManager.setTaskInterval("neopattern_update", interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::setState(const NeoPatternState& state) {
|
||||||
|
currentState = state;
|
||||||
|
|
||||||
|
// Apply state to NeoPattern
|
||||||
|
neoPattern->Index = 0;
|
||||||
|
neoPattern->Color1 = state.color;
|
||||||
|
neoPattern->Color2 = state.color2;
|
||||||
|
neoPattern->TotalSteps = state.totalSteps;
|
||||||
|
neoPattern->ActivePattern = static_cast<::pattern>(state.pattern);
|
||||||
|
neoPattern->Direction = FORWARD;
|
||||||
|
|
||||||
|
setBrightness(state.brightness);
|
||||||
|
setPattern(static_cast<NeoPatternType>(state.pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
NeoPatternState NeoPatternService::getState() const {
|
||||||
|
return currentState;
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::registerTasks() {
|
||||||
|
taskManager.registerTask("neopattern_update", updateIntervalMs, [this]() { update(); });
|
||||||
|
taskManager.registerTask("neopattern_status_print", 10000, [this]() {
|
||||||
|
LOG_INFO("NeoPattern", "Status update");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::registerPatterns() {
|
||||||
|
// Register all patterns with their metadata and callbacks
|
||||||
|
patternRegistry.registerPattern(
|
||||||
|
"none",
|
||||||
|
static_cast<uint8_t>(NeoPatternType::NONE),
|
||||||
|
"No pattern - solid color",
|
||||||
|
[this]() { /* No initialization needed */ },
|
||||||
|
[this]() { updateNone(); },
|
||||||
|
false, // doesn't require color2
|
||||||
|
false // doesn't support direction
|
||||||
|
);
|
||||||
|
|
||||||
|
patternRegistry.registerPattern(
|
||||||
|
"rainbow_cycle",
|
||||||
|
static_cast<uint8_t>(NeoPatternType::RAINBOW_CYCLE),
|
||||||
|
"Rainbow cycle pattern",
|
||||||
|
[this]() { neoPattern->RainbowCycle(updateIntervalMs, static_cast<::direction>(direction)); },
|
||||||
|
[this]() { updateRainbowCycle(); },
|
||||||
|
false, // doesn't require color2
|
||||||
|
true // supports direction
|
||||||
|
);
|
||||||
|
|
||||||
|
patternRegistry.registerPattern(
|
||||||
|
"theater_chase",
|
||||||
|
static_cast<uint8_t>(NeoPatternType::THEATER_CHASE),
|
||||||
|
"Theater chase pattern",
|
||||||
|
[this]() { neoPattern->TheaterChase(currentState.color, currentState.color2, updateIntervalMs, static_cast<::direction>(direction)); },
|
||||||
|
[this]() { updateTheaterChase(); },
|
||||||
|
true, // requires color2
|
||||||
|
true // supports direction
|
||||||
|
);
|
||||||
|
|
||||||
|
patternRegistry.registerPattern(
|
||||||
|
"color_wipe",
|
||||||
|
static_cast<uint8_t>(NeoPatternType::COLOR_WIPE),
|
||||||
|
"Color wipe pattern",
|
||||||
|
[this]() { neoPattern->ColorWipe(currentState.color, updateIntervalMs, static_cast<::direction>(direction)); },
|
||||||
|
[this]() { updateColorWipe(); },
|
||||||
|
false, // doesn't require color2
|
||||||
|
true // supports direction
|
||||||
|
);
|
||||||
|
|
||||||
|
patternRegistry.registerPattern(
|
||||||
|
"scanner",
|
||||||
|
static_cast<uint8_t>(NeoPatternType::SCANNER),
|
||||||
|
"Scanner pattern",
|
||||||
|
[this]() { neoPattern->Scanner(currentState.color, updateIntervalMs); },
|
||||||
|
[this]() { updateScanner(); },
|
||||||
|
false, // doesn't require color2
|
||||||
|
false // doesn't support direction
|
||||||
|
);
|
||||||
|
|
||||||
|
patternRegistry.registerPattern(
|
||||||
|
"fade",
|
||||||
|
static_cast<uint8_t>(NeoPatternType::FADE),
|
||||||
|
"Fade pattern",
|
||||||
|
[this]() { neoPattern->Fade(currentState.color, currentState.color2, currentState.totalSteps, updateIntervalMs, static_cast<::direction>(direction)); },
|
||||||
|
[this]() { updateFade(); },
|
||||||
|
true, // requires color2
|
||||||
|
true // supports direction
|
||||||
|
);
|
||||||
|
|
||||||
|
patternRegistry.registerPattern(
|
||||||
|
"fire",
|
||||||
|
static_cast<uint8_t>(NeoPatternType::FIRE),
|
||||||
|
"Fire effect pattern",
|
||||||
|
[this]() { neoPattern->Fire(50, 120); },
|
||||||
|
[this]() { updateFire(); },
|
||||||
|
false, // doesn't require color2
|
||||||
|
false // doesn't support direction
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<String> NeoPatternService::patternNamesVector() const {
|
||||||
|
return patternRegistry.getAllPatternNames();
|
||||||
|
}
|
||||||
|
|
||||||
|
String NeoPatternService::currentPatternName() const {
|
||||||
|
return patternRegistry.getPatternName(static_cast<uint8_t>(activePattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
NeoPatternService::NeoPatternType NeoPatternService::nameToPattern(const String& name) const {
|
||||||
|
uint8_t type = patternRegistry.getPatternType(name);
|
||||||
|
return static_cast<NeoPatternType>(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::resetStateForPattern(NeoPatternType pattern) {
|
||||||
|
neoPattern->Index = 0;
|
||||||
|
neoPattern->Direction = static_cast<::direction>(direction);
|
||||||
|
neoPattern->completed = 0;
|
||||||
|
lastUpdateMs = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t NeoPatternService::parseColor(const String& colorStr) const {
|
||||||
|
if (colorStr.startsWith("#")) {
|
||||||
|
return strtoul(colorStr.substring(1).c_str(), nullptr, 16);
|
||||||
|
} else if (colorStr.startsWith("0x") || colorStr.startsWith("0X")) {
|
||||||
|
return strtoul(colorStr.c_str(), nullptr, 16);
|
||||||
|
} else {
|
||||||
|
return strtoul(colorStr.c_str(), nullptr, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NeoPatternService::isValidPattern(const String& name) const {
|
||||||
|
return patternRegistry.isValidPattern(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NeoPatternService::isValidPattern(NeoPatternType type) const {
|
||||||
|
return patternRegistry.isValidPattern(static_cast<uint8_t>(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NeoPatternService::patternRequiresColor2(const String& name) const {
|
||||||
|
const PatternInfo* info = patternRegistry.getPattern(name);
|
||||||
|
return info ? info->requiresColor2 : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NeoPatternService::patternSupportsDirection(const String& name) const {
|
||||||
|
const PatternInfo* info = patternRegistry.getPattern(name);
|
||||||
|
return info ? info->supportsDirection : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String NeoPatternService::getPatternDescription(const String& name) const {
|
||||||
|
const PatternInfo* info = patternRegistry.getPattern(name);
|
||||||
|
return info ? info->description : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::update() {
|
||||||
|
if (!initialized) return;
|
||||||
|
|
||||||
|
//unsigned long now = millis();
|
||||||
|
//if (now - lastUpdateMs < updateIntervalMs) return;
|
||||||
|
//lastUpdateMs = now;
|
||||||
|
|
||||||
|
// Use pattern registry to execute the current pattern
|
||||||
|
patternRegistry.executePattern(static_cast<uint8_t>(activePattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::updateRainbowCycle() {
|
||||||
|
neoPattern->RainbowCycleUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::updateTheaterChase() {
|
||||||
|
neoPattern->TheaterChaseUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::updateColorWipe() {
|
||||||
|
neoPattern->ColorWipeUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::updateScanner() {
|
||||||
|
neoPattern->ScannerUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::updateFade() {
|
||||||
|
neoPattern->FadeUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::updateFire() {
|
||||||
|
neoPattern->Fire(50, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
void NeoPatternService::updateNone() {
|
||||||
|
// For NONE pattern, just show the current color
|
||||||
|
neoPattern->ColorSet(neoPattern->Color1);
|
||||||
|
}
|
||||||
95
examples/neopattern/NeoPatternService.h
Normal file
95
examples/neopattern/NeoPatternService.h
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "spore/Service.h"
|
||||||
|
#include "spore/core/TaskManager.h"
|
||||||
|
#include "NeoPattern.h"
|
||||||
|
#include "NeoPatternState.h"
|
||||||
|
#include "NeoPixelConfig.h"
|
||||||
|
#include "PatternRegistry.h"
|
||||||
|
#include <map>
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
class NeoPatternService : public Service {
|
||||||
|
public:
|
||||||
|
enum class NeoPatternType {
|
||||||
|
NONE = 0,
|
||||||
|
RAINBOW_CYCLE = 1,
|
||||||
|
THEATER_CHASE = 2,
|
||||||
|
COLOR_WIPE = 3,
|
||||||
|
SCANNER = 4,
|
||||||
|
FADE = 5,
|
||||||
|
FIRE = 6
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class NeoDirection {
|
||||||
|
FORWARD,
|
||||||
|
REVERSE
|
||||||
|
};
|
||||||
|
|
||||||
|
NeoPatternService(TaskManager& taskMgr, const NeoPixelConfig& config);
|
||||||
|
~NeoPatternService();
|
||||||
|
|
||||||
|
void registerEndpoints(ApiServer& api) override;
|
||||||
|
const char* getName() const override { return "NeoPattern"; }
|
||||||
|
|
||||||
|
// Pattern control methods
|
||||||
|
void setPattern(NeoPatternType pattern);
|
||||||
|
void setPatternByName(const String& name);
|
||||||
|
void setColor(uint32_t color);
|
||||||
|
void setColor2(uint32_t color2);
|
||||||
|
void setBrightness(uint8_t brightness);
|
||||||
|
void setTotalSteps(uint16_t steps);
|
||||||
|
void setDirection(NeoDirection direction);
|
||||||
|
void setUpdateInterval(unsigned long interval);
|
||||||
|
|
||||||
|
// State management
|
||||||
|
void setState(const NeoPatternState& state);
|
||||||
|
NeoPatternState getState() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void registerTasks();
|
||||||
|
void registerPatterns();
|
||||||
|
void update();
|
||||||
|
|
||||||
|
// Pattern updaters
|
||||||
|
void updateRainbowCycle();
|
||||||
|
void updateTheaterChase();
|
||||||
|
void updateColorWipe();
|
||||||
|
void updateScanner();
|
||||||
|
void updateFade();
|
||||||
|
void updateFire();
|
||||||
|
void updateNone();
|
||||||
|
|
||||||
|
// API handlers
|
||||||
|
void handleStatusRequest(AsyncWebServerRequest* request);
|
||||||
|
void handlePatternsRequest(AsyncWebServerRequest* request);
|
||||||
|
void handleControlRequest(AsyncWebServerRequest* request);
|
||||||
|
void handleStateRequest(AsyncWebServerRequest* request);
|
||||||
|
|
||||||
|
// Utility methods
|
||||||
|
std::vector<String> patternNamesVector() const;
|
||||||
|
String currentPatternName() const;
|
||||||
|
NeoPatternType nameToPattern(const String& name) const;
|
||||||
|
void resetStateForPattern(NeoPatternType pattern);
|
||||||
|
uint32_t parseColor(const String& colorStr) const;
|
||||||
|
|
||||||
|
// Pattern validation methods
|
||||||
|
bool isValidPattern(const String& name) const;
|
||||||
|
bool isValidPattern(NeoPatternType type) const;
|
||||||
|
bool patternRequiresColor2(const String& name) const;
|
||||||
|
bool patternSupportsDirection(const String& name) const;
|
||||||
|
String getPatternDescription(const String& name) const;
|
||||||
|
|
||||||
|
TaskManager& taskManager;
|
||||||
|
NeoPattern* neoPattern;
|
||||||
|
NeoPixelConfig config;
|
||||||
|
NeoPatternState currentState;
|
||||||
|
|
||||||
|
// Pattern registry for centralized pattern management
|
||||||
|
PatternRegistry patternRegistry;
|
||||||
|
|
||||||
|
NeoPatternType activePattern;
|
||||||
|
NeoDirection direction;
|
||||||
|
unsigned long updateIntervalMs;
|
||||||
|
unsigned long lastUpdateMs;
|
||||||
|
bool initialized;
|
||||||
|
};
|
||||||
59
examples/neopattern/NeoPatternState.h
Normal file
59
examples/neopattern/NeoPatternState.h
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
#ifndef __NEOPATTERN_STATE__
|
||||||
|
#define __NEOPATTERN_STATE__
|
||||||
|
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
struct NeoPatternState {
|
||||||
|
// Default values
|
||||||
|
static constexpr uint DEFAULT_PATTERN = 0;
|
||||||
|
static constexpr uint DEFAULT_COLOR = 0xFF0000; // Red
|
||||||
|
static constexpr uint DEFAULT_COLOR2 = 0x0000FF; // Blue
|
||||||
|
static constexpr uint DEFAULT_TOTAL_STEPS = 16;
|
||||||
|
static constexpr uint DEFAULT_BRIGHTNESS = 64;
|
||||||
|
|
||||||
|
uint pattern = DEFAULT_PATTERN;
|
||||||
|
uint color = DEFAULT_COLOR;
|
||||||
|
uint color2 = DEFAULT_COLOR2;
|
||||||
|
uint totalSteps = DEFAULT_TOTAL_STEPS;
|
||||||
|
uint brightness = DEFAULT_BRIGHTNESS;
|
||||||
|
|
||||||
|
NeoPatternState() = default;
|
||||||
|
|
||||||
|
NeoPatternState(uint p, uint c, uint c2, uint steps, uint b)
|
||||||
|
: pattern(p), color(c), color2(c2), totalSteps(steps), brightness(b) {}
|
||||||
|
|
||||||
|
void mapJsonObject(JsonObject& root) const {
|
||||||
|
root["pattern"] = pattern;
|
||||||
|
root["color"] = color;
|
||||||
|
root["color2"] = color2;
|
||||||
|
root["totalSteps"] = totalSteps;
|
||||||
|
root["brightness"] = brightness;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fromJsonObject(JsonObject& json) {
|
||||||
|
pattern = json["pattern"] | pattern;
|
||||||
|
color = json["color"] | color;
|
||||||
|
color2 = json["color2"] | color2;
|
||||||
|
totalSteps = json["totalSteps"] | totalSteps;
|
||||||
|
brightness = json["brightness"] | brightness;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods for JSON string conversion
|
||||||
|
String toJsonString() const {
|
||||||
|
JsonDocument doc;
|
||||||
|
JsonObject root = doc.to<JsonObject>();
|
||||||
|
mapJsonObject(root);
|
||||||
|
String result;
|
||||||
|
serializeJson(doc, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fromJsonString(const String& jsonStr) {
|
||||||
|
JsonDocument doc;
|
||||||
|
deserializeJson(doc, jsonStr);
|
||||||
|
JsonObject root = doc.as<JsonObject>();
|
||||||
|
fromJsonObject(root);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif
|
||||||
45
examples/neopattern/NeoPixelConfig.h
Normal file
45
examples/neopattern/NeoPixelConfig.h
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#ifndef __NEOPIXEL_CONFIG__
|
||||||
|
#define __NEOPIXEL_CONFIG__
|
||||||
|
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
struct NeoPixelConfig
|
||||||
|
{
|
||||||
|
// Configuration constants
|
||||||
|
static constexpr int DEFAULT_PIN = 2;
|
||||||
|
static constexpr int DEFAULT_LENGTH = 8;
|
||||||
|
static constexpr int DEFAULT_BRIGHTNESS = 100;
|
||||||
|
static constexpr int DEFAULT_UPDATE_INTERVAL = 100;
|
||||||
|
static constexpr int DEFAULT_COLOR = 0xFF0000; // Red
|
||||||
|
|
||||||
|
int pin = DEFAULT_PIN;
|
||||||
|
int length = DEFAULT_LENGTH;
|
||||||
|
int brightness = DEFAULT_BRIGHTNESS;
|
||||||
|
int updateInterval = DEFAULT_UPDATE_INTERVAL;
|
||||||
|
int defaultColor = DEFAULT_COLOR;
|
||||||
|
|
||||||
|
NeoPixelConfig() = default;
|
||||||
|
|
||||||
|
NeoPixelConfig(int p, int l, int b, int interval, int color = DEFAULT_COLOR)
|
||||||
|
: pin(p), length(l), brightness(b), updateInterval(interval), defaultColor(color) {}
|
||||||
|
|
||||||
|
void mapJsonObject(JsonObject &root) const
|
||||||
|
{
|
||||||
|
root["pin"] = pin;
|
||||||
|
root["length"] = length;
|
||||||
|
root["brightness"] = brightness;
|
||||||
|
root["updateInterval"] = updateInterval;
|
||||||
|
root["defaultColor"] = defaultColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fromJsonObject(JsonObject &json)
|
||||||
|
{
|
||||||
|
pin = json["pin"] | pin;
|
||||||
|
length = json["length"] | length;
|
||||||
|
brightness = json["brightness"] | brightness;
|
||||||
|
updateInterval = json["updateInterval"] | updateInterval;
|
||||||
|
defaultColor = json["defaultColor"] | defaultColor;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif
|
||||||
101
examples/neopattern/PatternRegistry.cpp
Normal file
101
examples/neopattern/PatternRegistry.cpp
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
#include "PatternRegistry.h"
|
||||||
|
|
||||||
|
PatternRegistry::PatternRegistry() {
|
||||||
|
// Constructor - patterns will be registered by the service
|
||||||
|
}
|
||||||
|
|
||||||
|
void PatternRegistry::registerPattern(const PatternInfo& pattern) {
|
||||||
|
patternMap_[pattern.name] = pattern;
|
||||||
|
typeToNameMap_[pattern.type] = pattern.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PatternRegistry::registerPattern(const String& name, uint8_t type, const String& description,
|
||||||
|
std::function<void()> initializer, std::function<void()> updater,
|
||||||
|
bool requiresColor2, bool supportsDirection) {
|
||||||
|
PatternInfo info(name, type, description, initializer, updater, requiresColor2, supportsDirection);
|
||||||
|
registerPattern(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PatternInfo* PatternRegistry::getPattern(const String& name) const {
|
||||||
|
auto it = patternMap_.find(name);
|
||||||
|
return (it != patternMap_.end()) ? &it->second : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PatternInfo* PatternRegistry::getPattern(uint8_t type) const {
|
||||||
|
auto typeIt = typeToNameMap_.find(type);
|
||||||
|
if (typeIt == typeToNameMap_.end()) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
return getPattern(typeIt->second);
|
||||||
|
}
|
||||||
|
|
||||||
|
String PatternRegistry::getPatternName(uint8_t type) const {
|
||||||
|
auto it = typeToNameMap_.find(type);
|
||||||
|
return (it != typeToNameMap_.end()) ? it->second : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t PatternRegistry::getPatternType(const String& name) const {
|
||||||
|
const PatternInfo* info = getPattern(name);
|
||||||
|
return info ? info->type : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<String> PatternRegistry::getAllPatternNames() const {
|
||||||
|
std::vector<String> names;
|
||||||
|
names.reserve(patternMap_.size());
|
||||||
|
for (const auto& pair : patternMap_) {
|
||||||
|
names.push_back(pair.first);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<PatternInfo> PatternRegistry::getAllPatterns() const {
|
||||||
|
std::vector<PatternInfo> patterns;
|
||||||
|
patterns.reserve(patternMap_.size());
|
||||||
|
for (const auto& pair : patternMap_) {
|
||||||
|
patterns.push_back(pair.second);
|
||||||
|
}
|
||||||
|
return patterns;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PatternRegistry::isValidPattern(const String& name) const {
|
||||||
|
return patternMap_.find(name) != patternMap_.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PatternRegistry::isValidPattern(uint8_t type) const {
|
||||||
|
return typeToNameMap_.find(type) != typeToNameMap_.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PatternRegistry::executePattern(const String& name) const {
|
||||||
|
const PatternInfo* info = getPattern(name);
|
||||||
|
if (info && info->updater) {
|
||||||
|
info->updater();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PatternRegistry::executePattern(uint8_t type) const {
|
||||||
|
const PatternInfo* info = getPattern(type);
|
||||||
|
if (info && info->updater) {
|
||||||
|
info->updater();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PatternRegistry::initializePattern(const String& name) const {
|
||||||
|
const PatternInfo* info = getPattern(name);
|
||||||
|
if (info && info->initializer) {
|
||||||
|
info->initializer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PatternRegistry::initializePattern(uint8_t type) const {
|
||||||
|
const PatternInfo* info = getPattern(type);
|
||||||
|
if (info && info->initializer) {
|
||||||
|
info->initializer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PatternRegistry::updateTypeMap() {
|
||||||
|
typeToNameMap_.clear();
|
||||||
|
for (const auto& pair : patternMap_) {
|
||||||
|
typeToNameMap_[pair.second.type] = pair.first;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
examples/neopattern/PatternRegistry.h
Normal file
73
examples/neopattern/PatternRegistry.h
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <map>
|
||||||
|
#include <functional>
|
||||||
|
#include <vector>
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PatternInfo structure containing all information needed for a pattern
|
||||||
|
*/
|
||||||
|
struct PatternInfo {
|
||||||
|
String name;
|
||||||
|
uint8_t type;
|
||||||
|
String description;
|
||||||
|
std::function<void()> updater;
|
||||||
|
std::function<void()> initializer;
|
||||||
|
bool requiresColor2;
|
||||||
|
bool supportsDirection;
|
||||||
|
|
||||||
|
// Default constructor for std::map compatibility
|
||||||
|
PatternInfo() : type(0), requiresColor2(false), supportsDirection(false) {}
|
||||||
|
|
||||||
|
// Parameterized constructor
|
||||||
|
PatternInfo(const String& n, uint8_t t, const String& desc,
|
||||||
|
std::function<void()> initFunc, std::function<void()> updateFunc = nullptr,
|
||||||
|
bool needsColor2 = false, bool supportsDir = true)
|
||||||
|
: name(n), type(t), description(desc), updater(updateFunc),
|
||||||
|
initializer(initFunc), requiresColor2(needsColor2), supportsDirection(supportsDir) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PatternRegistry class for centralized pattern management
|
||||||
|
*/
|
||||||
|
class PatternRegistry {
|
||||||
|
public:
|
||||||
|
using PatternMap = std::map<String, PatternInfo>;
|
||||||
|
using PatternTypeMap = std::map<uint8_t, String>;
|
||||||
|
|
||||||
|
PatternRegistry();
|
||||||
|
~PatternRegistry() = default;
|
||||||
|
|
||||||
|
// Pattern registration
|
||||||
|
void registerPattern(const PatternInfo& pattern);
|
||||||
|
void registerPattern(const String& name, uint8_t type, const String& description,
|
||||||
|
std::function<void()> initializer, std::function<void()> updater = nullptr,
|
||||||
|
bool requiresColor2 = false, bool supportsDirection = true);
|
||||||
|
|
||||||
|
// Pattern lookup
|
||||||
|
const PatternInfo* getPattern(const String& name) const;
|
||||||
|
const PatternInfo* getPattern(uint8_t type) const;
|
||||||
|
String getPatternName(uint8_t type) const;
|
||||||
|
uint8_t getPatternType(const String& name) const;
|
||||||
|
|
||||||
|
// Pattern enumeration
|
||||||
|
std::vector<String> getAllPatternNames() const;
|
||||||
|
std::vector<PatternInfo> getAllPatterns() const;
|
||||||
|
const PatternMap& getPatternMap() const { return patternMap_; }
|
||||||
|
|
||||||
|
// Pattern validation
|
||||||
|
bool isValidPattern(const String& name) const;
|
||||||
|
bool isValidPattern(uint8_t type) const;
|
||||||
|
|
||||||
|
// Pattern execution
|
||||||
|
void executePattern(const String& name) const;
|
||||||
|
void executePattern(uint8_t type) const;
|
||||||
|
void initializePattern(const String& name) const;
|
||||||
|
void initializePattern(uint8_t type) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
PatternMap patternMap_;
|
||||||
|
PatternTypeMap typeToNameMap_;
|
||||||
|
|
||||||
|
void updateTypeMap();
|
||||||
|
};
|
||||||
131
examples/neopattern/README.md
Normal file
131
examples/neopattern/README.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# NeoPattern Service for Spore Framework
|
||||||
|
|
||||||
|
This example demonstrates how to integrate a NeoPixel pattern service with the Spore framework. It provides a comprehensive LED strip control system with multiple animation patterns and REST API endpoints.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Multiple Animation Patterns**: Rainbow cycle, theater chase, color wipe, scanner, fade, and fire effects
|
||||||
|
- **REST API Control**: Full HTTP API for pattern control and configuration
|
||||||
|
- **Real-time Updates**: Smooth pattern transitions and real-time parameter changes
|
||||||
|
- **State Management**: Persistent state with JSON serialization
|
||||||
|
- **Spore Integration**: Built on the Spore framework for networking and task management
|
||||||
|
|
||||||
|
## Hardware Requirements
|
||||||
|
|
||||||
|
- ESP32 or compatible microcontroller
|
||||||
|
- NeoPixel LED strip (WS2812B or compatible)
|
||||||
|
- Appropriate power supply for your LED strip
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Edit `NeoPixelConfig.h` to configure your setup:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
static constexpr int DEFAULT_PIN = 2;
|
||||||
|
static constexpr int DEFAULT_LENGTH = 8;
|
||||||
|
static constexpr int DEFAULT_BRIGHTNESS = 100;
|
||||||
|
static constexpr int DEFAULT_UPDATE_INTERVAL = 100;
|
||||||
|
static constexpr int DEFAULT_COLOR = 0xFF0000; // Red
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Status Information
|
||||||
|
- `GET /api/neopattern/status` - Get current status and configuration
|
||||||
|
- `GET /api/neopattern/patterns` - List available patterns
|
||||||
|
|
||||||
|
### Pattern Control
|
||||||
|
- `POST /api/neopattern` - Control pattern parameters
|
||||||
|
- `pattern`: Pattern name (none, rainbow_cycle, theater_chase, color_wipe, scanner, fade, fire)
|
||||||
|
- `color`: Primary color (hex string like "#FF0000" or "0xFF0000")
|
||||||
|
- `color2`: Secondary color for two-color patterns
|
||||||
|
- `brightness`: Brightness level (0-255)
|
||||||
|
- `total_steps`: Number of steps for patterns
|
||||||
|
- `direction`: Pattern direction (forward, reverse)
|
||||||
|
- `interval`: Update interval in milliseconds
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
- `POST /api/neopattern/state` - Set complete state via JSON
|
||||||
|
|
||||||
|
## Example API Usage
|
||||||
|
|
||||||
|
### Set a rainbow cycle pattern
|
||||||
|
```bash
|
||||||
|
curl -X POST http://esp32-ip/api/neopattern \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
-d "pattern=rainbow_cycle&interval=50"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set custom colors for theater chase
|
||||||
|
```bash
|
||||||
|
curl -X POST http://esp32-ip/api/neopattern \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
-d "pattern=theater_chase&color=0xFF0000&color2=0x0000FF&brightness=150"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set complete state via JSON
|
||||||
|
```bash
|
||||||
|
curl -X POST http://esp32-ip/api/neopattern/state \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"pattern": 3,
|
||||||
|
"color": 16711680,
|
||||||
|
"color2": 255,
|
||||||
|
"totalSteps": 32,
|
||||||
|
"brightness": 128
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Patterns
|
||||||
|
|
||||||
|
1. **none** - Static color display
|
||||||
|
2. **rainbow_cycle** - Smooth rainbow cycling
|
||||||
|
3. **theater_chase** - Moving dots with two colors
|
||||||
|
4. **color_wipe** - Progressive color filling
|
||||||
|
5. **scanner** - Scanning light effect
|
||||||
|
6. **fade** - Smooth color transition
|
||||||
|
7. **fire** - Fire simulation effect
|
||||||
|
|
||||||
|
## Building and Flashing
|
||||||
|
|
||||||
|
1. Install PlatformIO
|
||||||
|
2. Configure your `platformio.ini` to include the Spore framework
|
||||||
|
3. Build and upload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pio run -t upload
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration with Spore Framework
|
||||||
|
|
||||||
|
This service integrates with the Spore framework by:
|
||||||
|
|
||||||
|
- Extending the `Service` base class
|
||||||
|
- Using `TaskManager` for pattern updates
|
||||||
|
- Registering REST API endpoints with `ApiServer`
|
||||||
|
- Following Spore's logging and configuration patterns
|
||||||
|
|
||||||
|
The service automatically registers itself with the Spore framework and provides all functionality through the standard Spore API infrastructure.
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
To add new patterns:
|
||||||
|
|
||||||
|
1. Add the pattern enum to `NeoPatternService.h`
|
||||||
|
2. Implement the pattern logic in `NeoPattern.cpp`
|
||||||
|
3. Add the pattern updater to `registerPatterns()` in `NeoPatternService.cpp`
|
||||||
|
4. Update the pattern mapping in `nameToPatternMap`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **LEDs not lighting**: Check wiring and power supply
|
||||||
|
- **Patterns not updating**: Verify the update interval and task registration
|
||||||
|
- **API not responding**: Check network configuration and Spore framework setup
|
||||||
|
- **Memory issues**: Reduce LED count or pattern complexity
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Spore Framework
|
||||||
|
- Adafruit NeoPixel library
|
||||||
|
- ArduinoJson
|
||||||
|
- ESP32 Arduino Core
|
||||||
60
examples/neopattern/main.cpp
Normal file
60
examples/neopattern/main.cpp
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
#include <Arduino.h>
|
||||||
|
#include "spore/Spore.h"
|
||||||
|
#include "spore/util/Logging.h"
|
||||||
|
#include "NeoPatternService.h"
|
||||||
|
#include "NeoPixelConfig.h"
|
||||||
|
|
||||||
|
// Configuration constants
|
||||||
|
#ifndef NEOPIXEL_PIN
|
||||||
|
#define NEOPIXEL_PIN 2
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef NEOPIXEL_LENGTH
|
||||||
|
#define NEOPIXEL_LENGTH 8
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef NEOPIXEL_BRIGHTNESS
|
||||||
|
#define NEOPIXEL_BRIGHTNESS 100
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef NEOPIXEL_UPDATE_INTERVAL
|
||||||
|
#define NEOPIXEL_UPDATE_INTERVAL 100
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Create Spore instance with custom labels
|
||||||
|
Spore spore({
|
||||||
|
{"app", "neopattern"},
|
||||||
|
{"role", "led"},
|
||||||
|
{"pixels", String(NEOPIXEL_LENGTH)},
|
||||||
|
{"pin", String(NEOPIXEL_PIN)}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create custom service
|
||||||
|
NeoPatternService* neoPatternService = nullptr;
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
// Initialize the Spore framework
|
||||||
|
spore.setup();
|
||||||
|
|
||||||
|
// Create configuration
|
||||||
|
NeoPixelConfig config(
|
||||||
|
NEOPIXEL_PIN,
|
||||||
|
NEOPIXEL_LENGTH,
|
||||||
|
NEOPIXEL_BRIGHTNESS,
|
||||||
|
NEOPIXEL_UPDATE_INTERVAL
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create and add custom service
|
||||||
|
neoPatternService = new NeoPatternService(spore.getTaskManager(), config);
|
||||||
|
spore.addService(neoPatternService);
|
||||||
|
|
||||||
|
// Start the API server and complete initialization
|
||||||
|
spore.begin();
|
||||||
|
|
||||||
|
LOG_INFO("Main", "NeoPattern service registered and ready!");
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
// Run the Spore framework loop
|
||||||
|
spore.loop();
|
||||||
|
}
|
||||||
143
examples/relay/README.md
Normal file
143
examples/relay/README.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# Relay Service Example
|
||||||
|
|
||||||
|
A minimal example that demonstrates the Spore framework with a custom RelayService and web interface. The Spore framework automatically handles all core functionality (WiFi, clustering, API server, task management) while allowing easy registration of custom services.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **API Control**: RESTful API endpoints for programmatic control
|
||||||
|
- **Web Interface**: Beautiful web UI for manual control at `http://<device-ip>/relay.html`
|
||||||
|
- **Real-time Status**: Live status updates and visual feedback
|
||||||
|
- **Toggle Functionality**: One-click toggle between ON/OFF states
|
||||||
|
|
||||||
|
- Default relay pin: `GPIO0` (ESP-01). Override with `-DRELAY_PIN=<pin>`.
|
||||||
|
- WiFi and API port are configured in `src/Config.cpp`.
|
||||||
|
|
||||||
|
## Spore Framework Usage
|
||||||
|
|
||||||
|
This example demonstrates the simplified Spore framework approach:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include "Spore.h"
|
||||||
|
#include "RelayService.h"
|
||||||
|
|
||||||
|
Spore spore({
|
||||||
|
{"app", "relay"},
|
||||||
|
{"device", "actuator"},
|
||||||
|
{"pin", String(RELAY_PIN)}
|
||||||
|
});
|
||||||
|
|
||||||
|
RelayService* relayService = nullptr;
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
spore.setup();
|
||||||
|
|
||||||
|
relayService = new RelayService(spore.getContext(), spore.getTaskManager(), RELAY_PIN);
|
||||||
|
spore.addService(relayService);
|
||||||
|
|
||||||
|
spore.begin();
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
spore.loop();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The Spore framework automatically provides:
|
||||||
|
- WiFi connectivity management
|
||||||
|
- Cluster discovery and management
|
||||||
|
- REST API server with core endpoints
|
||||||
|
- Task scheduling and execution
|
||||||
|
- Node status monitoring
|
||||||
|
- Static file serving for web interfaces (core service)
|
||||||
|
|
||||||
|
## Build & Upload
|
||||||
|
|
||||||
|
- ESP‑01S:
|
||||||
|
```bash
|
||||||
|
pio run -e esp01_1m_relay -t upload
|
||||||
|
```
|
||||||
|
- D1 Mini:
|
||||||
|
```bash
|
||||||
|
pio run -e d1_mini_relay -t upload
|
||||||
|
```
|
||||||
|
|
||||||
|
Monitor serial logs:
|
||||||
|
```bash
|
||||||
|
pio device monitor -b 115200
|
||||||
|
```
|
||||||
|
|
||||||
|
Assume the device IP is 192.168.1.50 below (replace with your device's IP shown in serial output).
|
||||||
|
|
||||||
|
## Web Interface
|
||||||
|
|
||||||
|
The web interface is located in the `data/` folder and will be served by the core StaticFileService.
|
||||||
|
|
||||||
|
Access the web interface at: `http://192.168.1.50/relay.html`
|
||||||
|
|
||||||
|
The web interface provides:
|
||||||
|
- Visual status indicator (red for OFF, green for ON)
|
||||||
|
- Turn ON/OFF buttons
|
||||||
|
- Toggle button for quick switching
|
||||||
|
- Real-time status updates every 2 seconds
|
||||||
|
- Uptime display
|
||||||
|
- Error handling and user feedback
|
||||||
|
|
||||||
|
## Relay API
|
||||||
|
|
||||||
|
- Get relay status
|
||||||
|
```bash
|
||||||
|
curl http://192.168.1.50/api/relay/status
|
||||||
|
```
|
||||||
|
|
||||||
|
- Turn relay ON
|
||||||
|
```bash
|
||||||
|
curl -X POST http://192.168.1.50/api/relay -d state=on
|
||||||
|
```
|
||||||
|
|
||||||
|
- Turn relay OFF
|
||||||
|
```bash
|
||||||
|
curl -X POST http://192.168.1.50/api/relay -d state=off
|
||||||
|
```
|
||||||
|
|
||||||
|
- Toggle relay
|
||||||
|
```bash
|
||||||
|
curl -X POST http://192.168.1.50/api/relay -d state=toggle
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Requests use `application/x-www-form-urlencoded` by default when using `curl -d`.
|
||||||
|
|
||||||
|
## Task Management (optional)
|
||||||
|
The example registers a periodic task `relay_status_print` that logs the current relay state.
|
||||||
|
|
||||||
|
- Fetch all task statuses
|
||||||
|
```bash
|
||||||
|
curl http://192.168.1.50/api/tasks/status
|
||||||
|
```
|
||||||
|
|
||||||
|
- Query a specific task status
|
||||||
|
```bash
|
||||||
|
curl -X POST http://192.168.1.50/api/tasks/control \
|
||||||
|
-d task=relay_status_print -d action=status
|
||||||
|
```
|
||||||
|
|
||||||
|
- Enable / Disable the task
|
||||||
|
```bash
|
||||||
|
curl -X POST http://192.168.1.50/api/tasks/control \
|
||||||
|
-d task=relay_status_print -d action=enable
|
||||||
|
|
||||||
|
curl -X POST http://192.168.1.50/api/tasks/control \
|
||||||
|
-d task=relay_status_print -d action=disable
|
||||||
|
```
|
||||||
|
|
||||||
|
## General System Endpoints (optional)
|
||||||
|
- Node status
|
||||||
|
```bash
|
||||||
|
curl http://192.168.1.50/api/node/status
|
||||||
|
```
|
||||||
|
|
||||||
|
- Restart device
|
||||||
|
```bash
|
||||||
|
curl -X POST http://192.168.1.50/api/node/restart
|
||||||
|
```
|
||||||
89
examples/relay/RelayService.cpp
Normal file
89
examples/relay/RelayService.cpp
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
#include "RelayService.h"
|
||||||
|
#include "spore/core/ApiServer.h"
|
||||||
|
#include "spore/util/Logging.h"
|
||||||
|
|
||||||
|
RelayService::RelayService(NodeContext& ctx, TaskManager& taskMgr, int pin)
|
||||||
|
: ctx(ctx), taskManager(taskMgr), relayPin(pin), relayOn(false) {
|
||||||
|
pinMode(relayPin, OUTPUT);
|
||||||
|
// Many relay modules are active LOW. Start in OFF state (relay de-energized).
|
||||||
|
digitalWrite(relayPin, HIGH);
|
||||||
|
registerTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RelayService::registerEndpoints(ApiServer& api) {
|
||||||
|
api.addEndpoint("/api/relay/status", HTTP_GET,
|
||||||
|
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
|
||||||
|
std::vector<ParamSpec>{});
|
||||||
|
|
||||||
|
api.addEndpoint("/api/relay", HTTP_POST,
|
||||||
|
[this](AsyncWebServerRequest* request) { handleControlRequest(request); },
|
||||||
|
std::vector<ParamSpec>{
|
||||||
|
ParamSpec{String("state"), true, String("body"), String("string"),
|
||||||
|
{String("on"), String("off"), String("toggle")}}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void RelayService::handleStatusRequest(AsyncWebServerRequest* request) {
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["pin"] = relayPin;
|
||||||
|
doc["state"] = relayOn ? "on" : "off";
|
||||||
|
doc["uptime"] = millis();
|
||||||
|
|
||||||
|
String json;
|
||||||
|
serializeJson(doc, json);
|
||||||
|
request->send(200, "application/json", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
void RelayService::handleControlRequest(AsyncWebServerRequest* request) {
|
||||||
|
String state = request->hasParam("state", true) ? request->getParam("state", true)->value() : "";
|
||||||
|
bool ok = false;
|
||||||
|
|
||||||
|
if (state.equalsIgnoreCase("on")) {
|
||||||
|
turnOn();
|
||||||
|
ok = true;
|
||||||
|
} else if (state.equalsIgnoreCase("off")) {
|
||||||
|
turnOff();
|
||||||
|
ok = true;
|
||||||
|
} else if (state.equalsIgnoreCase("toggle")) {
|
||||||
|
toggle();
|
||||||
|
ok = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonDocument resp;
|
||||||
|
resp["success"] = ok;
|
||||||
|
resp["state"] = relayOn ? "on" : "off";
|
||||||
|
if (!ok) {
|
||||||
|
resp["message"] = "Invalid state. Use: on, off, or toggle";
|
||||||
|
}
|
||||||
|
|
||||||
|
String json;
|
||||||
|
serializeJson(resp, json);
|
||||||
|
request->send(ok ? 200 : 400, "application/json", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
void RelayService::turnOn() {
|
||||||
|
relayOn = true;
|
||||||
|
// Active LOW relay
|
||||||
|
digitalWrite(relayPin, LOW);
|
||||||
|
LOG_INFO("RelayService", "Relay ON");
|
||||||
|
}
|
||||||
|
|
||||||
|
void RelayService::turnOff() {
|
||||||
|
relayOn = false;
|
||||||
|
digitalWrite(relayPin, HIGH);
|
||||||
|
LOG_INFO("RelayService", "Relay OFF");
|
||||||
|
}
|
||||||
|
|
||||||
|
void RelayService::toggle() {
|
||||||
|
if (relayOn) {
|
||||||
|
turnOff();
|
||||||
|
} else {
|
||||||
|
turnOn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RelayService::registerTasks() {
|
||||||
|
taskManager.registerTask("relay_status_print", 5000, [this]() {
|
||||||
|
LOG_INFO("RelayService", "Status - pin: " + String(relayPin) + ", state: " + (relayOn ? "ON" : "OFF"));
|
||||||
|
});
|
||||||
|
}
|
||||||
27
examples/relay/RelayService.h
Normal file
27
examples/relay/RelayService.h
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "spore/Service.h"
|
||||||
|
#include "spore/core/TaskManager.h"
|
||||||
|
#include "spore/core/NodeContext.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
class RelayService : public Service {
|
||||||
|
public:
|
||||||
|
RelayService(NodeContext& ctx, TaskManager& taskMgr, int pin);
|
||||||
|
void registerEndpoints(ApiServer& api) override;
|
||||||
|
const char* getName() const override { return "Relay"; }
|
||||||
|
|
||||||
|
void turnOn();
|
||||||
|
void turnOff();
|
||||||
|
void toggle();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void registerTasks();
|
||||||
|
|
||||||
|
NodeContext& ctx;
|
||||||
|
TaskManager& taskManager;
|
||||||
|
int relayPin;
|
||||||
|
bool relayOn;
|
||||||
|
|
||||||
|
void handleStatusRequest(AsyncWebServerRequest* request);
|
||||||
|
void handleControlRequest(AsyncWebServerRequest* request);
|
||||||
|
};
|
||||||
305
examples/relay/data/public/index.html
Normal file
305
examples/relay/data/public/index.html
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Relay Control - Spore</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-size: 2.2em;
|
||||||
|
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-status {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.off {
|
||||||
|
background: rgba(255, 0, 0, 0.3);
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.on {
|
||||||
|
background: rgba(0, 255, 0, 0.3);
|
||||||
|
color: #51cf66;
|
||||||
|
box-shadow: 0 0 20px rgba(81, 207, 102, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-info {
|
||||||
|
font-size: 0.9em;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 25px;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
min-width: 100px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: white;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: rgba(81, 207, 102, 0.8);
|
||||||
|
color: white;
|
||||||
|
border: 2px solid rgba(81, 207, 102, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover {
|
||||||
|
background: rgba(81, 207, 102, 1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: rgba(255, 107, 107, 0.8);
|
||||||
|
color: white;
|
||||||
|
border: 2px solid rgba(255, 107, 107, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: rgba(255, 107, 107, 1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ff6b6b;
|
||||||
|
margin-top: 15px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: #51cf66;
|
||||||
|
margin-top: 15px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uptime {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>🔌 Relay Control</h1>
|
||||||
|
|
||||||
|
<div class="relay-status">
|
||||||
|
<div class="status-indicator off" id="statusIndicator">
|
||||||
|
OFF
|
||||||
|
</div>
|
||||||
|
<div class="status-text" id="statusText">Relay is OFF</div>
|
||||||
|
<div class="pin-info" id="pinInfo">Pin: Loading...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<button class="btn btn-success" id="turnOnBtn" onclick="controlRelay('on')">
|
||||||
|
Turn ON
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger" id="turnOffBtn" onclick="controlRelay('off')">
|
||||||
|
Turn OFF
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" id="toggleBtn" onclick="controlRelay('toggle')">
|
||||||
|
Toggle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="message"></div>
|
||||||
|
<div class="uptime" id="uptime"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentState = 'off';
|
||||||
|
let relayPin = '';
|
||||||
|
|
||||||
|
// Load initial relay status
|
||||||
|
async function loadRelayStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/relay/status');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch relay status');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
currentState = data.state;
|
||||||
|
relayPin = data.pin;
|
||||||
|
|
||||||
|
updateUI();
|
||||||
|
updateUptime(data.uptime);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading relay status:', error);
|
||||||
|
showMessage('Error loading relay status: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control relay
|
||||||
|
async function controlRelay(action) {
|
||||||
|
const buttons = document.querySelectorAll('.btn');
|
||||||
|
buttons.forEach(btn => btn.classList.add('loading'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/relay', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: `state=${action}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
currentState = data.state;
|
||||||
|
updateUI();
|
||||||
|
showMessage(`Relay turned ${data.state.toUpperCase()}`, 'success');
|
||||||
|
} else {
|
||||||
|
showMessage(data.message || 'Failed to control relay', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error controlling relay:', error);
|
||||||
|
showMessage('Error controlling relay: ' + error.message, 'error');
|
||||||
|
} finally {
|
||||||
|
buttons.forEach(btn => btn.classList.remove('loading'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update UI based on current state
|
||||||
|
function updateUI() {
|
||||||
|
const statusIndicator = document.getElementById('statusIndicator');
|
||||||
|
const statusText = document.getElementById('statusText');
|
||||||
|
const pinInfo = document.getElementById('pinInfo');
|
||||||
|
|
||||||
|
if (currentState === 'on') {
|
||||||
|
statusIndicator.className = 'status-indicator on';
|
||||||
|
statusIndicator.textContent = 'ON';
|
||||||
|
statusText.textContent = 'Relay is ON';
|
||||||
|
} else {
|
||||||
|
statusIndicator.className = 'status-indicator off';
|
||||||
|
statusIndicator.textContent = 'OFF';
|
||||||
|
statusText.textContent = 'Relay is OFF';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relayPin) {
|
||||||
|
pinInfo.textContent = `Pin: ${relayPin}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update uptime display
|
||||||
|
function updateUptime(uptime) {
|
||||||
|
const uptimeElement = document.getElementById('uptime');
|
||||||
|
if (uptime) {
|
||||||
|
const seconds = Math.floor(uptime / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
|
||||||
|
let uptimeText = `Uptime: ${seconds}s`;
|
||||||
|
if (hours > 0) {
|
||||||
|
uptimeText = `Uptime: ${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
uptimeText = `Uptime: ${minutes}m ${seconds % 60}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
uptimeElement.textContent = uptimeText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show message to user
|
||||||
|
function showMessage(message, type) {
|
||||||
|
const messageElement = document.getElementById('message');
|
||||||
|
messageElement.textContent = message;
|
||||||
|
messageElement.className = type;
|
||||||
|
|
||||||
|
// Clear message after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
messageElement.textContent = '';
|
||||||
|
messageElement.className = '';
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load status on page load
|
||||||
|
loadRelayStatus();
|
||||||
|
|
||||||
|
// Refresh status every 2 seconds
|
||||||
|
setInterval(loadRelayStatus, 2000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
177
examples/relay/data/public/script.js
Normal file
177
examples/relay/data/public/script.js
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
// Only initialize if not already done
|
||||||
|
if (!window.relayControlInitialized) {
|
||||||
|
class RelayControl {
|
||||||
|
constructor() {
|
||||||
|
this.currentState = 'off';
|
||||||
|
this.relayPin = '';
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.createComponent();
|
||||||
|
this.loadRelayStatus();
|
||||||
|
this.setupEventListeners();
|
||||||
|
|
||||||
|
// Refresh status every 2 seconds
|
||||||
|
setInterval(() => this.loadRelayStatus(), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
createComponent() {
|
||||||
|
// Create the main container
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'relay-component';
|
||||||
|
container.innerHTML = `
|
||||||
|
<h1>🔌 Relay Control</h1>
|
||||||
|
|
||||||
|
<div class="relay-status">
|
||||||
|
<div class="status-indicator off" id="statusIndicator">
|
||||||
|
OFF
|
||||||
|
</div>
|
||||||
|
<div class="status-text" id="statusText">Relay is OFF</div>
|
||||||
|
<div class="pin-info" id="pinInfo">Pin: Loading...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<button class="btn btn-success" id="turnOnBtn">
|
||||||
|
Turn ON
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger" id="turnOffBtn">
|
||||||
|
Turn OFF
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" id="toggleBtn">
|
||||||
|
Toggle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="message"></div>
|
||||||
|
<div class="uptime" id="uptime"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Append to component div
|
||||||
|
const componentDiv = document.getElementById('component');
|
||||||
|
if (componentDiv) {
|
||||||
|
componentDiv.appendChild(container);
|
||||||
|
} else {
|
||||||
|
// Fallback to body if component div not found
|
||||||
|
document.body.appendChild(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
document.getElementById('turnOnBtn').addEventListener('click', () => this.controlRelay('on'));
|
||||||
|
document.getElementById('turnOffBtn').addEventListener('click', () => this.controlRelay('off'));
|
||||||
|
document.getElementById('toggleBtn').addEventListener('click', () => this.controlRelay('toggle'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load initial relay status
|
||||||
|
async loadRelayStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/relay/status');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch relay status');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
this.currentState = data.state;
|
||||||
|
this.relayPin = data.pin;
|
||||||
|
|
||||||
|
this.updateUI();
|
||||||
|
this.updateUptime(data.uptime);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading relay status:', error);
|
||||||
|
this.showMessage('Error loading relay status: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control relay
|
||||||
|
async controlRelay(action) {
|
||||||
|
const buttons = document.querySelectorAll('.btn');
|
||||||
|
buttons.forEach(btn => btn.classList.add('loading'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/relay', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: `state=${action}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.currentState = data.state;
|
||||||
|
this.updateUI();
|
||||||
|
this.showMessage(`Relay turned ${data.state.toUpperCase()}`, 'success');
|
||||||
|
} else {
|
||||||
|
this.showMessage(data.message || 'Failed to control relay', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error controlling relay:', error);
|
||||||
|
this.showMessage('Error controlling relay: ' + error.message, 'error');
|
||||||
|
} finally {
|
||||||
|
buttons.forEach(btn => btn.classList.remove('loading'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update UI based on current state
|
||||||
|
updateUI() {
|
||||||
|
const statusIndicator = document.getElementById('statusIndicator');
|
||||||
|
const statusText = document.getElementById('statusText');
|
||||||
|
const pinInfo = document.getElementById('pinInfo');
|
||||||
|
|
||||||
|
if (this.currentState === 'on') {
|
||||||
|
statusIndicator.className = 'status-indicator on';
|
||||||
|
statusIndicator.textContent = 'ON';
|
||||||
|
statusText.textContent = 'Relay is ON';
|
||||||
|
} else {
|
||||||
|
statusIndicator.className = 'status-indicator off';
|
||||||
|
statusIndicator.textContent = 'OFF';
|
||||||
|
statusText.textContent = 'Relay is OFF';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.relayPin) {
|
||||||
|
pinInfo.textContent = `Pin: ${this.relayPin}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update uptime display
|
||||||
|
updateUptime(uptime) {
|
||||||
|
const uptimeElement = document.getElementById('uptime');
|
||||||
|
if (uptime) {
|
||||||
|
const seconds = Math.floor(uptime / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
|
||||||
|
let uptimeText = `Uptime: ${seconds}s`;
|
||||||
|
if (hours > 0) {
|
||||||
|
uptimeText = `Uptime: ${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
uptimeText = `Uptime: ${minutes}m ${seconds % 60}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
uptimeElement.textContent = uptimeText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show message to user
|
||||||
|
showMessage(message, type) {
|
||||||
|
const messageElement = document.getElementById('message');
|
||||||
|
messageElement.textContent = message;
|
||||||
|
messageElement.className = type;
|
||||||
|
|
||||||
|
// Clear message after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
messageElement.textContent = '';
|
||||||
|
messageElement.className = '';
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the relay control when the page loads
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
new RelayControl();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.relayControlInitialized = true;
|
||||||
|
}
|
||||||
141
examples/relay/data/public/styles.css
Normal file
141
examples/relay/data/public/styles.css
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
.relay-component {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
color: white;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-component h1 {
|
||||||
|
margin: 0 0 30px 0;
|
||||||
|
font-size: 2.2em;
|
||||||
|
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-component .relay-status {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-component .status-indicator {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-component .status-indicator.off {
|
||||||
|
background: rgba(255, 0, 0, 0.3);
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-component .status-indicator.on {
|
||||||
|
background: rgba(0, 255, 0, 0.3);
|
||||||
|
color: #51cf66;
|
||||||
|
box-shadow: 0 0 20px rgba(81, 207, 102, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-component .status-text {
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-component .pin-info {
|
||||||
|
font-size: 0.9em;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-component .controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-component .btn {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 25px;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
min-width: 100px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-component .btn-primary {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: #333;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.8);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-component .btn-primary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.4);
|
||||||
|
color: #222;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-component .btn-primary:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-component .btn-success {
|
||||||
|
background: rgba(81, 207, 102, 0.8);
|
||||||
|
color: white;
|
||||||
|
border: 2px solid rgba(81, 207, 102, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-component .btn-success:hover {
|
||||||
|
background: rgba(81, 207, 102, 1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-component .btn-danger {
|
||||||
|
background: rgba(255, 107, 107, 0.8);
|
||||||
|
color: white;
|
||||||
|
border: 2px solid rgba(255, 107, 107, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-component .btn-danger:hover {
|
||||||
|
background: rgba(255, 107, 107, 1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-component .loading {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-component .error {
|
||||||
|
color: #ff6b6b;
|
||||||
|
margin-top: 15px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-component .success {
|
||||||
|
color: #51cf66;
|
||||||
|
margin-top: 15px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-component .uptime {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
38
examples/relay/main.cpp
Normal file
38
examples/relay/main.cpp
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
#include <Arduino.h>
|
||||||
|
#include "spore/Spore.h"
|
||||||
|
#include "RelayService.h"
|
||||||
|
|
||||||
|
// Choose a default relay pin. For ESP-01 this is GPIO0. Adjust as needed for your board.
|
||||||
|
#ifndef RELAY_PIN
|
||||||
|
#define RELAY_PIN 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Create Spore instance with custom labels
|
||||||
|
Spore spore({
|
||||||
|
{"app", "relay"},
|
||||||
|
{"device", "actuator"},
|
||||||
|
{"pin", String(RELAY_PIN)}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create custom service
|
||||||
|
RelayService* relayService = nullptr;
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
// Initialize the Spore framework
|
||||||
|
spore.setup();
|
||||||
|
|
||||||
|
// Create and add custom service
|
||||||
|
relayService = new RelayService(spore.getContext(), spore.getTaskManager(), RELAY_PIN);
|
||||||
|
spore.addService(relayService);
|
||||||
|
|
||||||
|
// Start the API server and complete initialization
|
||||||
|
spore.begin();
|
||||||
|
|
||||||
|
LOG_INFO("Main", "Relay service registered and ready!");
|
||||||
|
LOG_INFO("Main", "Web interface available at http://<node-ip>/relay.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
// Run the Spore framework loop
|
||||||
|
spore.loop();
|
||||||
|
}
|
||||||
9
include/spore/Service.h
Normal file
9
include/spore/Service.h
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "spore/core/ApiServer.h"
|
||||||
|
|
||||||
|
class Service {
|
||||||
|
public:
|
||||||
|
virtual ~Service() = default;
|
||||||
|
virtual void registerEndpoints(ApiServer& api) = 0;
|
||||||
|
virtual const char* getName() const = 0;
|
||||||
|
};
|
||||||
59
include/spore/Spore.h
Normal file
59
include/spore/Spore.h
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
#include <memory>
|
||||||
|
#include <initializer_list>
|
||||||
|
#include <utility>
|
||||||
|
#include "core/NodeContext.h"
|
||||||
|
#include "core/NetworkManager.h"
|
||||||
|
#include "core/ClusterManager.h"
|
||||||
|
#include "core/ApiServer.h"
|
||||||
|
#include "core/TaskManager.h"
|
||||||
|
#include "Service.h"
|
||||||
|
#include "util/Logging.h"
|
||||||
|
#include "util/CpuUsage.h"
|
||||||
|
|
||||||
|
class Spore {
|
||||||
|
public:
|
||||||
|
Spore();
|
||||||
|
Spore(std::initializer_list<std::pair<String, String>> initialLabels);
|
||||||
|
~Spore();
|
||||||
|
|
||||||
|
// Core lifecycle methods
|
||||||
|
void setup();
|
||||||
|
void begin();
|
||||||
|
void loop();
|
||||||
|
|
||||||
|
// Service management
|
||||||
|
void addService(std::shared_ptr<Service> service);
|
||||||
|
void addService(Service* service);
|
||||||
|
|
||||||
|
// Access to core components
|
||||||
|
NodeContext& getContext() { return ctx; }
|
||||||
|
NetworkManager& getNetwork() { return network; }
|
||||||
|
TaskManager& getTaskManager() { return taskManager; }
|
||||||
|
ClusterManager& getCluster() { return cluster; }
|
||||||
|
ApiServer& getApiServer() { return apiServer; }
|
||||||
|
|
||||||
|
// CPU usage monitoring
|
||||||
|
CpuUsage& getCpuUsage() { return cpuUsage; }
|
||||||
|
float getCurrentCpuUsage() const { return cpuUsage.getCpuUsage(); }
|
||||||
|
float getAverageCpuUsage() const { return cpuUsage.getAverageCpuUsage(); }
|
||||||
|
|
||||||
|
|
||||||
|
private:
|
||||||
|
void initializeCore();
|
||||||
|
void registerCoreServices();
|
||||||
|
void startApiServer();
|
||||||
|
|
||||||
|
NodeContext ctx;
|
||||||
|
NetworkManager network;
|
||||||
|
TaskManager taskManager;
|
||||||
|
ClusterManager cluster;
|
||||||
|
ApiServer apiServer;
|
||||||
|
CpuUsage cpuUsage;
|
||||||
|
|
||||||
|
std::vector<std::shared_ptr<Service>> services;
|
||||||
|
bool initialized;
|
||||||
|
bool apiServerStarted;
|
||||||
|
};
|
||||||
54
include/spore/core/ApiServer.h
Normal file
54
include/spore/core/ApiServer.h
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <ESPAsyncWebServer.h>
|
||||||
|
#include <Updater.h>
|
||||||
|
#include <functional>
|
||||||
|
#include <vector>
|
||||||
|
#include <tuple>
|
||||||
|
|
||||||
|
#include "spore/core/NodeContext.h"
|
||||||
|
#include "spore/types/NodeInfo.h"
|
||||||
|
#include "spore/core/TaskManager.h"
|
||||||
|
#include "spore/types/ApiTypes.h"
|
||||||
|
#include "spore/util/Logging.h"
|
||||||
|
|
||||||
|
class Service; // Forward declaration
|
||||||
|
|
||||||
|
class ApiServer {
|
||||||
|
public:
|
||||||
|
ApiServer(NodeContext& ctx, TaskManager& taskMgr, uint16_t port = 80);
|
||||||
|
void begin();
|
||||||
|
void addService(Service& service);
|
||||||
|
void addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||||
|
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");
|
||||||
|
|
||||||
|
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 = "");
|
||||||
|
|
||||||
|
static const char* methodToStr(int method);
|
||||||
|
|
||||||
|
// Access to endpoints for endpoints endpoint
|
||||||
|
const std::vector<EndpointInfo>& getEndpoints() const { return endpoints; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
AsyncWebServer server;
|
||||||
|
NodeContext& ctx;
|
||||||
|
TaskManager& taskManager;
|
||||||
|
std::vector<std::reference_wrapper<Service>> services;
|
||||||
|
std::vector<EndpointInfo> endpoints; // Single source of truth for endpoints
|
||||||
|
|
||||||
|
// Internal helpers
|
||||||
|
void registerEndpoint(const String& uri, int method,
|
||||||
|
const std::vector<ParamSpec>& params,
|
||||||
|
const String& serviceName);
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include "Globals.h"
|
#include "spore/core/NodeContext.h"
|
||||||
#include "NodeContext.h"
|
#include "spore/types/NodeInfo.h"
|
||||||
#include "NodeInfo.h"
|
#include "spore/core/TaskManager.h"
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <ESP8266WiFi.h>
|
#include <ESP8266WiFi.h>
|
||||||
#include <ArduinoJson.h>
|
#include <ArduinoJson.h>
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
|
|
||||||
class ClusterManager {
|
class ClusterManager {
|
||||||
public:
|
public:
|
||||||
ClusterManager(NodeContext& ctx);
|
ClusterManager(NodeContext& ctx, TaskManager& taskMgr);
|
||||||
|
void registerTasks();
|
||||||
void sendDiscovery();
|
void sendDiscovery();
|
||||||
void listenForDiscovery();
|
void listenForDiscovery();
|
||||||
void addOrUpdateNode(const String& nodeHost, IPAddress nodeIP);
|
void addOrUpdateNode(const String& nodeHost, IPAddress nodeIP);
|
||||||
@@ -24,4 +25,5 @@ public:
|
|||||||
void updateAllMembersInfoTaskCallback();
|
void updateAllMembersInfoTaskCallback();
|
||||||
private:
|
private:
|
||||||
NodeContext& ctx;
|
NodeContext& ctx;
|
||||||
|
TaskManager& taskManager;
|
||||||
};
|
};
|
||||||
49
include/spore/core/NetworkManager.h
Normal file
49
include/spore/core/NetworkManager.h
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "spore/core/NodeContext.h"
|
||||||
|
#include <ESP8266WiFi.h>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
struct AccessPoint {
|
||||||
|
String ssid;
|
||||||
|
int32_t rssi;
|
||||||
|
uint8_t encryptionType;
|
||||||
|
uint8_t* bssid;
|
||||||
|
int32_t channel;
|
||||||
|
bool isHidden;
|
||||||
|
};
|
||||||
|
|
||||||
|
class NetworkManager {
|
||||||
|
public:
|
||||||
|
NetworkManager(NodeContext& ctx);
|
||||||
|
void setupWiFi();
|
||||||
|
void setHostnameFromMac();
|
||||||
|
|
||||||
|
// WiFi scanning methods
|
||||||
|
void scanWifi();
|
||||||
|
void processAccessPoints();
|
||||||
|
std::vector<AccessPoint> getAccessPoints() const;
|
||||||
|
|
||||||
|
// WiFi configuration methods
|
||||||
|
void setWiFiConfig(const String& ssid, const String& password,
|
||||||
|
uint32_t connect_timeout_ms = 10000,
|
||||||
|
uint32_t retry_delay_ms = 500);
|
||||||
|
|
||||||
|
// Network status methods
|
||||||
|
bool isConnected() const { return WiFi.isConnected(); }
|
||||||
|
String getSSID() const { return WiFi.SSID(); }
|
||||||
|
IPAddress getLocalIP() const { return WiFi.localIP(); }
|
||||||
|
String getMacAddress() const { return WiFi.macAddress(); }
|
||||||
|
String getHostname() const { return WiFi.hostname(); }
|
||||||
|
int32_t getRSSI() const { return WiFi.RSSI(); }
|
||||||
|
WiFiMode_t getMode() const { return WiFi.getMode(); }
|
||||||
|
|
||||||
|
// AP mode specific methods
|
||||||
|
IPAddress getAPIP() const { return WiFi.softAPIP(); }
|
||||||
|
String getAPMacAddress() const { return WiFi.softAPmacAddress(); }
|
||||||
|
uint8_t getConnectedStations() const { return WiFi.softAPgetStationNum(); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
NodeContext& ctx;
|
||||||
|
std::vector<AccessPoint> accessPoints;
|
||||||
|
bool isScanning = false;
|
||||||
|
};
|
||||||
@@ -1,20 +1,23 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <TaskSchedulerDeclarations.h>
|
|
||||||
#include <WiFiUdp.h>
|
#include <WiFiUdp.h>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include "NodeInfo.h"
|
#include "spore/types/NodeInfo.h"
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include "Config.h"
|
#include <initializer_list>
|
||||||
|
#include "spore/types/Config.h"
|
||||||
|
#include "spore/types/ApiTypes.h"
|
||||||
|
|
||||||
class NodeContext {
|
class NodeContext {
|
||||||
public:
|
public:
|
||||||
NodeContext();
|
NodeContext();
|
||||||
|
NodeContext(std::initializer_list<std::pair<String, String>> initialLabels);
|
||||||
~NodeContext();
|
~NodeContext();
|
||||||
Scheduler* scheduler;
|
|
||||||
WiFiUDP* udp;
|
WiFiUDP* udp;
|
||||||
String hostname;
|
String hostname;
|
||||||
IPAddress localIP;
|
IPAddress localIP;
|
||||||
|
NodeInfo self;
|
||||||
std::map<String, NodeInfo>* memberList;
|
std::map<String, NodeInfo>* memberList;
|
||||||
Config config;
|
Config config;
|
||||||
|
|
||||||
63
include/spore/core/TaskManager.h
Normal file
63
include/spore/core/TaskManager.h
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
#include <functional>
|
||||||
|
#include <string>
|
||||||
|
#include <map>
|
||||||
|
#include "spore/core/NodeContext.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
// Define our own callback type to avoid conflict with TaskScheduler
|
||||||
|
using TaskFunction = std::function<void()>;
|
||||||
|
|
||||||
|
struct TaskDefinition {
|
||||||
|
std::string name;
|
||||||
|
unsigned long interval;
|
||||||
|
TaskFunction callback;
|
||||||
|
bool enabled;
|
||||||
|
bool autoStart;
|
||||||
|
|
||||||
|
TaskDefinition(const std::string& n, unsigned long intv, TaskFunction cb, bool en = true, bool autoS = true)
|
||||||
|
: name(n), interval(intv), callback(cb), enabled(en), autoStart(autoS) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
class TaskManager {
|
||||||
|
public:
|
||||||
|
TaskManager(NodeContext& ctx);
|
||||||
|
~TaskManager();
|
||||||
|
|
||||||
|
// Task registration methods
|
||||||
|
void registerTask(const std::string& name, unsigned long interval, TaskFunction callback, bool enabled = true, bool autoStart = true);
|
||||||
|
void registerTask(const TaskDefinition& taskDef);
|
||||||
|
|
||||||
|
// Task control methods
|
||||||
|
void enableTask(const std::string& name);
|
||||||
|
void disableTask(const std::string& name);
|
||||||
|
void setTaskInterval(const std::string& name, unsigned long interval);
|
||||||
|
void startTask(const std::string& name);
|
||||||
|
void stopTask(const std::string& name);
|
||||||
|
|
||||||
|
// Task status methods
|
||||||
|
bool isTaskEnabled(const std::string& name) const;
|
||||||
|
bool isTaskRunning(const std::string& name) const;
|
||||||
|
unsigned long getTaskInterval(const std::string& name) const;
|
||||||
|
|
||||||
|
// Get comprehensive task status information
|
||||||
|
std::vector<std::pair<std::string, JsonObject>> getAllTaskStatuses(JsonDocument& doc) const;
|
||||||
|
|
||||||
|
// Management methods
|
||||||
|
void initialize();
|
||||||
|
void enableAllTasks();
|
||||||
|
void disableAllTasks();
|
||||||
|
void printTaskStatus() const;
|
||||||
|
|
||||||
|
// Task execution
|
||||||
|
void execute();
|
||||||
|
|
||||||
|
private:
|
||||||
|
NodeContext& ctx;
|
||||||
|
std::vector<TaskDefinition> taskDefinitions;
|
||||||
|
std::vector<unsigned long> lastExecutionTimes;
|
||||||
|
|
||||||
|
int findTaskIndex(const std::string& name) const;
|
||||||
|
};
|
||||||
16
include/spore/services/ClusterService.h
Normal file
16
include/spore/services/ClusterService.h
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "spore/Service.h"
|
||||||
|
#include "spore/core/NodeContext.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
class ClusterService : public Service {
|
||||||
|
public:
|
||||||
|
ClusterService(NodeContext& ctx);
|
||||||
|
void registerEndpoints(ApiServer& api) override;
|
||||||
|
const char* getName() const override { return "Cluster"; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
NodeContext& ctx;
|
||||||
|
|
||||||
|
void handleMembersRequest(AsyncWebServerRequest* request);
|
||||||
|
};
|
||||||
51
include/spore/services/MonitoringService.h
Normal file
51
include/spore/services/MonitoringService.h
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "spore/Service.h"
|
||||||
|
#include "spore/util/CpuUsage.h"
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
class MonitoringService : public Service {
|
||||||
|
public:
|
||||||
|
MonitoringService(CpuUsage& cpuUsage);
|
||||||
|
void registerEndpoints(ApiServer& api) override;
|
||||||
|
const char* getName() const override { return "Monitoring"; }
|
||||||
|
|
||||||
|
// System resource information
|
||||||
|
struct SystemResources {
|
||||||
|
// CPU information
|
||||||
|
float currentCpuUsage;
|
||||||
|
float averageCpuUsage;
|
||||||
|
float maxCpuUsage;
|
||||||
|
float minCpuUsage;
|
||||||
|
unsigned long measurementCount;
|
||||||
|
bool isMeasuring;
|
||||||
|
|
||||||
|
// Memory information
|
||||||
|
size_t freeHeap;
|
||||||
|
size_t totalHeap;
|
||||||
|
size_t minFreeHeap;
|
||||||
|
size_t maxAllocHeap;
|
||||||
|
size_t heapFragmentation;
|
||||||
|
|
||||||
|
// Filesystem information
|
||||||
|
size_t totalBytes;
|
||||||
|
size_t usedBytes;
|
||||||
|
size_t freeBytes;
|
||||||
|
float usagePercent;
|
||||||
|
|
||||||
|
// System uptime
|
||||||
|
unsigned long uptimeMs;
|
||||||
|
unsigned long uptimeSeconds;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get current system resources
|
||||||
|
SystemResources getSystemResources() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void handleResourcesRequest(AsyncWebServerRequest* request);
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
size_t calculateHeapFragmentation() const;
|
||||||
|
void getFilesystemInfo(size_t& totalBytes, size_t& usedBytes) const;
|
||||||
|
|
||||||
|
CpuUsage& cpuUsage;
|
||||||
|
};
|
||||||
22
include/spore/services/NetworkService.h
Normal file
22
include/spore/services/NetworkService.h
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "spore/Service.h"
|
||||||
|
#include "spore/core/NetworkManager.h"
|
||||||
|
#include "spore/core/NodeContext.h"
|
||||||
|
|
||||||
|
class NetworkService : public Service {
|
||||||
|
public:
|
||||||
|
NetworkService(NetworkManager& networkManager);
|
||||||
|
void registerEndpoints(ApiServer& api) override;
|
||||||
|
const char* getName() const override { return "Network"; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
NetworkManager& networkManager;
|
||||||
|
|
||||||
|
// WiFi scanning endpoints
|
||||||
|
void handleWifiScanRequest(AsyncWebServerRequest* request);
|
||||||
|
void handleGetWifiNetworks(AsyncWebServerRequest* request);
|
||||||
|
|
||||||
|
// Network status endpoints
|
||||||
|
void handleNetworkStatus(AsyncWebServerRequest* request);
|
||||||
|
void handleSetWifiConfig(AsyncWebServerRequest* request);
|
||||||
|
};
|
||||||
22
include/spore/services/NodeService.h
Normal file
22
include/spore/services/NodeService.h
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "spore/Service.h"
|
||||||
|
#include "spore/core/NodeContext.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <Updater.h>
|
||||||
|
|
||||||
|
class NodeService : public Service {
|
||||||
|
public:
|
||||||
|
NodeService(NodeContext& ctx, ApiServer& apiServer);
|
||||||
|
void registerEndpoints(ApiServer& api) override;
|
||||||
|
const char* getName() const override { return "Node"; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
NodeContext& ctx;
|
||||||
|
ApiServer& apiServer;
|
||||||
|
|
||||||
|
void handleStatusRequest(AsyncWebServerRequest* request);
|
||||||
|
void handleUpdateRequest(AsyncWebServerRequest* request);
|
||||||
|
void handleUpdateUpload(AsyncWebServerRequest* request, const String& filename, size_t index, uint8_t* data, size_t len, bool final);
|
||||||
|
void handleRestartRequest(AsyncWebServerRequest* request);
|
||||||
|
void handleEndpointsRequest(AsyncWebServerRequest* request);
|
||||||
|
};
|
||||||
18
include/spore/services/StaticFileService.h
Normal file
18
include/spore/services/StaticFileService.h
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "spore/Service.h"
|
||||||
|
#include "spore/core/ApiServer.h"
|
||||||
|
#include "spore/core/NodeContext.h"
|
||||||
|
#include <FS.h>
|
||||||
|
#include <LittleFS.h>
|
||||||
|
|
||||||
|
class StaticFileService : public Service {
|
||||||
|
public:
|
||||||
|
StaticFileService(NodeContext& ctx, ApiServer& apiServer);
|
||||||
|
void registerEndpoints(ApiServer& api) override;
|
||||||
|
const char* getName() const override { return name.c_str(); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
static const String name;
|
||||||
|
NodeContext& ctx;
|
||||||
|
ApiServer& apiServer;
|
||||||
|
};
|
||||||
17
include/spore/services/TaskService.h
Normal file
17
include/spore/services/TaskService.h
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "spore/Service.h"
|
||||||
|
#include "spore/core/TaskManager.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
class TaskService : public Service {
|
||||||
|
public:
|
||||||
|
TaskService(TaskManager& taskManager);
|
||||||
|
void registerEndpoints(ApiServer& api) override;
|
||||||
|
const char* getName() const override { return "Task"; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
TaskManager& taskManager;
|
||||||
|
|
||||||
|
void handleStatusRequest(AsyncWebServerRequest* request);
|
||||||
|
void handleControlRequest(AsyncWebServerRequest* request);
|
||||||
|
};
|
||||||
114
include/spore/types/ApiResponse.h
Normal file
114
include/spore/types/ApiResponse.h
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "spore/util/JsonSerializable.h"
|
||||||
|
#include "spore/util/Logging.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
namespace spore {
|
||||||
|
namespace types {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for API responses that can be serialized to JSON
|
||||||
|
* Handles complete JsonDocument creation and serialization
|
||||||
|
*/
|
||||||
|
class ApiResponse {
|
||||||
|
protected:
|
||||||
|
mutable JsonDocument doc;
|
||||||
|
|
||||||
|
public:
|
||||||
|
virtual ~ApiResponse() = default;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the complete JSON string representation of this response
|
||||||
|
* @return JSON string ready to send as HTTP response
|
||||||
|
*/
|
||||||
|
virtual String toJsonString() const {
|
||||||
|
String json;
|
||||||
|
serializeJson(doc, json);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the JsonDocument for direct manipulation if needed
|
||||||
|
* @return Reference to the internal JsonDocument
|
||||||
|
*/
|
||||||
|
JsonDocument& getDocument() { return doc; }
|
||||||
|
const JsonDocument& getDocument() const { return doc; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the document and reset for reuse
|
||||||
|
*/
|
||||||
|
virtual void clear() {
|
||||||
|
doc.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for API responses that contain a collection of serializable items
|
||||||
|
*/
|
||||||
|
template<typename ItemType>
|
||||||
|
class CollectionResponse : public ApiResponse {
|
||||||
|
protected:
|
||||||
|
String collectionKey;
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit CollectionResponse(const String& key) : collectionKey(key) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a serializable item to the collection
|
||||||
|
* @param item The serializable item to add
|
||||||
|
*/
|
||||||
|
void addItem(const util::JsonSerializable& item) {
|
||||||
|
// Ensure the array exists and get a reference to it
|
||||||
|
if (!doc[collectionKey].is<JsonArray>()) {
|
||||||
|
doc[collectionKey] = JsonArray();
|
||||||
|
}
|
||||||
|
JsonArray arr = doc[collectionKey].as<JsonArray>();
|
||||||
|
JsonObject obj = arr.add<JsonObject>();
|
||||||
|
item.toJson(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a serializable item to the collection (move version)
|
||||||
|
* @param item The serializable item to add
|
||||||
|
*/
|
||||||
|
void addItem(util::JsonSerializable&& item) {
|
||||||
|
// Ensure the array exists and get a reference to it
|
||||||
|
if (!doc[collectionKey].is<JsonArray>()) {
|
||||||
|
doc[collectionKey] = JsonArray();
|
||||||
|
}
|
||||||
|
JsonArray arr = doc[collectionKey].as<JsonArray>();
|
||||||
|
JsonObject obj = arr.add<JsonObject>();
|
||||||
|
item.toJson(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add multiple items from a container
|
||||||
|
* @param items Container of serializable items
|
||||||
|
*/
|
||||||
|
template<typename Container>
|
||||||
|
void addItems(const Container& items) {
|
||||||
|
// Ensure the array exists and get a reference to it
|
||||||
|
if (!doc[collectionKey].is<JsonArray>()) {
|
||||||
|
doc[collectionKey] = JsonArray();
|
||||||
|
}
|
||||||
|
JsonArray arr = doc[collectionKey].as<JsonArray>();
|
||||||
|
for (const auto& item : items) {
|
||||||
|
JsonObject obj = arr.add<JsonObject>();
|
||||||
|
item.toJson(obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current number of items in the collection
|
||||||
|
* @return Number of items
|
||||||
|
*/
|
||||||
|
size_t getItemCount() const {
|
||||||
|
if (doc[collectionKey].is<JsonArray>()) {
|
||||||
|
return doc[collectionKey].as<JsonArray>().size();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace types
|
||||||
|
} // namespace spore
|
||||||
37
include/spore/types/ApiTypes.h
Normal file
37
include/spore/types/ApiTypes.h
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
struct ParamSpec {
|
||||||
|
String name;
|
||||||
|
bool required;
|
||||||
|
String location; // "query" | "body" | "path" | "header"
|
||||||
|
String type; // e.g. "string", "number", "boolean"
|
||||||
|
std::vector<String> values; // optional allowed values
|
||||||
|
String defaultValue; // optional default value (stringified)
|
||||||
|
};
|
||||||
|
|
||||||
|
struct EndpointCapability {
|
||||||
|
String uri;
|
||||||
|
int method;
|
||||||
|
std::vector<ParamSpec> params;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct EndpointInfo {
|
||||||
|
String uri;
|
||||||
|
int method;
|
||||||
|
std::vector<ParamSpec> params;
|
||||||
|
String serviceName; // Name of the service that registered this endpoint
|
||||||
|
bool isLocal; // Whether this endpoint is on the local node
|
||||||
|
|
||||||
|
// Constructor for individual parameters
|
||||||
|
EndpointInfo(const String& u, int m, const std::vector<ParamSpec>& p, const String& service, bool local)
|
||||||
|
: uri(u), method(m), params(p), serviceName(service), isLocal(local) {}
|
||||||
|
|
||||||
|
// Constructor for easy conversion from EndpointCapability
|
||||||
|
EndpointInfo(const EndpointCapability& cap, const String& service = "", bool local = true)
|
||||||
|
: uri(cap.uri), method(cap.method), params(cap.params), serviceName(service), isLocal(local) {}
|
||||||
|
|
||||||
|
// Default constructor
|
||||||
|
EndpointInfo() : isLocal(true) {}
|
||||||
|
};
|
||||||
47
include/spore/types/ClusterResponse.h
Normal file
47
include/spore/types/ClusterResponse.h
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "ApiResponse.h"
|
||||||
|
#include "NodeInfoSerializable.h"
|
||||||
|
#include <map>
|
||||||
|
|
||||||
|
namespace spore {
|
||||||
|
namespace types {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response class for cluster members endpoint
|
||||||
|
* Handles complete JSON document creation for cluster member data
|
||||||
|
*/
|
||||||
|
class ClusterMembersResponse : public CollectionResponse<NodeInfoSerializable> {
|
||||||
|
public:
|
||||||
|
ClusterMembersResponse() : CollectionResponse("members") {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a single node to the response
|
||||||
|
* @param node The NodeInfo to add
|
||||||
|
*/
|
||||||
|
void addNode(const NodeInfo& node) {
|
||||||
|
addItem(NodeInfoSerializable(const_cast<NodeInfo&>(node)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add multiple nodes from a member list
|
||||||
|
* @param memberList Map of hostname to NodeInfo
|
||||||
|
*/
|
||||||
|
void addNodes(const std::map<String, NodeInfo>& memberList) {
|
||||||
|
for (const auto& pair : memberList) {
|
||||||
|
addNode(pair.second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add nodes from a pointer to member list
|
||||||
|
* @param memberList Pointer to map of hostname to NodeInfo
|
||||||
|
*/
|
||||||
|
void addNodes(const std::map<String, NodeInfo>* memberList) {
|
||||||
|
if (memberList) {
|
||||||
|
addNodes(*memberList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace types
|
||||||
|
} // namespace spore
|
||||||
@@ -32,6 +32,11 @@ public:
|
|||||||
unsigned long restart_delay_ms;
|
unsigned long restart_delay_ms;
|
||||||
uint16_t json_doc_size;
|
uint16_t json_doc_size;
|
||||||
|
|
||||||
|
// Memory Management
|
||||||
|
uint32_t low_memory_threshold_bytes;
|
||||||
|
uint32_t critical_memory_threshold_bytes;
|
||||||
|
size_t max_concurrent_http_requests;
|
||||||
|
|
||||||
// Constructor
|
// Constructor
|
||||||
Config();
|
Config();
|
||||||
};
|
};
|
||||||
76
include/spore/types/EndpointInfoSerializable.h
Normal file
76
include/spore/types/EndpointInfoSerializable.h
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "ApiTypes.h"
|
||||||
|
#include "spore/util/JsonSerializable.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
namespace spore {
|
||||||
|
namespace types {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializable wrapper for EndpointInfo that implements JsonSerializable interface
|
||||||
|
* Handles conversion between EndpointInfo struct and JSON representation
|
||||||
|
*/
|
||||||
|
class EndpointInfoSerializable : public util::JsonSerializable {
|
||||||
|
private:
|
||||||
|
const EndpointInfo& endpoint;
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit EndpointInfoSerializable(const EndpointInfo& ep) : endpoint(ep) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize EndpointInfo to JsonObject
|
||||||
|
*/
|
||||||
|
void toJson(JsonObject& obj) const override {
|
||||||
|
obj["uri"] = endpoint.uri;
|
||||||
|
obj["method"] = endpoint.method;
|
||||||
|
|
||||||
|
// Add parameters if present
|
||||||
|
if (!endpoint.params.empty()) {
|
||||||
|
JsonArray paramsArr = obj["params"].to<JsonArray>();
|
||||||
|
for (const auto& param : endpoint.params) {
|
||||||
|
JsonObject paramObj = paramsArr.add<JsonObject>();
|
||||||
|
paramObj["name"] = param.name;
|
||||||
|
paramObj["location"] = param.location;
|
||||||
|
paramObj["required"] = param.required;
|
||||||
|
paramObj["type"] = param.type;
|
||||||
|
|
||||||
|
if (!param.values.empty()) {
|
||||||
|
JsonArray valuesArr = paramObj["values"].to<JsonArray>();
|
||||||
|
for (const auto& value : param.values) {
|
||||||
|
valuesArr.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (param.defaultValue.length() > 0) {
|
||||||
|
paramObj["default"] = param.defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize EndpointInfo from JsonObject
|
||||||
|
*/
|
||||||
|
void fromJson(const JsonObject& obj) override {
|
||||||
|
// Note: This would require modifying the EndpointInfo struct to be mutable
|
||||||
|
// For now, this is a placeholder as EndpointInfo is typically read-only
|
||||||
|
// in the context where this serialization is used
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience function to create a JsonArray from a collection of EndpointInfo objects
|
||||||
|
*/
|
||||||
|
template<typename Container>
|
||||||
|
JsonArray endpointInfoToJsonArray(JsonDocument& doc, const Container& endpoints) {
|
||||||
|
JsonArray arr = doc.to<JsonArray>();
|
||||||
|
for (const auto& endpoint : endpoints) {
|
||||||
|
EndpointInfoSerializable serializable(endpoint);
|
||||||
|
JsonObject obj = arr.add<JsonObject>();
|
||||||
|
serializable.toJson(obj);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace types
|
||||||
|
} // namespace spore
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include "Globals.h"
|
|
||||||
#include <IPAddress.h>
|
#include <IPAddress.h>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <tuple>
|
#include <tuple>
|
||||||
|
#include <map>
|
||||||
|
#include "ApiTypes.h"
|
||||||
|
|
||||||
struct NodeInfo {
|
struct NodeInfo {
|
||||||
String hostname;
|
String hostname;
|
||||||
@@ -17,7 +18,8 @@ struct NodeInfo {
|
|||||||
uint32_t flashChipSize = 0;
|
uint32_t flashChipSize = 0;
|
||||||
} resources;
|
} resources;
|
||||||
unsigned long latency = 0; // ms since lastSeen
|
unsigned long latency = 0; // ms since lastSeen
|
||||||
std::vector<std::tuple<String, int>> apiEndpoints; // List of registered endpoints
|
std::vector<EndpointInfo> endpoints; // List of registered endpoints
|
||||||
|
std::map<String, String> labels; // Arbitrary node labels (key -> value)
|
||||||
};
|
};
|
||||||
|
|
||||||
const char* statusToStr(NodeInfo::Status status);
|
const char* statusToStr(NodeInfo::Status status);
|
||||||
141
include/spore/types/NodeInfoSerializable.h
Normal file
141
include/spore/types/NodeInfoSerializable.h
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "NodeInfo.h"
|
||||||
|
#include "ApiTypes.h"
|
||||||
|
#include "spore/util/JsonSerializable.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
namespace spore {
|
||||||
|
namespace types {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializable wrapper for NodeInfo that implements JsonSerializable interface
|
||||||
|
* Handles conversion between NodeInfo struct and JSON representation
|
||||||
|
*/
|
||||||
|
class NodeInfoSerializable : public util::JsonSerializable {
|
||||||
|
private:
|
||||||
|
NodeInfo& nodeInfo;
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit NodeInfoSerializable(NodeInfo& node) : nodeInfo(node) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize NodeInfo to JsonObject
|
||||||
|
* Maps all NodeInfo fields to appropriate JSON structure
|
||||||
|
*/
|
||||||
|
void toJson(JsonObject& obj) const override {
|
||||||
|
obj["hostname"] = nodeInfo.hostname;
|
||||||
|
obj["ip"] = nodeInfo.ip.toString();
|
||||||
|
obj["lastSeen"] = nodeInfo.lastSeen;
|
||||||
|
obj["latency"] = nodeInfo.latency;
|
||||||
|
obj["status"] = statusToStr(nodeInfo.status);
|
||||||
|
|
||||||
|
// Serialize resources
|
||||||
|
JsonObject resources = obj["resources"].to<JsonObject>();
|
||||||
|
resources["freeHeap"] = nodeInfo.resources.freeHeap;
|
||||||
|
resources["chipId"] = nodeInfo.resources.chipId;
|
||||||
|
resources["sdkVersion"] = nodeInfo.resources.sdkVersion;
|
||||||
|
resources["cpuFreqMHz"] = nodeInfo.resources.cpuFreqMHz;
|
||||||
|
resources["flashChipSize"] = nodeInfo.resources.flashChipSize;
|
||||||
|
|
||||||
|
// Serialize labels if present
|
||||||
|
if (!nodeInfo.labels.empty()) {
|
||||||
|
JsonObject labels = obj["labels"].to<JsonObject>();
|
||||||
|
for (const auto& kv : nodeInfo.labels) {
|
||||||
|
labels[kv.first.c_str()] = kv.second;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize endpoints if present
|
||||||
|
if (!nodeInfo.endpoints.empty()) {
|
||||||
|
JsonArray endpoints = obj["api"].to<JsonArray>();
|
||||||
|
for (const auto& endpoint : nodeInfo.endpoints) {
|
||||||
|
JsonObject endpointObj = endpoints.add<JsonObject>();
|
||||||
|
endpointObj["uri"] = endpoint.uri;
|
||||||
|
endpointObj["method"] = endpoint.method;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize NodeInfo from JsonObject
|
||||||
|
* Populates NodeInfo fields from JSON structure
|
||||||
|
*/
|
||||||
|
void fromJson(const JsonObject& obj) override {
|
||||||
|
nodeInfo.hostname = obj["hostname"].as<String>();
|
||||||
|
|
||||||
|
// Parse IP address
|
||||||
|
const char* ipStr = obj["ip"];
|
||||||
|
if (ipStr) {
|
||||||
|
nodeInfo.ip.fromString(ipStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeInfo.lastSeen = obj["lastSeen"].as<unsigned long>();
|
||||||
|
nodeInfo.latency = obj["latency"].as<unsigned long>();
|
||||||
|
|
||||||
|
// Parse status
|
||||||
|
const char* statusStr = obj["status"];
|
||||||
|
if (statusStr) {
|
||||||
|
if (strcmp(statusStr, "ACTIVE") == 0) {
|
||||||
|
nodeInfo.status = NodeInfo::ACTIVE;
|
||||||
|
} else if (strcmp(statusStr, "INACTIVE") == 0) {
|
||||||
|
nodeInfo.status = NodeInfo::INACTIVE;
|
||||||
|
} else if (strcmp(statusStr, "DEAD") == 0) {
|
||||||
|
nodeInfo.status = NodeInfo::DEAD;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse resources
|
||||||
|
if (obj["resources"].is<JsonObject>()) {
|
||||||
|
JsonObject resources = obj["resources"].as<JsonObject>();
|
||||||
|
nodeInfo.resources.freeHeap = resources["freeHeap"].as<uint32_t>();
|
||||||
|
nodeInfo.resources.chipId = resources["chipId"].as<uint32_t>();
|
||||||
|
nodeInfo.resources.sdkVersion = resources["sdkVersion"].as<String>();
|
||||||
|
nodeInfo.resources.cpuFreqMHz = resources["cpuFreqMHz"].as<uint32_t>();
|
||||||
|
nodeInfo.resources.flashChipSize = resources["flashChipSize"].as<uint32_t>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse labels
|
||||||
|
nodeInfo.labels.clear();
|
||||||
|
if (obj["labels"].is<JsonObject>()) {
|
||||||
|
JsonObject labels = obj["labels"].as<JsonObject>();
|
||||||
|
for (JsonPair kvp : labels) {
|
||||||
|
nodeInfo.labels[kvp.key().c_str()] = kvp.value().as<String>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse endpoints
|
||||||
|
nodeInfo.endpoints.clear();
|
||||||
|
if (obj["api"].is<JsonArray>()) {
|
||||||
|
JsonArray endpoints = obj["api"].as<JsonArray>();
|
||||||
|
for (JsonObject endpointObj : endpoints) {
|
||||||
|
EndpointInfo endpoint;
|
||||||
|
endpoint.uri = endpointObj["uri"].as<String>();
|
||||||
|
endpoint.method = endpointObj["method"].as<int>();
|
||||||
|
endpoint.isLocal = false;
|
||||||
|
endpoint.serviceName = "remote";
|
||||||
|
nodeInfo.endpoints.push_back(std::move(endpoint));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience function to create a JsonArray from a collection of NodeInfo objects
|
||||||
|
* @param doc The JsonDocument to create the array in
|
||||||
|
* @param nodes Collection of NodeInfo objects
|
||||||
|
* @return A JsonArray containing all serialized NodeInfo objects
|
||||||
|
*/
|
||||||
|
template<typename Container>
|
||||||
|
JsonArray nodeInfoToJsonArray(JsonDocument& doc, const Container& nodes) {
|
||||||
|
JsonArray arr = doc.to<JsonArray>();
|
||||||
|
for (const auto& pair : nodes) {
|
||||||
|
const NodeInfo& node = pair.second;
|
||||||
|
NodeInfoSerializable serializable(const_cast<NodeInfo&>(node));
|
||||||
|
JsonObject obj = arr.add<JsonObject>();
|
||||||
|
serializable.toJson(obj);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace types
|
||||||
|
} // namespace spore
|
||||||
102
include/spore/types/NodeResponse.h
Normal file
102
include/spore/types/NodeResponse.h
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "ApiResponse.h"
|
||||||
|
#include "EndpointInfoSerializable.h"
|
||||||
|
#include "NodeInfo.h"
|
||||||
|
#include "spore/util/Logging.h"
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace spore {
|
||||||
|
namespace types {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response class for node status endpoint
|
||||||
|
* Handles complete JSON document creation for node status data
|
||||||
|
*/
|
||||||
|
class NodeStatusResponse : public ApiResponse {
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* Set basic system information
|
||||||
|
*/
|
||||||
|
void setSystemInfo() {
|
||||||
|
doc["freeHeap"] = ESP.getFreeHeap();
|
||||||
|
doc["chipId"] = ESP.getChipId();
|
||||||
|
doc["sdkVersion"] = ESP.getSdkVersion();
|
||||||
|
doc["cpuFreqMHz"] = ESP.getCpuFreqMHz();
|
||||||
|
doc["flashChipSize"] = ESP.getFlashChipSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add labels to the response
|
||||||
|
* @param labels Map of label key-value pairs
|
||||||
|
*/
|
||||||
|
void addLabels(const std::map<String, String>& labels) {
|
||||||
|
if (!labels.empty()) {
|
||||||
|
JsonObject labelsObj = doc["labels"].to<JsonObject>();
|
||||||
|
for (const auto& kv : labels) {
|
||||||
|
labelsObj[kv.first.c_str()] = kv.second;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build complete response with system info and labels
|
||||||
|
* @param labels Optional labels to include
|
||||||
|
*/
|
||||||
|
void buildCompleteResponse(const std::map<String, String>& labels = {}) {
|
||||||
|
setSystemInfo();
|
||||||
|
addLabels(labels);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response class for node endpoints endpoint
|
||||||
|
* Handles complete JSON document creation for endpoint data
|
||||||
|
*/
|
||||||
|
class NodeEndpointsResponse : public CollectionResponse<EndpointInfoSerializable> {
|
||||||
|
public:
|
||||||
|
NodeEndpointsResponse() : CollectionResponse("endpoints") {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a single endpoint to the response
|
||||||
|
* @param endpoint The EndpointInfo to add
|
||||||
|
*/
|
||||||
|
void addEndpoint(const EndpointInfo& endpoint) {
|
||||||
|
addItem(EndpointInfoSerializable(endpoint));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add multiple endpoints from a container
|
||||||
|
* @param endpoints Container of EndpointInfo objects
|
||||||
|
*/
|
||||||
|
void addEndpoints(const std::vector<EndpointInfo>& endpoints) {
|
||||||
|
for (const auto& endpoint : endpoints) {
|
||||||
|
addEndpoint(endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response class for simple status operations (update, restart)
|
||||||
|
* Handles simple JSON responses for node operations
|
||||||
|
*/
|
||||||
|
class NodeOperationResponse : public ApiResponse {
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* Set success response
|
||||||
|
* @param status Status message
|
||||||
|
*/
|
||||||
|
void setSuccess(const String& status) {
|
||||||
|
doc["status"] = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set error response
|
||||||
|
* @param status Error status message
|
||||||
|
*/
|
||||||
|
void setError(const String& status) {
|
||||||
|
doc["status"] = status;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace types
|
||||||
|
} // namespace spore
|
||||||
99
include/spore/types/TaskInfoSerializable.h
Normal file
99
include/spore/types/TaskInfoSerializable.h
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "spore/util/JsonSerializable.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
namespace spore {
|
||||||
|
namespace types {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializable wrapper for task information that implements JsonSerializable interface
|
||||||
|
* Handles conversion between task data and JSON representation
|
||||||
|
*/
|
||||||
|
class TaskInfoSerializable : public util::JsonSerializable {
|
||||||
|
private:
|
||||||
|
String taskName;
|
||||||
|
const JsonObject& taskData;
|
||||||
|
|
||||||
|
public:
|
||||||
|
TaskInfoSerializable(const String& name, const JsonObject& data)
|
||||||
|
: taskName(name), taskData(data) {}
|
||||||
|
|
||||||
|
TaskInfoSerializable(const std::string& name, const JsonObject& data)
|
||||||
|
: taskName(name.c_str()), taskData(data) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize task info to JsonObject
|
||||||
|
*/
|
||||||
|
void toJson(JsonObject& obj) const override {
|
||||||
|
obj["name"] = taskName;
|
||||||
|
obj["interval"] = taskData["interval"];
|
||||||
|
obj["enabled"] = taskData["enabled"];
|
||||||
|
obj["running"] = taskData["running"];
|
||||||
|
obj["autoStart"] = taskData["autoStart"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize task info from JsonObject
|
||||||
|
* Note: This is read-only for task status, so fromJson is not implemented
|
||||||
|
*/
|
||||||
|
void fromJson(const JsonObject& obj) override {
|
||||||
|
// Task info is typically read-only in this context
|
||||||
|
// Implementation would go here if needed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializable wrapper for system information
|
||||||
|
*/
|
||||||
|
class SystemInfoSerializable : public util::JsonSerializable {
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* Serialize system info to JsonObject
|
||||||
|
*/
|
||||||
|
void toJson(JsonObject& obj) const override {
|
||||||
|
obj["freeHeap"] = ESP.getFreeHeap();
|
||||||
|
obj["uptime"] = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize system info from JsonObject
|
||||||
|
* Note: System info is typically read-only, so fromJson is not implemented
|
||||||
|
*/
|
||||||
|
void fromJson(const JsonObject& obj) override {
|
||||||
|
// System info is typically read-only
|
||||||
|
// Implementation would go here if needed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializable wrapper for task summary information
|
||||||
|
*/
|
||||||
|
class TaskSummarySerializable : public util::JsonSerializable {
|
||||||
|
private:
|
||||||
|
size_t totalTasks;
|
||||||
|
size_t activeTasks;
|
||||||
|
|
||||||
|
public:
|
||||||
|
TaskSummarySerializable(size_t total, size_t active)
|
||||||
|
: totalTasks(total), activeTasks(active) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize task summary to JsonObject
|
||||||
|
*/
|
||||||
|
void toJson(JsonObject& obj) const override {
|
||||||
|
obj["totalTasks"] = totalTasks;
|
||||||
|
obj["activeTasks"] = activeTasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize task summary from JsonObject
|
||||||
|
*/
|
||||||
|
void fromJson(const JsonObject& obj) override {
|
||||||
|
totalTasks = obj["totalTasks"].as<size_t>();
|
||||||
|
activeTasks = obj["activeTasks"].as<size_t>();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace types
|
||||||
|
} // namespace spore
|
||||||
194
include/spore/types/TaskResponse.h
Normal file
194
include/spore/types/TaskResponse.h
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "ApiResponse.h"
|
||||||
|
#include "TaskInfoSerializable.h"
|
||||||
|
#include <map>
|
||||||
|
|
||||||
|
namespace spore {
|
||||||
|
namespace types {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response class for task status endpoint
|
||||||
|
* Handles complete JSON document creation for task status data
|
||||||
|
*/
|
||||||
|
class TaskStatusResponse : public ApiResponse {
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* Set the task summary information
|
||||||
|
* @param totalTasks Total number of tasks
|
||||||
|
* @param activeTasks Number of active tasks
|
||||||
|
*/
|
||||||
|
void setSummary(size_t totalTasks, size_t activeTasks) {
|
||||||
|
TaskSummarySerializable summary(totalTasks, activeTasks);
|
||||||
|
JsonObject summaryObj = doc["summary"].to<JsonObject>();
|
||||||
|
summary.toJson(summaryObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a single task to the response
|
||||||
|
* @param taskName Name of the task
|
||||||
|
* @param taskData Task data as JsonObject
|
||||||
|
*/
|
||||||
|
void addTask(const String& taskName, const JsonObject& taskData) {
|
||||||
|
TaskInfoSerializable serializable(taskName, taskData);
|
||||||
|
if (!doc["tasks"].is<JsonArray>()) {
|
||||||
|
doc["tasks"] = JsonArray();
|
||||||
|
}
|
||||||
|
JsonArray tasksArr = doc["tasks"].as<JsonArray>();
|
||||||
|
JsonObject taskObj = tasksArr.add<JsonObject>();
|
||||||
|
serializable.toJson(taskObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a single task to the response (std::string version)
|
||||||
|
* @param taskName Name of the task
|
||||||
|
* @param taskData Task data as JsonObject
|
||||||
|
*/
|
||||||
|
void addTask(const std::string& taskName, const JsonObject& taskData) {
|
||||||
|
TaskInfoSerializable serializable(taskName, taskData);
|
||||||
|
if (!doc["tasks"].is<JsonArray>()) {
|
||||||
|
doc["tasks"] = JsonArray();
|
||||||
|
}
|
||||||
|
JsonArray tasksArr = doc["tasks"].as<JsonArray>();
|
||||||
|
JsonObject taskObj = tasksArr.add<JsonObject>();
|
||||||
|
serializable.toJson(taskObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add multiple tasks from a task statuses map
|
||||||
|
* @param taskStatuses Map of task name to task data
|
||||||
|
*/
|
||||||
|
void addTasks(const std::map<String, JsonObject>& taskStatuses) {
|
||||||
|
for (const auto& pair : taskStatuses) {
|
||||||
|
addTask(pair.first, pair.second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the system information
|
||||||
|
*/
|
||||||
|
void setSystemInfo() {
|
||||||
|
SystemInfoSerializable systemInfo;
|
||||||
|
JsonObject systemObj = doc["system"].to<JsonObject>();
|
||||||
|
systemInfo.toJson(systemObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build complete response with all components
|
||||||
|
* @param taskStatuses Map of task name to task data
|
||||||
|
*/
|
||||||
|
void buildCompleteResponse(const std::map<String, JsonObject>& taskStatuses) {
|
||||||
|
// Set summary
|
||||||
|
size_t totalTasks = taskStatuses.size();
|
||||||
|
size_t activeTasks = 0;
|
||||||
|
for (const auto& pair : taskStatuses) {
|
||||||
|
if (pair.second["enabled"]) {
|
||||||
|
activeTasks++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSummary(totalTasks, activeTasks);
|
||||||
|
|
||||||
|
// Add all tasks
|
||||||
|
addTasks(taskStatuses);
|
||||||
|
|
||||||
|
// Set system info
|
||||||
|
setSystemInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build complete response with all components from vector
|
||||||
|
* @param taskStatuses Vector of pairs of task name to task data
|
||||||
|
*/
|
||||||
|
void buildCompleteResponse(const std::vector<std::pair<std::string, JsonObject>>& taskStatuses) {
|
||||||
|
// Clear the document first since getAllTaskStatuses creates a root array
|
||||||
|
doc.clear();
|
||||||
|
|
||||||
|
// Set summary
|
||||||
|
size_t totalTasks = taskStatuses.size();
|
||||||
|
size_t activeTasks = 0;
|
||||||
|
for (const auto& pair : taskStatuses) {
|
||||||
|
if (pair.second["enabled"]) {
|
||||||
|
activeTasks++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSummary(totalTasks, activeTasks);
|
||||||
|
|
||||||
|
// Add all tasks - extract data before clearing to avoid invalid references
|
||||||
|
JsonArray tasksArr = doc["tasks"].to<JsonArray>();
|
||||||
|
for (const auto& pair : taskStatuses) {
|
||||||
|
// Extract data from JsonObject before it becomes invalid
|
||||||
|
String taskName = pair.first.c_str();
|
||||||
|
unsigned long interval = pair.second["interval"];
|
||||||
|
bool enabled = pair.second["enabled"];
|
||||||
|
bool running = pair.second["running"];
|
||||||
|
bool autoStart = pair.second["autoStart"];
|
||||||
|
|
||||||
|
// Create new JsonObject in our document
|
||||||
|
JsonObject taskObj = tasksArr.add<JsonObject>();
|
||||||
|
taskObj["name"] = taskName;
|
||||||
|
taskObj["interval"] = interval;
|
||||||
|
taskObj["enabled"] = enabled;
|
||||||
|
taskObj["running"] = running;
|
||||||
|
taskObj["autoStart"] = autoStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set system info
|
||||||
|
setSystemInfo();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response class for task control operations
|
||||||
|
* Handles JSON responses for task enable/disable/start/stop operations
|
||||||
|
*/
|
||||||
|
class TaskControlResponse : public ApiResponse {
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* Set the response data for a task control operation
|
||||||
|
* @param success Whether the operation was successful
|
||||||
|
* @param message Response message
|
||||||
|
* @param taskName Name of the task
|
||||||
|
* @param action Action performed
|
||||||
|
*/
|
||||||
|
void setResponse(bool success, const String& message, const String& taskName, const String& action) {
|
||||||
|
doc["success"] = success;
|
||||||
|
doc["message"] = message;
|
||||||
|
doc["task"] = taskName;
|
||||||
|
doc["action"] = action;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add detailed task information to the response
|
||||||
|
* @param taskName Name of the task
|
||||||
|
* @param enabled Whether task is enabled
|
||||||
|
* @param running Whether task is running
|
||||||
|
* @param interval Task interval
|
||||||
|
*/
|
||||||
|
void addTaskDetails(const String& taskName, bool enabled, bool running, unsigned long interval) {
|
||||||
|
JsonObject taskDetails = doc["taskDetails"].to<JsonObject>();
|
||||||
|
taskDetails["name"] = taskName;
|
||||||
|
taskDetails["enabled"] = enabled;
|
||||||
|
taskDetails["running"] = running;
|
||||||
|
taskDetails["interval"] = interval;
|
||||||
|
|
||||||
|
// Add system info
|
||||||
|
SystemInfoSerializable systemInfo;
|
||||||
|
JsonObject systemObj = taskDetails["system"].to<JsonObject>();
|
||||||
|
systemInfo.toJson(systemObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set error response
|
||||||
|
* @param message Error message
|
||||||
|
* @param example Optional example for correct usage
|
||||||
|
*/
|
||||||
|
void setError(const String& message, const String& example = "") {
|
||||||
|
doc["success"] = false;
|
||||||
|
doc["message"] = message;
|
||||||
|
if (example.length() > 0) {
|
||||||
|
doc["example"] = example;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace types
|
||||||
|
} // namespace spore
|
||||||
118
include/spore/util/CpuUsage.h
Normal file
118
include/spore/util/CpuUsage.h
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief CPU usage measurement utility for ESP32/ESP8266
|
||||||
|
*
|
||||||
|
* This class provides methods to measure CPU usage by tracking idle time
|
||||||
|
* and calculating the percentage of time the CPU is busy vs idle.
|
||||||
|
*/
|
||||||
|
class CpuUsage {
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* @brief Construct a new CpuUsage object
|
||||||
|
*/
|
||||||
|
CpuUsage();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Destructor
|
||||||
|
*/
|
||||||
|
~CpuUsage() = default;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Initialize the CPU usage measurement
|
||||||
|
* Call this once during setup
|
||||||
|
*/
|
||||||
|
void begin();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Start measuring CPU usage for the current cycle
|
||||||
|
* Call this at the beginning of your main loop
|
||||||
|
*/
|
||||||
|
void startMeasurement();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief End measuring CPU usage for the current cycle
|
||||||
|
* Call this at the end of your main loop
|
||||||
|
*/
|
||||||
|
void endMeasurement();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Get the current CPU usage percentage
|
||||||
|
* @return float CPU usage percentage (0.0 to 100.0)
|
||||||
|
*/
|
||||||
|
float getCpuUsage() const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Get the average CPU usage over the measurement window
|
||||||
|
* @return float Average CPU usage percentage (0.0 to 100.0)
|
||||||
|
*/
|
||||||
|
float getAverageCpuUsage() const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Get the maximum CPU usage recorded
|
||||||
|
* @return float Maximum CPU usage percentage (0.0 to 100.0)
|
||||||
|
*/
|
||||||
|
float getMaxCpuUsage() const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Get the minimum CPU usage recorded
|
||||||
|
* @return float Minimum CPU usage percentage (0.0 to 100.0)
|
||||||
|
*/
|
||||||
|
float getMinCpuUsage() const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Reset all CPU usage statistics
|
||||||
|
*/
|
||||||
|
void reset();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Check if measurement is currently active
|
||||||
|
* @return true if measurement is active, false otherwise
|
||||||
|
*/
|
||||||
|
bool isMeasuring() const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Get the number of measurements taken
|
||||||
|
* @return unsigned long Number of measurements
|
||||||
|
*/
|
||||||
|
unsigned long getMeasurementCount() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Measurement state
|
||||||
|
bool _initialized;
|
||||||
|
bool _measuring;
|
||||||
|
unsigned long _measurementCount;
|
||||||
|
|
||||||
|
// Timing variables
|
||||||
|
unsigned long _cycleStartTime;
|
||||||
|
unsigned long _idleStartTime;
|
||||||
|
unsigned long _totalIdleTime;
|
||||||
|
unsigned long _totalCycleTime;
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
float _currentCpuUsage;
|
||||||
|
float _averageCpuUsage;
|
||||||
|
float _maxCpuUsage;
|
||||||
|
float _minCpuUsage;
|
||||||
|
unsigned long _totalCpuTime;
|
||||||
|
|
||||||
|
// Rolling average window
|
||||||
|
static constexpr size_t ROLLING_WINDOW_SIZE = 10;
|
||||||
|
float _rollingWindow[ROLLING_WINDOW_SIZE];
|
||||||
|
size_t _rollingIndex;
|
||||||
|
bool _rollingWindowFull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Update rolling average calculation
|
||||||
|
* @param value New value to add to rolling average
|
||||||
|
*/
|
||||||
|
void updateRollingAverage(float value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Update min/max statistics
|
||||||
|
* @param value New value to check against min/max
|
||||||
|
*/
|
||||||
|
void updateMinMax(float value);
|
||||||
|
};
|
||||||
56
include/spore/util/JsonSerializable.h
Normal file
56
include/spore/util/JsonSerializable.h
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
namespace spore {
|
||||||
|
namespace util {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract base class for objects that can be serialized to/from JSON
|
||||||
|
* Provides a clean interface for converting objects to JsonObject and back
|
||||||
|
*/
|
||||||
|
class JsonSerializable {
|
||||||
|
public:
|
||||||
|
virtual ~JsonSerializable() = default;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize this object to a JsonObject
|
||||||
|
* @param obj The JsonObject to populate with this object's data
|
||||||
|
*/
|
||||||
|
virtual void toJson(JsonObject& obj) const = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize this object from a JsonObject
|
||||||
|
* @param obj The JsonObject containing the data to populate this object
|
||||||
|
*/
|
||||||
|
virtual void fromJson(const JsonObject& obj) = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method to create a JsonObject from this object
|
||||||
|
* @param doc The JsonDocument to create the object in
|
||||||
|
* @return A JsonObject containing this object's serialized data
|
||||||
|
*/
|
||||||
|
JsonObject toJsonObject(JsonDocument& doc) const {
|
||||||
|
JsonObject obj = doc.to<JsonObject>();
|
||||||
|
toJson(obj);
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method to create a JsonArray from a collection of serializable objects
|
||||||
|
* @param doc The JsonDocument to create the array in
|
||||||
|
* @param objects Collection of objects implementing JsonSerializable
|
||||||
|
* @return A JsonArray containing all serialized objects
|
||||||
|
*/
|
||||||
|
template<typename Container>
|
||||||
|
static JsonArray toJsonArray(JsonDocument& doc, const Container& objects) {
|
||||||
|
JsonArray arr = doc.to<JsonArray>();
|
||||||
|
for (const auto& obj : objects) {
|
||||||
|
JsonObject item = arr.add<JsonObject>();
|
||||||
|
obj.toJson(item);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace util
|
||||||
|
} // namespace spore
|
||||||
29
include/spore/util/Logging.h
Normal file
29
include/spore/util/Logging.h
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
enum class LogLevel {
|
||||||
|
DEBUG = 0,
|
||||||
|
INFO = 1,
|
||||||
|
WARN = 2,
|
||||||
|
ERROR = 3
|
||||||
|
};
|
||||||
|
|
||||||
|
// Global log level - can be changed at runtime
|
||||||
|
extern LogLevel g_logLevel;
|
||||||
|
|
||||||
|
// Simple logging functions
|
||||||
|
void logMessage(LogLevel level, const String& component, const String& message);
|
||||||
|
void logDebug(const String& component, const String& message);
|
||||||
|
void logInfo(const String& component, const String& message);
|
||||||
|
void logWarn(const String& component, const String& message);
|
||||||
|
void logError(const String& component, const String& message);
|
||||||
|
|
||||||
|
// Set global log level
|
||||||
|
void setLogLevel(LogLevel level);
|
||||||
|
|
||||||
|
// Convenience macros - no context needed
|
||||||
|
#define LOG_DEBUG(component, message) logDebug(component, message)
|
||||||
|
#define LOG_INFO(component, message) logInfo(component, message)
|
||||||
|
#define LOG_WARN(component, message) logWarn(component, message)
|
||||||
|
#define LOG_ERROR(component, message) logError(component, message)
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
# Name, Type, SubType, Offset, Size, Flags
|
|
||||||
nvs, data, nvs, , 0x4000,
|
|
||||||
otadata, data, ota, , 0x2000,
|
|
||||||
app0, app, ota_0, , 0x3E000,
|
|
||||||
app1, app, ota_1, , 0x3E000,
|
|
||||||
spiffs, data, spiffs, , 0x1C000,
|
|
||||||
|
@@ -8,16 +8,95 @@
|
|||||||
; Please visit documentation for the other options and examples
|
; Please visit documentation for the other options and examples
|
||||||
; https://docs.platformio.org/page/projectconf.html
|
; https://docs.platformio.org/page/projectconf.html
|
||||||
|
|
||||||
[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
|
platform = platformio/espressif8266@^4.2.1
|
||||||
board = esp01_1m
|
board = esp01_1m
|
||||||
framework = arduino
|
framework = arduino
|
||||||
upload_speed = 115200
|
upload_speed = 115200
|
||||||
monitor_speed = 115200
|
monitor_speed = 115200
|
||||||
board_build.partitions = partitions_ota_1M.csv
|
board_build.f_cpu = 80000000L
|
||||||
board_build.flash_mode = dout ; ESP‑01S uses DOUT on 1 Mbit flash
|
board_build.flash_mode = qio
|
||||||
board_build.flash_size = 1M
|
board_build.filesystem = littlefs
|
||||||
lib_deps =
|
; note: somehow partition table is not working, so we need to use the ldscript
|
||||||
esp32async/ESPAsyncWebServer@^3.8.0
|
board_build.ldscript = eagle.flash.1m64.ld ; 64KB -> FS Size
|
||||||
bblanchon/ArduinoJson@^7.4.2
|
lib_deps = ${common.lib_deps}
|
||||||
arkhipenko/TaskScheduler@^3.8.5
|
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>
|
||||||
|
|
||||||
|
[env:relay]
|
||||||
|
platform = platformio/espressif8266@^4.2.1
|
||||||
|
board = esp01_1m
|
||||||
|
framework = arduino
|
||||||
|
upload_speed = 115200
|
||||||
|
monitor_speed = 115200
|
||||||
|
board_build.filesystem = littlefs
|
||||||
|
board_build.flash_mode = dout
|
||||||
|
board_build.ldscript = eagle.flash.1m64.ld
|
||||||
|
lib_deps = ${common.lib_deps}
|
||||||
|
;data_dir = examples/relay/data
|
||||||
|
build_src_filter =
|
||||||
|
+<examples/relay/*.cpp>
|
||||||
|
+<src/spore/*.cpp>
|
||||||
|
+<src/spore/core/*.cpp>
|
||||||
|
+<src/spore/services/*.cpp>
|
||||||
|
+<src/spore/types/*.cpp>
|
||||||
|
+<src/spore/util/*.cpp>
|
||||||
|
+<src/internal/*.cpp>
|
||||||
|
|
||||||
|
[env:neopattern]
|
||||||
|
platform = platformio/espressif8266@^4.2.1
|
||||||
|
board = esp01_1m
|
||||||
|
framework = arduino
|
||||||
|
upload_speed = 115200
|
||||||
|
monitor_speed = 115200
|
||||||
|
board_build.filesystem = littlefs
|
||||||
|
board_build.flash_mode = dout
|
||||||
|
board_build.ldscript = eagle.flash.1m64.ld
|
||||||
|
lib_deps = ${common.lib_deps}
|
||||||
|
adafruit/Adafruit NeoPixel@^1.15.1
|
||||||
|
build_flags = -DLED_STRIP_PIN=2
|
||||||
|
build_src_filter =
|
||||||
|
+<examples/neopattern/*.cpp>
|
||||||
|
+<src/spore/*.cpp>
|
||||||
|
+<src/spore/core/*.cpp>
|
||||||
|
+<src/spore/services/*.cpp>
|
||||||
|
+<src/spore/types/*.cpp>
|
||||||
|
+<src/spore/util/*.cpp>
|
||||||
|
+<src/internal/*.cpp>
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
#include "ApiServer.h"
|
|
||||||
|
|
||||||
ApiServer::ApiServer(NodeContext& ctx, uint16_t port) : server(port), ctx(ctx) {}
|
|
||||||
|
|
||||||
void ApiServer::addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler) {
|
|
||||||
serviceRegistry.push_back(std::make_tuple(uri, method));
|
|
||||||
// Store in NodeInfo for local node
|
|
||||||
if (ctx.memberList && !ctx.memberList->empty()) {
|
|
||||||
auto it = ctx.memberList->find(ctx.hostname);
|
|
||||||
if (it != ctx.memberList->end()) {
|
|
||||||
it->second.apiEndpoints.push_back(std::make_tuple(uri, method));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
serviceRegistry.push_back(std::make_tuple(uri, method));
|
|
||||||
if (ctx.memberList && !ctx.memberList->empty()) {
|
|
||||||
auto it = ctx.memberList->find(ctx.hostname);
|
|
||||||
if (it != ctx.memberList->end()) {
|
|
||||||
it->second.apiEndpoints.push_back(std::make_tuple(uri, method));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
server.on(uri.c_str(), method, requestHandler, uploadHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
void ApiServer::begin() {
|
|
||||||
addEndpoint("/api/node/status", HTTP_GET,
|
|
||||||
std::bind(&ApiServer::onSystemStatusRequest, this, std::placeholders::_1));
|
|
||||||
addEndpoint("/api/cluster/members", HTTP_GET,
|
|
||||||
std::bind(&ApiServer::onClusterMembersRequest, this, std::placeholders::_1));
|
|
||||||
addEndpoint("/api/node/update", HTTP_POST,
|
|
||||||
std::bind(&ApiServer::onFirmwareUpdateRequest, this, std::placeholders::_1),
|
|
||||||
std::bind(&ApiServer::onFirmwareUpload, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4, std::placeholders::_5, std::placeholders::_6)
|
|
||||||
);
|
|
||||||
addEndpoint("/api/node/restart", HTTP_POST,
|
|
||||||
std::bind(&ApiServer::onRestartRequest, this, std::placeholders::_1));
|
|
||||||
server.begin();
|
|
||||||
}
|
|
||||||
|
|
||||||
void ApiServer::onSystemStatusRequest(AsyncWebServerRequest *request) {
|
|
||||||
JsonDocument doc;
|
|
||||||
doc["freeHeap"] = ESP.getFreeHeap();
|
|
||||||
doc["chipId"] = ESP.getChipId();
|
|
||||||
doc["sdkVersion"] = ESP.getSdkVersion();
|
|
||||||
doc["cpuFreqMHz"] = ESP.getCpuFreqMHz();
|
|
||||||
doc["flashChipSize"] = ESP.getFlashChipSize();
|
|
||||||
JsonArray apiArr = doc["api"].to<JsonArray>();
|
|
||||||
for (const auto& entry : serviceRegistry) {
|
|
||||||
JsonObject apiObj = apiArr.add<JsonObject>();
|
|
||||||
apiObj["uri"] = std::get<0>(entry);
|
|
||||||
apiObj["method"] = std::get<1>(entry);
|
|
||||||
}
|
|
||||||
String json;
|
|
||||||
serializeJson(doc, json);
|
|
||||||
request->send(200, "application/json", json);
|
|
||||||
}
|
|
||||||
|
|
||||||
void ApiServer::onClusterMembersRequest(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;
|
|
||||||
JsonArray apiArr = obj["api"].to<JsonArray>();
|
|
||||||
for (const auto& endpoint : node.apiEndpoints) {
|
|
||||||
JsonObject apiObj = apiArr.add<JsonObject>();
|
|
||||||
apiObj["uri"] = std::get<0>(endpoint);
|
|
||||||
methodToStr(endpoint, apiObj);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
String json;
|
|
||||||
serializeJson(doc, json);
|
|
||||||
request->send(200, "application/json", json);
|
|
||||||
}
|
|
||||||
|
|
||||||
void ApiServer::methodToStr(const std::tuple<String, int> &endpoint, ArduinoJson::V742PB22::JsonObject &apiObj)
|
|
||||||
{
|
|
||||||
int method = std::get<1>(endpoint);
|
|
||||||
const char *methodStr = nullptr;
|
|
||||||
switch (method)
|
|
||||||
{
|
|
||||||
case HTTP_GET:
|
|
||||||
methodStr = "GET";
|
|
||||||
break;
|
|
||||||
case HTTP_POST:
|
|
||||||
methodStr = "POST";
|
|
||||||
break;
|
|
||||||
case HTTP_PUT:
|
|
||||||
methodStr = "PUT";
|
|
||||||
break;
|
|
||||||
case HTTP_DELETE:
|
|
||||||
methodStr = "DELETE";
|
|
||||||
break;
|
|
||||||
case HTTP_PATCH:
|
|
||||||
methodStr = "PATCH";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
methodStr = "UNKNOWN";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
apiObj["method"] = methodStr;
|
|
||||||
}
|
|
||||||
|
|
||||||
void ApiServer::onFirmwareUpdateRequest(AsyncWebServerRequest *request) {
|
|
||||||
bool hasError = !Update.hasError();
|
|
||||||
AsyncWebServerResponse *response = request->beginResponse(200, "application/json", hasError ? "{\"status\": \"OK\"}" : "{\"status\": \"FAIL\"}");
|
|
||||||
response->addHeader("Connection", "close");
|
|
||||||
request->send(response);
|
|
||||||
request->onDisconnect([]() {
|
|
||||||
Serial.println("[API] Restart device");
|
|
||||||
delay(10);
|
|
||||||
ESP.restart();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void ApiServer::onFirmwareUpload(AsyncWebServerRequest *request, const String &filename, size_t index, uint8_t *data, size_t len, bool final) {
|
|
||||||
if (!index) {
|
|
||||||
Serial.print("[OTA] Update Start ");
|
|
||||||
Serial.println(filename);
|
|
||||||
Update.runAsync(true);
|
|
||||||
if(!Update.begin(request->contentLength(), U_FLASH)) {
|
|
||||||
Serial.println("[OTA] Update failed: not enough space");
|
|
||||||
Update.printError(Serial);
|
|
||||||
AsyncWebServerResponse *response = request->beginResponse(500, "application/json", "{\"status\": \"FAIL\"}");
|
|
||||||
response->addHeader("Connection", "close");
|
|
||||||
request->send(response);
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
Update.printError(Serial);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!Update.hasError()){
|
|
||||||
if (Update.write(data, len) != len) {
|
|
||||||
Update.printError(Serial);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (final) {
|
|
||||||
if (Update.end(true)) {
|
|
||||||
if(Update.isFinished()) {
|
|
||||||
Serial.print("[OTA] Update Success with ");
|
|
||||||
Serial.print(index + len);
|
|
||||||
Serial.println("B");
|
|
||||||
} else {
|
|
||||||
Serial.println("[OTA] Update not finished");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Serial.print("[OTA] Update failed: ");
|
|
||||||
Update.printError(Serial);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void ApiServer::onRestartRequest(AsyncWebServerRequest *request) {
|
|
||||||
AsyncWebServerResponse *response = request->beginResponse(200, "application/json", "{\"status\": \"restarting\"}");
|
|
||||||
response->addHeader("Connection", "close");
|
|
||||||
request->send(response);
|
|
||||||
request->onDisconnect([]() {
|
|
||||||
Serial.println("[API] Restart device");
|
|
||||||
delay(10);
|
|
||||||
ESP.restart();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include <Arduino.h>
|
|
||||||
#include <ArduinoJson.h>
|
|
||||||
#include <ESPAsyncWebServer.h>
|
|
||||||
#include <Updater.h>
|
|
||||||
#include <functional>
|
|
||||||
#include <vector>
|
|
||||||
#include <tuple>
|
|
||||||
|
|
||||||
#include "NodeContext.h"
|
|
||||||
#include "NodeInfo.h"
|
|
||||||
|
|
||||||
using namespace std;
|
|
||||||
using namespace std::placeholders;
|
|
||||||
|
|
||||||
class ApiServer {
|
|
||||||
public:
|
|
||||||
ApiServer(NodeContext& ctx, uint16_t port = 80);
|
|
||||||
void begin();
|
|
||||||
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);
|
|
||||||
private:
|
|
||||||
AsyncWebServer server;
|
|
||||||
NodeContext& ctx;
|
|
||||||
std::vector<std::tuple<String, int>> serviceRegistry;
|
|
||||||
void onClusterMembersRequest(AsyncWebServerRequest *request);
|
|
||||||
void methodToStr(const std::tuple<String, int> &endpoint, ArduinoJson::V742PB22::JsonObject &apiObj);
|
|
||||||
void onSystemStatusRequest(AsyncWebServerRequest *request);
|
|
||||||
void onFirmwareUpdateRequest(AsyncWebServerRequest *request);
|
|
||||||
void onFirmwareUpload(AsyncWebServerRequest *request, const String &filename, size_t index, uint8_t *data, size_t len, bool final);
|
|
||||||
void onRestartRequest(AsyncWebServerRequest *request);
|
|
||||||
};
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
#include "ClusterManager.h"
|
|
||||||
|
|
||||||
ClusterManager::ClusterManager(NodeContext& ctx) : ctx(ctx) {
|
|
||||||
// Register callback for node_discovered event
|
|
||||||
ctx.on("node_discovered", [this](void* data) {
|
|
||||||
NodeInfo* node = static_cast<NodeInfo*>(data);
|
|
||||||
this->addOrUpdateNode(node->hostname, node->ip);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void ClusterManager::sendDiscovery() {
|
|
||||||
//Serial.println("[Cluster] Sending discovery packet...");
|
|
||||||
ctx.udp->beginPacket("255.255.255.255", ctx.config.udp_port);
|
|
||||||
ctx.udp->write(ClusterProtocol::DISCOVERY_MSG);
|
|
||||||
ctx.udp->endPacket();
|
|
||||||
}
|
|
||||||
|
|
||||||
void ClusterManager::listenForDiscovery() {
|
|
||||||
int packetSize = ctx.udp->parsePacket();
|
|
||||||
if (packetSize) {
|
|
||||||
char incoming[ClusterProtocol::UDP_BUF_SIZE];
|
|
||||||
int len = ctx.udp->read(incoming, ClusterProtocol::UDP_BUF_SIZE);
|
|
||||||
if (len > 0) {
|
|
||||||
incoming[len] = 0;
|
|
||||||
}
|
|
||||||
//Serial.printf("[UDP] Packet received: %s\n", incoming);
|
|
||||||
if (strcmp(incoming, ClusterProtocol::DISCOVERY_MSG) == 0) {
|
|
||||||
//Serial.printf("[UDP] Discovery request from: %s\n", ctx.udp->remoteIP().toString().c_str());
|
|
||||||
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();
|
|
||||||
//Serial.printf("[UDP] Sent response with hostname: %s\n", ctx.hostname.c_str());
|
|
||||||
} 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void ClusterManager::addOrUpdateNode(const String& nodeHost, IPAddress nodeIP) {
|
|
||||||
auto& memberList = *ctx.memberList;
|
|
||||||
|
|
||||||
// O(1) lookup instead of O(n) search
|
|
||||||
auto it = memberList.find(nodeHost);
|
|
||||||
if (it != memberList.end()) {
|
|
||||||
// Update existing node
|
|
||||||
it->second.ip = nodeIP;
|
|
||||||
it->second.lastSeen = millis();
|
|
||||||
//fetchNodeInfo(nodeIP); // Do not fetch here, handled by periodic task
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add new node
|
|
||||||
NodeInfo newNode;
|
|
||||||
newNode.hostname = nodeHost;
|
|
||||||
newNode.ip = nodeIP;
|
|
||||||
newNode.lastSeen = millis();
|
|
||||||
updateNodeStatus(newNode, newNode.lastSeen, ctx.config.node_inactive_threshold_ms, ctx.config.node_dead_threshold_ms);
|
|
||||||
memberList[nodeHost] = newNode;
|
|
||||||
Serial.printf("[Cluster] Added node: %s @ %s | Status: %s | last update: 0\n",
|
|
||||||
nodeHost.c_str(),
|
|
||||||
newNode.ip.toString().c_str(),
|
|
||||||
statusToStr(newNode.status));
|
|
||||||
//fetchNodeInfo(nodeIP); // Do not fetch here, handled by periodic task
|
|
||||||
}
|
|
||||||
|
|
||||||
void ClusterManager::fetchNodeInfo(const IPAddress& ip) {
|
|
||||||
if(ip == ctx.localIP) {
|
|
||||||
Serial.println("[Cluster] Skipping fetch for local node");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
HTTPClient http;
|
|
||||||
WiFiClient client;
|
|
||||||
String url = "http://" + ip.toString() + ClusterProtocol::API_NODE_STATUS;
|
|
||||||
http.begin(client, url);
|
|
||||||
int httpCode = http.GET();
|
|
||||||
if (httpCode == 200) {
|
|
||||||
String payload = http.getString();
|
|
||||||
JsonDocument doc;
|
|
||||||
DeserializationError err = deserializeJson(doc, payload);
|
|
||||||
if (!err) {
|
|
||||||
auto& memberList = *ctx.memberList;
|
|
||||||
// Still need to iterate since we're searching by IP, not hostname
|
|
||||||
for (auto& pair : memberList) {
|
|
||||||
NodeInfo& node = pair.second;
|
|
||||||
if (node.ip == ip) {
|
|
||||||
node.resources.freeHeap = doc["freeHeap"];
|
|
||||||
node.resources.chipId = doc["chipId"];
|
|
||||||
node.resources.sdkVersion = (const char*)doc["sdkVersion"];
|
|
||||||
node.resources.cpuFreqMHz = doc["cpuFreqMHz"];
|
|
||||||
node.resources.flashChipSize = doc["flashChipSize"];
|
|
||||||
node.status = NodeInfo::ACTIVE;
|
|
||||||
node.lastSeen = millis();
|
|
||||||
node.apiEndpoints.clear();
|
|
||||||
if (doc["api"].is<JsonArray>()) {
|
|
||||||
JsonArray apiArr = doc["api"].as<JsonArray>();
|
|
||||||
for (JsonObject apiObj : apiArr) {
|
|
||||||
String uri = (const char*)apiObj["uri"];
|
|
||||||
int method = apiObj["method"];
|
|
||||||
node.apiEndpoints.push_back(std::make_tuple(uri, method));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Serial.printf("[Cluster] Fetched info for node: %s @ %s\n", node.hostname.c_str(), ip.toString().c_str());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Serial.printf("[Cluster] Failed to fetch info for node @ %s, HTTP code: %d\n", ip.toString().c_str(), httpCode);
|
|
||||||
}
|
|
||||||
http.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
void ClusterManager::heartbeatTaskCallback() {
|
|
||||||
auto& memberList = *ctx.memberList;
|
|
||||||
auto it = memberList.find(ctx.hostname);
|
|
||||||
if (it != memberList.end()) {
|
|
||||||
NodeInfo& node = it->second;
|
|
||||||
node.lastSeen = millis();
|
|
||||||
node.status = NodeInfo::ACTIVE;
|
|
||||||
updateLocalNodeResources();
|
|
||||||
ctx.fire("node_discovered", &node);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void ClusterManager::updateAllMembersInfoTaskCallback() {
|
|
||||||
auto& memberList = *ctx.memberList;
|
|
||||||
for (auto& pair : memberList) {
|
|
||||||
const NodeInfo& node = pair.second;
|
|
||||||
if (node.ip != ctx.localIP) {
|
|
||||||
fetchNodeInfo(node.ip);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void ClusterManager::updateAllNodeStatuses() {
|
|
||||||
auto& memberList = *ctx.memberList;
|
|
||||||
unsigned long now = millis();
|
|
||||||
for (auto& pair : memberList) {
|
|
||||||
NodeInfo& node = pair.second;
|
|
||||||
updateNodeStatus(node, now, ctx.config.node_inactive_threshold_ms, ctx.config.node_dead_threshold_ms);
|
|
||||||
node.latency = now - node.lastSeen;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void ClusterManager::removeDeadNodes() {
|
|
||||||
auto& memberList = *ctx.memberList;
|
|
||||||
unsigned long now = millis();
|
|
||||||
|
|
||||||
// Use iterator to safely remove elements from map
|
|
||||||
for (auto it = memberList.begin(); it != memberList.end(); ) {
|
|
||||||
unsigned long diff = now - it->second.lastSeen;
|
|
||||||
if (it->second.status == NodeInfo::DEAD && diff > ctx.config.node_dead_threshold_ms) {
|
|
||||||
Serial.printf("[Cluster] Removing node: %s\n", it->second.hostname.c_str());
|
|
||||||
it = memberList.erase(it);
|
|
||||||
} else {
|
|
||||||
++it;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void ClusterManager::printMemberList() {
|
|
||||||
auto& memberList = *ctx.memberList;
|
|
||||||
if (memberList.empty()) {
|
|
||||||
Serial.println("[Cluster] Member List: empty");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Serial.println("[Cluster] Member List:");
|
|
||||||
for (const auto& pair : memberList) {
|
|
||||||
const NodeInfo& node = pair.second;
|
|
||||||
Serial.printf(" %s @ %s | Status: %s | last seen: %lu\n", node.hostname.c_str(), node.ip.toString().c_str(), statusToStr(node.status), millis() - node.lastSeen);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void ClusterManager::updateLocalNodeResources() {
|
|
||||||
auto& memberList = *ctx.memberList;
|
|
||||||
auto it = memberList.find(ctx.hostname);
|
|
||||||
if (it != memberList.end()) {
|
|
||||||
NodeInfo& node = it->second;
|
|
||||||
node.resources.freeHeap = ESP.getFreeHeap();
|
|
||||||
node.resources.chipId = ESP.getChipId();
|
|
||||||
node.resources.sdkVersion = String(ESP.getSdkVersion());
|
|
||||||
node.resources.cpuFreqMHz = ESP.getCpuFreqMHz();
|
|
||||||
node.resources.flashChipSize = ESP.getFlashChipSize();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
#include "NetworkManager.h"
|
|
||||||
|
|
||||||
// SSID and password are now configured via Config class
|
|
||||||
|
|
||||||
NetworkManager::NetworkManager(NodeContext& ctx) : ctx(ctx) {}
|
|
||||||
|
|
||||||
void NetworkManager::setHostnameFromMac() {
|
|
||||||
uint8_t mac[6];
|
|
||||||
WiFi.macAddress(mac);
|
|
||||||
char buf[32];
|
|
||||||
sprintf(buf, "esp-%02X%02X%02X%02X%02X%02X", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
|
|
||||||
WiFi.hostname(buf);
|
|
||||||
ctx.hostname = String(buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
void NetworkManager::setupWiFi() {
|
|
||||||
Serial.begin(115200);
|
|
||||||
WiFi.mode(WIFI_STA);
|
|
||||||
WiFi.begin(ctx.config.wifi_ssid.c_str(), ctx.config.wifi_password.c_str());
|
|
||||||
Serial.println("[WiFi] Connecting to AP...");
|
|
||||||
unsigned long startAttemptTime = millis();
|
|
||||||
while (WiFi.status() != WL_CONNECTED && millis() - startAttemptTime < ctx.config.wifi_connect_timeout_ms) {
|
|
||||||
delay(ctx.config.wifi_retry_delay_ms);
|
|
||||||
Serial.print(".");
|
|
||||||
}
|
|
||||||
if (WiFi.status() == WL_CONNECTED) {
|
|
||||||
Serial.println();
|
|
||||||
Serial.print("[WiFi] Connected to AP, IP: ");
|
|
||||||
Serial.println(WiFi.localIP());
|
|
||||||
} else {
|
|
||||||
Serial.println();
|
|
||||||
Serial.println("[WiFi] Failed to connect to AP. Creating AP...");
|
|
||||||
WiFi.mode(WIFI_AP);
|
|
||||||
WiFi.softAP(ctx.config.wifi_ssid.c_str(), ctx.config.wifi_password.c_str());
|
|
||||||
Serial.print("[WiFi] AP created, IP: ");
|
|
||||||
Serial.println(WiFi.softAPIP());
|
|
||||||
}
|
|
||||||
setHostnameFromMac();
|
|
||||||
ctx.udp->begin(ctx.config.udp_port);
|
|
||||||
ctx.localIP = WiFi.localIP();
|
|
||||||
ctx.hostname = WiFi.hostname();
|
|
||||||
Serial.print("[WiFi] Hostname set to: ");
|
|
||||||
Serial.println(ctx.hostname);
|
|
||||||
Serial.printf("[WiFi] UDP listening on port %d\n", ctx.config.udp_port);
|
|
||||||
|
|
||||||
// Register this node in the memberlist via event system
|
|
||||||
NodeInfo self;
|
|
||||||
self.hostname = ctx.hostname;
|
|
||||||
if(WiFi.isConnected()) {
|
|
||||||
self.ip = WiFi.localIP();
|
|
||||||
} else {
|
|
||||||
// Fallback to AP IP if not connected
|
|
||||||
self.ip = WiFi.softAPIP();
|
|
||||||
}
|
|
||||||
self.lastSeen = millis();
|
|
||||||
self.status = NodeInfo::ACTIVE;
|
|
||||||
ctx.fire("node_discovered", &self);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include "NodeContext.h"
|
|
||||||
#include <ESP8266WiFi.h>
|
|
||||||
|
|
||||||
class NetworkManager {
|
|
||||||
public:
|
|
||||||
NetworkManager(NodeContext& ctx);
|
|
||||||
void setupWiFi();
|
|
||||||
void setHostnameFromMac();
|
|
||||||
private:
|
|
||||||
NodeContext& ctx;
|
|
||||||
};
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
/*
|
|
||||||
* https://github.com/arkhipenko/TaskScheduler/tree/master/examples/Scheduler_example16_Multitab
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include <TaskScheduler.h>
|
|
||||||
50
src/main.cpp
50
src/main.cpp
@@ -1,50 +0,0 @@
|
|||||||
#include <Arduino.h>
|
|
||||||
#include "Globals.h"
|
|
||||||
#include "NodeContext.h"
|
|
||||||
#include "NetworkManager.h"
|
|
||||||
#include "ClusterManager.h"
|
|
||||||
#include "ApiServer.h"
|
|
||||||
|
|
||||||
NodeContext ctx;
|
|
||||||
NetworkManager network(ctx);
|
|
||||||
ClusterManager cluster(ctx);
|
|
||||||
ApiServer apiServer(ctx, ctx.config.api_server_port);
|
|
||||||
|
|
||||||
Task tSendDiscovery(0, TASK_FOREVER, [](){ cluster.sendDiscovery(); });
|
|
||||||
Task tListenForDiscovery(0, TASK_FOREVER, [](){ cluster.listenForDiscovery(); });
|
|
||||||
Task tUpdateStatus(0, TASK_FOREVER, [](){ cluster.updateAllNodeStatuses(); cluster.removeDeadNodes(); });
|
|
||||||
Task tPrintMemberList(0, TASK_FOREVER, [](){ cluster.printMemberList(); });
|
|
||||||
Task tHeartbeat(0, TASK_FOREVER, [](){ cluster.heartbeatTaskCallback(); });
|
|
||||||
Task tUpdateAllMembersInfo(0, TASK_FOREVER, [](){ cluster.updateAllMembersInfoTaskCallback(); });
|
|
||||||
|
|
||||||
void setup() {
|
|
||||||
network.setupWiFi();
|
|
||||||
ctx.scheduler->init();
|
|
||||||
|
|
||||||
// Set task intervals from config
|
|
||||||
tSendDiscovery.setInterval(ctx.config.discovery_interval_ms);
|
|
||||||
tListenForDiscovery.setInterval(ctx.config.discovery_interval_ms / 10); // Listen more frequently
|
|
||||||
tUpdateStatus.setInterval(ctx.config.status_update_interval_ms);
|
|
||||||
tPrintMemberList.setInterval(ctx.config.print_interval_ms);
|
|
||||||
tHeartbeat.setInterval(ctx.config.heartbeat_interval_ms);
|
|
||||||
tUpdateAllMembersInfo.setInterval(ctx.config.member_info_update_interval_ms);
|
|
||||||
|
|
||||||
ctx.scheduler->addTask(tSendDiscovery);
|
|
||||||
ctx.scheduler->addTask(tListenForDiscovery);
|
|
||||||
ctx.scheduler->addTask(tUpdateStatus);
|
|
||||||
ctx.scheduler->addTask(tPrintMemberList);
|
|
||||||
ctx.scheduler->addTask(tHeartbeat);
|
|
||||||
ctx.scheduler->addTask(tUpdateAllMembersInfo);
|
|
||||||
tSendDiscovery.enable();
|
|
||||||
tListenForDiscovery.enable();
|
|
||||||
tUpdateStatus.enable();
|
|
||||||
tPrintMemberList.enable();
|
|
||||||
tHeartbeat.enable();
|
|
||||||
tUpdateAllMembersInfo.enable();
|
|
||||||
apiServer.begin();
|
|
||||||
}
|
|
||||||
|
|
||||||
void loop() {
|
|
||||||
ctx.scheduler->execute();
|
|
||||||
yield();
|
|
||||||
}
|
|
||||||
171
src/spore/Spore.cpp
Normal file
171
src/spore/Spore.cpp
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
#include "spore/Spore.h"
|
||||||
|
#include "spore/services/NodeService.h"
|
||||||
|
#include "spore/services/NetworkService.h"
|
||||||
|
#include "spore/services/ClusterService.h"
|
||||||
|
#include "spore/services/TaskService.h"
|
||||||
|
#include "spore/services/StaticFileService.h"
|
||||||
|
#include "spore/services/MonitoringService.h"
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
Spore::Spore() : ctx(), network(ctx), taskManager(ctx), cluster(ctx, taskManager),
|
||||||
|
apiServer(ctx, taskManager, ctx.config.api_server_port),
|
||||||
|
cpuUsage(), initialized(false), apiServerStarted(false) {
|
||||||
|
}
|
||||||
|
|
||||||
|
Spore::Spore(std::initializer_list<std::pair<String, String>> initialLabels)
|
||||||
|
: ctx(initialLabels), network(ctx), taskManager(ctx), cluster(ctx, taskManager),
|
||||||
|
apiServer(ctx, taskManager, ctx.config.api_server_port),
|
||||||
|
cpuUsage(), initialized(false), apiServerStarted(false) {
|
||||||
|
}
|
||||||
|
|
||||||
|
Spore::~Spore() {
|
||||||
|
// Services will be automatically cleaned up by shared_ptr
|
||||||
|
}
|
||||||
|
|
||||||
|
void Spore::setup() {
|
||||||
|
if (initialized) {
|
||||||
|
LOG_INFO("Spore", "Already initialized, skipping setup");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.begin(115200);
|
||||||
|
LOG_INFO("Spore", "Starting Spore framework...");
|
||||||
|
|
||||||
|
// Initialize core components
|
||||||
|
initializeCore();
|
||||||
|
|
||||||
|
// Initialize CPU usage monitoring
|
||||||
|
cpuUsage.begin();
|
||||||
|
|
||||||
|
// Register core services
|
||||||
|
registerCoreServices();
|
||||||
|
|
||||||
|
initialized = true;
|
||||||
|
LOG_INFO("Spore", "Framework setup complete - call begin() to start API server");
|
||||||
|
}
|
||||||
|
|
||||||
|
void Spore::begin() {
|
||||||
|
if (!initialized) {
|
||||||
|
LOG_ERROR("Spore", "Framework not initialized, call setup() first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiServerStarted) {
|
||||||
|
LOG_WARN("Spore", "API server already started");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_INFO("Spore", "Starting API server...");
|
||||||
|
|
||||||
|
// Start API server
|
||||||
|
startApiServer();
|
||||||
|
|
||||||
|
// Print initial task status
|
||||||
|
taskManager.printTaskStatus();
|
||||||
|
|
||||||
|
LOG_INFO("Spore", "Framework ready!");
|
||||||
|
}
|
||||||
|
|
||||||
|
void Spore::loop() {
|
||||||
|
if (!initialized) {
|
||||||
|
LOG_ERROR("Spore", "Framework not initialized, call setup() first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start CPU usage measurement
|
||||||
|
cpuUsage.startMeasurement();
|
||||||
|
|
||||||
|
// Execute main tasks
|
||||||
|
taskManager.execute();
|
||||||
|
|
||||||
|
// End CPU usage measurement before yield
|
||||||
|
cpuUsage.endMeasurement();
|
||||||
|
|
||||||
|
// Yield to allow other tasks to run
|
||||||
|
yield();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Spore::addService(std::shared_ptr<Service> service) {
|
||||||
|
if (!service) {
|
||||||
|
LOG_WARN("Spore", "Attempted to add null service");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
services.push_back(service);
|
||||||
|
|
||||||
|
if (apiServerStarted) {
|
||||||
|
// If API server is already started, register the service immediately
|
||||||
|
apiServer.addService(*service);
|
||||||
|
LOG_INFO("Spore", "Added service '" + String(service->getName()) + "' to running API server");
|
||||||
|
} else {
|
||||||
|
LOG_INFO("Spore", "Registered service '" + String(service->getName()) + "' (will be added to API server when begin() is called)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Spore::addService(Service* service) {
|
||||||
|
if (!service) {
|
||||||
|
LOG_WARN("Spore", "Attempted to add null service");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap raw pointer in shared_ptr with no-op deleter to avoid double-delete
|
||||||
|
addService(std::shared_ptr<Service>(service, [](Service*){}));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void Spore::initializeCore() {
|
||||||
|
LOG_INFO("Spore", "Initializing core components...");
|
||||||
|
|
||||||
|
// Setup WiFi first
|
||||||
|
network.setupWiFi();
|
||||||
|
|
||||||
|
// Initialize task manager
|
||||||
|
taskManager.initialize();
|
||||||
|
|
||||||
|
LOG_INFO("Spore", "Core components initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
void Spore::registerCoreServices() {
|
||||||
|
LOG_INFO("Spore", "Registering core services...");
|
||||||
|
|
||||||
|
// Create core services
|
||||||
|
auto nodeService = std::make_shared<NodeService>(ctx, apiServer);
|
||||||
|
auto networkService = std::make_shared<NetworkService>(network);
|
||||||
|
auto clusterService = std::make_shared<ClusterService>(ctx);
|
||||||
|
auto taskService = std::make_shared<TaskService>(taskManager);
|
||||||
|
auto staticFileService = std::make_shared<StaticFileService>(ctx, apiServer);
|
||||||
|
auto monitoringService = std::make_shared<MonitoringService>(cpuUsage);
|
||||||
|
|
||||||
|
// Add to services list
|
||||||
|
services.push_back(nodeService);
|
||||||
|
services.push_back(networkService);
|
||||||
|
services.push_back(clusterService);
|
||||||
|
services.push_back(taskService);
|
||||||
|
services.push_back(staticFileService);
|
||||||
|
services.push_back(monitoringService);
|
||||||
|
|
||||||
|
LOG_INFO("Spore", "Core services registered");
|
||||||
|
}
|
||||||
|
|
||||||
|
void Spore::startApiServer() {
|
||||||
|
if (apiServerStarted) {
|
||||||
|
LOG_WARN("Spore", "API server already started");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_INFO("Spore", "Starting API server...");
|
||||||
|
|
||||||
|
// Register all services with API server
|
||||||
|
for (auto& service : services) {
|
||||||
|
if (service) {
|
||||||
|
apiServer.addService(*service);
|
||||||
|
LOG_INFO("Spore", "Added service '" + String(service->getName()) + "' to API server");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the API server
|
||||||
|
apiServer.begin();
|
||||||
|
apiServerStarted = true;
|
||||||
|
|
||||||
|
LOG_INFO("Spore", "API server started");
|
||||||
|
}
|
||||||
71
src/spore/core/ApiServer.cpp
Normal file
71
src/spore/core/ApiServer.cpp
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
#include "spore/core/ApiServer.h"
|
||||||
|
#include "spore/Service.h"
|
||||||
|
#include "spore/util/Logging.h"
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
const char* ApiServer::methodToStr(int method) {
|
||||||
|
switch (method) {
|
||||||
|
case HTTP_GET: return "GET";
|
||||||
|
case HTTP_POST: return "POST";
|
||||||
|
case HTTP_PUT: return "PUT";
|
||||||
|
case HTTP_DELETE: return "DELETE";
|
||||||
|
case HTTP_PATCH: return "PATCH";
|
||||||
|
default: return "UNKNOWN";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiServer::ApiServer(NodeContext& ctx, TaskManager& taskMgr, uint16_t port) : server(port), ctx(ctx), taskManager(taskMgr) {}
|
||||||
|
|
||||||
|
void ApiServer::registerEndpoint(const String& uri, int method,
|
||||||
|
const std::vector<ParamSpec>& params,
|
||||||
|
const String& serviceName) {
|
||||||
|
// Add to local endpoints
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
registerEndpoint(uri, method, params, serviceName);
|
||||||
|
server.on(uri.c_str(), method, requestHandler, uploadHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiServer::addService(Service& service) {
|
||||||
|
services.push_back(service);
|
||||||
|
LOG_INFO("API", "Added service: " + String(service.getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiServer::serveStatic(const String& uri, fs::FS& fs, const String& path, const String& cache_header) {
|
||||||
|
server.serveStatic(uri.c_str(), fs, path.c_str(), cache_header.c_str()).setDefaultFile("index.html");
|
||||||
|
LOG_INFO("API", "Registered static file serving: " + uri + " -> " + path);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiServer::begin() {
|
||||||
|
// Register all service endpoints
|
||||||
|
for (auto& service : services) {
|
||||||
|
service.get().registerEndpoints(*this);
|
||||||
|
LOG_INFO("API", "Registered endpoints for service: " + String(service.get().getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
server.begin();
|
||||||
|
}
|
||||||
281
src/spore/core/ClusterManager.cpp
Normal file
281
src/spore/core/ClusterManager.cpp
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
#include "spore/core/ClusterManager.h"
|
||||||
|
#include "spore/internal/Globals.h"
|
||||||
|
#include "spore/util/Logging.h"
|
||||||
|
|
||||||
|
ClusterManager::ClusterManager(NodeContext& ctx, TaskManager& taskMgr) : ctx(ctx), taskManager(taskMgr) {
|
||||||
|
// Register callback for node_discovered event
|
||||||
|
ctx.on("node_discovered", [this](void* data) {
|
||||||
|
NodeInfo* node = static_cast<NodeInfo*>(data);
|
||||||
|
this->addOrUpdateNode(node->hostname, node->ip);
|
||||||
|
});
|
||||||
|
// Register tasks
|
||||||
|
registerTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ClusterManager::registerTasks() {
|
||||||
|
taskManager.registerTask("discovery_send", ctx.config.discovery_interval_ms, [this]() { sendDiscovery(); });
|
||||||
|
taskManager.registerTask("discovery_listen", ctx.config.discovery_interval_ms / 10, [this]() { listenForDiscovery(); });
|
||||||
|
taskManager.registerTask("status_update", ctx.config.status_update_interval_ms, [this]() { updateAllNodeStatuses(); removeDeadNodes(); });
|
||||||
|
taskManager.registerTask("print_members", ctx.config.print_interval_ms, [this]() { printMemberList(); });
|
||||||
|
taskManager.registerTask("heartbeat", ctx.config.heartbeat_interval_ms, [this]() { heartbeatTaskCallback(); });
|
||||||
|
taskManager.registerTask("update_members_info", ctx.config.member_info_update_interval_ms, [this]() { updateAllMembersInfoTaskCallback(); });
|
||||||
|
LOG_INFO("ClusterManager", "Registered all cluster tasks");
|
||||||
|
}
|
||||||
|
|
||||||
|
void ClusterManager::sendDiscovery() {
|
||||||
|
//LOG_DEBUG(ctx, "Cluster", "Sending discovery packet...");
|
||||||
|
ctx.udp->beginPacket("255.255.255.255", ctx.config.udp_port);
|
||||||
|
ctx.udp->write(ClusterProtocol::DISCOVERY_MSG);
|
||||||
|
ctx.udp->endPacket();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ClusterManager::listenForDiscovery() {
|
||||||
|
int packetSize = ctx.udp->parsePacket();
|
||||||
|
if (packetSize) {
|
||||||
|
char incoming[ClusterProtocol::UDP_BUF_SIZE];
|
||||||
|
int len = ctx.udp->read(incoming, ClusterProtocol::UDP_BUF_SIZE);
|
||||||
|
if (len > 0) {
|
||||||
|
incoming[len] = 0;
|
||||||
|
}
|
||||||
|
//LOG_DEBUG(ctx, "UDP", "Packet received: " + String(incoming));
|
||||||
|
if (strcmp(incoming, ClusterProtocol::DISCOVERY_MSG) == 0) {
|
||||||
|
//LOG_DEBUG(ctx, "UDP", "Discovery request from: " + ctx.udp->remoteIP().toString());
|
||||||
|
ctx.udp->beginPacket(ctx.udp->remoteIP(), ctx.config.udp_port);
|
||||||
|
String response = String(ClusterProtocol::RESPONSE_MSG) + ":" + ctx.hostname;
|
||||||
|
ctx.udp->write(response.c_str());
|
||||||
|
ctx.udp->endPacket();
|
||||||
|
//LOG_DEBUG(ctx, "UDP", "Sent response with hostname: " + ctx.hostname);
|
||||||
|
} else if (strncmp(incoming, ClusterProtocol::RESPONSE_MSG, strlen(ClusterProtocol::RESPONSE_MSG)) == 0) {
|
||||||
|
char* hostPtr = incoming + strlen(ClusterProtocol::RESPONSE_MSG) + 1;
|
||||||
|
String nodeHost = String(hostPtr);
|
||||||
|
addOrUpdateNode(nodeHost, ctx.udp->remoteIP());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ClusterManager::addOrUpdateNode(const String& nodeHost, IPAddress nodeIP) {
|
||||||
|
auto& memberList = *ctx.memberList;
|
||||||
|
|
||||||
|
// O(1) lookup instead of O(n) search
|
||||||
|
auto it = memberList.find(nodeHost);
|
||||||
|
if (it != memberList.end()) {
|
||||||
|
// Update existing node
|
||||||
|
it->second.ip = nodeIP;
|
||||||
|
it->second.lastSeen = millis();
|
||||||
|
//fetchNodeInfo(nodeIP); // Do not fetch here, handled by periodic task
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new node
|
||||||
|
NodeInfo newNode;
|
||||||
|
newNode.hostname = nodeHost;
|
||||||
|
newNode.ip = nodeIP;
|
||||||
|
newNode.lastSeen = millis();
|
||||||
|
updateNodeStatus(newNode, newNode.lastSeen, ctx.config.node_inactive_threshold_ms, ctx.config.node_dead_threshold_ms);
|
||||||
|
memberList[nodeHost] = newNode;
|
||||||
|
LOG_INFO("Cluster", "Added node: " + nodeHost + " @ " + newNode.ip.toString() + " | Status: " + statusToStr(newNode.status) + " | last update: 0");
|
||||||
|
//fetchNodeInfo(nodeIP); // Do not fetch here, handled by periodic task
|
||||||
|
}
|
||||||
|
|
||||||
|
void ClusterManager::fetchNodeInfo(const IPAddress& ip) {
|
||||||
|
if(ip == ctx.localIP) {
|
||||||
|
LOG_DEBUG("Cluster", "Skipping fetch for local node");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned long requestStart = millis();
|
||||||
|
HTTPClient http;
|
||||||
|
WiFiClient client;
|
||||||
|
String url = "http://" + ip.toString() + ClusterProtocol::API_NODE_STATUS;
|
||||||
|
|
||||||
|
// Use RAII pattern to ensure http.end() is always called
|
||||||
|
bool httpInitialized = false;
|
||||||
|
bool success = false;
|
||||||
|
|
||||||
|
httpInitialized = http.begin(client, url);
|
||||||
|
if (!httpInitialized) {
|
||||||
|
LOG_ERROR("Cluster", "Failed to initialize HTTP client for " + ip.toString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set timeout to prevent hanging
|
||||||
|
http.setTimeout(5000); // 5 second timeout
|
||||||
|
|
||||||
|
int httpCode = http.GET();
|
||||||
|
unsigned long requestEnd = millis();
|
||||||
|
unsigned long requestDuration = requestEnd - requestStart;
|
||||||
|
|
||||||
|
if (httpCode == 200) {
|
||||||
|
String payload = http.getString();
|
||||||
|
|
||||||
|
// Use stack-allocated JsonDocument with proper cleanup
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError err = deserializeJson(doc, payload);
|
||||||
|
|
||||||
|
if (!err) {
|
||||||
|
auto& memberList = *ctx.memberList;
|
||||||
|
// Still need to iterate since we're searching by IP, not hostname
|
||||||
|
for (auto& pair : memberList) {
|
||||||
|
NodeInfo& node = pair.second;
|
||||||
|
if (node.ip == ip) {
|
||||||
|
// Update resources efficiently
|
||||||
|
node.resources.freeHeap = doc["freeHeap"];
|
||||||
|
node.resources.chipId = doc["chipId"];
|
||||||
|
node.resources.sdkVersion = (const char*)doc["sdkVersion"];
|
||||||
|
node.resources.cpuFreqMHz = doc["cpuFreqMHz"];
|
||||||
|
node.resources.flashChipSize = doc["flashChipSize"];
|
||||||
|
node.status = NodeInfo::ACTIVE;
|
||||||
|
node.latency = requestDuration;
|
||||||
|
node.lastSeen = millis();
|
||||||
|
|
||||||
|
// Clear and rebuild endpoints efficiently
|
||||||
|
node.endpoints.clear();
|
||||||
|
node.endpoints.reserve(10); // Pre-allocate to avoid reallocations
|
||||||
|
|
||||||
|
if (doc["api"].is<JsonArray>()) {
|
||||||
|
JsonArray apiArr = doc["api"].as<JsonArray>();
|
||||||
|
for (JsonObject apiObj : apiArr) {
|
||||||
|
// Use const char* to avoid String copies
|
||||||
|
const char* uri = apiObj["uri"];
|
||||||
|
int method = apiObj["method"];
|
||||||
|
|
||||||
|
// Create basic EndpointInfo without params for cluster nodes
|
||||||
|
EndpointInfo endpoint;
|
||||||
|
endpoint.uri = uri; // String assignment is more efficient than construction
|
||||||
|
endpoint.method = method;
|
||||||
|
endpoint.isLocal = false;
|
||||||
|
endpoint.serviceName = "remote";
|
||||||
|
node.endpoints.push_back(std::move(endpoint));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse labels efficiently
|
||||||
|
node.labels.clear();
|
||||||
|
if (doc["labels"].is<JsonObject>()) {
|
||||||
|
JsonObject labelsObj = doc["labels"].as<JsonObject>();
|
||||||
|
for (JsonPair kvp : labelsObj) {
|
||||||
|
// Use const char* to avoid String copies
|
||||||
|
const char* key = kvp.key().c_str();
|
||||||
|
const char* value = labelsObj[kvp.key()];
|
||||||
|
node.labels[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DEBUG("Cluster", "Fetched info for node: " + node.hostname + " @ " + ip.toString());
|
||||||
|
success = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LOG_ERROR("Cluster", "JSON parse error for node @ " + ip.toString() + ": " + String(err.c_str()));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LOG_ERROR("Cluster", "Failed to fetch info for node @ " + ip.toString() + ", HTTP code: " + String(httpCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always ensure HTTP client is properly closed
|
||||||
|
if (httpInitialized) {
|
||||||
|
http.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log success/failure for debugging
|
||||||
|
if (!success) {
|
||||||
|
LOG_DEBUG("Cluster", "Failed to update node info for " + ip.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ClusterManager::heartbeatTaskCallback() {
|
||||||
|
auto& memberList = *ctx.memberList;
|
||||||
|
auto it = memberList.find(ctx.hostname);
|
||||||
|
if (it != memberList.end()) {
|
||||||
|
NodeInfo& node = it->second;
|
||||||
|
node.lastSeen = millis();
|
||||||
|
node.status = NodeInfo::ACTIVE;
|
||||||
|
updateLocalNodeResources();
|
||||||
|
ctx.fire("node_discovered", &node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ClusterManager::updateAllNodeStatuses() {
|
||||||
|
auto& memberList = *ctx.memberList;
|
||||||
|
unsigned long now = millis();
|
||||||
|
for (auto& pair : memberList) {
|
||||||
|
NodeInfo& node = pair.second;
|
||||||
|
updateNodeStatus(node, now, ctx.config.node_inactive_threshold_ms, ctx.config.node_dead_threshold_ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ClusterManager::removeDeadNodes() {
|
||||||
|
auto& memberList = *ctx.memberList;
|
||||||
|
unsigned long now = millis();
|
||||||
|
|
||||||
|
// Use iterator to safely remove elements from map
|
||||||
|
for (auto it = memberList.begin(); it != memberList.end(); ) {
|
||||||
|
unsigned long diff = now - it->second.lastSeen;
|
||||||
|
if (it->second.status == NodeInfo::DEAD && diff > ctx.config.node_dead_threshold_ms) {
|
||||||
|
LOG_INFO("Cluster", "Removing node: " + it->second.hostname);
|
||||||
|
it = memberList.erase(it);
|
||||||
|
} else {
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ClusterManager::printMemberList() {
|
||||||
|
auto& memberList = *ctx.memberList;
|
||||||
|
if (memberList.empty()) {
|
||||||
|
LOG_INFO("Cluster", "Member List: empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LOG_INFO("Cluster", "Member List:");
|
||||||
|
for (const auto& pair : memberList) {
|
||||||
|
const NodeInfo& node = pair.second;
|
||||||
|
LOG_INFO("Cluster", " " + node.hostname + " @ " + node.ip.toString() + " | Status: " + statusToStr(node.status) + " | last seen: " + String(millis() - node.lastSeen));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ClusterManager::updateLocalNodeResources() {
|
||||||
|
auto& memberList = *ctx.memberList;
|
||||||
|
auto it = memberList.find(ctx.hostname);
|
||||||
|
if (it != memberList.end()) {
|
||||||
|
NodeInfo& node = it->second;
|
||||||
|
uint32_t freeHeap = ESP.getFreeHeap();
|
||||||
|
node.resources.freeHeap = freeHeap;
|
||||||
|
node.resources.chipId = ESP.getChipId();
|
||||||
|
node.resources.sdkVersion = String(ESP.getSdkVersion());
|
||||||
|
node.resources.cpuFreqMHz = ESP.getCpuFreqMHz();
|
||||||
|
node.resources.flashChipSize = ESP.getFlashChipSize();
|
||||||
|
|
||||||
|
// Log memory warnings if heap is getting low
|
||||||
|
if (freeHeap < ctx.config.low_memory_threshold_bytes) {
|
||||||
|
LOG_WARN("Cluster", "Low memory warning: " + String(freeHeap) + " bytes free");
|
||||||
|
} else if (freeHeap < ctx.config.critical_memory_threshold_bytes) {
|
||||||
|
LOG_ERROR("Cluster", "Critical memory warning: " + String(freeHeap) + " bytes free");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/spore/core/NetworkManager.cpp
Normal file
122
src/spore/core/NetworkManager.cpp
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
#include "spore/core/NetworkManager.h"
|
||||||
|
#include "spore/util/Logging.h"
|
||||||
|
|
||||||
|
// SSID and password are now configured via Config class
|
||||||
|
|
||||||
|
void NetworkManager::scanWifi() {
|
||||||
|
if (!isScanning) {
|
||||||
|
isScanning = true;
|
||||||
|
LOG_INFO("WiFi", "Starting WiFi scan...");
|
||||||
|
// Start async WiFi scan
|
||||||
|
WiFi.scanNetworksAsync([this](int networksFound) {
|
||||||
|
LOG_INFO("WiFi", "Scan completed, found " + String(networksFound) + " networks");
|
||||||
|
this->processAccessPoints();
|
||||||
|
this->isScanning = false;
|
||||||
|
}, true);
|
||||||
|
} else {
|
||||||
|
LOG_WARN("WiFi", "Scan already in progress...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetworkManager::processAccessPoints() {
|
||||||
|
int numNetworks = WiFi.scanComplete();
|
||||||
|
if (numNetworks <= 0) {
|
||||||
|
LOG_WARN("WiFi", "No networks found or scan not complete");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing access points
|
||||||
|
accessPoints.clear();
|
||||||
|
|
||||||
|
// Process each network found
|
||||||
|
for (int i = 0; i < numNetworks; i++) {
|
||||||
|
AccessPoint ap;
|
||||||
|
ap.ssid = WiFi.SSID(i);
|
||||||
|
ap.rssi = WiFi.RSSI(i);
|
||||||
|
ap.encryptionType = WiFi.encryptionType(i);
|
||||||
|
ap.channel = WiFi.channel(i);
|
||||||
|
ap.isHidden = ap.ssid.length() == 0;
|
||||||
|
|
||||||
|
// Copy BSSID
|
||||||
|
uint8_t* newBssid = new uint8_t[6];
|
||||||
|
memcpy(newBssid, WiFi.BSSID(i), 6);
|
||||||
|
ap.bssid = newBssid;
|
||||||
|
|
||||||
|
accessPoints.push_back(ap);
|
||||||
|
|
||||||
|
LOG_DEBUG("WiFi", "Found network " + String(i + 1) + ": " + ap.ssid + ", Ch: " + String(ap.channel) + ", RSSI: " + String(ap.rssi));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free the memory used by the scan
|
||||||
|
WiFi.scanDelete();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<AccessPoint> NetworkManager::getAccessPoints() const {
|
||||||
|
return accessPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkManager::NetworkManager(NodeContext& ctx) : ctx(ctx) {}
|
||||||
|
|
||||||
|
void NetworkManager::setWiFiConfig(const String& ssid, const String& password,
|
||||||
|
uint32_t connect_timeout_ms, uint32_t retry_delay_ms) {
|
||||||
|
ctx.config.wifi_ssid = ssid;
|
||||||
|
ctx.config.wifi_password = password;
|
||||||
|
ctx.config.wifi_connect_timeout_ms = connect_timeout_ms;
|
||||||
|
ctx.config.wifi_retry_delay_ms = retry_delay_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetworkManager::setHostnameFromMac() {
|
||||||
|
uint8_t mac[6];
|
||||||
|
WiFi.macAddress(mac);
|
||||||
|
char buf[32];
|
||||||
|
sprintf(buf, "esp-%02X%02X%02X%02X%02X%02X", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
|
||||||
|
WiFi.hostname(buf);
|
||||||
|
ctx.hostname = String(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetworkManager::setupWiFi() {
|
||||||
|
WiFi.mode(WIFI_STA);
|
||||||
|
WiFi.begin(ctx.config.wifi_ssid.c_str(), ctx.config.wifi_password.c_str());
|
||||||
|
LOG_INFO("WiFi", "Connecting to AP...");
|
||||||
|
unsigned long startAttemptTime = millis();
|
||||||
|
while (WiFi.status() != WL_CONNECTED && millis() - startAttemptTime < ctx.config.wifi_connect_timeout_ms) {
|
||||||
|
delay(ctx.config.wifi_retry_delay_ms);
|
||||||
|
// Progress dots handled by delay, no logging needed
|
||||||
|
}
|
||||||
|
if (WiFi.status() == WL_CONNECTED) {
|
||||||
|
LOG_INFO("WiFi", "Connected to AP, IP: " + WiFi.localIP().toString());
|
||||||
|
} else {
|
||||||
|
LOG_WARN("WiFi", "Failed to connect to AP. Creating AP...");
|
||||||
|
WiFi.mode(WIFI_AP);
|
||||||
|
WiFi.softAP(ctx.config.wifi_ssid.c_str(), ctx.config.wifi_password.c_str());
|
||||||
|
LOG_INFO("WiFi", "AP created, IP: " + WiFi.softAPIP().toString());
|
||||||
|
}
|
||||||
|
setHostnameFromMac();
|
||||||
|
ctx.udp->begin(ctx.config.udp_port);
|
||||||
|
ctx.localIP = WiFi.localIP();
|
||||||
|
ctx.hostname = WiFi.hostname();
|
||||||
|
LOG_INFO("WiFi", "Hostname set to: " + ctx.hostname);
|
||||||
|
LOG_INFO("WiFi", "UDP listening on port " + String(ctx.config.udp_port));
|
||||||
|
|
||||||
|
// Populate self NodeInfo
|
||||||
|
ctx.self.hostname = ctx.hostname;
|
||||||
|
if (WiFi.isConnected()) {
|
||||||
|
ctx.self.ip = WiFi.localIP();
|
||||||
|
} else {
|
||||||
|
ctx.self.ip = WiFi.softAPIP();
|
||||||
|
}
|
||||||
|
ctx.self.lastSeen = millis();
|
||||||
|
ctx.self.status = NodeInfo::ACTIVE;
|
||||||
|
|
||||||
|
// Ensure member list has an entry for this node
|
||||||
|
auto &memberList = *ctx.memberList;
|
||||||
|
auto existing = memberList.find(ctx.hostname);
|
||||||
|
if (existing == memberList.end()) {
|
||||||
|
memberList[ctx.hostname] = ctx.self;
|
||||||
|
} else {
|
||||||
|
existing->second = ctx.self;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify listeners that the node is (re)discovered
|
||||||
|
ctx.fire("node_discovered", &ctx.self);
|
||||||
|
}
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
#include "NodeContext.h"
|
#include "spore/core/NodeContext.h"
|
||||||
|
|
||||||
NodeContext::NodeContext() {
|
NodeContext::NodeContext() {
|
||||||
scheduler = new Scheduler();
|
|
||||||
udp = new WiFiUDP();
|
udp = new WiFiUDP();
|
||||||
memberList = new std::map<String, NodeInfo>();
|
memberList = new std::map<String, NodeInfo>();
|
||||||
hostname = "";
|
hostname = "";
|
||||||
|
self.hostname = "";
|
||||||
|
self.ip = IPAddress();
|
||||||
|
self.lastSeen = 0;
|
||||||
|
self.status = NodeInfo::INACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
NodeContext::NodeContext(std::initializer_list<std::pair<String, String>> initialLabels) : NodeContext() {
|
||||||
|
for (const auto& kv : initialLabels) {
|
||||||
|
self.labels[kv.first] = kv.second;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
NodeContext::~NodeContext() {
|
NodeContext::~NodeContext() {
|
||||||
delete scheduler;
|
|
||||||
delete udp;
|
delete udp;
|
||||||
delete memberList;
|
delete memberList;
|
||||||
}
|
}
|
||||||
158
src/spore/core/TaskManager.cpp
Normal file
158
src/spore/core/TaskManager.cpp
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
#include "spore/core/TaskManager.h"
|
||||||
|
#include "spore/util/Logging.h"
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
TaskManager::TaskManager(NodeContext& ctx) : ctx(ctx) {}
|
||||||
|
|
||||||
|
TaskManager::~TaskManager() {
|
||||||
|
}
|
||||||
|
|
||||||
|
void TaskManager::registerTask(const std::string& name, unsigned long interval, TaskFunction callback, bool enabled, bool autoStart) {
|
||||||
|
TaskDefinition taskDef(name, interval, callback, enabled, autoStart);
|
||||||
|
registerTask(taskDef);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TaskManager::registerTask(const TaskDefinition& taskDef) {
|
||||||
|
taskDefinitions.push_back(taskDef);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TaskManager::initialize() {
|
||||||
|
// Ensure timing vector matches number of tasks
|
||||||
|
lastExecutionTimes.assign(taskDefinitions.size(), 0UL);
|
||||||
|
|
||||||
|
// Enable tasks that should auto-start
|
||||||
|
for (auto& taskDef : taskDefinitions) {
|
||||||
|
if (taskDef.autoStart && taskDef.enabled) {
|
||||||
|
taskDef.enabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TaskManager::enableTask(const std::string& name) {
|
||||||
|
int idx = findTaskIndex(name);
|
||||||
|
if (idx >= 0) {
|
||||||
|
taskDefinitions[idx].enabled = true;
|
||||||
|
LOG_INFO("TaskManager", "Enabled task: " + String(name.c_str()));
|
||||||
|
} else {
|
||||||
|
LOG_WARN("TaskManager", "Task not found: " + String(name.c_str()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TaskManager::disableTask(const std::string& name) {
|
||||||
|
int idx = findTaskIndex(name);
|
||||||
|
if (idx >= 0) {
|
||||||
|
taskDefinitions[idx].enabled = false;
|
||||||
|
LOG_INFO("TaskManager", "Disabled task: " + String(name.c_str()));
|
||||||
|
} else {
|
||||||
|
LOG_WARN("TaskManager", "Task not found: " + String(name.c_str()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TaskManager::setTaskInterval(const std::string& name, unsigned long interval) {
|
||||||
|
int idx = findTaskIndex(name);
|
||||||
|
if (idx >= 0) {
|
||||||
|
taskDefinitions[idx].interval = interval;
|
||||||
|
LOG_INFO("TaskManager", "Set interval for task " + String(name.c_str()) + ": " + String(interval) + " ms");
|
||||||
|
} else {
|
||||||
|
LOG_WARN("TaskManager", "Task not found: " + String(name.c_str()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TaskManager::startTask(const std::string& name) {
|
||||||
|
enableTask(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TaskManager::stopTask(const std::string& name) {
|
||||||
|
disableTask(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TaskManager::isTaskEnabled(const std::string& name) const {
|
||||||
|
int idx = findTaskIndex(name);
|
||||||
|
return idx >= 0 ? taskDefinitions[idx].enabled : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TaskManager::isTaskRunning(const std::string& name) const {
|
||||||
|
int idx = findTaskIndex(name);
|
||||||
|
return idx >= 0 ? taskDefinitions[idx].enabled : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned long TaskManager::getTaskInterval(const std::string& name) const {
|
||||||
|
int idx = findTaskIndex(name);
|
||||||
|
return idx >= 0 ? taskDefinitions[idx].interval : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TaskManager::enableAllTasks() {
|
||||||
|
for (auto& taskDef : taskDefinitions) {
|
||||||
|
taskDef.enabled = true;
|
||||||
|
}
|
||||||
|
LOG_INFO("TaskManager", "Enabled all tasks");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TaskManager::disableAllTasks() {
|
||||||
|
for (auto& taskDef : taskDefinitions) {
|
||||||
|
taskDef.enabled = false;
|
||||||
|
}
|
||||||
|
LOG_INFO("TaskManager", "Disabled all tasks");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TaskManager::printTaskStatus() const {
|
||||||
|
LOG_INFO("TaskManager", "\nTask Status:");
|
||||||
|
LOG_INFO("TaskManager", "==========================");
|
||||||
|
|
||||||
|
for (const auto& taskDef : taskDefinitions) {
|
||||||
|
LOG_INFO("TaskManager", " " + String(taskDef.name.c_str()) + ": " + (taskDef.enabled ? "ENABLED" : "DISABLED") + " (interval: " + String(taskDef.interval) + " ms)");
|
||||||
|
}
|
||||||
|
LOG_INFO("TaskManager", "==========================\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TaskManager::execute() {
|
||||||
|
// Ensure timing vector matches number of tasks
|
||||||
|
if (lastExecutionTimes.size() != taskDefinitions.size()) {
|
||||||
|
lastExecutionTimes.assign(taskDefinitions.size(), 0UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned long currentTime = millis();
|
||||||
|
|
||||||
|
for (size_t i = 0; i < taskDefinitions.size(); ++i) {
|
||||||
|
auto& taskDef = taskDefinitions[i];
|
||||||
|
|
||||||
|
if (taskDef.enabled) {
|
||||||
|
// Check if it's time to run this task
|
||||||
|
if (currentTime - lastExecutionTimes[i] >= taskDef.interval) {
|
||||||
|
if (taskDef.callback) {
|
||||||
|
taskDef.callback();
|
||||||
|
}
|
||||||
|
// Update the last execution time
|
||||||
|
lastExecutionTimes[i] = currentTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int TaskManager::findTaskIndex(const std::string& name) const {
|
||||||
|
for (size_t i = 0; i < taskDefinitions.size(); ++i) {
|
||||||
|
if (taskDefinitions[i].name == name) {
|
||||||
|
return static_cast<int>(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::pair<std::string, JsonObject>> TaskManager::getAllTaskStatuses(JsonDocument& doc) const {
|
||||||
|
std::vector<std::pair<std::string, JsonObject>> taskStatuses;
|
||||||
|
|
||||||
|
for (size_t i = 0; i < taskDefinitions.size(); ++i) {
|
||||||
|
const auto& taskDef = taskDefinitions[i];
|
||||||
|
|
||||||
|
JsonObject taskStatus = doc.add<JsonObject>();
|
||||||
|
taskStatus["name"] = taskDef.name;
|
||||||
|
taskStatus["interval"] = taskDef.interval;
|
||||||
|
taskStatus["enabled"] = taskDef.enabled;
|
||||||
|
taskStatus["running"] = taskDef.enabled; // For now, enabled = running
|
||||||
|
taskStatus["autoStart"] = taskDef.autoStart;
|
||||||
|
|
||||||
|
taskStatuses.push_back(std::make_pair(taskDef.name, taskStatus));
|
||||||
|
}
|
||||||
|
|
||||||
|
return taskStatuses;
|
||||||
|
}
|
||||||
19
src/spore/services/ClusterService.cpp
Normal file
19
src/spore/services/ClusterService.cpp
Normal file
@@ -0,0 +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>{}, "ClusterService");
|
||||||
|
}
|
||||||
|
|
||||||
|
void ClusterService::handleMembersRequest(AsyncWebServerRequest* request) {
|
||||||
|
ClusterMembersResponse response;
|
||||||
|
response.addNodes(ctx.memberList);
|
||||||
|
request->send(200, "application/json", response.toJsonString());
|
||||||
|
}
|
||||||
115
src/spore/services/MonitoringService.cpp
Normal file
115
src/spore/services/MonitoringService.cpp
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
#include "spore/services/MonitoringService.h"
|
||||||
|
#include "spore/core/ApiServer.h"
|
||||||
|
#include "spore/util/Logging.h"
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <FS.h>
|
||||||
|
#include <LittleFS.h>
|
||||||
|
|
||||||
|
MonitoringService::MonitoringService(CpuUsage& cpuUsage)
|
||||||
|
: cpuUsage(cpuUsage) {
|
||||||
|
}
|
||||||
|
|
||||||
|
void MonitoringService::registerEndpoints(ApiServer& api) {
|
||||||
|
api.addEndpoint("/api/monitoring/resources", HTTP_GET,
|
||||||
|
[this](AsyncWebServerRequest* request) { handleResourcesRequest(request); },
|
||||||
|
std::vector<ParamSpec>{}, "MonitoringService");
|
||||||
|
}
|
||||||
|
|
||||||
|
MonitoringService::SystemResources MonitoringService::getSystemResources() const {
|
||||||
|
SystemResources resources;
|
||||||
|
|
||||||
|
// CPU information
|
||||||
|
resources.currentCpuUsage = cpuUsage.getCpuUsage();
|
||||||
|
resources.averageCpuUsage = cpuUsage.getAverageCpuUsage();
|
||||||
|
resources.maxCpuUsage = cpuUsage.getMaxCpuUsage();
|
||||||
|
resources.minCpuUsage = cpuUsage.getMinCpuUsage();
|
||||||
|
resources.measurementCount = cpuUsage.getMeasurementCount();
|
||||||
|
resources.isMeasuring = cpuUsage.isMeasuring();
|
||||||
|
|
||||||
|
// Memory information - ESP8266 compatible
|
||||||
|
resources.freeHeap = ESP.getFreeHeap();
|
||||||
|
resources.totalHeap = 81920; // ESP8266 has ~80KB RAM
|
||||||
|
resources.minFreeHeap = 0; // Not available on ESP8266
|
||||||
|
resources.maxAllocHeap = 0; // Not available on ESP8266
|
||||||
|
resources.heapFragmentation = calculateHeapFragmentation();
|
||||||
|
|
||||||
|
// Filesystem information
|
||||||
|
getFilesystemInfo(resources.totalBytes, resources.usedBytes);
|
||||||
|
resources.freeBytes = resources.totalBytes - resources.usedBytes;
|
||||||
|
resources.usagePercent = resources.totalBytes > 0 ?
|
||||||
|
(float)resources.usedBytes / (float)resources.totalBytes * 100.0f : 0.0f;
|
||||||
|
|
||||||
|
// System uptime
|
||||||
|
resources.uptimeMs = millis();
|
||||||
|
resources.uptimeSeconds = resources.uptimeMs / 1000;
|
||||||
|
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MonitoringService::handleResourcesRequest(AsyncWebServerRequest* request) {
|
||||||
|
SystemResources resources = getSystemResources();
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
|
||||||
|
// CPU section
|
||||||
|
JsonObject cpu = doc["cpu"].to<JsonObject>();
|
||||||
|
cpu["current_usage"] = resources.currentCpuUsage;
|
||||||
|
cpu["average_usage"] = resources.averageCpuUsage;
|
||||||
|
cpu["max_usage"] = resources.maxCpuUsage;
|
||||||
|
cpu["min_usage"] = resources.minCpuUsage;
|
||||||
|
cpu["measurement_count"] = resources.measurementCount;
|
||||||
|
cpu["is_measuring"] = resources.isMeasuring;
|
||||||
|
|
||||||
|
// Memory section
|
||||||
|
JsonObject memory = doc["memory"].to<JsonObject>();
|
||||||
|
memory["free_heap"] = resources.freeHeap;
|
||||||
|
memory["total_heap"] = resources.totalHeap;
|
||||||
|
memory["min_free_heap"] = resources.minFreeHeap;
|
||||||
|
memory["max_alloc_heap"] = resources.maxAllocHeap;
|
||||||
|
memory["heap_fragmentation"] = resources.heapFragmentation;
|
||||||
|
memory["heap_usage_percent"] = resources.totalHeap > 0 ?
|
||||||
|
(float)(resources.totalHeap - resources.freeHeap) / (float)resources.totalHeap * 100.0f : 0.0f;
|
||||||
|
|
||||||
|
// Filesystem section
|
||||||
|
JsonObject filesystem = doc["filesystem"].to<JsonObject>();
|
||||||
|
filesystem["total_bytes"] = resources.totalBytes;
|
||||||
|
filesystem["used_bytes"] = resources.usedBytes;
|
||||||
|
filesystem["free_bytes"] = resources.freeBytes;
|
||||||
|
filesystem["usage_percent"] = resources.usagePercent;
|
||||||
|
|
||||||
|
// System section
|
||||||
|
JsonObject system = doc["system"].to<JsonObject>();
|
||||||
|
system["uptime_ms"] = resources.uptimeMs;
|
||||||
|
system["uptime_seconds"] = resources.uptimeSeconds;
|
||||||
|
system["uptime_formatted"] = String(resources.uptimeSeconds / 3600) + "h " +
|
||||||
|
String((resources.uptimeSeconds % 3600) / 60) + "m " +
|
||||||
|
String(resources.uptimeSeconds % 60) + "s";
|
||||||
|
|
||||||
|
String json;
|
||||||
|
serializeJson(doc, json);
|
||||||
|
request->send(200, "application/json", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t MonitoringService::calculateHeapFragmentation() const {
|
||||||
|
size_t freeHeap = ESP.getFreeHeap();
|
||||||
|
size_t maxAllocHeap = 0; // Not available on ESP8266
|
||||||
|
|
||||||
|
if (maxAllocHeap == 0) return 0;
|
||||||
|
|
||||||
|
// Calculate fragmentation as percentage of free heap that can't be allocated in one block
|
||||||
|
return (freeHeap - maxAllocHeap) * 100 / freeHeap;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MonitoringService::getFilesystemInfo(size_t& totalBytes, size_t& usedBytes) const {
|
||||||
|
totalBytes = 0;
|
||||||
|
usedBytes = 0;
|
||||||
|
|
||||||
|
if (LittleFS.begin()) {
|
||||||
|
FSInfo fsInfo;
|
||||||
|
if (LittleFS.info(fsInfo)) {
|
||||||
|
totalBytes = fsInfo.totalBytes;
|
||||||
|
usedBytes = fsInfo.usedBytes;
|
||||||
|
}
|
||||||
|
LittleFS.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
132
src/spore/services/NetworkService.cpp
Normal file
132
src/spore/services/NetworkService.cpp
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
#include "spore/services/NetworkService.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
NetworkService::NetworkService(NetworkManager& networkManager)
|
||||||
|
: networkManager(networkManager) {}
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
api.addEndpoint("/api/network/wifi/scan", HTTP_GET,
|
||||||
|
[this](AsyncWebServerRequest* request) { handleGetWifiNetworks(request); },
|
||||||
|
std::vector<ParamSpec>{}, "NetworkService");
|
||||||
|
|
||||||
|
// Network status and configuration endpoints
|
||||||
|
api.addEndpoint("/api/network/status", HTTP_GET,
|
||||||
|
[this](AsyncWebServerRequest* request) { handleNetworkStatus(request); },
|
||||||
|
std::vector<ParamSpec>{}, "NetworkService");
|
||||||
|
|
||||||
|
api.addEndpoint("/api/network/wifi/config", HTTP_POST,
|
||||||
|
[this](AsyncWebServerRequest* request) { handleSetWifiConfig(request); },
|
||||||
|
std::vector<ParamSpec>{
|
||||||
|
ParamSpec{String("ssid"), true, String("body"), String("string"), {}, String("")},
|
||||||
|
ParamSpec{String("password"), true, String("body"), String("string"), {}, String("")},
|
||||||
|
ParamSpec{String("connect_timeout_ms"), false, String("body"), String("number"), {}, String("10000")},
|
||||||
|
ParamSpec{String("retry_delay_ms"), false, String("body"), String("number"), {}, String("500")}
|
||||||
|
}, "NetworkService");
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetworkService::handleWifiScanRequest(AsyncWebServerRequest* request) {
|
||||||
|
networkManager.scanWifi();
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["status"] = "scanning";
|
||||||
|
doc["message"] = "WiFi scan started";
|
||||||
|
|
||||||
|
String json;
|
||||||
|
serializeJson(doc, json);
|
||||||
|
request->send(200, "application/json", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetworkService::handleGetWifiNetworks(AsyncWebServerRequest* request) {
|
||||||
|
auto accessPoints = networkManager.getAccessPoints();
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["access_points"].to<JsonArray>();
|
||||||
|
|
||||||
|
for (const auto& ap : accessPoints) {
|
||||||
|
JsonObject apObj = doc["access_points"].add<JsonObject>();
|
||||||
|
apObj["ssid"] = ap.ssid;
|
||||||
|
apObj["rssi"] = ap.rssi;
|
||||||
|
apObj["channel"] = ap.channel;
|
||||||
|
apObj["encryption_type"] = ap.encryptionType;
|
||||||
|
apObj["hidden"] = ap.isHidden;
|
||||||
|
|
||||||
|
// Convert BSSID to string
|
||||||
|
char bssid[18];
|
||||||
|
sprintf(bssid, "%02X:%02X:%02X:%02X:%02X:%02X",
|
||||||
|
ap.bssid[0], ap.bssid[1], ap.bssid[2],
|
||||||
|
ap.bssid[3], ap.bssid[4], ap.bssid[5]);
|
||||||
|
apObj["bssid"] = bssid;
|
||||||
|
}
|
||||||
|
|
||||||
|
String json;
|
||||||
|
serializeJson(doc, json);
|
||||||
|
request->send(200, "application/json", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetworkService::handleNetworkStatus(AsyncWebServerRequest* request) {
|
||||||
|
JsonDocument doc;
|
||||||
|
|
||||||
|
// WiFi status
|
||||||
|
doc["wifi"]["connected"] = networkManager.isConnected();
|
||||||
|
doc["wifi"]["mode"] = networkManager.getMode() == WIFI_AP ? "AP" : "STA";
|
||||||
|
doc["wifi"]["ssid"] = networkManager.getSSID();
|
||||||
|
doc["wifi"]["ip"] = networkManager.getLocalIP().toString();
|
||||||
|
doc["wifi"]["mac"] = networkManager.getMacAddress();
|
||||||
|
doc["wifi"]["hostname"] = networkManager.getHostname();
|
||||||
|
doc["wifi"]["rssi"] = networkManager.getRSSI();
|
||||||
|
|
||||||
|
// If in AP mode, add AP specific info
|
||||||
|
if (networkManager.getMode() == WIFI_AP) {
|
||||||
|
doc["wifi"]["ap_ip"] = networkManager.getAPIP().toString();
|
||||||
|
doc["wifi"]["ap_mac"] = networkManager.getAPMacAddress();
|
||||||
|
doc["wifi"]["stations_connected"] = networkManager.getConnectedStations();
|
||||||
|
}
|
||||||
|
|
||||||
|
String json;
|
||||||
|
serializeJson(doc, json);
|
||||||
|
request->send(200, "application/json", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetworkService::handleSetWifiConfig(AsyncWebServerRequest* request) {
|
||||||
|
if (!request->hasParam("ssid", true) || !request->hasParam("password", true)) {
|
||||||
|
request->send(400, "application/json", "{\"error\": \"Missing required parameters\"}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String ssid = request->getParam("ssid", true)->value();
|
||||||
|
String password = request->getParam("password", true)->value();
|
||||||
|
|
||||||
|
// Optional parameters with defaults
|
||||||
|
uint32_t connect_timeout_ms = 10000; // Default 10 seconds
|
||||||
|
uint32_t retry_delay_ms = 500; // Default 500ms
|
||||||
|
|
||||||
|
if (request->hasParam("connect_timeout_ms", true)) {
|
||||||
|
connect_timeout_ms = request->getParam("connect_timeout_ms", true)->value().toInt();
|
||||||
|
}
|
||||||
|
if (request->hasParam("retry_delay_ms", true)) {
|
||||||
|
retry_delay_ms = request->getParam("retry_delay_ms", true)->value().toInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update configuration
|
||||||
|
networkManager.setWiFiConfig(ssid, password, connect_timeout_ms, retry_delay_ms);
|
||||||
|
|
||||||
|
// Attempt to connect with new settings
|
||||||
|
networkManager.setupWiFi();
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["status"] = "success";
|
||||||
|
doc["message"] = "WiFi configuration updated";
|
||||||
|
doc["connected"] = WiFi.isConnected();
|
||||||
|
if (WiFi.isConnected()) {
|
||||||
|
doc["ip"] = WiFi.localIP().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
String json;
|
||||||
|
serializeJson(doc, json);
|
||||||
|
request->send(200, "application/json", json);
|
||||||
|
}
|
||||||
124
src/spore/services/NodeService.cpp
Normal file
124
src/spore/services/NodeService.cpp
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
#include "spore/services/NodeService.h"
|
||||||
|
#include "spore/core/ApiServer.h"
|
||||||
|
#include "spore/util/Logging.h"
|
||||||
|
#include "spore/types/NodeResponse.h"
|
||||||
|
|
||||||
|
using spore::types::NodeStatusResponse;
|
||||||
|
using spore::types::NodeOperationResponse;
|
||||||
|
using spore::types::NodeEndpointsResponse;
|
||||||
|
|
||||||
|
NodeService::NodeService(NodeContext& ctx, ApiServer& apiServer) : ctx(ctx), apiServer(apiServer) {}
|
||||||
|
|
||||||
|
void NodeService::registerEndpoints(ApiServer& api) {
|
||||||
|
// Status endpoint
|
||||||
|
api.addEndpoint("/api/node/status", HTTP_GET,
|
||||||
|
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
|
||||||
|
std::vector<ParamSpec>{}, "NodeService");
|
||||||
|
|
||||||
|
// Update endpoint with file upload
|
||||||
|
api.addEndpoint("/api/node/update", HTTP_POST,
|
||||||
|
[this](AsyncWebServerRequest* request) { handleUpdateRequest(request); },
|
||||||
|
[this](AsyncWebServerRequest* request, const String& filename, size_t index, uint8_t* data, size_t len, bool final) {
|
||||||
|
handleUpdateUpload(request, filename, index, data, len, final);
|
||||||
|
},
|
||||||
|
std::vector<ParamSpec>{
|
||||||
|
ParamSpec{String("firmware"), true, String("body"), String("file"), {}, String("")}
|
||||||
|
}, "NodeService");
|
||||||
|
|
||||||
|
// Restart endpoint
|
||||||
|
api.addEndpoint("/api/node/restart", HTTP_POST,
|
||||||
|
[this](AsyncWebServerRequest* request) { handleRestartRequest(request); },
|
||||||
|
std::vector<ParamSpec>{}, "NodeService");
|
||||||
|
|
||||||
|
// Endpoints endpoint
|
||||||
|
api.addEndpoint("/api/node/endpoints", HTTP_GET,
|
||||||
|
[this](AsyncWebServerRequest* request) { handleEndpointsRequest(request); },
|
||||||
|
std::vector<ParamSpec>{}, "NodeService");
|
||||||
|
}
|
||||||
|
|
||||||
|
void NodeService::handleStatusRequest(AsyncWebServerRequest* request) {
|
||||||
|
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()) {
|
||||||
|
labels = it->second.labels;
|
||||||
|
} else if (!ctx.self.labels.empty()) {
|
||||||
|
labels = ctx.self.labels;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.buildCompleteResponse(labels);
|
||||||
|
request->send(200, "application/json", response.toJsonString());
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
request->onDisconnect([this]() {
|
||||||
|
LOG_INFO("API", "Restart device");
|
||||||
|
delay(10);
|
||||||
|
ESP.restart();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void NodeService::handleUpdateUpload(AsyncWebServerRequest* request, const String& filename,
|
||||||
|
size_t index, uint8_t* data, size_t len, bool final) {
|
||||||
|
if (!index) {
|
||||||
|
LOG_INFO("OTA", "Update Start " + String(filename));
|
||||||
|
Update.runAsync(true);
|
||||||
|
if(!Update.begin(request->contentLength(), U_FLASH)) {
|
||||||
|
LOG_ERROR("OTA", "Update failed: not enough space");
|
||||||
|
Update.printError(Serial);
|
||||||
|
AsyncWebServerResponse* response = request->beginResponse(500, "application/json",
|
||||||
|
"{\"status\": \"FAIL\"}");
|
||||||
|
response->addHeader("Connection", "close");
|
||||||
|
request->send(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Update.hasError()) {
|
||||||
|
if (Update.write(data, len) != len) {
|
||||||
|
Update.printError(Serial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (final) {
|
||||||
|
if (Update.end(true)) {
|
||||||
|
if(Update.isFinished()) {
|
||||||
|
LOG_INFO("OTA", "Update Success with " + String(index + len) + "B");
|
||||||
|
} else {
|
||||||
|
LOG_WARN("OTA", "Update not finished");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LOG_ERROR("OTA", "Update failed: " + String(Update.getError()));
|
||||||
|
Update.printError(Serial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
request->onDisconnect([this]() {
|
||||||
|
LOG_INFO("API", "Restart device");
|
||||||
|
delay(10);
|
||||||
|
ESP.restart();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void NodeService::handleEndpointsRequest(AsyncWebServerRequest* request) {
|
||||||
|
NodeEndpointsResponse response;
|
||||||
|
response.addEndpoints(apiServer.getEndpoints());
|
||||||
|
request->send(200, "application/json", response.toJsonString());
|
||||||
|
}
|
||||||
22
src/spore/services/StaticFileService.cpp
Normal file
22
src/spore/services/StaticFileService.cpp
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#include "spore/services/StaticFileService.h"
|
||||||
|
#include "spore/util/Logging.h"
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
const String StaticFileService::name = "StaticFileService";
|
||||||
|
|
||||||
|
StaticFileService::StaticFileService(NodeContext& ctx, ApiServer& apiServer)
|
||||||
|
: ctx(ctx), apiServer(apiServer) {
|
||||||
|
}
|
||||||
|
|
||||||
|
void StaticFileService::registerEndpoints(ApiServer& api) {
|
||||||
|
// Initialize LittleFS
|
||||||
|
if (!LittleFS.begin()) {
|
||||||
|
LOG_ERROR("StaticFileService", "LittleFS Mount Failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LOG_INFO("StaticFileService", "LittleFS mounted successfully");
|
||||||
|
|
||||||
|
// Use the built-in static file serving from ESPAsyncWebServer
|
||||||
|
api.serveStatic("/", LittleFS, "/public", "max-age=3600");
|
||||||
|
}
|
||||||
|
|
||||||
101
src/spore/services/TaskService.cpp
Normal file
101
src/spore/services/TaskService.cpp
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
#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");
|
||||||
|
|
||||||
|
api.addEndpoint("/api/tasks/control", HTTP_POST,
|
||||||
|
[this](AsyncWebServerRequest* request) { handleControlRequest(request); },
|
||||||
|
std::vector<ParamSpec>{
|
||||||
|
ParamSpec{
|
||||||
|
String("task"),
|
||||||
|
true,
|
||||||
|
String("body"),
|
||||||
|
String("string"),
|
||||||
|
{},
|
||||||
|
String("")
|
||||||
|
},
|
||||||
|
ParamSpec{
|
||||||
|
String("action"),
|
||||||
|
true,
|
||||||
|
String("body"),
|
||||||
|
String("string"),
|
||||||
|
{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());
|
||||||
|
}
|
||||||
|
|
||||||
|
void TaskService::handleControlRequest(AsyncWebServerRequest* request) {
|
||||||
|
if (request->hasParam("task", true) && request->hasParam("action", true)) {
|
||||||
|
String taskName = request->getParam("task", true)->value();
|
||||||
|
String action = request->getParam("action", true)->value();
|
||||||
|
|
||||||
|
bool success = false;
|
||||||
|
String message = "";
|
||||||
|
|
||||||
|
if (action == "enable") {
|
||||||
|
taskManager.enableTask(taskName.c_str());
|
||||||
|
success = true;
|
||||||
|
message = "Task enabled";
|
||||||
|
} else if (action == "disable") {
|
||||||
|
taskManager.disableTask(taskName.c_str());
|
||||||
|
success = true;
|
||||||
|
message = "Task disabled";
|
||||||
|
} else if (action == "start") {
|
||||||
|
taskManager.startTask(taskName.c_str());
|
||||||
|
success = true;
|
||||||
|
message = "Task started";
|
||||||
|
} else if (action == "stop") {
|
||||||
|
taskManager.stopTask(taskName.c_str());
|
||||||
|
success = true;
|
||||||
|
message = "Task stopped";
|
||||||
|
} else if (action == "status") {
|
||||||
|
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()));
|
||||||
|
|
||||||
|
request->send(200, "application/json", response.toJsonString());
|
||||||
|
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());
|
||||||
|
} else {
|
||||||
|
TaskControlResponse response;
|
||||||
|
response.setError("Missing parameters. Required: task, action",
|
||||||
|
"{\"task\": \"discovery_send\", \"action\": \"status\"}");
|
||||||
|
request->send(400, "application/json", response.toJsonString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
#include "Config.h"
|
#include "spore/types/Config.h"
|
||||||
|
|
||||||
Config::Config() {
|
Config::Config() {
|
||||||
// WiFi Configuration
|
// WiFi Configuration
|
||||||
@@ -28,4 +28,9 @@ Config::Config() {
|
|||||||
// System Configuration
|
// System Configuration
|
||||||
restart_delay_ms = 10;
|
restart_delay_ms = 10;
|
||||||
json_doc_size = 1024;
|
json_doc_size = 1024;
|
||||||
|
|
||||||
|
// Memory Management
|
||||||
|
low_memory_threshold_bytes = 10000; // 10KB
|
||||||
|
critical_memory_threshold_bytes = 5000; // 5KB
|
||||||
|
max_concurrent_http_requests = 3;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#include "NodeInfo.h"
|
#include "spore/types/NodeInfo.h"
|
||||||
|
#include "spore/internal/Globals.h"
|
||||||
|
|
||||||
const char* statusToStr(NodeInfo::Status status) {
|
const char* statusToStr(NodeInfo::Status status) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -14,7 +15,7 @@ void updateNodeStatus(NodeInfo &node, unsigned long now, unsigned long inactive_
|
|||||||
if (diff < inactive_threshold) {
|
if (diff < inactive_threshold) {
|
||||||
node.status = NodeInfo::ACTIVE;
|
node.status = NodeInfo::ACTIVE;
|
||||||
} else if (diff < dead_threshold) {
|
} else if (diff < dead_threshold) {
|
||||||
node.status = NodeInfo::DEAD;
|
node.status = NodeInfo::INACTIVE;
|
||||||
} else {
|
} else {
|
||||||
node.status = NodeInfo::DEAD;
|
node.status = NodeInfo::DEAD;
|
||||||
}
|
}
|
||||||
185
src/spore/util/CpuUsage.cpp
Normal file
185
src/spore/util/CpuUsage.cpp
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
#include "spore/util/CpuUsage.h"
|
||||||
|
|
||||||
|
CpuUsage::CpuUsage()
|
||||||
|
: _initialized(false)
|
||||||
|
, _measuring(false)
|
||||||
|
, _measurementCount(0)
|
||||||
|
, _cycleStartTime(0)
|
||||||
|
, _idleStartTime(0)
|
||||||
|
, _totalIdleTime(0)
|
||||||
|
, _totalCycleTime(0)
|
||||||
|
, _currentCpuUsage(0.0f)
|
||||||
|
, _averageCpuUsage(0.0f)
|
||||||
|
, _maxCpuUsage(0.0f)
|
||||||
|
, _minCpuUsage(100.0f)
|
||||||
|
, _totalCpuTime(0)
|
||||||
|
, _rollingIndex(0)
|
||||||
|
, _rollingWindowFull(false) {
|
||||||
|
|
||||||
|
// Initialize rolling window
|
||||||
|
for (size_t i = 0; i < ROLLING_WINDOW_SIZE; ++i) {
|
||||||
|
_rollingWindow[i] = 0.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CpuUsage::begin() {
|
||||||
|
if (_initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_initialized = true;
|
||||||
|
_measurementCount = 0;
|
||||||
|
_totalIdleTime = 0;
|
||||||
|
_totalCycleTime = 0;
|
||||||
|
_totalCpuTime = 0;
|
||||||
|
_currentCpuUsage = 0.0f;
|
||||||
|
_averageCpuUsage = 0.0f;
|
||||||
|
_maxCpuUsage = 0.0f;
|
||||||
|
_minCpuUsage = 100.0f;
|
||||||
|
_rollingIndex = 0;
|
||||||
|
_rollingWindowFull = false;
|
||||||
|
|
||||||
|
// Initialize rolling window
|
||||||
|
for (size_t i = 0; i < ROLLING_WINDOW_SIZE; ++i) {
|
||||||
|
_rollingWindow[i] = 0.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CpuUsage::startMeasurement() {
|
||||||
|
if (!_initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_measuring) {
|
||||||
|
// If already measuring, end the previous measurement first
|
||||||
|
endMeasurement();
|
||||||
|
}
|
||||||
|
|
||||||
|
_measuring = true;
|
||||||
|
_cycleStartTime = millis();
|
||||||
|
_idleStartTime = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CpuUsage::endMeasurement() {
|
||||||
|
if (!_initialized || !_measuring) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned long cycleEndTime = millis();
|
||||||
|
unsigned long cycleDuration = cycleEndTime - _cycleStartTime;
|
||||||
|
|
||||||
|
// Calculate idle time (time spent in yield() calls)
|
||||||
|
unsigned long idleTime = cycleEndTime - _idleStartTime;
|
||||||
|
|
||||||
|
// Calculate CPU usage
|
||||||
|
if (cycleDuration > 0) {
|
||||||
|
_currentCpuUsage = ((float)(cycleDuration - idleTime) / (float)cycleDuration) * 100.0f;
|
||||||
|
|
||||||
|
// Clamp to valid range
|
||||||
|
if (_currentCpuUsage < 0.0f) {
|
||||||
|
_currentCpuUsage = 0.0f;
|
||||||
|
} else if (_currentCpuUsage > 100.0f) {
|
||||||
|
_currentCpuUsage = 100.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update statistics
|
||||||
|
_totalCycleTime += cycleDuration;
|
||||||
|
_totalIdleTime += idleTime;
|
||||||
|
_totalCpuTime += (cycleDuration - idleTime);
|
||||||
|
_measurementCount++;
|
||||||
|
|
||||||
|
// Update rolling average
|
||||||
|
updateRollingAverage(_currentCpuUsage);
|
||||||
|
|
||||||
|
// Update min/max
|
||||||
|
updateMinMax(_currentCpuUsage);
|
||||||
|
|
||||||
|
// Calculate overall average
|
||||||
|
if (_measurementCount > 0) {
|
||||||
|
_averageCpuUsage = ((float)_totalCpuTime / (float)_totalCycleTime) * 100.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_measuring = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
float CpuUsage::getCpuUsage() const {
|
||||||
|
return _currentCpuUsage;
|
||||||
|
}
|
||||||
|
|
||||||
|
float CpuUsage::getAverageCpuUsage() const {
|
||||||
|
if (_rollingWindowFull) {
|
||||||
|
return _averageCpuUsage;
|
||||||
|
} else if (_measurementCount > 0) {
|
||||||
|
// Calculate average from rolling window
|
||||||
|
float sum = 0.0f;
|
||||||
|
for (size_t i = 0; i < _rollingIndex; ++i) {
|
||||||
|
sum += _rollingWindow[i];
|
||||||
|
}
|
||||||
|
return sum / (float)_rollingIndex;
|
||||||
|
}
|
||||||
|
return 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
float CpuUsage::getMaxCpuUsage() const {
|
||||||
|
return _maxCpuUsage;
|
||||||
|
}
|
||||||
|
|
||||||
|
float CpuUsage::getMinCpuUsage() const {
|
||||||
|
return _minCpuUsage;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CpuUsage::reset() {
|
||||||
|
_measurementCount = 0;
|
||||||
|
_totalIdleTime = 0;
|
||||||
|
_totalCycleTime = 0;
|
||||||
|
_totalCpuTime = 0;
|
||||||
|
_currentCpuUsage = 0.0f;
|
||||||
|
_averageCpuUsage = 0.0f;
|
||||||
|
_maxCpuUsage = 0.0f;
|
||||||
|
_minCpuUsage = 100.0f;
|
||||||
|
_rollingIndex = 0;
|
||||||
|
_rollingWindowFull = false;
|
||||||
|
|
||||||
|
// Reset rolling window
|
||||||
|
for (size_t i = 0; i < ROLLING_WINDOW_SIZE; ++i) {
|
||||||
|
_rollingWindow[i] = 0.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CpuUsage::isMeasuring() const {
|
||||||
|
return _measuring;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned long CpuUsage::getMeasurementCount() const {
|
||||||
|
return _measurementCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CpuUsage::updateRollingAverage(float value) {
|
||||||
|
_rollingWindow[_rollingIndex] = value;
|
||||||
|
_rollingIndex++;
|
||||||
|
|
||||||
|
if (_rollingIndex >= ROLLING_WINDOW_SIZE) {
|
||||||
|
_rollingIndex = 0;
|
||||||
|
_rollingWindowFull = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate rolling average
|
||||||
|
float sum = 0.0f;
|
||||||
|
size_t count = _rollingWindowFull ? ROLLING_WINDOW_SIZE : _rollingIndex;
|
||||||
|
|
||||||
|
for (size_t i = 0; i < count; ++i) {
|
||||||
|
sum += _rollingWindow[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
_averageCpuUsage = sum / (float)count;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CpuUsage::updateMinMax(float value) {
|
||||||
|
if (value > _maxCpuUsage) {
|
||||||
|
_maxCpuUsage = value;
|
||||||
|
}
|
||||||
|
if (value < _minCpuUsage) {
|
||||||
|
_minCpuUsage = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/spore/util/Logging.cpp
Normal file
47
src/spore/util/Logging.cpp
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
#include "spore/util/Logging.h"
|
||||||
|
|
||||||
|
// Global log level - defaults to INFO
|
||||||
|
LogLevel g_logLevel = LogLevel::INFO;
|
||||||
|
|
||||||
|
void logMessage(LogLevel level, const String& component, const String& message) {
|
||||||
|
// Skip if below current log level
|
||||||
|
if (level < g_logLevel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format: [timestamp] [level] [component] message
|
||||||
|
String timestamp = String(millis());
|
||||||
|
String levelStr;
|
||||||
|
|
||||||
|
switch (level) {
|
||||||
|
case LogLevel::DEBUG: levelStr = "DEBUG"; break;
|
||||||
|
case LogLevel::INFO: levelStr = "INFO"; break;
|
||||||
|
case LogLevel::WARN: levelStr = "WARN"; break;
|
||||||
|
case LogLevel::ERROR: levelStr = "ERROR"; break;
|
||||||
|
default: levelStr = "UNKNOWN"; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatted = "[" + timestamp + "] [" + levelStr + "] [" + component + "] " + message;
|
||||||
|
Serial.println(formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
void logDebug(const String& component, const String& message) {
|
||||||
|
logMessage(LogLevel::DEBUG, component, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
void logInfo(const String& component, const String& message) {
|
||||||
|
logMessage(LogLevel::INFO, component, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
void logWarn(const String& component, const String& message) {
|
||||||
|
logMessage(LogLevel::WARN, component, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
void logError(const String& component, const String& message) {
|
||||||
|
logMessage(LogLevel::ERROR, component, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setLogLevel(LogLevel level) {
|
||||||
|
g_logLevel = level;
|
||||||
|
logInfo("Logging", "Log level set to " + String((int)level));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user