Compare commits
107 Commits
81bc70fa83
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e60093c419 | |||
| 633957c95c | |||
| ad879bfe7b | |||
| 4559e13d7d | |||
| 682849650d | |||
| 0f003335b3 | |||
| eab10cffa5 | |||
| 7f40626187 | |||
| e796375a9f | |||
| daae29dd3f | |||
| 37a68e26d8 | |||
| 7bd3e87271 | |||
| 0d09c5900c | |||
| 407b651b82 | |||
| 23289d9f09 | |||
| b6ad479352 | |||
| 3ed44cd00f | |||
| ce70830678 | |||
| b404852fc7 | |||
| 7063b1ab16 | |||
| 1a74d1fe90 | |||
| 993a431310 | |||
| 7a7400422e | |||
| a45871625e | |||
| 52f9098c1b | |||
| 0fcebc0459 | |||
| d07c1f40de | |||
| 3077f8685b | |||
| 3ff5df5a6f | |||
| be1a2f1e21 | |||
| 1383f6d32f | |||
| d1fb5fc96e | |||
| f78dd8b843 | |||
| f3d99b174f | |||
| 99e7ed4809 | |||
| dbb3b5bfd3 | |||
| 2aa887a037 | |||
| 49fe0dabf4 | |||
| c0bf16fecb | |||
| af46b5c2f4 | |||
| 7a37901fb2 | |||
| 3cc5405292 | |||
| f0df84dc87 | |||
| 950142bf7f | |||
| b9b91d71b5 | |||
| 0e6adc999f | |||
| f4ccb1c7ef | |||
| 8da9f77441 | |||
| cabf857bbd | |||
| 2bb0742850 | |||
| 69bc3fc829 | |||
| eaeb9bbea8 | |||
| 096cf12704 | |||
| 356ec3d381 | |||
| 51bd7bd909 | |||
| 921eec3848 | |||
| 921e2c7152 | |||
| 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 | |||
| fd89c8e7eb | |||
| 3124a7f2db | |||
| 175ed8cae8 | |||
| 9af056283d | |||
| 1738017fd5 | |||
| 5870695465 |
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
|
||||
276
README.md
276
README.md
@@ -1,51 +1,271 @@
|
||||
# SPORE
|
||||
|
||||
SProcket ORchestration Engine
|
||||
> **S**Procket **OR**chestration **E**ngine
|
||||
|
||||
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)
|
||||
- [Cluster Broadcast](#cluster-broadcast)
|
||||
- [Streaming API](#streaming-api)
|
||||
- [API Reference](#api-reference)
|
||||
- [Configuration](#configuration)
|
||||
- [Development](#development)
|
||||
- [Current Limitations](#current-limitations)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Documentation](#documentation)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
- [Acknowledgments](#acknowledgments)
|
||||
|
||||
## Features
|
||||
|
||||
- WiFi STA / AP
|
||||
- node auto discovery over UDP
|
||||
- service registry
|
||||
- pub/sub event system
|
||||
- Over-The-Air updates
|
||||
- **WiFi Management**: Automatic WiFi STA/AP configuration with MAC-based hostname generation
|
||||
- **Auto Discovery**: UDP-based node discovery with automatic cluster membership
|
||||
- **Service Registry**: Dynamic API endpoint discovery and registration
|
||||
- **Health Monitoring**: Real-time node status tracking with resource monitoring
|
||||
- **Event System**: Local and cluster-wide event publishing/subscription
|
||||
- **Cluster Broadcast**: Centralized UDP broadcast of events (CLUSTER_EVENT)
|
||||
- **Streaming API**: WebSocket bridge for real-time event send/receive
|
||||
- **Over-The-Air Updates**: Seamless firmware updates across the cluster
|
||||
- **REST API**: HTTP-based cluster management and monitoring
|
||||
- **Capability Discovery**: Automatic API endpoint and service capability detection
|
||||
|
||||
## Supported Hardware
|
||||
|
||||
- ESP8266
|
||||
- **ESP-01 / ESP-01S** (1MB Flash)
|
||||
- **Wemos D1** (4MB Flash)
|
||||
- Other ESP8266 boards with 1MB+ flash
|
||||
|
||||
## Architecture
|
||||
|
||||
SPORE uses a modular architecture with automatic node discovery, health monitoring, and distributed task management.
|
||||
|
||||
**Core Components:**
|
||||
- **Spore Framework**: Main framework class that orchestrates all components
|
||||
- **Network Manager**: WiFi connection handling and hostname configuration
|
||||
- **Cluster Manager**: Node discovery, member list management, and health monitoring
|
||||
- **API Server**: HTTP API server with dynamic endpoint registration
|
||||
- **Task Scheduler**: Cooperative multitasking system for background operations
|
||||
- **Node Context**: Central context providing event system and shared resources
|
||||
|
||||
**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
|
||||
|
||||
📖 **Detailed Architecture:** See [`docs/Architecture.md`](./docs/Architecture.md) for comprehensive system design and implementation details.
|
||||
|
||||
## Quick Start
|
||||
|
||||
The Spore framework provides a simple, unified interface for all core functionality:
|
||||
|
||||
```cpp
|
||||
#include <Arduino.h>
|
||||
#include "Spore.h"
|
||||
|
||||
// Create Spore instance with custom labels
|
||||
Spore spore({
|
||||
{"app", "my_app"},
|
||||
{"role", "controller"}
|
||||
});
|
||||
|
||||
void setup() {
|
||||
spore.setup();
|
||||
spore.begin();
|
||||
}
|
||||
|
||||
void loop() {
|
||||
spore.loop();
|
||||
}
|
||||
```
|
||||
|
||||
**Adding Custom Services:**
|
||||
```cpp
|
||||
void setup() {
|
||||
spore.setup();
|
||||
|
||||
// Create and register custom services
|
||||
RelayService* relayService = new RelayService(spore.getContext(), spore.getTaskManager(), 2);
|
||||
spore.registerService(relayService);
|
||||
|
||||
// Or using smart pointers
|
||||
auto sensorService = std::make_shared<SensorService>(spore.getContext(), spore.getTaskManager());
|
||||
spore.registerService(sensorService);
|
||||
|
||||
// Start the API server and complete initialization
|
||||
spore.begin();
|
||||
}
|
||||
```
|
||||
|
||||
**Examples:** See [`examples/base/`](./examples/base/) for basic usage and [`examples/relay/`](./examples/relay/) for custom service integration.
|
||||
|
||||
## Cluster Broadcast
|
||||
|
||||
Broadcast an event to all peers using the centralized core broadcaster. Services never touch UDP directly; instead they fire a local event that the core transmits as a `CLUSTER_EVENT`.
|
||||
|
||||
Usage:
|
||||
|
||||
```cpp
|
||||
// 1) Apply locally via the same event your service already handles
|
||||
JsonDocument payload;
|
||||
payload["pattern"] = "rainbow_cycle";
|
||||
payload["brightness"] = 100;
|
||||
String payloadStr; serializeJson(payload, payloadStr);
|
||||
ctx.fire("api/neopattern", &payloadStr);
|
||||
|
||||
// 2) Broadcast to peers via the core
|
||||
JsonDocument envelope;
|
||||
envelope["event"] = "api/neopattern";
|
||||
envelope["data"] = payloadStr; // JSON string
|
||||
String eventJson; serializeJson(envelope, eventJson);
|
||||
ctx.fire("cluster/broadcast", &eventJson);
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The core sends subnet-directed broadcasts (e.g., 192.168.1.255) for reliability.
|
||||
- Peers receive `CLUSTER_EVENT` and forward to local subscribers with `ctx.fire(event, data)`.
|
||||
- `data` can be a JSON string or nested JSON; receivers handle both.
|
||||
|
||||
📖 See the dedicated guide: [`docs/ClusterBroadcast.md`](./docs/ClusterBroadcast.md)
|
||||
|
||||
## Streaming API
|
||||
|
||||
Real-time event bridge available at `/ws` using WebSocket.
|
||||
|
||||
- Send JSON `{ event, payload }` to dispatch events via `ctx.fire`.
|
||||
- Receive all local events as `{ event, payload }`.
|
||||
|
||||
Examples:
|
||||
```json
|
||||
{ "event": "api/neopattern/color", "payload": { "color": "#FF0000", "brightness": 128 } }
|
||||
```
|
||||
```json
|
||||
{ "event": "cluster/broadcast", "payload": { "event": "api/neopattern/color", "data": { "color": "#00FF00" } } }
|
||||
```
|
||||
|
||||
📖 See the dedicated guide: [`docs/StreamingAPI.md`](./docs/StreamingAPI.md)
|
||||
|
||||
## API Reference
|
||||
|
||||
The system provides a comprehensive RESTful API for monitoring and controlling the embedded device. All endpoints return JSON responses and support standard HTTP status codes.
|
||||
|
||||
**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
|
||||
|
||||
Choose one of your nodes as the API node to interact with the cluster and configure it in `.env`:
|
||||
```sh
|
||||
export API_NODE=10.0.1.x
|
||||
The project uses PlatformIO with Arduino framework and supports multiple ESP8266 boards.
|
||||
|
||||
**Key Settings:**
|
||||
- **Framework**: Arduino
|
||||
- **Board**: ESP-01 with 1MB flash (default)
|
||||
- **Upload Speed**: 115200 baud
|
||||
- **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
|
||||
|
||||
**Quick Commands:**
|
||||
```bash
|
||||
# Build firmware
|
||||
./ctl.sh build target esp01_1m
|
||||
|
||||
# Flash device
|
||||
./ctl.sh flash target esp01_1m
|
||||
|
||||
# OTA update
|
||||
./ctl.sh ota update 192.168.1.100 esp01_1m
|
||||
|
||||
# Cluster management
|
||||
./ctl.sh cluster members
|
||||
```
|
||||
|
||||
## Build
|
||||
**Prerequisites:** PlatformIO Core, ESP8266 tools, `jq` for JSON processing
|
||||
|
||||
Build the firmware:
|
||||
📖 **Complete Development Guide:** See [`docs/Development.md`](./docs/Development.md) for comprehensive build, deployment, and troubleshooting instructions.
|
||||
|
||||
```sh
|
||||
./ctl.sh build
|
||||
## Current Limitations
|
||||
|
||||
- WiFi credentials are hardcoded in `Config.cpp` (should be configurable)
|
||||
- Limited error handling for network failures
|
||||
- No persistent storage for configuration
|
||||
- Task monitoring and system health metrics
|
||||
- Task execution history and performance analytics not yet implemented
|
||||
- No authentication or security features implemented
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## Flash
|
||||
## Documentation
|
||||
|
||||
Flash firmware to a connected device:
|
||||
📚 **Comprehensive documentation is available in the [`docs/`](./docs/) folder:**
|
||||
|
||||
```sh
|
||||
./ctl.sh flash
|
||||
```
|
||||
## OTA
|
||||
- **[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
|
||||
|
||||
Update one nodes:
|
||||
## Contributing
|
||||
|
||||
```sh
|
||||
./ctl.sh ota update 10.0.1.x
|
||||
```
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Test thoroughly on ESP8266 hardware
|
||||
5. Submit a pull request
|
||||
|
||||
Update all nodes:
|
||||
## License
|
||||
|
||||
```sh
|
||||
./ctl.sh ota all
|
||||
```
|
||||
[Add your license information here]
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Built with [PlatformIO](https://platformio.org/)
|
||||
- Uses [TaskScheduler](https://github.com/arkhipenko/TaskScheduler) for cooperative multitasking
|
||||
- [ESPAsyncWebServer](https://github.com/me-no-dev/ESPAsyncWebServer) for HTTP API
|
||||
- [ArduinoJson](https://arduinojson.org/) for JSON processing
|
||||
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
463
ctl.sh
463
ctl.sh
@@ -4,31 +4,89 @@ set -e
|
||||
|
||||
source .env
|
||||
|
||||
## Spore Control Script
|
||||
## Usage: ./ctl.sh <command> [options]
|
||||
##
|
||||
## Commands:
|
||||
## build [target] - Build firmware for target (base, d1_mini, etc.)
|
||||
## flash [target] - Flash firmware to device
|
||||
## uploadfs [target] - Upload filesystem to device
|
||||
## ota update <ip> <target> - OTA update specific node
|
||||
## ota all <target> - OTA update all nodes in cluster
|
||||
## cluster members - List cluster members
|
||||
## node wifi <ssid> <password> [ip] - Configure WiFi on node
|
||||
## node label set <key=value> [ip] - Set a label on node
|
||||
## node label delete <key> [ip] - Delete a label from node
|
||||
## node config get [ip] - Get node configuration
|
||||
## node status [ip] - Get node status and information
|
||||
## monitor - Monitor serial output
|
||||
##
|
||||
## Examples:
|
||||
## ./ctl.sh build base
|
||||
## ./ctl.sh flash d1_mini
|
||||
## ./ctl.sh node wifi "MyNetwork" "MyPassword"
|
||||
## ./ctl.sh node wifi "MyNetwork" "MyPassword" 192.168.1.100
|
||||
## ./ctl.sh node label set "environment=production"
|
||||
## ./ctl.sh node label set "location=office" 192.168.1.100
|
||||
## ./ctl.sh node label delete "environment"
|
||||
## ./ctl.sh node config get
|
||||
## ./ctl.sh node config get 192.168.1.100
|
||||
## ./ctl.sh node status
|
||||
## ./ctl.sh node status 192.168.1.100
|
||||
|
||||
function info {
|
||||
sed -n 's/^##//p' ctl.sh
|
||||
}
|
||||
|
||||
function build {
|
||||
echo "Building project..."
|
||||
pio run
|
||||
function target {
|
||||
echo "Building project for $1..."
|
||||
pio run -e $1
|
||||
}
|
||||
function all {
|
||||
pio run
|
||||
}
|
||||
${@:-all}
|
||||
}
|
||||
|
||||
function flash {
|
||||
echo "Flashing firmware..."
|
||||
pio run --target upload
|
||||
function target {
|
||||
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 update {
|
||||
echo "Updating node at $1"
|
||||
echo "Updating node at $1 with $2... "
|
||||
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'
|
||||
}
|
||||
function all {
|
||||
echo "Updating all nodes..."
|
||||
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
|
||||
}
|
||||
${@:-info}
|
||||
@@ -41,4 +99,395 @@ function cluster {
|
||||
${@:-info}
|
||||
}
|
||||
|
||||
function node {
|
||||
function wifi {
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "Usage: $0 node wifi <ssid> <password> [node_ip]"
|
||||
echo " ssid: WiFi network name"
|
||||
echo " password: WiFi password"
|
||||
echo " node_ip: Optional IP address (defaults to API_NODE from .env)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local ssid="$1"
|
||||
local password="$2"
|
||||
local node_ip="${3:-$API_NODE}"
|
||||
|
||||
echo "Configuring WiFi on node $node_ip..."
|
||||
echo "SSID: $ssid"
|
||||
|
||||
# Configure WiFi using the API endpoint
|
||||
response=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "ssid=$ssid&password=$password" \
|
||||
"http://$node_ip/api/network/wifi/config" 2>/dev/null || echo -e "\n000")
|
||||
|
||||
# Extract HTTP status code and response body
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
response_body=$(echo "$response" | head -n -1)
|
||||
|
||||
# Check if curl succeeded
|
||||
if [ "$http_code" = "000" ] || [ -z "$response_body" ]; then
|
||||
echo "Error: Failed to connect to node at $node_ip"
|
||||
echo "Please check:"
|
||||
echo " - Node is powered on and connected to network"
|
||||
echo " - IP address is correct"
|
||||
echo " - Node is running Spore firmware"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check HTTP status code
|
||||
if [ "$http_code" != "200" ]; then
|
||||
echo "Error: HTTP $http_code - Server error"
|
||||
echo "Response: $response_body"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Parse and display the response
|
||||
status=$(echo "$response_body" | jq -r '.status // "unknown"')
|
||||
message=$(echo "$response_body" | jq -r '.message // "No message"')
|
||||
config_saved=$(echo "$response_body" | jq -r '.config_saved // false')
|
||||
restarting=$(echo "$response_body" | jq -r '.restarting // false')
|
||||
connected=$(echo "$response_body" | jq -r '.connected // false')
|
||||
ip=$(echo "$response_body" | jq -r '.ip // "N/A"')
|
||||
|
||||
echo "Status: $status"
|
||||
echo "Message: $message"
|
||||
echo "Config saved: $config_saved"
|
||||
if [ "$restarting" = "true" ]; then
|
||||
echo "Restarting: true"
|
||||
echo "Note: Node will restart to apply new WiFi settings"
|
||||
fi
|
||||
echo "Connected: $connected"
|
||||
if [ "$connected" = "true" ]; then
|
||||
echo "IP Address: $ip"
|
||||
fi
|
||||
|
||||
# Return appropriate exit code
|
||||
if [ "$status" = "success" ]; then
|
||||
echo "WiFi configuration completed successfully!"
|
||||
return 0
|
||||
else
|
||||
echo "WiFi configuration failed!"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
function label {
|
||||
function set {
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 node label set <key=value> [node_ip]"
|
||||
echo " key=value: Label key and value in format 'key=value'"
|
||||
echo " node_ip: Optional IP address (defaults to API_NODE from .env)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local key_value="$1"
|
||||
local node_ip="${2:-$API_NODE}"
|
||||
|
||||
# Parse key=value format
|
||||
if [[ ! "$key_value" =~ ^[^=]+=.+$ ]]; then
|
||||
echo "Error: Label must be in format 'key=value'"
|
||||
echo "Example: environment=production"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local key="${key_value%%=*}"
|
||||
local value="${key_value#*=}"
|
||||
|
||||
echo "Setting label '$key=$value' on node $node_ip..."
|
||||
|
||||
# First get current labels
|
||||
current_labels=$(curl -s "http://$node_ip/api/node/status" | jq -r '.labels // {}')
|
||||
|
||||
# Add/update the new label
|
||||
updated_labels=$(echo "$current_labels" | jq --arg key "$key" --arg value "$value" '. + {($key): $value}')
|
||||
|
||||
# Send updated labels to the node
|
||||
response=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "labels=$updated_labels" \
|
||||
"http://$node_ip/api/node/config" 2>/dev/null || echo -e "\n000")
|
||||
|
||||
# Extract HTTP status code and response body
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
response_body=$(echo "$response" | head -n -1)
|
||||
|
||||
# Check if curl succeeded
|
||||
if [ "$http_code" = "000" ] || [ -z "$response_body" ]; then
|
||||
echo "Error: Failed to connect to node at $node_ip"
|
||||
echo "Please check:"
|
||||
echo " - Node is powered on and connected to network"
|
||||
echo " - IP address is correct"
|
||||
echo " - Node is running Spore firmware"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check HTTP status code
|
||||
if [ "$http_code" != "200" ]; then
|
||||
echo "Error: HTTP $http_code - Server error"
|
||||
echo "Response: $response_body"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Parse and display the response
|
||||
status=$(echo "$response_body" | jq -r '.status // "unknown"')
|
||||
message=$(echo "$response_body" | jq -r '.message // "No message"')
|
||||
|
||||
echo "Status: $status"
|
||||
echo "Message: $message"
|
||||
|
||||
# Return appropriate exit code
|
||||
if [ "$status" = "success" ]; then
|
||||
echo "Label '$key=$value' set successfully!"
|
||||
return 0
|
||||
else
|
||||
echo "Failed to set label!"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
function delete {
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 node label delete <key> [node_ip]"
|
||||
echo " key: Label key to delete"
|
||||
echo " node_ip: Optional IP address (defaults to API_NODE from .env)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local key="$1"
|
||||
local node_ip="${2:-$API_NODE}"
|
||||
|
||||
echo "Deleting label '$key' from node $node_ip..."
|
||||
|
||||
# First get current labels
|
||||
current_labels=$(curl -s "http://$node_ip/api/node/status" | jq -r '.labels // {}')
|
||||
|
||||
# Check if key exists
|
||||
if [ "$(echo "$current_labels" | jq -r --arg key "$key" 'has($key)')" != "true" ]; then
|
||||
echo "Warning: Label '$key' does not exist on node"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Remove the key
|
||||
updated_labels=$(echo "$current_labels" | jq --arg key "$key" 'del(.[$key])')
|
||||
|
||||
# Send updated labels to the node
|
||||
response=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "labels=$updated_labels" \
|
||||
"http://$node_ip/api/node/config" 2>/dev/null || echo -e "\n000")
|
||||
|
||||
# Extract HTTP status code and response body
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
response_body=$(echo "$response" | head -n -1)
|
||||
|
||||
# Check if curl succeeded
|
||||
if [ "$http_code" = "000" ] || [ -z "$response_body" ]; then
|
||||
echo "Error: Failed to connect to node at $node_ip"
|
||||
echo "Please check:"
|
||||
echo " - Node is powered on and connected to network"
|
||||
echo " - IP address is correct"
|
||||
echo " - Node is running Spore firmware"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check HTTP status code
|
||||
if [ "$http_code" != "200" ]; then
|
||||
echo "Error: HTTP $http_code - Server error"
|
||||
echo "Response: $response_body"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Parse and display the response
|
||||
status=$(echo "$response_body" | jq -r '.status // "unknown"')
|
||||
message=$(echo "$response_body" | jq -r '.message // "No message"')
|
||||
|
||||
echo "Status: $status"
|
||||
echo "Message: $message"
|
||||
|
||||
# Return appropriate exit code
|
||||
if [ "$status" = "success" ]; then
|
||||
echo "Label '$key' deleted successfully!"
|
||||
return 0
|
||||
else
|
||||
echo "Failed to delete label!"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
${@:-info}
|
||||
}
|
||||
|
||||
function config {
|
||||
function get {
|
||||
local node_ip="${1:-$API_NODE}"
|
||||
|
||||
echo "Getting configuration for node $node_ip..."
|
||||
|
||||
# Get node configuration
|
||||
response=$(curl -s -w "\n%{http_code}" "http://$node_ip/api/node/config" 2>/dev/null || echo -e "\n000")
|
||||
|
||||
# Extract HTTP status code and response body
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
response_body=$(echo "$response" | head -n -1)
|
||||
|
||||
# Check if curl succeeded
|
||||
if [ "$http_code" = "000" ] || [ -z "$response_body" ]; then
|
||||
echo "Error: Failed to connect to node at $node_ip"
|
||||
echo "Please check:"
|
||||
echo " - Node is powered on and connected to network"
|
||||
echo " - IP address is correct"
|
||||
echo " - Node is running Spore firmware"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check HTTP status code
|
||||
if [ "$http_code" != "200" ]; then
|
||||
echo "Error: HTTP $http_code - Server error"
|
||||
echo "Response: $response_body"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Parse and display the response in a nice format
|
||||
echo ""
|
||||
echo "=== Node Configuration ==="
|
||||
echo "Node IP: $node_ip"
|
||||
echo "Retrieved at: $(date)"
|
||||
echo ""
|
||||
|
||||
# WiFi Configuration
|
||||
echo "=== WiFi Configuration ==="
|
||||
echo "SSID: $(echo "$response_body" | jq -r '.wifi.ssid // "N/A"')"
|
||||
echo "Connect Timeout: $(echo "$response_body" | jq -r '.wifi.connect_timeout_ms // "N/A"') ms"
|
||||
echo "Retry Delay: $(echo "$response_body" | jq -r '.wifi.retry_delay_ms // "N/A"') ms"
|
||||
echo "Password: [HIDDEN]"
|
||||
echo ""
|
||||
|
||||
# Network Configuration
|
||||
echo "=== Network Configuration ==="
|
||||
echo "UDP Port: $(echo "$response_body" | jq -r '.network.udp_port // "N/A"')"
|
||||
echo "API Server Port: $(echo "$response_body" | jq -r '.network.api_server_port // "N/A"')"
|
||||
echo ""
|
||||
|
||||
# Cluster Configuration
|
||||
echo "=== Cluster Configuration ==="
|
||||
echo "Heartbeat Interval: $(echo "$response_body" | jq -r '.cluster.heartbeat_interval_ms // "N/A"') ms"
|
||||
echo "Cluster Listen Interval: $(echo "$response_body" | jq -r '.cluster.cluster_listen_interval_ms // "N/A"') ms"
|
||||
echo "Status Update Interval: $(echo "$response_body" | jq -r '.cluster.status_update_interval_ms // "N/A"') ms"
|
||||
echo ""
|
||||
|
||||
# Node Status Thresholds
|
||||
echo "=== Node Status Thresholds ==="
|
||||
echo "Active Threshold: $(echo "$response_body" | jq -r '.thresholds.node_active_threshold_ms // "N/A"') ms"
|
||||
echo "Inactive Threshold: $(echo "$response_body" | jq -r '.thresholds.node_inactive_threshold_ms // "N/A"') ms"
|
||||
echo "Dead Threshold: $(echo "$response_body" | jq -r '.thresholds.node_dead_threshold_ms // "N/A"') ms"
|
||||
echo ""
|
||||
|
||||
# System Configuration
|
||||
echo "=== System Configuration ==="
|
||||
echo "Restart Delay: $(echo "$response_body" | jq -r '.system.restart_delay_ms // "N/A"') ms"
|
||||
echo "JSON Doc Size: $(echo "$response_body" | jq -r '.system.json_doc_size // "N/A"') bytes"
|
||||
echo ""
|
||||
|
||||
# Memory Management
|
||||
echo "=== Memory Management ==="
|
||||
echo "Low Memory Threshold: $(echo "$response_body" | jq -r '.memory.low_memory_threshold_bytes // "N/A"') bytes"
|
||||
echo "Critical Memory Threshold: $(echo "$response_body" | jq -r '.memory.critical_memory_threshold_bytes // "N/A"') bytes"
|
||||
echo "Max Concurrent HTTP Requests: $(echo "$response_body" | jq -r '.memory.max_concurrent_http_requests // "N/A"')"
|
||||
echo ""
|
||||
|
||||
# Custom Labels
|
||||
labels=$(echo "$response_body" | jq -r '.labels // {}')
|
||||
if [ "$labels" != "{}" ] && [ "$labels" != "null" ]; then
|
||||
echo "=== Custom Labels ==="
|
||||
echo "$labels" | jq -r 'to_entries[] | "\(.key): \(.value)"'
|
||||
echo ""
|
||||
else
|
||||
echo "=== Custom Labels ==="
|
||||
echo "No custom labels set"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Metadata
|
||||
echo "=== Metadata ==="
|
||||
echo "Configuration Version: $(echo "$response_body" | jq -r '.version // "N/A"')"
|
||||
echo "Retrieved Timestamp: $(echo "$response_body" | jq -r '.retrieved_at // "N/A"')"
|
||||
echo ""
|
||||
|
||||
echo "=== Raw JSON Response ==="
|
||||
echo "$response_body" | jq '.'
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
${@:-info}
|
||||
}
|
||||
|
||||
function status {
|
||||
local node_ip="${1:-$API_NODE}"
|
||||
|
||||
echo "Getting status for node $node_ip..."
|
||||
|
||||
# Get node status
|
||||
response=$(curl -s -w "\n%{http_code}" "http://$node_ip/api/node/status" 2>/dev/null || echo -e "\n000")
|
||||
|
||||
# Extract HTTP status code and response body
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
response_body=$(echo "$response" | head -n -1)
|
||||
|
||||
# Check if curl succeeded
|
||||
if [ "$http_code" = "000" ] || [ -z "$response_body" ]; then
|
||||
echo "Error: Failed to connect to node at $node_ip"
|
||||
echo "Please check:"
|
||||
echo " - Node is powered on and connected to network"
|
||||
echo " - IP address is correct"
|
||||
echo " - Node is running Spore firmware"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check HTTP status code
|
||||
if [ "$http_code" != "200" ]; then
|
||||
echo "Error: HTTP $http_code - Server error"
|
||||
echo "Response: $response_body"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Parse and display the response in a nice format
|
||||
echo ""
|
||||
echo "=== Node Status ==="
|
||||
echo "Hostname: $(echo "$response_body" | jq -r '.hostname // "N/A"')"
|
||||
echo "IP Address: $node_ip"
|
||||
echo "Free Heap: $(echo "$response_body" | jq -r '.freeHeap // "N/A"') bytes"
|
||||
echo "Chip ID: $(echo "$response_body" | jq -r '.chipId // "N/A"')"
|
||||
echo "SDK Version: $(echo "$response_body" | jq -r '.sdkVersion // "N/A"')"
|
||||
echo "CPU Frequency: $(echo "$response_body" | jq -r '.cpuFreqMHz // "N/A"') MHz"
|
||||
echo "Flash Size: $(echo "$response_body" | jq -r '.flashChipSize // "N/A"') bytes"
|
||||
|
||||
# Display labels if present
|
||||
labels=$(echo "$response_body" | jq -r '.labels // {}')
|
||||
if [ "$labels" != "{}" ] && [ "$labels" != "null" ]; then
|
||||
echo ""
|
||||
echo "=== Labels ==="
|
||||
echo "$labels" | jq -r 'to_entries[] | "\(.key): \(.value)"'
|
||||
else
|
||||
echo ""
|
||||
echo "=== Labels ==="
|
||||
echo "No labels set"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Raw JSON Response ==="
|
||||
echo "$response_body" | jq '.'
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
${@:-info}
|
||||
}
|
||||
|
||||
function monitor {
|
||||
pio run --target monitor
|
||||
}
|
||||
|
||||
${@:-info}
|
||||
|
||||
634
docs/API.md
Normal file
634
docs/API.md
Normal file
@@ -0,0 +1,634 @@
|
||||
# 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 | System metrics |
|
||||
| `/api/node/endpoints` | GET | API endpoints and parameters | Detailed endpoint specifications |
|
||||
| `/api/cluster/members` | GET | Cluster membership and node health information | Cluster topology and health status |
|
||||
| `/api/node/update` | POST | Handle firmware updates via OTA | Update progress and status |
|
||||
| `/api/node/restart` | POST | Trigger system restart | Restart confirmation |
|
||||
|
||||
### Monitoring API
|
||||
|
||||
| Endpoint | Method | Description | Response |
|
||||
|----------|--------|-------------|----------|
|
||||
| `/api/monitoring/resources` | GET | CPU, memory, filesystem, and uptime | System resource metrics |
|
||||
|
||||
### Network Management API
|
||||
|
||||
| Endpoint | Method | Description | Response |
|
||||
|----------|--------|-------------|----------|
|
||||
| `/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 and chip details. For a list of available API endpoints, use `/api/node/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. Methods are returned as strings (e.g., "GET", "POST").
|
||||
|
||||
**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.
|
||||
|
||||
### Monitoring
|
||||
|
||||
#### GET /api/monitoring/resources
|
||||
|
||||
Returns real-time system resource metrics.
|
||||
|
||||
Response Fields:
|
||||
- `cpu.current_usage`: Current CPU usage percent
|
||||
- `cpu.average_usage`: Average CPU usage percent
|
||||
- `cpu.max_usage`: Max observed CPU usage
|
||||
- `cpu.min_usage`: Min observed CPU usage
|
||||
- `cpu.measurement_count`: Number of measurements
|
||||
- `cpu.is_measuring`: Whether measurement is active
|
||||
- `memory.free_heap`: Free heap bytes
|
||||
- `memory.total_heap`: Total heap bytes (approximate)
|
||||
- `memory.heap_fragmentation`: Fragmentation percent (0 on ESP8266)
|
||||
- `filesystem.total_bytes`: LittleFS total bytes
|
||||
- `filesystem.used_bytes`: Used bytes
|
||||
- `filesystem.free_bytes`: Free bytes
|
||||
- `filesystem.usage_percent`: Usage percent
|
||||
- `system.uptime_ms`: Uptime in milliseconds
|
||||
Example Response:
|
||||
```json
|
||||
{
|
||||
"cpu": {
|
||||
"current_usage": 3.5,
|
||||
"average_usage": 2.1,
|
||||
"max_usage": 15.2,
|
||||
"min_usage": 0.0,
|
||||
"measurement_count": 120,
|
||||
"is_measuring": true
|
||||
},
|
||||
"memory": {
|
||||
"free_heap": 48748,
|
||||
"total_heap": 81920,
|
||||
"heap_fragmentation": 0
|
||||
},
|
||||
"filesystem": {
|
||||
"total_bytes": 65536,
|
||||
"used_bytes": 10240,
|
||||
"free_bytes": 55296,
|
||||
"usage_percent": 15.6
|
||||
},
|
||||
"system": {
|
||||
"uptime_ms": 123456
|
||||
}
|
||||
}
|
||||
```
|
||||
### Network Management
|
||||
|
||||
#### GET /api/network/status
|
||||
|
||||
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
|
||||
```
|
||||
444
docs/Architecture.md
Normal file
444
docs/Architecture.md
Normal file
@@ -0,0 +1,444 @@
|
||||
# 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**: Services register endpoints via `registerEndpoints(ApiServer&)`
|
||||
- **Service Registry**: Track available services across the cluster
|
||||
- **Service Lifecycle**: Services register both endpoints and tasks through unified interface
|
||||
|
||||
### Task Scheduler
|
||||
- **Cooperative Multitasking**: Background task management system (`TaskManager`)
|
||||
- **Service Task Registration**: Services register tasks via `registerTasks(TaskManager&)`
|
||||
- **Task Lifecycle Management**: Enable/disable tasks and set intervals at runtime
|
||||
- **Execution Model**: Tasks run in `Spore::loop()` when their interval elapses
|
||||
|
||||
### 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 heartbeat messages on port `udp_port` (default 4210)
|
||||
2. **Response Handling**: Nodes respond with node update information containing their current state
|
||||
3. **Member Management**: Discovered nodes are added/updated in the cluster with current information
|
||||
4. **Node Synchronization**: Periodic broadcasts ensure all nodes maintain current cluster state
|
||||
|
||||
### Protocol Details
|
||||
|
||||
- **UDP Port**: 4210 (configurable via `Config.udp_port`)
|
||||
- **Heartbeat Message**: `CLUSTER_HEARTBEAT:hostname`
|
||||
- **Node Update Message**: `NODE_UPDATE:hostname:{json}`
|
||||
- **Broadcast Address**: 255.255.255.255
|
||||
- **Listen Interval**: `Config.cluster_listen_interval_ms` (default 10 ms)
|
||||
- **Heartbeat Interval**: `Config.heartbeat_interval_ms` (default 5000 ms)
|
||||
|
||||
### Message Formats
|
||||
|
||||
- **Heartbeat**: `CLUSTER_HEARTBEAT:hostname`
|
||||
- Sender: each node, broadcast to 255.255.255.255:`udp_port` on interval
|
||||
- Purpose: announce presence, prompt peers for node info, and keep liveness
|
||||
- **Node Update**: `NODE_UPDATE:hostname:{json}`
|
||||
- Sender: node responding to heartbeat or broadcasting current state
|
||||
- JSON fields: hostname, ip, uptime, optional labels
|
||||
- Purpose: provide current node information for cluster synchronization
|
||||
|
||||
### Discovery Flow
|
||||
|
||||
1. **A node broadcasts** `CLUSTER_HEARTBEAT:hostname` to announce its presence
|
||||
2. **Each receiver responds** with `NODE_UPDATE:hostname:{json}` containing current node state
|
||||
3. **The sender**:
|
||||
- Ensures the responding node exists or creates it with current IP and information
|
||||
- Parses JSON and updates node info, `status = ACTIVE`, `lastSeen = now`
|
||||
- Calculates `latency = now - lastHeartbeatSentAt` for network performance monitoring
|
||||
|
||||
### Node Synchronization
|
||||
|
||||
1. **Event-driven broadcasts**: Nodes broadcast `NODE_UPDATE:hostname:{json}` when node information changes
|
||||
2. **All receivers**: Update their memberlist entry for the broadcasting node
|
||||
3. **Purpose**: Ensures all nodes maintain current cluster state and configuration
|
||||
|
||||
### Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant N1 as Node A (esp-node1)
|
||||
participant N2 as Node B (esp-node2)
|
||||
|
||||
Note over N1,N2: Discovery via heartbeat broadcast
|
||||
N1->>+N2: CLUSTER_HEARTBEAT:esp-node1
|
||||
|
||||
Note over N2: Node B responds with its current state
|
||||
N2->>+N1: NODE_UPDATE:esp-node1:{"hostname":"esp-node2","uptime":12345,"labels":{"role":"sensor"}}
|
||||
|
||||
Note over N1: Process NODE_UPDATE response
|
||||
N1-->>N1: Update memberlist for Node B
|
||||
N1-->>N1: Set Node B status = ACTIVE
|
||||
N1-->>N1: Calculate latency for Node B
|
||||
|
||||
Note over N1,N2: Event-driven node synchronization
|
||||
N1->>+N2: NODE_UPDATE:esp-node1:{"hostname":"esp-node1","uptime":12346,"labels":{"role":"controller"}}
|
||||
|
||||
Note over N2: Update memberlist with latest information
|
||||
N2-->>N2: Update Node A info, maintain ACTIVE status
|
||||
```
|
||||
|
||||
### Listener Behavior
|
||||
|
||||
The `cluster_listen` task parses one UDP packet per run and dispatches by prefix to:
|
||||
- **Heartbeat** → add/update responding node and send `NODE_UPDATE` response
|
||||
- **Node Update** → update node information and trigger memberlist logging
|
||||
|
||||
### Timing and Intervals
|
||||
|
||||
- **UDP Port**: `Config.udp_port` (default 4210)
|
||||
- **Listen Interval**: `Config.cluster_listen_interval_ms` (default 10 ms)
|
||||
- **Heartbeat Interval**: `Config.heartbeat_interval_ms` (default 5000 ms)
|
||||
|
||||
### Node Status Categories
|
||||
|
||||
Nodes are automatically categorized by their activity:
|
||||
|
||||
- **ACTIVE**: lastSeen < `node_inactive_threshold_ms` (default 10s)
|
||||
- **INACTIVE**: < `node_dead_threshold_ms` (default 120s)
|
||||
- **DEAD**: ≥ `node_dead_threshold_ms`
|
||||
|
||||
## Task Scheduling System
|
||||
|
||||
The system runs several background tasks at different intervals:
|
||||
|
||||
### Core System Tasks
|
||||
|
||||
| Task | Interval (default) | Purpose |
|
||||
|------|--------------------|---------|
|
||||
| `cluster_listen` | 10 ms | Listen for heartbeat/node-info messages |
|
||||
| `status_update` | 1000 ms | Update node status categories, purge dead |
|
||||
| `heartbeat` | 5000 ms | Broadcast heartbeat and update local resources |
|
||||
|
||||
### Task 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 or local node refreshed
|
||||
|
||||
## 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
|
||||
|
||||
- **Hostname**: Derived from MAC (`esp-<mac>`) and assigned to `ctx.hostname`
|
||||
- **AP Mode**: If STA connection fails, device switches to AP mode with configured SSID/password
|
||||
|
||||
## Cluster Topology
|
||||
|
||||
### Node Types
|
||||
|
||||
- **Master Node**: Primary cluster coordinator (if applicable)
|
||||
- **Worker Nodes**: Standard cluster members
|
||||
- **Edge Nodes**: Network edge devices
|
||||
|
||||
### Network Architecture
|
||||
|
||||
- UDP broadcast-based discovery and heartbeats on local subnet
|
||||
- Optional HTTP polling (disabled by default; node info exchanged via UDP)
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Node Discovery
|
||||
1. **UDP Broadcast**: Nodes broadcast discovery packets on port 4210
|
||||
2. **UDP Response**: Receiving nodes respond with hostname
|
||||
3. **Registration**: Discovered nodes are added to local cluster member list
|
||||
|
||||
### Health Monitoring
|
||||
1. **Periodic Checks**: Cluster manager updates node status categories
|
||||
2. **Status Collection**: Each node updates resources via UDP node-info messages
|
||||
|
||||
### Task Management
|
||||
1. **Scheduling**: `TaskManager` executes registered tasks at configured intervals
|
||||
2. **Execution**: Tasks run cooperatively in the main loop without preemption
|
||||
3. **Monitoring**: Task status is exposed via REST (`/api/tasks/status`)
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
### Memory Usage
|
||||
|
||||
- **Base System**: ~15-20KB RAM (device dependent)
|
||||
- **Per Task**: ~100-200 bytes per task
|
||||
- **Cluster Members**: ~50-100 bytes per member
|
||||
- **API Endpoints**: ~20-30 bytes per endpoint
|
||||
|
||||
### 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; LAN-only access assumed
|
||||
- **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
|
||||
|
||||
SPORE implements a persistent configuration system that manages device settings across reboots and provides runtime reconfiguration capabilities.
|
||||
|
||||
### Configuration Architecture
|
||||
|
||||
The configuration system consists of several key components:
|
||||
|
||||
- **`Config` Class**: Central configuration management with default constants
|
||||
- **LittleFS Storage**: Persistent file-based storage (`/config.json`)
|
||||
- **Runtime Updates**: Live configuration changes via HTTP API
|
||||
- **Automatic Persistence**: Configuration changes are automatically saved
|
||||
|
||||
### Configuration Categories
|
||||
|
||||
| Category | Description | Examples |
|
||||
|----------|-------------|----------|
|
||||
| **WiFi Configuration** | Network connection settings | SSID, password, timeouts |
|
||||
| **Network Configuration** | Network service settings | UDP port, API server port |
|
||||
| **Cluster Configuration** | Cluster management settings | Discovery intervals, heartbeat timing |
|
||||
| **Node Status Thresholds** | Health monitoring thresholds | Active/inactive/dead timeouts |
|
||||
| **System Configuration** | Core system settings | Restart delay, JSON document size |
|
||||
| **Memory Management** | Resource management settings | Memory thresholds, HTTP request limits |
|
||||
|
||||
### Configuration Lifecycle
|
||||
|
||||
1. **Boot Process**: Load configuration from `/config.json` or use defaults
|
||||
2. **Runtime Updates**: Configuration changes via HTTP API
|
||||
3. **Persistent Storage**: Changes automatically saved to LittleFS
|
||||
4. **Service Integration**: Configuration applied to all system services
|
||||
|
||||
### Default Value Management
|
||||
|
||||
All default values are defined as `constexpr` constants in the `Config` class:
|
||||
|
||||
```cpp
|
||||
static constexpr const char* DEFAULT_WIFI_SSID = "shroud";
|
||||
static constexpr uint16_t DEFAULT_UDP_PORT = 4210;
|
||||
static constexpr unsigned long DEFAULT_HEARTBEAT_INTERVAL_MS = 5000;
|
||||
```
|
||||
|
||||
This ensures:
|
||||
- **Single Source of Truth**: All defaults defined once
|
||||
- **Type Safety**: Compile-time type checking
|
||||
- **Maintainability**: Easy to update default values
|
||||
- **Consistency**: Same defaults used in `setDefaults()` and `loadFromFile()`
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
- **[Configuration Management](./ConfigurationManagement.md)** - Persistent configuration system
|
||||
- **[WiFi Configuration](./WiFiConfiguration.md)** - WiFi setup and reconfiguration process
|
||||
- **[Task Management](./TaskManagement.md)** - Background task system
|
||||
- **[API Reference](./API.md)** - REST API documentation
|
||||
- **[TaskManager API](./TaskManager.md)** - TaskManager class reference
|
||||
- **[OpenAPI Specification](../api/)** - Machine-readable API specification
|
||||
91
docs/ClusterBroadcast.md
Normal file
91
docs/ClusterBroadcast.md
Normal file
@@ -0,0 +1,91 @@
|
||||
## Cluster Broadcast (CLUSTER_EVENT)
|
||||
|
||||
### Overview
|
||||
|
||||
Spore supports cluster-wide event broadcasting via UDP. Services publish a local event, and the core broadcasts it to peers as a `CLUSTER_EVENT`. Peers receive the event and forward it to local subscribers through the internal event bus.
|
||||
|
||||
- **Local trigger**: `ctx.fire("cluster/broadcast", eventJson)`
|
||||
- **UDP message**: `CLUSTER_EVENT:{json}`
|
||||
- **Receiver action**: Parses `{json}` and calls `ctx.fire(event, data)`
|
||||
|
||||
This centralizes network broadcast in core, so services never touch UDP directly.
|
||||
|
||||
### Message format
|
||||
|
||||
- UDP payload prefix: `CLUSTER_EVENT:`
|
||||
- JSON body:
|
||||
```json
|
||||
{
|
||||
"event": "<event-name>",
|
||||
"data": "<json-string>" // or an inline JSON object/array
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The receiver accepts `data` as either a JSON string or a nested JSON object/array. Nested JSON is serialized back to a string before firing the local event.
|
||||
- Keep payloads small (UDP, default buffer 512 bytes).
|
||||
|
||||
### Core responsibilities
|
||||
|
||||
- `ClusterManager` registers a centralized handler:
|
||||
- Subscribes to `cluster/broadcast` to send the provided event JSON over UDP broadcast.
|
||||
- Listens for incoming UDP `CLUSTER_EVENT` messages and forwards them to local subscribers via `ctx.fire(event, data)`.
|
||||
- Broadcast target uses subnet-directed broadcast (e.g., `192.168.1.255`) for better reliability. Both nodes must share the same `udp_port`.
|
||||
|
||||
### Service responsibilities
|
||||
|
||||
Services send and receive events using the local event bus.
|
||||
|
||||
1) Subscribe to an event name and apply state from `data`:
|
||||
```cpp
|
||||
ctx.on("api/neopattern", [this](void* dataPtr) {
|
||||
String* jsonStr = static_cast<String*>(dataPtr);
|
||||
if (!jsonStr) return;
|
||||
JsonDocument doc;
|
||||
if (deserializeJson(doc, *jsonStr)) return;
|
||||
JsonObject obj = doc.as<JsonObject>();
|
||||
// Parse and apply fields from obj
|
||||
});
|
||||
```
|
||||
|
||||
2) Build a control payload and update locally via the same event:
|
||||
```cpp
|
||||
JsonDocument payload;
|
||||
payload["pattern"] = "rainbow_cycle"; // example
|
||||
payload["brightness"] = 100;
|
||||
String payloadStr; serializeJson(payload, payloadStr);
|
||||
ctx.fire("api/neopattern", &payloadStr);
|
||||
``;
|
||||
|
||||
3) Broadcast to peers by delegating to core:
|
||||
```cpp
|
||||
JsonDocument envelope;
|
||||
envelope["event"] = "api/neopattern";
|
||||
envelope["data"] = payloadStr; // JSON string
|
||||
String eventJson; serializeJson(envelope, eventJson);
|
||||
ctx.fire("cluster/broadcast", &eventJson);
|
||||
```
|
||||
|
||||
With this flow, services have a single codepath for applying state (the event handler). Broadcasting simply reuses the same payload.
|
||||
|
||||
### Logging
|
||||
|
||||
- Core logs source IP, payload length, and event name for received `CLUSTER_EVENT`s.
|
||||
- Services can log when submitting `cluster/broadcast` and when applying control events.
|
||||
|
||||
### Networking considerations
|
||||
|
||||
- Ensure all nodes:
|
||||
- Listen on the same `udp_port`.
|
||||
- Are in the same subnet (for subnet-directed broadcast).
|
||||
- Some networks may block global broadcast (`255.255.255.255`). Subnet-directed broadcast is used by default.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- If peers do not react:
|
||||
- Confirm logs show `CLUSTER_EVENT raw from <ip>` on the receiver.
|
||||
- Verify UDP port alignment and WiFi connection/subnet.
|
||||
- Check payload size (<512 bytes by default) and JSON validity.
|
||||
- Ensure the service subscribed to the correct `event` name and handles `data`.
|
||||
|
||||
|
||||
337
docs/ConfigurationManagement.md
Normal file
337
docs/ConfigurationManagement.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# SPORE Configuration Management
|
||||
|
||||
## Overview
|
||||
|
||||
SPORE implements a persistent configuration system that manages device settings across reboots and provides runtime reconfiguration capabilities. The system uses LittleFS for persistent storage and provides both programmatic and HTTP API access to configuration parameters.
|
||||
|
||||
## Configuration Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
- **`Config` Class**: Central configuration management
|
||||
- **LittleFS Storage**: Persistent file-based storage (`/config.json`)
|
||||
- **Default Constants**: Single source of truth for all default values
|
||||
- **Runtime Updates**: Live configuration changes via HTTP API
|
||||
- **Automatic Persistence**: Configuration changes are automatically saved
|
||||
|
||||
### Configuration Categories
|
||||
|
||||
The configuration system manages several categories of settings:
|
||||
|
||||
| Category | Description | Examples |
|
||||
|----------|-------------|----------|
|
||||
| **WiFi Configuration** | Network connection settings | SSID, password, timeouts |
|
||||
| **Network Configuration** | Network service settings | UDP port, API server port |
|
||||
| **Cluster Configuration** | Cluster management settings | Discovery intervals, heartbeat timing |
|
||||
| **Node Status Thresholds** | Health monitoring thresholds | Active/inactive/dead timeouts |
|
||||
| **System Configuration** | Core system settings | Restart delay, JSON document size |
|
||||
| **Memory Management** | Resource management settings | Memory thresholds, HTTP request limits |
|
||||
|
||||
## Configuration Lifecycle
|
||||
|
||||
### 1. Boot Process
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[System Boot] --> B[Initialize LittleFS]
|
||||
B --> C{Config File Exists?}
|
||||
C -->|Yes| D[Load from File]
|
||||
C -->|No| E[Use Defaults]
|
||||
D --> F[Apply Configuration]
|
||||
E --> G[Save Defaults to File]
|
||||
G --> F
|
||||
F --> H[Start Services]
|
||||
```
|
||||
|
||||
**Boot Sequence:**
|
||||
|
||||
1. **LittleFS Initialization**: Mount the filesystem for persistent storage
|
||||
2. **Configuration Loading**: Attempt to load `/config.json`
|
||||
3. **Fallback to Defaults**: If no config file exists, use hardcoded defaults
|
||||
4. **Default Persistence**: Save default configuration to file for future boots
|
||||
5. **Service Initialization**: Apply configuration to all system services
|
||||
|
||||
### 2. Runtime Configuration
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[HTTP API Request] --> B[Validate Parameters]
|
||||
B --> C[Update Config Object]
|
||||
C --> D[Save to File]
|
||||
D --> E{Requires Restart?}
|
||||
E -->|Yes| F[Schedule Restart]
|
||||
E -->|No| G[Apply Changes]
|
||||
F --> H[Send Response]
|
||||
G --> H
|
||||
```
|
||||
|
||||
**Runtime Update Process:**
|
||||
|
||||
1. **API Request**: Configuration change via HTTP API
|
||||
2. **Parameter Validation**: Validate input parameters
|
||||
3. **Memory Update**: Update configuration object in memory
|
||||
4. **Persistent Save**: Save changes to `/config.json`
|
||||
5. **Service Notification**: Notify affected services of changes
|
||||
6. **Restart if Needed**: Restart system for certain configuration changes
|
||||
|
||||
## Configuration File Format
|
||||
|
||||
### JSON Structure
|
||||
|
||||
The configuration is stored as a JSON file with the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"wifi": {
|
||||
"ssid": "MyNetwork",
|
||||
"password": "mypassword",
|
||||
"connect_timeout_ms": 15000,
|
||||
"retry_delay_ms": 500
|
||||
},
|
||||
"network": {
|
||||
"udp_port": 4210,
|
||||
"api_server_port": 80
|
||||
},
|
||||
"cluster": {
|
||||
"heartbeat_interval_ms": 5000,
|
||||
"cluster_listen_interval_ms": 10,
|
||||
"status_update_interval_ms": 1000
|
||||
},
|
||||
"thresholds": {
|
||||
"node_active_threshold_ms": 10000,
|
||||
"node_inactive_threshold_ms": 60000,
|
||||
"node_dead_threshold_ms": 120000
|
||||
},
|
||||
"system": {
|
||||
"restart_delay_ms": 10,
|
||||
"json_doc_size": 1024
|
||||
},
|
||||
"memory": {
|
||||
"low_memory_threshold_bytes": 10000,
|
||||
"critical_memory_threshold_bytes": 5000,
|
||||
"max_concurrent_http_requests": 3
|
||||
},
|
||||
"_meta": {
|
||||
"version": "1.0",
|
||||
"saved_at": 1234567890
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Metadata Fields
|
||||
|
||||
- **`version`**: Configuration schema version for future compatibility
|
||||
- **`saved_at`**: Timestamp when configuration was saved (millis())
|
||||
|
||||
## Default Configuration Constants
|
||||
|
||||
All default values are defined as `constexpr` constants in the `Config` class header:
|
||||
|
||||
```cpp
|
||||
// Default Configuration Constants
|
||||
static constexpr const char* DEFAULT_WIFI_SSID = "shroud";
|
||||
static constexpr const char* DEFAULT_WIFI_PASSWORD = "th3r31sn0sp00n";
|
||||
static constexpr uint16_t DEFAULT_UDP_PORT = 4210;
|
||||
static constexpr uint16_t DEFAULT_API_SERVER_PORT = 80;
|
||||
// ... additional constants
|
||||
```
|
||||
|
||||
### Benefits of Constants
|
||||
|
||||
- **Single Source of Truth**: All defaults defined once
|
||||
- **Type Safety**: Compile-time type checking
|
||||
- **Maintainability**: Easy to update default values
|
||||
- **Consistency**: Same defaults used in `setDefaults()` and `loadFromFile()`
|
||||
|
||||
## Configuration Methods
|
||||
|
||||
### Core Methods
|
||||
|
||||
| Method | Purpose | Parameters |
|
||||
|--------|---------|------------|
|
||||
| `setDefaults()` | Initialize with default values | None |
|
||||
| `loadFromFile()` | Load configuration from persistent storage | `filename` (optional) |
|
||||
| `saveToFile()` | Save configuration to persistent storage | `filename` (optional) |
|
||||
|
||||
### Loading Process
|
||||
|
||||
```cpp
|
||||
bool Config::loadFromFile(const String& filename) {
|
||||
// 1. Initialize LittleFS
|
||||
if (!LittleFS.begin()) {
|
||||
LOG_ERROR("Config", "LittleFS not initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Check file existence
|
||||
if (!LittleFS.exists(filename)) {
|
||||
LOG_DEBUG("Config", "Config file does not exist");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. Parse JSON with fallback defaults
|
||||
wifi_ssid = doc["wifi"]["ssid"] | DEFAULT_WIFI_SSID;
|
||||
wifi_password = doc["wifi"]["password"] | DEFAULT_WIFI_PASSWORD;
|
||||
// ... additional fields
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### Saving Process
|
||||
|
||||
```cpp
|
||||
bool Config::saveToFile(const String& filename) {
|
||||
// 1. Create JSON document
|
||||
JsonDocument doc;
|
||||
|
||||
// 2. Serialize all configuration fields
|
||||
doc["wifi"]["ssid"] = wifi_ssid;
|
||||
doc["wifi"]["password"] = wifi_password;
|
||||
// ... additional fields
|
||||
|
||||
// 3. Add metadata
|
||||
doc["_meta"]["version"] = "1.0";
|
||||
doc["_meta"]["saved_at"] = millis();
|
||||
|
||||
// 4. Write to file
|
||||
size_t bytesWritten = serializeJson(doc, file);
|
||||
return bytesWritten > 0;
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Error Scenarios
|
||||
|
||||
| Scenario | Error Handling | Recovery |
|
||||
|----------|----------------|----------|
|
||||
| **LittleFS Init Failure** | Log warning, use defaults | Continue with default configuration |
|
||||
| **File Not Found** | Log debug message, return false | Caller handles fallback to defaults |
|
||||
| **JSON Parse Error** | Log error, return false | Caller handles fallback to defaults |
|
||||
| **Write Failure** | Log error, return false | Configuration not persisted |
|
||||
| **Memory Allocation Failure** | Log error, return false | Operation aborted |
|
||||
|
||||
### Logging Levels
|
||||
|
||||
- **ERROR**: Critical failures that prevent operation
|
||||
- **WARN**: Non-critical issues that affect functionality
|
||||
- **INFO**: Normal operation events
|
||||
- **DEBUG**: Detailed diagnostic information
|
||||
|
||||
## Configuration Validation
|
||||
|
||||
### Input Validation
|
||||
|
||||
- **Required Fields**: SSID and password are mandatory for WiFi configuration
|
||||
- **Range Validation**: Numeric values are validated against reasonable ranges
|
||||
- **Type Validation**: JSON parsing ensures correct data types
|
||||
- **Length Limits**: String fields have maximum length constraints
|
||||
|
||||
### Default Value Fallback
|
||||
|
||||
The system uses the `|` operator for safe fallback to defaults:
|
||||
|
||||
```cpp
|
||||
// Safe loading with fallback
|
||||
wifi_ssid = doc["wifi"]["ssid"] | DEFAULT_WIFI_SSID;
|
||||
udp_port = doc["network"]["udp_port"] | DEFAULT_UDP_PORT;
|
||||
```
|
||||
|
||||
This ensures that:
|
||||
- Missing fields use default values
|
||||
- Invalid values are replaced with defaults
|
||||
- System remains functional with partial configuration
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Memory Usage
|
||||
|
||||
- **Configuration Object**: ~200 bytes in RAM
|
||||
- **JSON Document**: ~1KB during parsing/saving
|
||||
- **LittleFS Overhead**: ~2-4KB for filesystem
|
||||
|
||||
### Storage Requirements
|
||||
|
||||
- **Config File**: ~500-800 bytes on disk
|
||||
- **LittleFS Minimum**: ~64KB partition size
|
||||
- **Available Space**: Depends on flash size (1MB+ recommended)
|
||||
|
||||
### Processing Overhead
|
||||
|
||||
- **Load Time**: ~10-50ms for JSON parsing
|
||||
- **Save Time**: ~20-100ms for JSON serialization
|
||||
- **File I/O**: Minimal impact on system performance
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Current Implementation
|
||||
|
||||
- **Local Storage Only**: Configuration stored on device filesystem
|
||||
- **No Encryption**: Plain text storage (LAN-only access assumed)
|
||||
- **Access Control**: No authentication for configuration changes
|
||||
|
||||
### Future Enhancements
|
||||
|
||||
- **Configuration Encryption**: Encrypt sensitive fields (passwords)
|
||||
- **Access Control**: Authentication for configuration changes
|
||||
- **Audit Logging**: Track configuration modifications
|
||||
- **Backup/Restore**: Configuration backup and restore capabilities
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Configuration Not Persisting**
|
||||
- Check LittleFS initialization
|
||||
- Verify file write permissions
|
||||
- Monitor available flash space
|
||||
|
||||
2. **Default Values Not Applied**
|
||||
- Verify constants are properly defined
|
||||
- Check JSON parsing errors
|
||||
- Ensure fallback logic is working
|
||||
|
||||
3. **Configuration Corruption**
|
||||
- Delete `/config.json` to reset to defaults
|
||||
- Check for JSON syntax errors
|
||||
- Verify file system integrity
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Check configuration status
|
||||
curl -s http://192.168.1.100/api/network/status | jq '.'
|
||||
|
||||
# View current WiFi settings
|
||||
curl -s http://192.168.1.100/api/network/status | jq '.wifi'
|
||||
|
||||
# Test configuration save
|
||||
curl -X POST http://192.168.1.100/api/network/wifi/config \
|
||||
-d "ssid=TestNetwork&password=testpass"
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Configuration Management
|
||||
|
||||
1. **Use Constants**: Always define defaults as constants
|
||||
2. **Validate Input**: Check all configuration parameters
|
||||
3. **Handle Errors**: Implement proper error handling
|
||||
4. **Log Changes**: Log configuration modifications
|
||||
5. **Test Fallbacks**: Ensure default fallbacks work correctly
|
||||
|
||||
### Development Guidelines
|
||||
|
||||
1. **Single Source**: Define each default value only once
|
||||
2. **Type Safety**: Use appropriate data types
|
||||
3. **Documentation**: Document all configuration parameters
|
||||
4. **Versioning**: Include version metadata in config files
|
||||
5. **Backward Compatibility**: Handle old configuration formats
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **[WiFi Configuration Process](./WiFiConfiguration.md)** - Detailed WiFi setup workflow
|
||||
- **[API Reference](./API.md)** - HTTP API for configuration management
|
||||
- **[Architecture Overview](./Architecture.md)** - System architecture and components
|
||||
- **[OpenAPI Specification](../api/)** - Machine-readable API specification
|
||||
509
docs/Development.md
Normal file
509
docs/Development.md
Normal file
@@ -0,0 +1,509 @@
|
||||
# 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 (framework under src/spore)
|
||||
│ └── spore/
|
||||
│ ├── Spore.cpp # Framework lifecycle (setup/begin/loop)
|
||||
│ ├── core/ # Core components
|
||||
│ │ ├── ApiServer.cpp # HTTP API server implementation
|
||||
│ │ ├── ClusterManager.cpp # Cluster management logic
|
||||
│ │ ├── NetworkManager.cpp # WiFi and network handling
|
||||
│ │ ├── TaskManager.cpp # Background task management
|
||||
│ │ └── NodeContext.cpp # Central context and events
|
||||
│ ├── services/ # Built-in services (implement Service interface)
|
||||
│ │ ├── NodeService.cpp # registerEndpoints() + registerTasks()
|
||||
│ │ ├── NetworkService.cpp # registerEndpoints() + registerTasks()
|
||||
│ │ ├── ClusterService.cpp # registerEndpoints() + registerTasks()
|
||||
│ │ ├── TaskService.cpp # registerEndpoints() + registerTasks()
|
||||
│ │ ├── StaticFileService.cpp # registerEndpoints() + registerTasks()
|
||||
│ │ └── MonitoringService.cpp # registerEndpoints() + registerTasks()
|
||||
│ └── types/ # Shared types
|
||||
├── include/ # Header files
|
||||
├── examples/ # Example apps per env (base, relay, neopattern)
|
||||
├── docs/ # Documentation
|
||||
├── api/ # OpenAPI specification
|
||||
├── platformio.ini # PlatformIO configuration
|
||||
└── ctl.sh # Build and deployment scripts
|
||||
```
|
||||
|
||||
## PlatformIO Configuration
|
||||
|
||||
### Framework and Board
|
||||
|
||||
The project uses PlatformIO with the following configuration (excerpt):
|
||||
|
||||
```ini
|
||||
[platformio]
|
||||
default_envs = base
|
||||
src_dir = .
|
||||
data_dir = ${PROJECT_DIR}/examples/${PIOENV}/data
|
||||
|
||||
[common]
|
||||
monitor_speed = 115200
|
||||
lib_deps =
|
||||
esp32async/ESPAsyncWebServer@^3.8.0
|
||||
bblanchon/ArduinoJson@^7.4.2
|
||||
|
||||
[env:base]
|
||||
platform = platformio/espressif8266@^4.2.1
|
||||
board = esp01_1m
|
||||
framework = arduino
|
||||
upload_speed = 115200
|
||||
monitor_speed = 115200
|
||||
board_build.f_cpu = 80000000L
|
||||
board_build.flash_mode = qio
|
||||
board_build.filesystem = littlefs
|
||||
; note: somehow partition table is not working, so we need to use the ldscript
|
||||
board_build.ldscript = eagle.flash.1m64.ld
|
||||
lib_deps = ${common.lib_deps}
|
||||
build_src_filter =
|
||||
+<examples/base/*.cpp>
|
||||
+<src/spore/*.cpp>
|
||||
+<src/spore/core/*.cpp>
|
||||
+<src/spore/services/*.cpp>
|
||||
+<src/spore/types/*.cpp>
|
||||
+<src/spore/util/*.cpp>
|
||||
+<src/internal/*.cpp>
|
||||
|
||||
[env:d1_mini]
|
||||
platform = platformio/espressif8266@^4.2.1
|
||||
board = d1_mini
|
||||
framework = arduino
|
||||
upload_speed = 115200
|
||||
monitor_speed = 115200
|
||||
board_build.filesystem = littlefs
|
||||
board_build.flash_mode = dio ; D1 Mini uses DIO on 4 Mbit flash
|
||||
board_build.flash_size = 4M
|
||||
board_build.ldscript = eagle.flash.4m1m.ld
|
||||
lib_deps = ${common.lib_deps}
|
||||
build_src_filter =
|
||||
+<examples/base/*.cpp>
|
||||
+<src/spore/*.cpp>
|
||||
+<src/spore/core/*.cpp>
|
||||
+<src/spore/services/*.cpp>
|
||||
+<src/spore/types/*.cpp>
|
||||
+<src/spore/util/*.cpp>
|
||||
+<src/internal/*.cpp>
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
The project requires the following libraries (resolved via PlatformIO):
|
||||
|
||||
```ini
|
||||
lib_deps =
|
||||
esp32async/ESPAsyncWebServer@^3.8.0
|
||||
bblanchon/ArduinoJson@^7.4.2
|
||||
```
|
||||
|
||||
### 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/spore/types/Config.cpp`**: Default runtime 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
|
||||
79
docs/MonitoringService.md
Normal file
79
docs/MonitoringService.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Monitoring Service
|
||||
|
||||
Exposes system resource metrics via HTTP for observability.
|
||||
|
||||
## Overview
|
||||
|
||||
- **Service name**: `MonitoringService`
|
||||
- **Endpoint**: `GET /api/monitoring/resources`
|
||||
- **Metrics**: CPU usage, memory, filesystem, uptime
|
||||
|
||||
## Endpoint
|
||||
|
||||
### GET /api/monitoring/resources
|
||||
|
||||
Returns real-time system resource metrics.
|
||||
|
||||
Response fields:
|
||||
- `cpu.current_usage`: Current CPU usage percent
|
||||
- `cpu.average_usage`: Average CPU usage percent
|
||||
- `cpu.max_usage`: Max observed CPU usage
|
||||
- `cpu.min_usage`: Min observed CPU usage
|
||||
- `cpu.measurement_count`: Number of measurements
|
||||
- `cpu.is_measuring`: Whether measurement is active
|
||||
- `memory.free_heap`: Free heap bytes
|
||||
- `memory.total_heap`: Total heap bytes (approximate)
|
||||
- `memory.min_free_heap`: Minimum free heap (0 on ESP8266)
|
||||
- `memory.max_alloc_heap`: Max allocatable heap (0 on ESP8266)
|
||||
- `memory.heap_fragmentation`: Fragmentation percent (0 on ESP8266)
|
||||
- `filesystem.total_bytes`: LittleFS total bytes
|
||||
- `filesystem.used_bytes`: Used bytes
|
||||
- `filesystem.free_bytes`: Free bytes
|
||||
- `filesystem.usage_percent`: Usage percent
|
||||
- `system.uptime_ms`: Uptime in milliseconds
|
||||
- `system.uptime_seconds`: Uptime in seconds
|
||||
- `system.uptime_formatted`: Human-readable uptime
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"cpu": {
|
||||
"current_usage": 3.5,
|
||||
"average_usage": 2.1,
|
||||
"max_usage": 15.2,
|
||||
"min_usage": 0.0,
|
||||
"measurement_count": 120,
|
||||
"is_measuring": true
|
||||
},
|
||||
"memory": {
|
||||
"free_heap": 48748,
|
||||
"total_heap": 81920,
|
||||
"min_free_heap": 0,
|
||||
"max_alloc_heap": 0,
|
||||
"heap_fragmentation": 0,
|
||||
"heap_usage_percent": 40.4
|
||||
},
|
||||
"filesystem": {
|
||||
"total_bytes": 65536,
|
||||
"used_bytes": 10240,
|
||||
"free_bytes": 55296,
|
||||
"usage_percent": 15.6
|
||||
},
|
||||
"system": {
|
||||
"uptime_ms": 123456,
|
||||
"uptime_seconds": 123,
|
||||
"uptime_formatted": "0h 2m 3s"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `MonitoringService` reads from `CpuUsage` and ESP8266 SDK APIs.
|
||||
- Filesystem metrics are gathered from LittleFS.
|
||||
- CPU measurement is bracketed by `Spore::loop()` calling `cpuUsage.startMeasurement()` and `cpuUsage.endMeasurement()`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If `filesystem.total_bytes` is zero, ensure LittleFS is enabled in `platformio.ini` and an FS image is uploaded.
|
||||
- CPU usage values remain zero until the main loop runs and CPU measurement is started.
|
||||
82
docs/README.md
Normal file
82
docs/README.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# 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
|
||||
|
||||
### 📖 [MonitoringService.md](./MonitoringService.md)
|
||||
System resource monitoring API for CPU, memory, filesystem, and uptime.
|
||||
|
||||
### 📖 [TaskManagement.md](./TaskManagement.md)
|
||||
Complete guide to the task management system with examples and best practices.
|
||||
|
||||
**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.
|
||||
98
docs/StreamingAPI.md
Normal file
98
docs/StreamingAPI.md
Normal file
@@ -0,0 +1,98 @@
|
||||
## Streaming API (WebSocket)
|
||||
|
||||
### Overview
|
||||
|
||||
The streaming API exposes an event-driven WebSocket at `/ws`. It bridges between external clients and the internal event bus:
|
||||
|
||||
- Incoming WebSocket JSON `{ event, payload }` → `ctx.fire(event, payload)`
|
||||
- Local events → broadcasted to all connected WebSocket clients as `{ event, payload }`
|
||||
|
||||
This allows real-time control and observation of the system without polling.
|
||||
|
||||
### URL
|
||||
|
||||
- `ws://<device-ip>/ws`
|
||||
|
||||
### Message Format
|
||||
|
||||
- Client → Device
|
||||
```json
|
||||
{
|
||||
"event": "<event-name>",
|
||||
"payload": "<json-string>" | { /* inline JSON */ }
|
||||
}
|
||||
```
|
||||
|
||||
- Device → Client
|
||||
```json
|
||||
{
|
||||
"event": "<event-name>",
|
||||
"payload": "<json-string>"
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The device accepts `payload` as a string or a JSON object/array. Objects are serialized into a string before dispatching to local subscribers to keep a consistent downstream contract.
|
||||
- A minimal ack `{ "ok": true }` is sent after a valid inbound message.
|
||||
|
||||
#### Echo suppression (origin tagging)
|
||||
|
||||
- To prevent the sender from receiving an immediate echo of its own message, the server injects a private field into JSON payloads:
|
||||
- `_origin: "ws:<clientId>"`
|
||||
- When re-broadcasting local events to WebSocket clients, the server:
|
||||
- Strips the `_origin` field from the outgoing payload
|
||||
- Skips the originating `clientId` so only other clients receive the message
|
||||
- If a payload is not valid JSON (plain string), no origin tag is injected and the message may be echoed
|
||||
|
||||
### Event Bus Integration
|
||||
|
||||
- The WebSocket registers an `onAny` subscriber to `NodeContext` so that all local events are mirrored to clients.
|
||||
- Services should subscribe to specific events via `ctx.on("<name>", ...)`.
|
||||
|
||||
### Examples
|
||||
|
||||
1) Set a solid color on NeoPattern:
|
||||
```json
|
||||
{
|
||||
"event": "api/neopattern/color",
|
||||
"payload": { "color": "#FF0000", "brightness": 128 }
|
||||
}
|
||||
```
|
||||
|
||||
2) Broadcast a cluster event (delegated to core):
|
||||
```json
|
||||
{
|
||||
"event": "cluster/broadcast",
|
||||
"payload": {
|
||||
"event": "api/neopattern/color",
|
||||
"data": { "color": "#00FF00", "brightness": 128 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reference Implementation
|
||||
|
||||
- WebSocket setup and bridging are implemented in `ApiServer`.
|
||||
- Global event subscription uses `NodeContext::onAny`.
|
||||
|
||||
Related docs:
|
||||
- [`ClusterBroadcast.md`](./ClusterBroadcast.md) — centralized UDP broadcasting and CLUSTER_EVENT format
|
||||
|
||||
### Things to consider
|
||||
|
||||
- High-frequency updates can overwhelm ESP8266:
|
||||
- Frequent JSON parse/serialize and `String` allocations fragment heap and may cause resets (e.g., Exception(3)).
|
||||
- UDP broadcast on every message amplifies load; WiFi/UDP buffers can back up.
|
||||
- Prefer ≥50–100 ms intervals; microbursts at 10 ms are risky.
|
||||
- Throttle and coalesce:
|
||||
- Add a minimum interval in the core `cluster/broadcast` handler.
|
||||
- Optionally drop redundant updates (e.g., same color as previous).
|
||||
- Reduce allocations:
|
||||
- Reuse `StaticJsonDocument`/preallocated buffers in hot paths.
|
||||
- Avoid re-serializing when possible; pass-through payload strings.
|
||||
- Reserve `String` capacity when reuse is needed.
|
||||
- Yielding:
|
||||
- Call `yield()` in long-running or bursty paths to avoid WDT.
|
||||
- Packet size:
|
||||
- Keep payloads small to fit `ClusterProtocol::UDP_BUF_SIZE` and reduce airtime.
|
||||
|
||||
510
docs/TaskManagement.md
Normal file
510
docs/TaskManagement.md
Normal file
@@ -0,0 +1,510 @@
|
||||
# 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
|
||||
|
||||
## Service Interface Integration
|
||||
|
||||
Services now implement a unified interface for both endpoint and task registration:
|
||||
|
||||
```cpp
|
||||
class MyService : public Service {
|
||||
public:
|
||||
void registerEndpoints(ApiServer& api) override {
|
||||
// Register HTTP endpoints
|
||||
api.registerEndpoint("/api/my/status", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleStatus(request); });
|
||||
}
|
||||
|
||||
void registerTasks(TaskManager& taskManager) override {
|
||||
// Register background tasks
|
||||
taskManager.registerTask("my_heartbeat", 2000,
|
||||
[this]() { sendHeartbeat(); });
|
||||
taskManager.registerTask("my_maintenance", 30000,
|
||||
[this]() { performMaintenance(); });
|
||||
}
|
||||
|
||||
const char* getName() const override { return "MyService"; }
|
||||
};
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```cpp
|
||||
#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();
|
||||
```
|
||||
|
||||
## Service Lifecycle
|
||||
|
||||
The Spore framework automatically manages service registration and task lifecycle:
|
||||
|
||||
### Service Registration Process
|
||||
|
||||
1. **Service Creation**: Services are created with required dependencies (NodeContext, TaskManager, etc.)
|
||||
2. **Service Registration**: Services are registered with the Spore framework via `spore.registerService()`
|
||||
3. **Endpoint Registration**: When `spore.begin()` is called, `registerEndpoints()` is called for each service
|
||||
4. **Task Registration**: Simultaneously, `registerTasks()` is called for each service
|
||||
5. **Task Initialization**: The TaskManager initializes all registered tasks
|
||||
6. **Execution**: Tasks run in the main loop when their intervals elapse
|
||||
|
||||
### Framework Integration
|
||||
|
||||
```cpp
|
||||
void setup() {
|
||||
spore.setup();
|
||||
|
||||
// Create service with dependencies
|
||||
MyService* service = new MyService(spore.getContext(), spore.getTaskManager());
|
||||
|
||||
// Register service (endpoints and tasks will be registered when begin() is called)
|
||||
spore.registerService(service);
|
||||
|
||||
// This triggers registerEndpoints() and registerTasks() for all services
|
||||
spore.begin();
|
||||
}
|
||||
```
|
||||
|
||||
### Dynamic Service Addition
|
||||
|
||||
Services can be added after the framework has started:
|
||||
|
||||
```cpp
|
||||
// Add service to running framework
|
||||
MyService* newService = new MyService(spore.getContext(), spore.getTaskManager());
|
||||
spore.registerService(newService); // Immediately registers endpoints and tasks
|
||||
```
|
||||
|
||||
## Task Registration Methods
|
||||
|
||||
### Using std::bind with Member Functions (Recommended)
|
||||
|
||||
```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: Service Interface (Recommended)
|
||||
|
||||
1. **Create your service class implementing the Service interface**:
|
||||
```cpp
|
||||
class SensorService : public Service {
|
||||
public:
|
||||
SensorService(NodeContext& ctx, TaskManager& taskManager)
|
||||
: ctx(ctx), taskManager(taskManager) {}
|
||||
|
||||
void registerEndpoints(ApiServer& api) override {
|
||||
api.registerEndpoint("/api/sensor/status", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleStatus(request); });
|
||||
}
|
||||
|
||||
void registerTasks(TaskManager& taskManager) override {
|
||||
taskManager.registerTask("temp_read", 1000,
|
||||
[this]() { readTemperature(); });
|
||||
taskManager.registerTask("calibrate", 60000,
|
||||
[this]() { calibrateSensors(); });
|
||||
}
|
||||
|
||||
const char* getName() const override { return "SensorService"; }
|
||||
|
||||
private:
|
||||
NodeContext& ctx;
|
||||
TaskManager& taskManager;
|
||||
|
||||
void readTemperature() {
|
||||
// Read sensor logic
|
||||
Serial.println("Reading temperature");
|
||||
}
|
||||
|
||||
void calibrateSensors() {
|
||||
// Calibration logic
|
||||
Serial.println("Calibrating sensors");
|
||||
}
|
||||
|
||||
void handleStatus(AsyncWebServerRequest* request) {
|
||||
// Handle status request
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
2. **Register with Spore framework**:
|
||||
```cpp
|
||||
void setup() {
|
||||
spore.setup();
|
||||
|
||||
SensorService* sensorService = new SensorService(spore.getContext(), spore.getTaskManager());
|
||||
spore.registerService(sensorService);
|
||||
|
||||
spore.begin(); // This will call registerTasks() automatically
|
||||
}
|
||||
```
|
||||
|
||||
### Method 2: Direct TaskManager Registration
|
||||
|
||||
1. **Create your service class**:
|
||||
```cpp
|
||||
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 3: 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 Service Interface**: Implement the Service interface for clean integration with the framework
|
||||
2. **Group related tasks**: Register multiple related operations in a single service
|
||||
3. **Monitor task health**: Use the status API to monitor task performance
|
||||
4. **Plan intervals carefully**: Balance responsiveness with system resources
|
||||
5. **Use descriptive names**: Make task names clear and meaningful
|
||||
6. **Separate concerns**: Use registerEndpoints() for HTTP API and registerTasks() for background work
|
||||
7. **Dependency injection**: Pass required dependencies (NodeContext, TaskManager) to service constructors
|
||||
|
||||
## Migration to Service Interface
|
||||
|
||||
### Before (manual task registration in constructor):
|
||||
```cpp
|
||||
class MyService : public Service {
|
||||
public:
|
||||
MyService(TaskManager& taskManager) : taskManager(taskManager) {
|
||||
// Tasks registered in constructor
|
||||
taskManager.registerTask("heartbeat", 2000, [this]() { sendHeartbeat(); });
|
||||
}
|
||||
|
||||
void registerEndpoints(ApiServer& api) override {
|
||||
// Only endpoints registered here
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### After (using Service interface):
|
||||
```cpp
|
||||
class MyService : public Service {
|
||||
public:
|
||||
MyService(TaskManager& taskManager) : taskManager(taskManager) {
|
||||
// No task registration in constructor
|
||||
}
|
||||
|
||||
void registerEndpoints(ApiServer& api) override {
|
||||
// Register HTTP endpoints
|
||||
api.registerEndpoint("/api/my/status", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleStatus(request); });
|
||||
}
|
||||
|
||||
void registerTasks(TaskManager& taskManager) override {
|
||||
// Register background tasks
|
||||
taskManager.registerTask("heartbeat", 2000, [this]() { sendHeartbeat(); });
|
||||
}
|
||||
|
||||
const char* getName() const override { return "MyService"; }
|
||||
};
|
||||
```
|
||||
|
||||
### Migration from Wrapper Functions
|
||||
|
||||
### Before (with wrapper functions):
|
||||
```cpp
|
||||
void discoverySendTask() { cluster.sendDiscovery(); }
|
||||
void clusterListenTask() { cluster.listen(); }
|
||||
|
||||
taskManager.registerTask("discovery_send", interval, discoverySendTask);
|
||||
taskManager.registerTask("cluster_listen", interval, clusterListenTask);
|
||||
```
|
||||
|
||||
### After (with std::bind):
|
||||
```cpp
|
||||
taskManager.registerTask("discovery_send", interval,
|
||||
std::bind(&ClusterManager::sendDiscovery, &cluster));
|
||||
taskManager.registerTask("cluster_listen", interval,
|
||||
std::bind(&ClusterManager::listen, &cluster));
|
||||
```
|
||||
|
||||
## Compatibility
|
||||
|
||||
- The new Service interface is fully backward compatible
|
||||
- Existing code using direct TaskManager registration will continue to work
|
||||
- You can mix Service interface and direct registration in the same project
|
||||
- All existing TaskManager methods remain unchanged
|
||||
- The Service interface provides a cleaner, more organized approach for framework integration
|
||||
|
||||
## 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
|
||||
478
docs/WiFiConfiguration.md
Normal file
478
docs/WiFiConfiguration.md
Normal file
@@ -0,0 +1,478 @@
|
||||
# SPORE WiFi Configuration Process
|
||||
|
||||
## Overview
|
||||
|
||||
SPORE implements a WiFi configuration system that handles initial setup, runtime reconfiguration, and automatic fallback mechanisms. The system supports both Station (STA) and Access Point (AP) modes with seamless switching between them.
|
||||
|
||||
## WiFi Configuration Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
- **`NetworkManager`**: Handles WiFi operations and configuration
|
||||
- **`NetworkService`**: Provides HTTP API endpoints for WiFi management
|
||||
- **`Config`**: Stores WiFi credentials and connection parameters
|
||||
- **LittleFS**: Persistent storage for WiFi configuration
|
||||
- **ESP8266 WiFi Library**: Low-level WiFi operations
|
||||
|
||||
### Configuration Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `wifi_ssid` | String | "shroud" | Network SSID for connection |
|
||||
| `wifi_password` | String | "th3r31sn0sp00n" | Network password |
|
||||
| `wifi_connect_timeout_ms` | uint32_t | 15000 | Connection timeout (15 seconds) |
|
||||
| `wifi_retry_delay_ms` | uint32_t | 500 | Delay between connection attempts |
|
||||
|
||||
## WiFi Configuration Lifecycle
|
||||
|
||||
### 1. Boot Process
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[System Boot] --> B[Initialize LittleFS]
|
||||
B --> C[Load Configuration]
|
||||
C --> D[Initialize WiFi Mode]
|
||||
D --> E[Set Hostname from MAC]
|
||||
E --> F[Attempt STA Connection]
|
||||
F --> G{Connection Successful?}
|
||||
G -->|Yes| H[STA Mode Active]
|
||||
G -->|No| I[Switch to AP Mode]
|
||||
I --> J[Create Access Point]
|
||||
J --> K[AP Mode Active]
|
||||
H --> L[Start UDP Services]
|
||||
K --> L
|
||||
L --> M[Initialize Node Context]
|
||||
M --> N[Start Cluster Services]
|
||||
```
|
||||
|
||||
**Detailed Boot Sequence:**
|
||||
|
||||
1. **LittleFS Initialization**
|
||||
```cpp
|
||||
if (!LittleFS.begin()) {
|
||||
LOG_WARN("Config", "Failed to initialize LittleFS, using defaults");
|
||||
setDefaults();
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
2. **Configuration Loading**
|
||||
- Load WiFi credentials from `/config.json`
|
||||
- Fall back to defaults if file doesn't exist
|
||||
- Validate configuration parameters
|
||||
|
||||
3. **WiFi Mode Initialization**
|
||||
```cpp
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(ctx.config.wifi_ssid.c_str(), ctx.config.wifi_password.c_str());
|
||||
```
|
||||
|
||||
4. **Hostname Generation**
|
||||
```cpp
|
||||
void NetworkManager::setHostnameFromMac() {
|
||||
uint8_t mac[6];
|
||||
WiFi.macAddress(mac);
|
||||
char buf[32];
|
||||
sprintf(buf, "esp-%02X%02X%02X%02X%02X%02X",
|
||||
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
|
||||
WiFi.hostname(buf);
|
||||
ctx.hostname = String(buf);
|
||||
}
|
||||
```
|
||||
|
||||
5. **Connection Attempt**
|
||||
```cpp
|
||||
unsigned long startAttemptTime = millis();
|
||||
while (WiFi.status() != WL_CONNECTED &&
|
||||
millis() - startAttemptTime < ctx.config.wifi_connect_timeout_ms) {
|
||||
delay(ctx.config.wifi_retry_delay_ms);
|
||||
}
|
||||
```
|
||||
|
||||
6. **Fallback to AP Mode**
|
||||
```cpp
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
LOG_WARN("WiFi", "Failed to connect to AP. Creating AP...");
|
||||
WiFi.mode(WIFI_AP);
|
||||
WiFi.softAP(ctx.config.wifi_ssid.c_str(), ctx.config.wifi_password.c_str());
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Runtime Reconfiguration
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[HTTP API Request] --> B[Validate Parameters]
|
||||
B --> C[Update Config Object]
|
||||
C --> D[Save to Persistent Storage]
|
||||
D --> E[Send Response to Client]
|
||||
E --> F[Schedule Node Restart]
|
||||
F --> G[Node Restarts]
|
||||
G --> H[Apply New Configuration]
|
||||
H --> I[Attempt New Connection]
|
||||
```
|
||||
|
||||
**Runtime Reconfiguration Process:**
|
||||
|
||||
1. **API Request Validation**
|
||||
```cpp
|
||||
if (!request->hasParam("ssid", true) || !request->hasParam("password", true)) {
|
||||
request->send(400, "application/json", "{\"error\": \"Missing required parameters\"}");
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
2. **Configuration Update**
|
||||
```cpp
|
||||
void NetworkManager::setWiFiConfig(const String& ssid, const String& password,
|
||||
uint32_t connect_timeout_ms, uint32_t retry_delay_ms) {
|
||||
ctx.config.wifi_ssid = ssid;
|
||||
ctx.config.wifi_password = password;
|
||||
ctx.config.wifi_connect_timeout_ms = connect_timeout_ms;
|
||||
ctx.config.wifi_retry_delay_ms = retry_delay_ms;
|
||||
}
|
||||
```
|
||||
|
||||
3. **Persistent Storage**
|
||||
```cpp
|
||||
bool configSaved = networkManager.saveConfig();
|
||||
if (!configSaved) {
|
||||
LOG_WARN("NetworkService", "Failed to save WiFi configuration to persistent storage");
|
||||
}
|
||||
```
|
||||
|
||||
4. **Restart Scheduling**
|
||||
```cpp
|
||||
request->onDisconnect([this]() {
|
||||
LOG_INFO("NetworkService", "Restarting node to apply WiFi configuration...");
|
||||
delay(100); // Give time for response to be sent
|
||||
networkManager.restartNode();
|
||||
});
|
||||
```
|
||||
|
||||
## WiFi Modes
|
||||
|
||||
### Station Mode (STA)
|
||||
|
||||
**Purpose**: Connect to existing WiFi network as a client
|
||||
|
||||
**Configuration**:
|
||||
- SSID and password required
|
||||
- Automatic IP assignment via DHCP
|
||||
- Hostname set from MAC address
|
||||
- UDP services on configured port
|
||||
|
||||
**Connection Process**:
|
||||
1. Set WiFi mode to STA
|
||||
2. Begin connection with credentials
|
||||
3. Wait for connection with timeout
|
||||
4. Set hostname and start services
|
||||
|
||||
### Access Point Mode (AP)
|
||||
|
||||
**Purpose**: Create WiFi hotspot when STA connection fails
|
||||
|
||||
**Configuration**:
|
||||
- Uses configured SSID/password for AP
|
||||
- Fixed IP address (usually 192.168.4.1)
|
||||
- Allows other devices to connect
|
||||
- Maintains cluster functionality
|
||||
|
||||
**AP Creation Process**:
|
||||
1. Switch WiFi mode to AP
|
||||
2. Create soft access point
|
||||
3. Set AP IP address
|
||||
4. Start services on AP IP
|
||||
|
||||
## HTTP API Endpoints
|
||||
|
||||
### Network Status
|
||||
|
||||
#### GET `/api/network/status`
|
||||
|
||||
Returns comprehensive WiFi and network status information.
|
||||
|
||||
**Response Fields**:
|
||||
```json
|
||||
{
|
||||
"wifi": {
|
||||
"connected": true,
|
||||
"mode": "STA",
|
||||
"ssid": "MyNetwork",
|
||||
"ip": "192.168.1.100",
|
||||
"mac": "AA:BB:CC:DD:EE:FF",
|
||||
"hostname": "esp-AABBCCDDEEFF",
|
||||
"rssi": -45,
|
||||
"ap_ip": "192.168.4.1",
|
||||
"ap_mac": "AA:BB:CC:DD:EE:FF",
|
||||
"stations_connected": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### WiFi Scanning
|
||||
|
||||
#### GET `/api/network/wifi/scan`
|
||||
|
||||
Returns list of available WiFi networks from last scan.
|
||||
|
||||
**Response Fields**:
|
||||
```json
|
||||
{
|
||||
"access_points": [
|
||||
{
|
||||
"ssid": "MyNetwork",
|
||||
"rssi": -45,
|
||||
"channel": 6,
|
||||
"encryption_type": 4,
|
||||
"hidden": false,
|
||||
"bssid": "AA:BB:CC:DD:EE:FF"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### POST `/api/network/wifi/scan`
|
||||
|
||||
Initiates a new WiFi network scan.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"status": "scanning",
|
||||
"message": "WiFi scan started"
|
||||
}
|
||||
```
|
||||
|
||||
### WiFi Configuration
|
||||
|
||||
#### POST `/api/network/wifi/config`
|
||||
|
||||
Configures WiFi connection with new credentials.
|
||||
|
||||
**Parameters**:
|
||||
- `ssid` (required): Network SSID
|
||||
- `password` (required): Network password
|
||||
- `connect_timeout_ms` (optional): Connection timeout (default: 10000)
|
||||
- `retry_delay_ms` (optional): Retry delay (default: 500)
|
||||
|
||||
**Request Example**:
|
||||
```bash
|
||||
curl -X POST http://192.168.1.100/api/network/wifi/config \
|
||||
-d "ssid=MyNewNetwork&password=newpassword&connect_timeout_ms=15000"
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "WiFi configuration updated and saved",
|
||||
"config_saved": true,
|
||||
"restarting": true
|
||||
}
|
||||
```
|
||||
|
||||
## WiFi Scanning Process
|
||||
|
||||
### Scanning Implementation
|
||||
|
||||
```cpp
|
||||
void NetworkManager::scanWifi() {
|
||||
if (!isScanning) {
|
||||
isScanning = true;
|
||||
LOG_INFO("WiFi", "Starting WiFi scan...");
|
||||
WiFi.scanNetworksAsync([this](int networksFound) {
|
||||
LOG_INFO("WiFi", "Scan completed, found " + String(networksFound) + " networks");
|
||||
this->processAccessPoints();
|
||||
this->isScanning = false;
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Access Point Processing
|
||||
|
||||
```cpp
|
||||
void NetworkManager::processAccessPoints() {
|
||||
int numNetworks = WiFi.scanComplete();
|
||||
if (numNetworks <= 0) return;
|
||||
|
||||
accessPoints.clear();
|
||||
for (int i = 0; i < numNetworks; i++) {
|
||||
AccessPoint ap;
|
||||
ap.ssid = WiFi.SSID(i);
|
||||
ap.rssi = WiFi.RSSI(i);
|
||||
ap.encryptionType = WiFi.encryptionType(i);
|
||||
ap.channel = WiFi.channel(i);
|
||||
ap.isHidden = ap.ssid.length() == 0;
|
||||
|
||||
uint8_t* newBssid = new uint8_t[6];
|
||||
memcpy(newBssid, WiFi.BSSID(i), 6);
|
||||
ap.bssid = newBssid;
|
||||
|
||||
accessPoints.push_back(ap);
|
||||
}
|
||||
WiFi.scanDelete();
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Connection Failures
|
||||
|
||||
| Error Type | Handling | Recovery |
|
||||
|------------|----------|----------|
|
||||
| **Invalid Credentials** | Log error, switch to AP mode | Manual reconfiguration via API |
|
||||
| **Network Unavailable** | Log warning, switch to AP mode | Automatic retry on next boot |
|
||||
| **Timeout** | Log timeout, switch to AP mode | Increase timeout or check network |
|
||||
| **Hardware Failure** | Log error, continue in AP mode | Hardware replacement required |
|
||||
|
||||
### API Error Responses
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Missing required parameters",
|
||||
"status": "error"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Failed to save configuration",
|
||||
"status": "error",
|
||||
"config_saved": false
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Current Implementation
|
||||
|
||||
- **Plain Text Storage**: WiFi passwords stored unencrypted
|
||||
- **Local Network Only**: No internet exposure
|
||||
- **No Authentication**: API access without authentication
|
||||
- **MAC-based Hostnames**: Predictable hostname generation
|
||||
|
||||
### Security Best Practices
|
||||
|
||||
1. **Network Isolation**: Keep SPORE devices on isolated network
|
||||
2. **Strong Passwords**: Use complex WiFi passwords
|
||||
3. **Regular Updates**: Keep firmware updated
|
||||
4. **Access Control**: Implement network-level access control
|
||||
|
||||
### Future Security Enhancements
|
||||
|
||||
- **Password Encryption**: Encrypt stored WiFi credentials
|
||||
- **API Authentication**: Add authentication to configuration API
|
||||
- **Certificate-based Security**: Implement TLS/SSL
|
||||
- **Access Control Lists**: Role-based configuration access
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **WiFi Connection Fails**
|
||||
- Check SSID and password
|
||||
- Verify network availability
|
||||
- Check signal strength
|
||||
- Increase connection timeout
|
||||
|
||||
2. **Configuration Not Persisting**
|
||||
- Check LittleFS initialization
|
||||
- Verify file write permissions
|
||||
- Monitor available flash space
|
||||
|
||||
3. **AP Mode Not Working**
|
||||
- Check AP credentials
|
||||
- Verify IP address assignment
|
||||
- Check for IP conflicts
|
||||
|
||||
4. **Scan Not Finding Networks**
|
||||
- Wait for scan completion
|
||||
- Check WiFi hardware
|
||||
- Verify scan permissions
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Check WiFi status
|
||||
curl -s http://192.168.1.100/api/network/status | jq '.wifi'
|
||||
|
||||
# Scan for networks
|
||||
curl -X POST http://192.168.1.100/api/network/wifi/scan
|
||||
|
||||
# View scan results
|
||||
curl -s http://192.168.1.100/api/network/wifi/scan | jq '.'
|
||||
|
||||
# Test configuration
|
||||
curl -X POST http://192.168.1.100/api/network/wifi/config \
|
||||
-d "ssid=TestNetwork&password=testpass"
|
||||
```
|
||||
|
||||
### Log Analysis
|
||||
|
||||
**Successful Connection**:
|
||||
```
|
||||
[INFO] WiFi: Connected to AP, IP: 192.168.1.100
|
||||
[INFO] WiFi: Hostname set to: esp-AABBCCDDEEFF
|
||||
[INFO] WiFi: UDP listening on port 4210
|
||||
```
|
||||
|
||||
**Connection Failure**:
|
||||
```
|
||||
[WARN] WiFi: Failed to connect to AP. Creating AP...
|
||||
[INFO] WiFi: AP created, IP: 192.168.4.1
|
||||
```
|
||||
|
||||
**Configuration Update**:
|
||||
```
|
||||
[INFO] NetworkService: Restarting node to apply WiFi configuration...
|
||||
[INFO] WiFi: Connecting to AP...
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Connection Time
|
||||
|
||||
- **STA Connection**: 5-15 seconds typical
|
||||
- **AP Creation**: 1-3 seconds
|
||||
- **Scan Duration**: 5-10 seconds
|
||||
- **Configuration Save**: 100-500ms
|
||||
|
||||
### Memory Usage
|
||||
|
||||
- **WiFi Stack**: ~20-30KB RAM
|
||||
- **Scan Results**: ~1KB per network
|
||||
- **Configuration**: ~200 bytes
|
||||
- **API Buffers**: ~2-4KB
|
||||
|
||||
### Network Overhead
|
||||
|
||||
- **Scan Packets**: Minimal impact
|
||||
- **Configuration API**: ~500 bytes per request
|
||||
- **Status Updates**: ~200 bytes per response
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Configuration Management
|
||||
|
||||
1. **Use Strong Passwords**: Implement password complexity requirements
|
||||
2. **Set Appropriate Timeouts**: Balance connection speed vs reliability
|
||||
3. **Monitor Connection Quality**: Track RSSI and connection stability
|
||||
4. **Implement Retry Logic**: Handle temporary network issues
|
||||
5. **Log Configuration Changes**: Audit trail for troubleshooting
|
||||
|
||||
### Development Guidelines
|
||||
|
||||
1. **Handle All Error Cases**: Implement comprehensive error handling
|
||||
2. **Provide Clear Feedback**: Inform users of connection status
|
||||
3. **Optimize Scan Frequency**: Balance discovery vs performance
|
||||
4. **Test Fallback Scenarios**: Ensure AP mode works correctly
|
||||
5. **Document Configuration Options**: Clear parameter documentation
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **[Configuration Management](./ConfigurationManagement.md)** - Persistent configuration system
|
||||
- **[API Reference](./API.md)** - Complete HTTP API documentation
|
||||
- **[Architecture Overview](./Architecture.md)** - System architecture and components
|
||||
- **[OpenAPI Specification](../api/)** - Machine-readable API specification
|
||||
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();
|
||||
}
|
||||
248
examples/multimatrix/MultiMatrixService.cpp
Normal file
248
examples/multimatrix/MultiMatrixService.cpp
Normal file
@@ -0,0 +1,248 @@
|
||||
#include "MultiMatrixService.h"
|
||||
#include "spore/core/ApiServer.h"
|
||||
#include "spore/util/Logging.h"
|
||||
#include <ArduinoJson.h>
|
||||
#include <SoftwareSerial.h>
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t DEFAULT_VOLUME = 15;
|
||||
constexpr char API_STATUS_ENDPOINT[] = "/api/audio/status";
|
||||
constexpr char API_CONTROL_ENDPOINT[] = "/api/audio";
|
||||
constexpr char EVENT_TOPIC[] = "audio/player";
|
||||
}
|
||||
|
||||
MultiMatrixService::MultiMatrixService(NodeContext& ctx, TaskManager& taskManager, uint8_t rxPin, uint8_t txPin, uint8_t potentiometerPin)
|
||||
: m_ctx(ctx),
|
||||
m_taskManager(taskManager),
|
||||
m_serial(std::make_unique<SoftwareSerial>(rxPin, txPin)),
|
||||
m_potentiometerPin(potentiometerPin),
|
||||
m_volume(DEFAULT_VOLUME),
|
||||
m_playerReady(false),
|
||||
m_loopEnabled(false) {
|
||||
pinMode(m_potentiometerPin, INPUT);
|
||||
m_serial->begin(9600);
|
||||
|
||||
// DFPlayer Mini requires time to initialize after power-on
|
||||
delay(1000);
|
||||
|
||||
if (m_player.begin(*m_serial)) {
|
||||
m_playerReady = true;
|
||||
m_player.setTimeOut(500);
|
||||
m_player.EQ(DFPLAYER_EQ_NORMAL);
|
||||
m_player.volume(m_volume);
|
||||
LOG_INFO("MultiMatrixService", "DFPlayer initialized successfully");
|
||||
publishEvent("ready");
|
||||
} else {
|
||||
LOG_ERROR("MultiMatrixService", "Failed to initialize DFPlayer");
|
||||
}
|
||||
}
|
||||
|
||||
void MultiMatrixService::registerEndpoints(ApiServer& api) {
|
||||
api.registerEndpoint(API_STATUS_ENDPOINT, HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
api.registerEndpoint(API_CONTROL_ENDPOINT, HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleControlRequest(request); },
|
||||
std::vector<ParamSpec>{
|
||||
ParamSpec{String("action"), true, String("body"), String("string"),
|
||||
{String("play"), String("stop"), String("pause"), String("resume"), String("next"), String("previous"), String("volume"), String("loop")}},
|
||||
ParamSpec{String("volume"), false, String("body"), String("numberRange"), {}, String("15")},
|
||||
ParamSpec{String("loop"), false, String("body"), String("boolean"), {}}
|
||||
});
|
||||
}
|
||||
|
||||
bool MultiMatrixService::isReady() const {
|
||||
return m_playerReady;
|
||||
}
|
||||
|
||||
uint8_t MultiMatrixService::getVolume() const {
|
||||
return m_volume;
|
||||
}
|
||||
|
||||
bool MultiMatrixService::isLoopEnabled() const {
|
||||
return m_loopEnabled;
|
||||
}
|
||||
|
||||
void MultiMatrixService::play() {
|
||||
if (!m_playerReady) {
|
||||
return;
|
||||
}
|
||||
m_player.play();
|
||||
publishEvent("play");
|
||||
LOG_INFO("MultiMatrixService", "Playback started");
|
||||
}
|
||||
|
||||
void MultiMatrixService::stop() {
|
||||
if (!m_playerReady) {
|
||||
return;
|
||||
}
|
||||
m_player.stop();
|
||||
publishEvent("stop");
|
||||
LOG_INFO("MultiMatrixService", "Playback stopped");
|
||||
}
|
||||
|
||||
void MultiMatrixService::pause() {
|
||||
if (!m_playerReady) {
|
||||
return;
|
||||
}
|
||||
m_player.pause();
|
||||
publishEvent("pause");
|
||||
LOG_INFO("MultiMatrixService", "Playback paused");
|
||||
}
|
||||
|
||||
void MultiMatrixService::resume() {
|
||||
if (!m_playerReady) {
|
||||
return;
|
||||
}
|
||||
m_player.start();
|
||||
publishEvent("resume");
|
||||
LOG_INFO("MultiMatrixService", "Playback resumed");
|
||||
}
|
||||
|
||||
void MultiMatrixService::next() {
|
||||
if (!m_playerReady) {
|
||||
return;
|
||||
}
|
||||
m_player.next();
|
||||
publishEvent("next");
|
||||
LOG_INFO("MultiMatrixService", "Next track");
|
||||
}
|
||||
|
||||
void MultiMatrixService::previous() {
|
||||
if (!m_playerReady) {
|
||||
return;
|
||||
}
|
||||
m_player.previous();
|
||||
publishEvent("previous");
|
||||
LOG_INFO("MultiMatrixService", "Previous track");
|
||||
}
|
||||
|
||||
void MultiMatrixService::setVolume(uint8_t volume) {
|
||||
if (!m_playerReady) {
|
||||
return;
|
||||
}
|
||||
const uint8_t clampedVolume = std::min<uint8_t>(volume, MAX_VOLUME);
|
||||
if (clampedVolume == m_volume) {
|
||||
return;
|
||||
}
|
||||
applyVolume(clampedVolume);
|
||||
}
|
||||
|
||||
void MultiMatrixService::setLoop(bool enabled) {
|
||||
if (!m_playerReady) {
|
||||
return;
|
||||
}
|
||||
m_loopEnabled = enabled;
|
||||
if (enabled) {
|
||||
m_player.enableLoop();
|
||||
} else {
|
||||
m_player.disableLoop();
|
||||
}
|
||||
publishEvent("loop");
|
||||
LOG_INFO("MultiMatrixService", String("Loop ") + (enabled ? "enabled" : "disabled"));
|
||||
}
|
||||
|
||||
void MultiMatrixService::registerTasks(TaskManager& taskManager) {
|
||||
taskManager.registerTask("multimatrix_potentiometer", POTENTIOMETER_SAMPLE_INTERVAL_MS,
|
||||
[this]() { pollPotentiometer(); });
|
||||
}
|
||||
|
||||
void MultiMatrixService::pollPotentiometer() {
|
||||
if (!m_playerReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uint16_t rawValue = analogRead(static_cast<uint8_t>(m_potentiometerPin));
|
||||
const uint8_t targetVolume = calculateVolumeFromPotentiometer(rawValue);
|
||||
|
||||
if (targetVolume > m_volume + POT_VOLUME_EPSILON || targetVolume + POT_VOLUME_EPSILON < m_volume) {
|
||||
applyVolume(targetVolume);
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t MultiMatrixService::calculateVolumeFromPotentiometer(uint16_t rawValue) const {
|
||||
// Clamp raw value to prevent underflow (analogRead can return 1024)
|
||||
const uint16_t clampedValue = std::min<uint16_t>(rawValue, 1023U);
|
||||
// Invert: all down (0) = max volume, all up (1023) = min volume
|
||||
const uint16_t invertedValue = 1023U - clampedValue;
|
||||
const uint8_t scaledVolume = static_cast<uint8_t>((static_cast<uint32_t>(invertedValue) * MAX_VOLUME) / 1023U);
|
||||
return std::min<uint8_t>(scaledVolume, MAX_VOLUME);
|
||||
}
|
||||
|
||||
void MultiMatrixService::applyVolume(uint8_t targetVolume) {
|
||||
m_volume = targetVolume;
|
||||
m_player.volume(m_volume);
|
||||
publishEvent("volume");
|
||||
LOG_INFO("MultiMatrixService", String("Volume set to ") + String(m_volume));
|
||||
}
|
||||
|
||||
void MultiMatrixService::handleStatusRequest(AsyncWebServerRequest* request) {
|
||||
StaticJsonDocument<192> doc;
|
||||
doc["ready"] = m_playerReady;
|
||||
doc["volume"] = static_cast<int>(m_volume);
|
||||
doc["loop"] = m_loopEnabled;
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
request->send(200, "application/json", json);
|
||||
}
|
||||
|
||||
void MultiMatrixService::handleControlRequest(AsyncWebServerRequest* request) {
|
||||
String action = request->hasParam("action", true) ? request->getParam("action", true)->value() : "";
|
||||
bool ok = true;
|
||||
|
||||
if (action.equalsIgnoreCase("play")) {
|
||||
play();
|
||||
} else if (action.equalsIgnoreCase("stop")) {
|
||||
stop();
|
||||
} else if (action.equalsIgnoreCase("pause")) {
|
||||
pause();
|
||||
} else if (action.equalsIgnoreCase("resume")) {
|
||||
resume();
|
||||
} else if (action.equalsIgnoreCase("next")) {
|
||||
next();
|
||||
} else if (action.equalsIgnoreCase("previous")) {
|
||||
previous();
|
||||
} else if (action.equalsIgnoreCase("volume")) {
|
||||
if (request->hasParam("volume", true)) {
|
||||
int volumeValue = request->getParam("volume", true)->value().toInt();
|
||||
setVolume(static_cast<uint8_t>(std::max(0, std::min(static_cast<int>(MAX_VOLUME), volumeValue))));
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
} else if (action.equalsIgnoreCase("loop")) {
|
||||
if (request->hasParam("loop", true)) {
|
||||
String loopValue = request->getParam("loop", true)->value();
|
||||
bool enabled = loopValue.equalsIgnoreCase("true") || loopValue == "1";
|
||||
setLoop(enabled);
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
|
||||
StaticJsonDocument<256> resp;
|
||||
resp["success"] = ok;
|
||||
resp["ready"] = m_playerReady;
|
||||
resp["volume"] = static_cast<int>(m_volume);
|
||||
resp["loop"] = m_loopEnabled;
|
||||
if (!ok) {
|
||||
resp["message"] = "Invalid action";
|
||||
}
|
||||
|
||||
String json;
|
||||
serializeJson(resp, json);
|
||||
request->send(ok ? 200 : 400, "application/json", json);
|
||||
}
|
||||
|
||||
void MultiMatrixService::publishEvent(const char* action) {
|
||||
StaticJsonDocument<192> doc;
|
||||
doc["action"] = action;
|
||||
doc["volume"] = static_cast<int>(m_volume);
|
||||
doc["loop"] = m_loopEnabled;
|
||||
String payload;
|
||||
serializeJson(doc, payload);
|
||||
m_ctx.fire(EVENT_TOPIC, &payload);
|
||||
}
|
||||
53
examples/multimatrix/MultiMatrixService.h
Normal file
53
examples/multimatrix/MultiMatrixService.h
Normal file
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
#include <algorithm>
|
||||
#include <memory>
|
||||
#include <SoftwareSerial.h>
|
||||
#include <DFRobotDFPlayerMini.h>
|
||||
#include "spore/Service.h"
|
||||
#include "spore/core/TaskManager.h"
|
||||
#include "spore/core/NodeContext.h"
|
||||
|
||||
class ApiServer;
|
||||
class AsyncWebServerRequest;
|
||||
class MultiMatrixService : public Service {
|
||||
public:
|
||||
MultiMatrixService(NodeContext& ctx, TaskManager& taskManager, uint8_t rxPin, uint8_t txPin, uint8_t potentiometerPin);
|
||||
void registerEndpoints(ApiServer& api) override;
|
||||
void registerTasks(TaskManager& taskManager) override;
|
||||
const char* getName() const override { return "MultiMatrixAudio"; }
|
||||
|
||||
bool isReady() const;
|
||||
uint8_t getVolume() const;
|
||||
bool isLoopEnabled() const;
|
||||
|
||||
void play();
|
||||
void stop();
|
||||
void pause();
|
||||
void resume();
|
||||
void next();
|
||||
void previous();
|
||||
void setVolume(uint8_t volume);
|
||||
void setLoop(bool enabled);
|
||||
|
||||
private:
|
||||
static constexpr uint16_t POTENTIOMETER_SAMPLE_INTERVAL_MS = 200;
|
||||
static constexpr uint8_t MAX_VOLUME = 30;
|
||||
static constexpr uint8_t POT_VOLUME_EPSILON = 2;
|
||||
|
||||
void pollPotentiometer();
|
||||
void handleStatusRequest(AsyncWebServerRequest* request);
|
||||
void handleControlRequest(AsyncWebServerRequest* request);
|
||||
uint8_t calculateVolumeFromPotentiometer(uint16_t rawValue) const;
|
||||
void applyVolume(uint8_t targetVolume);
|
||||
void publishEvent(const char* action);
|
||||
|
||||
NodeContext& m_ctx;
|
||||
TaskManager& m_taskManager;
|
||||
std::unique_ptr<SoftwareSerial> m_serial;
|
||||
DFRobotDFPlayerMini m_player;
|
||||
uint8_t m_potentiometerPin;
|
||||
uint8_t m_volume;
|
||||
bool m_playerReady;
|
||||
bool m_loopEnabled;
|
||||
};
|
||||
19
examples/multimatrix/README.md
Normal file
19
examples/multimatrix/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Multi-Matrix
|
||||
|
||||
This example combines different capabilities:
|
||||
- Spore base stack
|
||||
- use PixelStreamController in Matrix mode 16x16
|
||||
- DFRobotDFPlayerMini audio playback
|
||||
- analog potentiometer to controll audio volume
|
||||
- API and web interface to control the audio player (start, stop, pause, next / previous track, volume)
|
||||
|
||||
## MCU
|
||||
- Wemos D1 Mini
|
||||
|
||||
## Pin Configuration
|
||||
```
|
||||
#define MP3PLAYER_PIN_RX D3
|
||||
#define MP3PLAYER_PIN_TX D4
|
||||
#define MATRIX_PIN D2
|
||||
#define POTI_PIN A0
|
||||
```
|
||||
508
examples/multimatrix/data/public/index.html
Normal file
508
examples/multimatrix/data/public/index.html
Normal file
@@ -0,0 +1,508 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Multi-Matrix Audio Player</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.player-container {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
padding: 3rem 2.5rem;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.player-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.player-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 1rem;
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
border-radius: 20px;
|
||||
font-size: 0.875rem;
|
||||
color: #667eea;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #10b981;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-dot.inactive {
|
||||
background: #ef4444;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.main-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
margin: 2.5rem 0;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
|
||||
.control-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
font-size: 1.5rem;
|
||||
box-shadow: 0 6px 25px rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
|
||||
.secondary-controls {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 2rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
padding: 0.6rem 1.25rem;
|
||||
border: 2px solid #667eea;
|
||||
background: white;
|
||||
color: #667eea;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.secondary-btn:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.secondary-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.volume-section {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.volume-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.volume-value {
|
||||
color: #667eea;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(to right, #667eea 0%, #764ba2 100%);
|
||||
outline: none;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.volume-slider:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(102, 126, 234, 0.5);
|
||||
border: 2px solid #667eea;
|
||||
}
|
||||
|
||||
.volume-slider::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(102, 126, 234, 0.5);
|
||||
border: 2px solid #667eea;
|
||||
}
|
||||
|
||||
.loop-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
border-radius: 12px;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.loop-label {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 52px;
|
||||
height: 28px;
|
||||
background: #cbd5e1;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.toggle-switch.active {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.toggle-switch.active .toggle-slider {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.player-container {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.player-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="player-container">
|
||||
<div class="player-header">
|
||||
<h1 class="player-title">Multi-Matrix Audio</h1>
|
||||
<div class="status-badge">
|
||||
<span class="status-dot" id="statusDot"></span>
|
||||
<span id="statusText">Connecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-controls">
|
||||
<button class="control-btn" onclick="sendAction('previous')" title="Previous">
|
||||
<span class="icon">⏮</span>
|
||||
</button>
|
||||
<button class="control-btn play-btn" id="playBtn" onclick="togglePlayPause()" title="Play/Pause">
|
||||
<span class="icon" id="playIcon">▶</span>
|
||||
</button>
|
||||
<button class="control-btn" onclick="sendAction('next')" title="Next">
|
||||
<span class="icon">⏭</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="secondary-controls">
|
||||
<button class="secondary-btn" onclick="sendAction('stop')">⏹ Stop</button>
|
||||
<button class="secondary-btn" onclick="sendAction('resume')">▶ Resume</button>
|
||||
</div>
|
||||
|
||||
<div class="volume-section">
|
||||
<div class="volume-header">
|
||||
<span>🔊 Volume</span>
|
||||
<span class="volume-value" id="volumeDisplay">15</span>
|
||||
</div>
|
||||
<input type="range"
|
||||
class="volume-slider"
|
||||
id="volumeSlider"
|
||||
min="0"
|
||||
max="30"
|
||||
value="15"
|
||||
oninput="updateVolumeDisplay(this.value)"
|
||||
onchange="setVolume(this.value)">
|
||||
</div>
|
||||
|
||||
<div class="loop-toggle">
|
||||
<div class="loop-label">
|
||||
<span>🔁 Loop Mode</span>
|
||||
</div>
|
||||
<div class="toggle-switch" id="loopToggle" onclick="toggleLoop()">
|
||||
<div class="toggle-slider"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let isPlaying = false;
|
||||
let loopEnabled = false;
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/audio/status');
|
||||
if (!response.ok) {
|
||||
throw new Error('Status request failed');
|
||||
}
|
||||
const status = await response.json();
|
||||
updateUIStatus(status);
|
||||
} catch (error) {
|
||||
document.getElementById('statusText').textContent = 'Error';
|
||||
document.getElementById('statusDot').className = 'status-dot inactive';
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateUIStatus(status) {
|
||||
const statusDot = document.getElementById('statusDot');
|
||||
const statusText = document.getElementById('statusText');
|
||||
|
||||
if (status.ready) {
|
||||
statusDot.className = 'status-dot';
|
||||
statusText.textContent = 'Ready';
|
||||
} else {
|
||||
statusDot.className = 'status-dot inactive';
|
||||
statusText.textContent = 'Not Ready';
|
||||
}
|
||||
|
||||
if ('volume' in status) {
|
||||
document.getElementById('volumeSlider').value = status.volume;
|
||||
document.getElementById('volumeDisplay').textContent = status.volume;
|
||||
}
|
||||
|
||||
if ('loop' in status) {
|
||||
loopEnabled = status.loop;
|
||||
const loopToggle = document.getElementById('loopToggle');
|
||||
if (loopEnabled) {
|
||||
loopToggle.classList.add('active');
|
||||
} else {
|
||||
loopToggle.classList.remove('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendAction(action, params = {}) {
|
||||
try {
|
||||
const formData = new URLSearchParams({ action, ...params });
|
||||
const response = await fetch('/api/audio', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: formData
|
||||
});
|
||||
const result = await response.json();
|
||||
updateUIStatus(result);
|
||||
|
||||
if (action === 'play' || action === 'resume') {
|
||||
isPlaying = true;
|
||||
updatePlayButton();
|
||||
} else if (action === 'pause' || action === 'stop') {
|
||||
isPlaying = false;
|
||||
updatePlayButton();
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
alert(result.message || 'Action failed');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Request failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlayPause() {
|
||||
if (isPlaying) {
|
||||
sendAction('pause');
|
||||
} else {
|
||||
sendAction('play');
|
||||
}
|
||||
}
|
||||
|
||||
function updatePlayButton() {
|
||||
const playIcon = document.getElementById('playIcon');
|
||||
playIcon.textContent = isPlaying ? '⏸' : '▶';
|
||||
}
|
||||
|
||||
function updateVolumeDisplay(value) {
|
||||
document.getElementById('volumeDisplay').textContent = value;
|
||||
}
|
||||
|
||||
function setVolume(value) {
|
||||
sendAction('volume', { volume: value });
|
||||
}
|
||||
|
||||
function toggleLoop() {
|
||||
loopEnabled = !loopEnabled;
|
||||
const loopToggle = document.getElementById('loopToggle');
|
||||
if (loopEnabled) {
|
||||
loopToggle.classList.add('active');
|
||||
} else {
|
||||
loopToggle.classList.remove('active');
|
||||
}
|
||||
sendAction('loop', { loop: loopEnabled });
|
||||
}
|
||||
|
||||
fetchStatus();
|
||||
setInterval(fetchStatus, 5000);
|
||||
|
||||
function setupWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(protocol + '//' + window.location.host + '/ws');
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
if (payload.event === 'audio/player' && payload.payload) {
|
||||
const data = JSON.parse(payload.payload);
|
||||
|
||||
// Skip debug messages
|
||||
if (data.action === 'pot_debug') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('volume' in data) {
|
||||
document.getElementById('volumeSlider').value = data.volume;
|
||||
document.getElementById('volumeDisplay').textContent = data.volume;
|
||||
}
|
||||
|
||||
if ('loop' in data) {
|
||||
loopEnabled = data.loop;
|
||||
const loopToggle = document.getElementById('loopToggle');
|
||||
if (loopEnabled) {
|
||||
loopToggle.classList.add('active');
|
||||
} else {
|
||||
loopToggle.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
if (data.action) {
|
||||
const statusText = document.getElementById('statusText');
|
||||
const statusDot = document.getElementById('statusDot');
|
||||
|
||||
if (data.action === 'play' || data.action === 'resume' || data.action === 'next' || data.action === 'previous') {
|
||||
statusText.textContent = 'Playing';
|
||||
statusDot.className = 'status-dot';
|
||||
isPlaying = true;
|
||||
updatePlayButton();
|
||||
} else if (data.action === 'pause') {
|
||||
statusText.textContent = 'Paused';
|
||||
statusDot.className = 'status-dot';
|
||||
isPlaying = false;
|
||||
updatePlayButton();
|
||||
} else if (data.action === 'stop') {
|
||||
statusText.textContent = 'Stopped';
|
||||
statusDot.className = 'status-dot inactive';
|
||||
isPlaying = false;
|
||||
updatePlayButton();
|
||||
} else if (data.action === 'ready') {
|
||||
statusText.textContent = 'Ready';
|
||||
statusDot.className = 'status-dot';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('WebSocket parse error', err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setTimeout(setupWebSocket, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
setupWebSocket();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
54
examples/multimatrix/main.cpp
Normal file
54
examples/multimatrix/main.cpp
Normal file
@@ -0,0 +1,54 @@
|
||||
#include <Arduino.h>
|
||||
#include <memory>
|
||||
#include "spore/Spore.h"
|
||||
#include "spore/util/Logging.h"
|
||||
#include "../pixelstream/PixelStreamController.h"
|
||||
#include "MultiMatrixService.h"
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t MATRIX_PIN = D2;
|
||||
constexpr uint16_t MATRIX_PIXEL_COUNT = 256;
|
||||
constexpr uint8_t MATRIX_BRIGHTNESS = 40;
|
||||
constexpr uint16_t MATRIX_WIDTH = 16;
|
||||
constexpr bool MATRIX_SERPENTINE = false;
|
||||
constexpr neoPixelType MATRIX_PIXEL_TYPE = NEO_GRB + NEO_KHZ800;
|
||||
constexpr uint8_t MP3PLAYER_PIN_RX = D3;
|
||||
constexpr uint8_t MP3PLAYER_PIN_TX = D4;
|
||||
constexpr uint8_t POTENTIOMETER_PIN = A0;
|
||||
}
|
||||
|
||||
Spore spore({
|
||||
{"app", "multimatrix"},
|
||||
{"role", "media"},
|
||||
{"matrix", String(MATRIX_PIXEL_COUNT)}
|
||||
});
|
||||
|
||||
std::unique_ptr<PixelStreamController> pixelController;
|
||||
std::shared_ptr<MultiMatrixService> audioService;
|
||||
|
||||
void setup() {
|
||||
spore.setup();
|
||||
|
||||
PixelStreamConfig config{
|
||||
MATRIX_PIN,
|
||||
MATRIX_PIXEL_COUNT,
|
||||
MATRIX_BRIGHTNESS,
|
||||
MATRIX_WIDTH,
|
||||
MATRIX_SERPENTINE,
|
||||
MATRIX_PIXEL_TYPE
|
||||
};
|
||||
|
||||
pixelController = std::make_unique<PixelStreamController>(spore.getContext(), config);
|
||||
pixelController->begin();
|
||||
|
||||
audioService = std::make_shared<MultiMatrixService>(spore.getContext(), spore.getTaskManager(), MP3PLAYER_PIN_RX, MP3PLAYER_PIN_TX, POTENTIOMETER_PIN);
|
||||
spore.registerService(audioService);
|
||||
|
||||
spore.begin();
|
||||
|
||||
LOG_INFO("MultiMatrix", "Setup complete");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
spore.loop();
|
||||
}
|
||||
361
examples/neopattern/NeoPattern.cpp
Normal file
361
examples/neopattern/NeoPattern.cpp
Normal file
@@ -0,0 +1,361 @@
|
||||
#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)
|
||||
{
|
||||
OnComplete = callback;
|
||||
TotalSteps = numPixels();
|
||||
begin();
|
||||
}
|
||||
|
||||
NeoPattern::NeoPattern(uint16_t pixels, uint8_t pin, uint8_t type)
|
||||
: Adafruit_NeoPixel(pixels, pin, type)
|
||||
{
|
||||
TotalSteps = numPixels();
|
||||
begin();
|
||||
}
|
||||
|
||||
NeoPattern::~NeoPattern() {
|
||||
// No frameBuffer to clean up
|
||||
}
|
||||
|
||||
// Removed unused handleStream and drawFrameBuffer functions
|
||||
|
||||
void NeoPattern::onCompleteDefault(int pixels)
|
||||
{
|
||||
//Serial.println("onCompleteDefault");
|
||||
// FIXME no specific code
|
||||
if (ActivePattern == THEATER_CHASE || ActivePattern == RAINBOW_CYCLE)
|
||||
{
|
||||
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();
|
||||
// RainbowCycle is continuous, just increment Index
|
||||
Index++;
|
||||
if (Index >= 255)
|
||||
{
|
||||
Index = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
104
examples/neopattern/NeoPattern.h
Normal file
104
examples/neopattern/NeoPattern.h
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 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);
|
||||
|
||||
// Constructor - calls base-class constructor to initialize strip
|
||||
NeoPattern(uint16_t pixels, uint8_t pin, uint8_t type, void (*callback)(int));
|
||||
NeoPattern(uint16_t pixels, uint8_t pin, uint8_t type);
|
||||
~NeoPattern();
|
||||
|
||||
// Stream handling functions removed
|
||||
|
||||
// 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
|
||||
598
examples/neopattern/NeoPatternService.cpp
Normal file
598
examples/neopattern/NeoPatternService.cpp
Normal file
@@ -0,0 +1,598 @@
|
||||
#include "NeoPatternService.h"
|
||||
#include "spore/core/ApiServer.h"
|
||||
#include "spore/util/Logging.h"
|
||||
#include "spore/internal/Globals.h"
|
||||
#include <ArduinoJson.h>
|
||||
#include <ESP8266WiFi.h>
|
||||
|
||||
NeoPatternService::NeoPatternService(NodeContext& ctx, TaskManager& taskMgr, const NeoPixelConfig& config)
|
||||
: taskManager(taskMgr),
|
||||
ctx(ctx),
|
||||
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();
|
||||
registerEventHandlers();
|
||||
initialized = true;
|
||||
|
||||
LOG_INFO("NeoPattern", "Service initialized");
|
||||
}
|
||||
|
||||
NeoPatternService::~NeoPatternService() {
|
||||
if (neoPattern) {
|
||||
delete neoPattern;
|
||||
}
|
||||
}
|
||||
|
||||
void NeoPatternService::registerEndpoints(ApiServer& api) {
|
||||
// Status endpoint
|
||||
api.registerEndpoint("/api/neopattern/status", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
// Patterns list endpoint
|
||||
api.registerEndpoint("/api/neopattern/patterns", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handlePatternsRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
// Control endpoint
|
||||
api.registerEndpoint("/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")},
|
||||
ParamSpec{String("broadcast"), false, String("body"), String("boolean"), {}}
|
||||
});
|
||||
|
||||
// State endpoint for complex state updates
|
||||
api.registerEndpoint("/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;
|
||||
bool broadcast = false;
|
||||
|
||||
if (request->hasParam("broadcast", true)) {
|
||||
String b = request->getParam("broadcast", true)->value();
|
||||
broadcast = b.equalsIgnoreCase("true") || b == "1";
|
||||
}
|
||||
|
||||
// Build JSON payload from provided params (single source of truth)
|
||||
JsonDocument payload;
|
||||
bool any = false;
|
||||
if (request->hasParam("pattern", true)) { payload["pattern"] = request->getParam("pattern", true)->value(); any = true; }
|
||||
if (request->hasParam("color", true)) { payload["color"] = request->getParam("color", true)->value(); any = true; }
|
||||
if (request->hasParam("color2", true)) { payload["color2"] = request->getParam("color2", true)->value(); any = true; }
|
||||
if (request->hasParam("brightness", true)) { payload["brightness"] = request->getParam("brightness", true)->value(); any = true; }
|
||||
if (request->hasParam("total_steps", true)) { payload["total_steps"] = request->getParam("total_steps", true)->value(); any = true; }
|
||||
if (request->hasParam("direction", true)) { payload["direction"] = request->getParam("direction", true)->value(); any = true; }
|
||||
if (request->hasParam("interval", true)) { payload["interval"] = request->getParam("interval", true)->value(); any = true; }
|
||||
|
||||
String payloadStr;
|
||||
serializeJson(payload, payloadStr);
|
||||
|
||||
// Always apply locally via event so we have a single codepath for updates
|
||||
if (any) {
|
||||
std::string ev = "api/neopattern";
|
||||
String localData = payloadStr;
|
||||
LOG_INFO("NeoPattern", String("Applying local api/neopattern via event payloadLen=") + String(payloadStr.length()));
|
||||
ctx.fire(ev, &localData);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
// Broadcast to peers if requested (delegate to core broadcast handler)
|
||||
if (broadcast && any) {
|
||||
JsonDocument eventDoc;
|
||||
eventDoc["event"] = "api/neopattern";
|
||||
eventDoc["data"] = payloadStr; // data is JSON string
|
||||
|
||||
String eventJson;
|
||||
serializeJson(eventDoc, eventJson);
|
||||
|
||||
LOG_INFO("NeoPattern", String("Submitting cluster/broadcast for api/neopattern payloadLen=") + String(payloadStr.length()));
|
||||
std::string ev = "cluster/broadcast";
|
||||
String eventStr = eventJson;
|
||||
ctx.fire(ev, &eventStr);
|
||||
}
|
||||
|
||||
// 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::registerEventHandlers() {
|
||||
ctx.on("api/neopattern", [this](void* dataPtr) {
|
||||
String* jsonStr = static_cast<String*>(dataPtr);
|
||||
if (!jsonStr) {
|
||||
LOG_WARN("NeoPattern", "Received api/neopattern with null dataPtr");
|
||||
return;
|
||||
}
|
||||
LOG_INFO("NeoPattern", String("Received api/neopattern event dataLen=") + String(jsonStr->length()));
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, *jsonStr);
|
||||
if (err) {
|
||||
LOG_WARN("NeoPattern", String("Failed to parse cluster/event data: ") + err.c_str());
|
||||
return;
|
||||
}
|
||||
JsonObject obj = doc.as<JsonObject>();
|
||||
bool applied = applyControlParams(obj);
|
||||
if (applied) {
|
||||
LOG_INFO("NeoPattern", "Applied control from cluster/event");
|
||||
}
|
||||
});
|
||||
|
||||
// Solid color event: sets all pixels to the same color
|
||||
ctx.on("api/neopattern/color", [this](void* dataPtr) {
|
||||
String* jsonStr = static_cast<String*>(dataPtr);
|
||||
if (!jsonStr) {
|
||||
LOG_WARN("NeoPattern", "Received api/neopattern/color with null dataPtr");
|
||||
return;
|
||||
}
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, *jsonStr);
|
||||
if (err) {
|
||||
LOG_WARN("NeoPattern", String("Failed to parse color event data: ") + err.c_str());
|
||||
return;
|
||||
}
|
||||
JsonObject obj = doc.as<JsonObject>();
|
||||
// color can be string or number
|
||||
String colorStr;
|
||||
if (obj["color"].is<const char*>() || obj["color"].is<String>()) {
|
||||
colorStr = obj["color"].as<String>();
|
||||
} else if (obj["color"].is<long>() || obj["color"].is<int>()) {
|
||||
colorStr = String(obj["color"].as<long>());
|
||||
} else {
|
||||
LOG_WARN("NeoPattern", "api/neopattern/color missing 'color'");
|
||||
return;
|
||||
}
|
||||
|
||||
// Optional brightness
|
||||
if (obj["brightness"].is<int>() || obj["brightness"].is<long>()) {
|
||||
int b = obj["brightness"].as<int>();
|
||||
if (b < 0) b = 0; if (b > 255) b = 255;
|
||||
setBrightness(static_cast<uint8_t>(b));
|
||||
}
|
||||
|
||||
uint32_t color = parseColor(colorStr);
|
||||
setPattern(NeoPatternType::NONE);
|
||||
setColor(color);
|
||||
LOG_INFO("NeoPattern", String("Set solid color ") + colorStr);
|
||||
});
|
||||
}
|
||||
|
||||
bool NeoPatternService::applyControlParams(const JsonObject& obj) {
|
||||
bool updated = false;
|
||||
if (obj["pattern"].is<const char*>() || obj["pattern"].is<String>()) {
|
||||
String name = obj["pattern"].as<String>();
|
||||
if (isValidPattern(name)) {
|
||||
setPatternByName(name);
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
if (obj["color"].is<const char*>() || obj["color"].is<String>() || obj["color"].is<long>() || obj["color"].is<int>()) {
|
||||
String colorStr;
|
||||
if (obj["color"].is<long>() || obj["color"].is<int>()) {
|
||||
colorStr = String(obj["color"].as<long>());
|
||||
} else {
|
||||
colorStr = obj["color"].as<String>();
|
||||
}
|
||||
uint32_t color = parseColor(colorStr);
|
||||
setColor(color);
|
||||
updated = true;
|
||||
}
|
||||
if (obj["color2"].is<const char*>() || obj["color2"].is<String>() || obj["color2"].is<long>() || obj["color2"].is<int>()) {
|
||||
String colorStr;
|
||||
if (obj["color2"].is<long>() || obj["color2"].is<int>()) {
|
||||
colorStr = String(obj["color2"].as<long>());
|
||||
} else {
|
||||
colorStr = obj["color2"].as<String>();
|
||||
}
|
||||
uint32_t color = parseColor(colorStr);
|
||||
setColor2(color);
|
||||
updated = true;
|
||||
}
|
||||
if (obj["brightness"].is<int>() || obj["brightness"].is<long>() || obj["brightness"].is<const char*>() || obj["brightness"].is<String>()) {
|
||||
int b = 0;
|
||||
if (obj["brightness"].is<int>() || obj["brightness"].is<long>()) {
|
||||
b = obj["brightness"].as<int>();
|
||||
} else {
|
||||
b = String(obj["brightness"].as<String>()).toInt();
|
||||
}
|
||||
if (b < 0) {
|
||||
b = 0;
|
||||
}
|
||||
if (b > 255) {
|
||||
b = 255;
|
||||
}
|
||||
setBrightness(static_cast<uint8_t>(b));
|
||||
updated = true;
|
||||
}
|
||||
if (obj["total_steps"].is<int>() || obj["total_steps"].is<long>() || obj["total_steps"].is<const char*>() || obj["total_steps"].is<String>()) {
|
||||
int steps = 0;
|
||||
if (obj["total_steps"].is<int>() || obj["total_steps"].is<long>()) {
|
||||
steps = obj["total_steps"].as<int>();
|
||||
} else {
|
||||
steps = String(obj["total_steps"].as<String>()).toInt();
|
||||
}
|
||||
if (steps > 0) { setTotalSteps(static_cast<uint16_t>(steps)); updated = true; }
|
||||
}
|
||||
if (obj["direction"].is<const char*>() || obj["direction"].is<String>()) {
|
||||
String dirStr = obj["direction"].as<String>();
|
||||
NeoDirection dir = (dirStr.equalsIgnoreCase("reverse")) ? NeoDirection::REVERSE : NeoDirection::FORWARD;
|
||||
setDirection(dir);
|
||||
updated = true;
|
||||
}
|
||||
if (obj["interval"].is<int>() || obj["interval"].is<long>() || obj["interval"].is<const char*>() || obj["interval"].is<String>()) {
|
||||
unsigned long interval = 0;
|
||||
if (obj["interval"].is<int>() || obj["interval"].is<long>()) {
|
||||
interval = obj["interval"].as<unsigned long>();
|
||||
} else {
|
||||
interval = String(obj["interval"].as<String>()).toInt();
|
||||
}
|
||||
if (interval > 0) { setUpdateInterval(interval); updated = true; }
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
void NeoPatternService::handleStateRequest(AsyncWebServerRequest* request) {
|
||||
if (request->contentType() != "application/json") {
|
||||
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);
|
||||
|
||||
// Set up pattern-specific parameters
|
||||
switch (pattern) {
|
||||
case NeoPatternType::RAINBOW_CYCLE:
|
||||
neoPattern->RainbowCycle(updateIntervalMs, static_cast<::direction>(direction));
|
||||
break;
|
||||
case NeoPatternType::THEATER_CHASE:
|
||||
neoPattern->TheaterChase(currentState.color, currentState.color2, updateIntervalMs, static_cast<::direction>(direction));
|
||||
break;
|
||||
case NeoPatternType::COLOR_WIPE:
|
||||
neoPattern->ColorWipe(currentState.color, updateIntervalMs, static_cast<::direction>(direction));
|
||||
break;
|
||||
case NeoPatternType::SCANNER:
|
||||
neoPattern->Scanner(currentState.color, updateIntervalMs);
|
||||
break;
|
||||
case NeoPatternType::FADE:
|
||||
neoPattern->Fade(currentState.color, currentState.color2, currentState.totalSteps, updateIntervalMs, static_cast<::direction>(direction));
|
||||
break;
|
||||
case NeoPatternType::FIRE:
|
||||
// Fire pattern doesn't need setup
|
||||
break;
|
||||
case NeoPatternType::NONE:
|
||||
// None pattern doesn't need setup
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void NeoPatternService::setPatternByName(const String& name) {
|
||||
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& taskManager) {
|
||||
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",
|
||||
nullptr, // No initializer needed, state is set up in setPattern
|
||||
[this]() { updateRainbowCycle(); },
|
||||
false, // doesn't require color2
|
||||
true // supports direction
|
||||
);
|
||||
|
||||
patternRegistry.registerPattern(
|
||||
"theater_chase",
|
||||
static_cast<uint8_t>(NeoPatternType::THEATER_CHASE),
|
||||
"Theater chase pattern",
|
||||
nullptr, // No initializer needed, state is set up in setPattern
|
||||
[this]() { updateTheaterChase(); },
|
||||
true, // requires color2
|
||||
true // supports direction
|
||||
);
|
||||
|
||||
patternRegistry.registerPattern(
|
||||
"color_wipe",
|
||||
static_cast<uint8_t>(NeoPatternType::COLOR_WIPE),
|
||||
"Color wipe pattern",
|
||||
nullptr, // No initializer needed, state is set up in setPattern
|
||||
[this]() { updateColorWipe(); },
|
||||
false, // doesn't require color2
|
||||
true // supports direction
|
||||
);
|
||||
|
||||
patternRegistry.registerPattern(
|
||||
"scanner",
|
||||
static_cast<uint8_t>(NeoPatternType::SCANNER),
|
||||
"Scanner pattern",
|
||||
nullptr, // No initializer needed, state is set up in setPattern
|
||||
[this]() { updateScanner(); },
|
||||
false, // doesn't require color2
|
||||
false // doesn't support direction
|
||||
);
|
||||
|
||||
patternRegistry.registerPattern(
|
||||
"fade",
|
||||
static_cast<uint8_t>(NeoPatternType::FADE),
|
||||
"Fade pattern",
|
||||
nullptr, // No initializer needed, state is set up in setPattern
|
||||
[this]() { updateFade(); },
|
||||
true, // requires color2
|
||||
true // supports direction
|
||||
);
|
||||
|
||||
patternRegistry.registerPattern(
|
||||
"fire",
|
||||
static_cast<uint8_t>(NeoPatternType::FIRE),
|
||||
"Fire effect pattern",
|
||||
nullptr, // No initializer needed, state is set up in setPattern
|
||||
[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;
|
||||
// Don't reset lastUpdateMs to 0, keep the current timing
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
99
examples/neopattern/NeoPatternService.h
Normal file
99
examples/neopattern/NeoPatternService.h
Normal file
@@ -0,0 +1,99 @@
|
||||
#pragma once
|
||||
#include "spore/Service.h"
|
||||
#include "spore/core/TaskManager.h"
|
||||
#include "spore/core/NodeContext.h"
|
||||
#include "NeoPattern.h"
|
||||
#include "NeoPatternState.h"
|
||||
#include "NeoPixelConfig.h"
|
||||
#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(NodeContext& ctx, TaskManager& taskMgr, const NeoPixelConfig& config);
|
||||
~NeoPatternService();
|
||||
|
||||
void registerEndpoints(ApiServer& api) override;
|
||||
void registerTasks(TaskManager& taskManager) override;
|
||||
const char* getName() const override { return "NeoPattern"; }
|
||||
|
||||
// Pattern control methods
|
||||
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 registerPatterns();
|
||||
void update();
|
||||
void registerEventHandlers();
|
||||
bool applyControlParams(const JsonObject& obj);
|
||||
|
||||
// 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;
|
||||
NodeContext& ctx;
|
||||
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 16
|
||||
#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.getContext(), spore.getTaskManager(), config);
|
||||
spore.registerService(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();
|
||||
}
|
||||
127
examples/pixelstream/PixelStreamController.cpp
Normal file
127
examples/pixelstream/PixelStreamController.cpp
Normal file
@@ -0,0 +1,127 @@
|
||||
#include "PixelStreamController.h"
|
||||
|
||||
namespace {
|
||||
constexpr int COMPONENTS_PER_PIXEL = 3;
|
||||
}
|
||||
|
||||
PixelStreamController::PixelStreamController(NodeContext& ctxRef, const PixelStreamConfig& cfg)
|
||||
: ctx(ctxRef), config(cfg), pixels(cfg.pixelCount, cfg.pin, cfg.pixelType) {
|
||||
}
|
||||
|
||||
void PixelStreamController::begin() {
|
||||
pixels.begin();
|
||||
pixels.setBrightness(config.brightness);
|
||||
// Default all pixels to green so we can verify hardware before streaming frames
|
||||
for (uint16_t i = 0; i < config.pixelCount; ++i) {
|
||||
pixels.setPixelColor(i, pixels.Color(0, 255, 0));
|
||||
}
|
||||
pixels.show();
|
||||
|
||||
ctx.on("udp/raw", [this](void* data) {
|
||||
this->handleEvent(data);
|
||||
});
|
||||
|
||||
LOG_INFO("PixelStream", String("PixelStreamController ready on pin ") + String(config.pin) + " with " + String(config.pixelCount) + " pixels");
|
||||
}
|
||||
|
||||
void PixelStreamController::handleEvent(void* data) {
|
||||
if (data == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
String* payload = static_cast<String*>(data);
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!applyFrame(*payload)) {
|
||||
LOG_WARN("PixelStream", String("Ignoring RAW payload with invalid length (") + String(payload->length()) + ")");
|
||||
}
|
||||
}
|
||||
|
||||
bool PixelStreamController::applyFrame(const String& payload) {
|
||||
static constexpr std::size_t frameWidth = COMPONENTS_PER_PIXEL * 2;
|
||||
const std::size_t payloadLength = static_cast<std::size_t>(payload.length());
|
||||
|
||||
if (payloadLength == 0 || (payloadLength % frameWidth) != 0) {
|
||||
LOG_WARN("PixelStream", String("Payload size ") + String(payloadLength) + " is not a multiple of " + String(frameWidth));
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint16_t framesProvided = static_cast<uint16_t>(payloadLength / frameWidth);
|
||||
const uint16_t pixelsToUpdate = std::min(config.pixelCount, framesProvided);
|
||||
|
||||
for (uint16_t index = 0; index < pixelsToUpdate; ++index) {
|
||||
const std::size_t base = static_cast<std::size_t>(index) * frameWidth;
|
||||
FrameComponents components{};
|
||||
if (!tryParsePixel(payload, base, components)) {
|
||||
LOG_WARN("PixelStream", String("Invalid hex data at pixel index ") + String(index));
|
||||
return false;
|
||||
}
|
||||
const uint16_t hardwareIndex = mapPixelIndex(index);
|
||||
pixels.setPixelColor(hardwareIndex, pixels.Color(components.red, components.green, components.blue));
|
||||
}
|
||||
|
||||
// Clear any remaining pixels so stale data is removed when fewer frames are provided
|
||||
for (uint16_t index = pixelsToUpdate; index < config.pixelCount; ++index) {
|
||||
const uint16_t hardwareIndex = mapPixelIndex(index);
|
||||
pixels.setPixelColor(hardwareIndex, 0);
|
||||
}
|
||||
|
||||
pixels.show();
|
||||
return true;
|
||||
}
|
||||
|
||||
uint16_t PixelStreamController::mapPixelIndex(uint16_t logicalIndex) const {
|
||||
if (config.matrixWidth == 0) {
|
||||
return logicalIndex;
|
||||
}
|
||||
|
||||
const uint16_t row = logicalIndex / config.matrixWidth;
|
||||
const uint16_t col = logicalIndex % config.matrixWidth;
|
||||
|
||||
if (!config.matrixSerpentine || (row % 2 == 0)) {
|
||||
return row * config.matrixWidth + col;
|
||||
}
|
||||
|
||||
const uint16_t reversedCol = (config.matrixWidth - 1) - col;
|
||||
return row * config.matrixWidth + reversedCol;
|
||||
}
|
||||
|
||||
int PixelStreamController::hexToNibble(char c) {
|
||||
if (c >= '0' && c <= '9') {
|
||||
return c - '0';
|
||||
}
|
||||
if (c >= 'a' && c <= 'f') {
|
||||
return 10 + c - 'a';
|
||||
}
|
||||
if (c >= 'A' && c <= 'F') {
|
||||
return 10 + c - 'A';
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool PixelStreamController::tryParsePixel(const String& payload, std::size_t startIndex, FrameComponents& components) const {
|
||||
static constexpr std::size_t frameWidth = COMPONENTS_PER_PIXEL * 2;
|
||||
if (startIndex + frameWidth > static_cast<std::size_t>(payload.length())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const int rHi = hexToNibble(payload[startIndex]);
|
||||
const int rLo = hexToNibble(payload[startIndex + 1]);
|
||||
const int gHi = hexToNibble(payload[startIndex + 2]);
|
||||
const int gLo = hexToNibble(payload[startIndex + 3]);
|
||||
const int bHi = hexToNibble(payload[startIndex + 4]);
|
||||
const int bLo = hexToNibble(payload[startIndex + 5]);
|
||||
|
||||
if (rHi < 0 || rLo < 0 || gHi < 0 || gLo < 0 || bHi < 0 || bLo < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
components.red = static_cast<uint8_t>((rHi << 4) | rLo);
|
||||
components.green = static_cast<uint8_t>((gHi << 4) | gLo);
|
||||
components.blue = static_cast<uint8_t>((bHi << 4) | bLo);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
42
examples/pixelstream/PixelStreamController.h
Normal file
42
examples/pixelstream/PixelStreamController.h
Normal file
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Adafruit_NeoPixel.h>
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include "spore/core/NodeContext.h"
|
||||
#include "spore/util/Logging.h"
|
||||
|
||||
struct PixelStreamConfig {
|
||||
uint8_t pin;
|
||||
uint16_t pixelCount;
|
||||
uint8_t brightness;
|
||||
uint16_t matrixWidth;
|
||||
bool matrixSerpentine;
|
||||
neoPixelType pixelType;
|
||||
};
|
||||
|
||||
class PixelStreamController {
|
||||
public:
|
||||
PixelStreamController(NodeContext& ctx, const PixelStreamConfig& config);
|
||||
void begin();
|
||||
|
||||
private:
|
||||
struct FrameComponents {
|
||||
uint8_t red;
|
||||
uint8_t green;
|
||||
uint8_t blue;
|
||||
};
|
||||
|
||||
bool tryParsePixel(const String& payload, std::size_t startIndex, FrameComponents& components) const;
|
||||
void handleEvent(void* data);
|
||||
bool applyFrame(const String& payload);
|
||||
uint16_t mapPixelIndex(uint16_t logicalIndex) const;
|
||||
static int hexToNibble(char c);
|
||||
|
||||
NodeContext& ctx;
|
||||
PixelStreamConfig config;
|
||||
Adafruit_NeoPixel pixels;
|
||||
};
|
||||
|
||||
|
||||
202
examples/pixelstream/PixelStreamService.cpp
Normal file
202
examples/pixelstream/PixelStreamService.cpp
Normal file
@@ -0,0 +1,202 @@
|
||||
#include "PixelStreamService.h"
|
||||
#include "spore/util/Logging.h"
|
||||
#include <Adafruit_NeoPixel.h>
|
||||
|
||||
PixelStreamService::PixelStreamService(NodeContext& ctx, ApiServer& apiServer, PixelStreamController* controller)
|
||||
: ctx(ctx), apiServer(apiServer), controller(controller) {}
|
||||
|
||||
void PixelStreamService::registerEndpoints(ApiServer& api) {
|
||||
// Config endpoint for setting pixelstream configuration
|
||||
api.registerEndpoint("/api/pixelstream/config", HTTP_PUT,
|
||||
[this](AsyncWebServerRequest* request) { handleConfigRequest(request); },
|
||||
std::vector<ParamSpec>{
|
||||
ParamSpec{String("pin"), false, String("body"), String("number"), {}, String("")},
|
||||
ParamSpec{String("pixel_count"), false, String("body"), String("number"), {}, String("")},
|
||||
ParamSpec{String("brightness"), false, String("body"), String("number"), {}, String("")},
|
||||
ParamSpec{String("matrix_width"), false, String("body"), String("number"), {}, String("")},
|
||||
ParamSpec{String("matrix_serpentine"), false, String("body"), String("boolean"), {}, String("")},
|
||||
ParamSpec{String("pixel_type"), false, String("body"), String("number"), {}, String("")}
|
||||
});
|
||||
|
||||
// Config endpoint for getting pixelstream configuration
|
||||
api.registerEndpoint("/api/pixelstream/config", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleGetConfigRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
}
|
||||
|
||||
void PixelStreamService::registerTasks(TaskManager& taskManager) {
|
||||
// PixelStreamService doesn't register any tasks itself
|
||||
}
|
||||
|
||||
PixelStreamConfig PixelStreamService::loadConfig() {
|
||||
// Initialize with proper defaults
|
||||
PixelStreamConfig config;
|
||||
config.pin = 2;
|
||||
config.pixelCount = 16;
|
||||
config.brightness = 80;
|
||||
config.matrixWidth = 16;
|
||||
config.matrixSerpentine = false;
|
||||
config.pixelType = NEO_GRB + NEO_KHZ800;
|
||||
|
||||
if (!LittleFS.begin()) {
|
||||
LOG_WARN("PixelStream", "Failed to initialize LittleFS, using defaults");
|
||||
return config;
|
||||
}
|
||||
|
||||
if (!LittleFS.exists(CONFIG_FILE())) {
|
||||
LOG_INFO("PixelStream", "No pixelstream config file found, using defaults");
|
||||
// Save defaults
|
||||
saveConfig(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
File file = LittleFS.open(CONFIG_FILE(), "r");
|
||||
if (!file) {
|
||||
LOG_ERROR("PixelStream", "Failed to open config file for reading");
|
||||
return config;
|
||||
}
|
||||
|
||||
JsonDocument doc;
|
||||
DeserializationError error = deserializeJson(doc, file);
|
||||
file.close();
|
||||
|
||||
if (error) {
|
||||
LOG_ERROR("PixelStream", "Failed to parse config file: " + String(error.c_str()));
|
||||
return config;
|
||||
}
|
||||
|
||||
if (doc["pin"].is<uint8_t>()) config.pin = doc["pin"].as<uint8_t>();
|
||||
if (doc["pixel_count"].is<uint16_t>()) config.pixelCount = doc["pixel_count"].as<uint16_t>();
|
||||
if (doc["brightness"].is<uint8_t>()) config.brightness = doc["brightness"].as<uint8_t>();
|
||||
if (doc["matrix_width"].is<uint16_t>()) config.matrixWidth = doc["matrix_width"].as<uint16_t>();
|
||||
if (doc["matrix_serpentine"].is<bool>()) config.matrixSerpentine = doc["matrix_serpentine"].as<bool>();
|
||||
if (doc["pixel_type"].is<uint8_t>()) config.pixelType = static_cast<neoPixelType>(doc["pixel_type"].as<uint8_t>());
|
||||
|
||||
LOG_INFO("PixelStream", "Configuration loaded from " + String(CONFIG_FILE()));
|
||||
return config;
|
||||
}
|
||||
|
||||
bool PixelStreamService::saveConfig(const PixelStreamConfig& config) {
|
||||
if (!LittleFS.begin()) {
|
||||
LOG_ERROR("PixelStream", "LittleFS not initialized, cannot save config");
|
||||
return false;
|
||||
}
|
||||
|
||||
File file = LittleFS.open(CONFIG_FILE(), "w");
|
||||
if (!file) {
|
||||
LOG_ERROR("PixelStream", "Failed to open config file for writing");
|
||||
return false;
|
||||
}
|
||||
|
||||
JsonDocument doc;
|
||||
doc["pin"] = config.pin;
|
||||
doc["pixel_count"] = config.pixelCount;
|
||||
doc["brightness"] = config.brightness;
|
||||
doc["matrix_width"] = config.matrixWidth;
|
||||
doc["matrix_serpentine"] = config.matrixSerpentine;
|
||||
doc["pixel_type"] = static_cast<uint8_t>(config.pixelType);
|
||||
|
||||
size_t bytesWritten = serializeJson(doc, file);
|
||||
file.close();
|
||||
|
||||
if (bytesWritten > 0) {
|
||||
LOG_INFO("PixelStream", "Configuration saved to " + String(CONFIG_FILE()) + " (" + String(bytesWritten) + " bytes)");
|
||||
return true;
|
||||
} else {
|
||||
LOG_ERROR("PixelStream", "Failed to write configuration to file");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void PixelStreamService::handleConfigRequest(AsyncWebServerRequest* request) {
|
||||
// Load current config from file
|
||||
PixelStreamConfig config = loadConfig();
|
||||
|
||||
bool updated = false;
|
||||
|
||||
// Handle individual form parameters
|
||||
if (request->hasParam("pin", true)) {
|
||||
String pinStr = request->getParam("pin", true)->value();
|
||||
if (pinStr.length() > 0) {
|
||||
int pinValue = pinStr.toInt();
|
||||
if (pinValue >= 0 && pinValue <= 255) {
|
||||
config.pin = static_cast<uint8_t>(pinValue);
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (request->hasParam("pixel_count", true)) {
|
||||
String countStr = request->getParam("pixel_count", true)->value();
|
||||
if (countStr.length() > 0) {
|
||||
int countValue = countStr.toInt();
|
||||
if (countValue > 0 && countValue <= 65535) {
|
||||
config.pixelCount = static_cast<uint16_t>(countValue);
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (request->hasParam("brightness", true)) {
|
||||
String brightnessStr = request->getParam("brightness", true)->value();
|
||||
if (brightnessStr.length() > 0) {
|
||||
int brightnessValue = brightnessStr.toInt();
|
||||
if (brightnessValue >= 0 && brightnessValue <= 255) {
|
||||
config.brightness = static_cast<uint8_t>(brightnessValue);
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (request->hasParam("matrix_width", true)) {
|
||||
String widthStr = request->getParam("matrix_width", true)->value();
|
||||
if (widthStr.length() > 0) {
|
||||
int widthValue = widthStr.toInt();
|
||||
if (widthValue > 0 && widthValue <= 65535) {
|
||||
config.matrixWidth = static_cast<uint16_t>(widthValue);
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (request->hasParam("matrix_serpentine", true)) {
|
||||
String serpentineStr = request->getParam("matrix_serpentine", true)->value();
|
||||
config.matrixSerpentine = (serpentineStr.equalsIgnoreCase("true") || serpentineStr == "1");
|
||||
updated = true;
|
||||
}
|
||||
if (request->hasParam("pixel_type", true)) {
|
||||
String typeStr = request->getParam("pixel_type", true)->value();
|
||||
if (typeStr.length() > 0) {
|
||||
int typeValue = typeStr.toInt();
|
||||
config.pixelType = static_cast<neoPixelType>(typeValue);
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!updated) {
|
||||
request->send(400, "application/json", "{\"error\":\"No valid configuration fields provided\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Save config to file
|
||||
if (saveConfig(config)) {
|
||||
LOG_INFO("PixelStreamService", "Configuration updated and saved to pixelstream.json");
|
||||
request->send(200, "application/json", "{\"status\":\"success\",\"message\":\"Configuration updated and saved\"}");
|
||||
} else {
|
||||
LOG_ERROR("PixelStreamService", "Failed to save configuration to file");
|
||||
request->send(500, "application/json", "{\"error\":\"Failed to save configuration\"}");
|
||||
}
|
||||
}
|
||||
|
||||
void PixelStreamService::handleGetConfigRequest(AsyncWebServerRequest* request) {
|
||||
PixelStreamConfig config = loadConfig();
|
||||
|
||||
JsonDocument doc;
|
||||
doc["pin"] = config.pin;
|
||||
doc["pixel_count"] = config.pixelCount;
|
||||
doc["brightness"] = config.brightness;
|
||||
doc["matrix_width"] = config.matrixWidth;
|
||||
doc["matrix_serpentine"] = config.matrixSerpentine;
|
||||
doc["pixel_type"] = static_cast<uint8_t>(config.pixelType);
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
request->send(200, "application/json", json);
|
||||
}
|
||||
|
||||
33
examples/pixelstream/PixelStreamService.h
Normal file
33
examples/pixelstream/PixelStreamService.h
Normal file
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
#include "spore/Service.h"
|
||||
#include "spore/core/NodeContext.h"
|
||||
#include "PixelStreamController.h"
|
||||
#include <ArduinoJson.h>
|
||||
#include <LittleFS.h>
|
||||
#include "spore/util/Logging.h"
|
||||
|
||||
// PixelStreamConfig is defined in PixelStreamController.h
|
||||
|
||||
class PixelStreamService : public Service {
|
||||
public:
|
||||
PixelStreamService(NodeContext& ctx, ApiServer& apiServer, PixelStreamController* controller);
|
||||
void registerEndpoints(ApiServer& api) override;
|
||||
void registerTasks(TaskManager& taskManager) override;
|
||||
const char* getName() const override { return "PixelStream"; }
|
||||
|
||||
// Config management
|
||||
PixelStreamConfig loadConfig();
|
||||
bool saveConfig(const PixelStreamConfig& config);
|
||||
void setController(PixelStreamController* ctrl) { controller = ctrl; }
|
||||
|
||||
private:
|
||||
NodeContext& ctx;
|
||||
ApiServer& apiServer;
|
||||
PixelStreamController* controller;
|
||||
|
||||
void handleConfigRequest(AsyncWebServerRequest* request);
|
||||
void handleGetConfigRequest(AsyncWebServerRequest* request);
|
||||
|
||||
static const char* CONFIG_FILE() { return "/pixelstream.json"; }
|
||||
};
|
||||
|
||||
33
examples/pixelstream/README.md
Normal file
33
examples/pixelstream/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# PixelStream Example
|
||||
|
||||
This example demonstrates how to consume the `udp/raw` cluster event and drive a NeoPixel strip or matrix directly from streamed RGB data. Frames are provided as hex encoded byte triplets (`RRGGBB` per pixel).
|
||||
|
||||
## Features
|
||||
|
||||
- Subscribes to `udp/raw` via `NodeContext::on`.
|
||||
- Converts incoming frames into pixel colors for strips or matrices.
|
||||
- Supports serpentine (zig-zag) matrix wiring.
|
||||
|
||||
## Payload Format
|
||||
|
||||
Each packet is expected to be `RAW:` followed by `pixelCount * 3 * 2` hexadecimal characters. For example, for 8 pixels:
|
||||
|
||||
```
|
||||
RAW:FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Strip Mode
|
||||
|
||||
Upload the example with `PIXEL_MATRIX_WIDTH` set to 0 (default). Send frames containing `PIXEL_COUNT * 3` bytes as hex.
|
||||
|
||||
### Matrix Mode
|
||||
|
||||
Set `PIXEL_MATRIX_WIDTH` to the number of columns. The controller remaps even/odd rows to support serpentine wiring.
|
||||
|
||||
## Configuration
|
||||
|
||||
Adjust `PIXEL_PIN`, `PIXEL_COUNT`, `PIXEL_BRIGHTNESS`, `PIXEL_MATRIX_WIDTH`, `PIXEL_MATRIX_SERPENTINE`, and `PIXEL_TYPE` through build defines or editing `main.cpp`.
|
||||
|
||||
|
||||
70
examples/pixelstream/main.cpp
Normal file
70
examples/pixelstream/main.cpp
Normal file
@@ -0,0 +1,70 @@
|
||||
#include <Arduino.h>
|
||||
#include "spore/Spore.h"
|
||||
#include "spore/util/Logging.h"
|
||||
#include "PixelStreamController.h"
|
||||
#include "PixelStreamService.h"
|
||||
#include <Adafruit_NeoPixel.h>
|
||||
|
||||
// Defaults are now loaded from config.json on LittleFS
|
||||
// Can still be overridden with preprocessor defines if needed
|
||||
#ifndef PIXEL_PIN
|
||||
#define PIXEL_PIN 2
|
||||
#endif
|
||||
|
||||
#ifndef PIXEL_COUNT
|
||||
#define PIXEL_COUNT 16
|
||||
#endif
|
||||
|
||||
#ifndef PIXEL_BRIGHTNESS
|
||||
#define PIXEL_BRIGHTNESS 80
|
||||
#endif
|
||||
|
||||
#ifndef PIXEL_MATRIX_WIDTH
|
||||
#define PIXEL_MATRIX_WIDTH 16
|
||||
#endif
|
||||
|
||||
#ifndef PIXEL_MATRIX_SERPENTINE
|
||||
#define PIXEL_MATRIX_SERPENTINE 0
|
||||
#endif
|
||||
|
||||
#ifndef PIXEL_TYPE
|
||||
#define PIXEL_TYPE NEO_GRB + NEO_KHZ800
|
||||
#endif
|
||||
|
||||
Spore spore({
|
||||
{"app", "pixelstream"},
|
||||
{"role", "led"},
|
||||
{"pixels", String(PIXEL_COUNT)}
|
||||
});
|
||||
|
||||
PixelStreamController* controller = nullptr;
|
||||
PixelStreamService* service = nullptr;
|
||||
|
||||
void setup() {
|
||||
spore.setup();
|
||||
|
||||
// Create service first (need it to load config)
|
||||
service = new PixelStreamService(spore.getContext(), spore.getApiServer(), nullptr);
|
||||
|
||||
// Load pixelstream config from LittleFS (pixelstream.json) or use defaults
|
||||
PixelStreamConfig config = service->loadConfig();
|
||||
|
||||
// Create controller with loaded config
|
||||
controller = new PixelStreamController(spore.getContext(), config);
|
||||
controller->begin();
|
||||
|
||||
// Update service with the actual controller
|
||||
service->setController(controller);
|
||||
|
||||
// Register service
|
||||
spore.registerService(service);
|
||||
|
||||
// Start the API server
|
||||
spore.begin();
|
||||
}
|
||||
|
||||
void loop() {
|
||||
spore.loop();
|
||||
}
|
||||
|
||||
|
||||
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.registerService(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
|
||||
```
|
||||
88
examples/relay/RelayService.cpp
Normal file
88
examples/relay/RelayService.cpp
Normal file
@@ -0,0 +1,88 @@
|
||||
#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);
|
||||
}
|
||||
|
||||
void RelayService::registerEndpoints(ApiServer& api) {
|
||||
api.registerEndpoint("/api/relay/status", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
api.registerEndpoint("/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& taskManager) {
|
||||
taskManager.registerTask("relay_status_print", 5000, [this]() {
|
||||
LOG_INFO("RelayService", "Status - pin: " + String(relayPin) + ", state: " + (relayOn ? "ON" : "OFF"));
|
||||
});
|
||||
}
|
||||
26
examples/relay/RelayService.h
Normal file
26
examples/relay/RelayService.h
Normal file
@@ -0,0 +1,26 @@
|
||||
#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;
|
||||
void registerTasks(TaskManager& taskManager) override;
|
||||
const char* getName() const override { return "Relay"; }
|
||||
|
||||
void turnOn();
|
||||
void turnOff();
|
||||
void toggle();
|
||||
|
||||
private:
|
||||
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.registerService(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();
|
||||
}
|
||||
12
include/spore/Service.h
Normal file
12
include/spore/Service.h
Normal file
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
#include "spore/core/ApiServer.h"
|
||||
|
||||
class TaskManager;
|
||||
|
||||
class Service {
|
||||
public:
|
||||
virtual ~Service() = default;
|
||||
virtual void registerEndpoints(ApiServer& api) = 0;
|
||||
virtual void registerTasks(TaskManager& taskManager) = 0;
|
||||
virtual const char* getName() const = 0;
|
||||
};
|
||||
51
include/spore/Spore.h
Normal file
51
include/spore/Spore.h
Normal file
@@ -0,0 +1,51 @@
|
||||
#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"
|
||||
|
||||
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 registerService(std::shared_ptr<Service> service);
|
||||
void registerService(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; }
|
||||
|
||||
private:
|
||||
void initializeCore();
|
||||
void registerCoreServices();
|
||||
void startApiServer();
|
||||
|
||||
NodeContext ctx;
|
||||
NetworkManager network;
|
||||
TaskManager taskManager;
|
||||
ClusterManager cluster;
|
||||
ApiServer apiServer;
|
||||
|
||||
std::vector<std::shared_ptr<Service>> services;
|
||||
bool initialized;
|
||||
bool apiServerStarted;
|
||||
};
|
||||
57
include/spore/core/ApiServer.h
Normal file
57
include/spore/core/ApiServer.h
Normal file
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <ESPAsyncWebServer.h>
|
||||
#include <AsyncWebSocket.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"
|
||||
|
||||
class Service; // Forward declaration
|
||||
|
||||
class ApiServer {
|
||||
public:
|
||||
ApiServer(NodeContext& ctx, TaskManager& taskMgr, uint16_t port = 80);
|
||||
void begin();
|
||||
void registerService(Service& service);
|
||||
void registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler);
|
||||
void registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
std::function<void(AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool)> uploadHandler);
|
||||
|
||||
void registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
const std::vector<ParamSpec>& params);
|
||||
void registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
std::function<void(AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool)> uploadHandler,
|
||||
const std::vector<ParamSpec>& params);
|
||||
|
||||
// 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;
|
||||
AsyncWebSocket ws{ "/ws" };
|
||||
NodeContext& ctx;
|
||||
TaskManager& taskManager;
|
||||
std::vector<std::reference_wrapper<Service>> services;
|
||||
std::vector<EndpointInfo> endpoints; // Single source of truth for endpoints
|
||||
std::vector<AsyncWebSocketClient*> wsClients;
|
||||
|
||||
// Internal helpers
|
||||
void registerEndpoint(const String& uri, int method,
|
||||
const std::vector<ParamSpec>& params,
|
||||
const String& serviceName);
|
||||
|
||||
// WebSocket helpers
|
||||
void setupWebSocket();
|
||||
};
|
||||
48
include/spore/core/ClusterManager.h
Normal file
48
include/spore/core/ClusterManager.h
Normal file
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
#include "spore/core/NodeContext.h"
|
||||
#include "spore/types/NodeInfo.h"
|
||||
#include "spore/core/TaskManager.h"
|
||||
#include <Arduino.h>
|
||||
#include <ESP8266WiFi.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <ESP8266HTTPClient.h>
|
||||
#include <map>
|
||||
#include <vector>
|
||||
#include <functional>
|
||||
|
||||
class ClusterManager {
|
||||
public:
|
||||
ClusterManager(NodeContext& ctx, TaskManager& taskMgr);
|
||||
void registerTasks();
|
||||
void listen();
|
||||
void addOrUpdateNode(const String& nodeHost, IPAddress nodeIP);
|
||||
void updateAllNodeStatuses();
|
||||
void removeDeadNodes();
|
||||
void printMemberList();
|
||||
size_t getMemberCount() const { return ctx.memberList->getMemberCount(); }
|
||||
void updateLocalNodeResources(NodeInfo& node);
|
||||
void heartbeatTaskCallback();
|
||||
void updateAllMembersInfoTaskCallback();
|
||||
void broadcastNodeUpdate();
|
||||
private:
|
||||
NodeContext& ctx;
|
||||
TaskManager& taskManager;
|
||||
struct MessageHandler {
|
||||
bool (*predicate)(const char*);
|
||||
std::function<void(const char*)> handle;
|
||||
const char* name;
|
||||
};
|
||||
void initMessageHandlers();
|
||||
void handleIncomingMessage(const char* incoming);
|
||||
static bool isHeartbeatMsg(const char* msg);
|
||||
static bool isNodeUpdateMsg(const char* msg);
|
||||
static bool isClusterEventMsg(const char* msg);
|
||||
static bool isRawMsg(const char* msg);
|
||||
void onHeartbeat(const char* msg);
|
||||
void onNodeUpdate(const char* msg);
|
||||
void onClusterEvent(const char* msg);
|
||||
void onRawMessage(const char* msg);
|
||||
void sendNodeInfo(const String& hostname, const IPAddress& targetIP);
|
||||
unsigned long lastHeartbeatSentAt = 0;
|
||||
std::vector<MessageHandler> messageHandlers;
|
||||
};
|
||||
133
include/spore/core/Memberlist.h
Normal file
133
include/spore/core/Memberlist.h
Normal file
@@ -0,0 +1,133 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <optional>
|
||||
#include <functional>
|
||||
#include "spore/types/NodeInfo.h"
|
||||
|
||||
/**
|
||||
* @brief Manages the list of cluster members.
|
||||
*
|
||||
* The Memberlist class maintains a collection of cluster members, where each member
|
||||
* is identified by its IP address and associated with a NodeInfo object. It provides
|
||||
* methods to add, update, and remove members, as well as handle node status changes
|
||||
* (stale and dead nodes).
|
||||
*/
|
||||
class Memberlist {
|
||||
public:
|
||||
/**
|
||||
* @brief Default constructor.
|
||||
*/
|
||||
Memberlist();
|
||||
|
||||
/**
|
||||
* @brief Destructor.
|
||||
*/
|
||||
~Memberlist();
|
||||
|
||||
/**
|
||||
* @brief Adds or updates a member in the list.
|
||||
*
|
||||
* If the member already exists, updates its information. Otherwise, adds a new member.
|
||||
* @param ip The IP address of the member (as string).
|
||||
* @param node The NodeInfo object containing member details.
|
||||
* @return True if the member was added or updated, false otherwise.
|
||||
*/
|
||||
bool addOrUpdateMember(const std::string& ip, const NodeInfo& node);
|
||||
|
||||
/**
|
||||
* @brief Adds a new member to the list.
|
||||
*
|
||||
* @param ip The IP address of the member (as string).
|
||||
* @param node The NodeInfo object containing member details.
|
||||
* @return True if the member was added, false if it already exists.
|
||||
*/
|
||||
bool addMember(const std::string& ip, const NodeInfo& node);
|
||||
|
||||
/**
|
||||
* @brief Updates an existing member in the list.
|
||||
*
|
||||
* @param ip The IP address of the member (as string).
|
||||
* @param node The updated NodeInfo object.
|
||||
* @return True if the member was updated, false if it doesn't exist.
|
||||
*/
|
||||
bool updateMember(const std::string& ip, const NodeInfo& node);
|
||||
|
||||
/**
|
||||
* @brief Removes a member from the list.
|
||||
*
|
||||
* @param ip The IP address of the member to remove (as string).
|
||||
* @return True if the member was removed, false if it doesn't exist.
|
||||
*/
|
||||
bool removeMember(const std::string& ip);
|
||||
|
||||
/**
|
||||
* @brief Retrieves a member by IP address.
|
||||
*
|
||||
* @param ip The IP address of the member (as string).
|
||||
* @return Optional containing the NodeInfo if found, or std::nullopt if not found.
|
||||
*/
|
||||
std::optional<NodeInfo> getMember(const std::string& ip) const;
|
||||
|
||||
/**
|
||||
* @brief Iterates over all members and calls the provided callback for each.
|
||||
*
|
||||
* @param callback Function to call for each member. Receives (ip, node) as parameters.
|
||||
*/
|
||||
void forEachMember(std::function<void(const std::string&, const NodeInfo&)> callback) const;
|
||||
|
||||
/**
|
||||
* @brief Iterates over all members and calls the provided callback for each.
|
||||
*
|
||||
* @param callback Function to call for each member. Receives (ip, node) as parameters.
|
||||
* If callback returns false, iteration stops.
|
||||
* @return True if all members were processed, false if iteration was stopped early.
|
||||
*/
|
||||
bool forEachMemberUntil(std::function<bool(const std::string&, const NodeInfo&)> callback) const;
|
||||
|
||||
/**
|
||||
* @brief Gets the number of members in the list.
|
||||
*
|
||||
* @return The number of members.
|
||||
*/
|
||||
size_t getMemberCount() const;
|
||||
|
||||
/**
|
||||
* @brief Updates the status of all members based on current time and thresholds.
|
||||
*
|
||||
* Marks nodes as stale or dead based on their last seen time.
|
||||
* @param currentTime The current time in milliseconds.
|
||||
* @param staleThresholdMs Threshold for marking a node as stale (milliseconds).
|
||||
* @param deadThresholdMs Threshold for marking a node as dead (milliseconds).
|
||||
* @param onStatusChange Optional callback fired when a node's status changes.
|
||||
*/
|
||||
void updateAllNodeStatuses(unsigned long currentTime,
|
||||
unsigned long staleThresholdMs,
|
||||
unsigned long deadThresholdMs,
|
||||
std::function<void(const std::string&, NodeInfo::Status, NodeInfo::Status)> onStatusChange = nullptr);
|
||||
|
||||
/**
|
||||
* @brief Removes all dead members from the list.
|
||||
*
|
||||
* @return The number of members removed.
|
||||
*/
|
||||
size_t removeDeadMembers();
|
||||
|
||||
/**
|
||||
* @brief Checks if a member exists in the list.
|
||||
*
|
||||
* @param ip The IP address of the member (as string).
|
||||
* @return True if the member exists, false otherwise.
|
||||
*/
|
||||
bool hasMember(const std::string& ip) const;
|
||||
|
||||
/**
|
||||
* @brief Clears all members from the list.
|
||||
*/
|
||||
void clear();
|
||||
|
||||
private:
|
||||
std::map<std::string, NodeInfo> m_members; ///< Internal map holding the members.
|
||||
};
|
||||
51
include/spore/core/NetworkManager.h
Normal file
51
include/spore/core/NetworkManager.h
Normal file
@@ -0,0 +1,51 @@
|
||||
#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);
|
||||
bool saveConfig();
|
||||
void restartNode();
|
||||
|
||||
// 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;
|
||||
};
|
||||
36
include/spore/core/NodeContext.h
Normal file
36
include/spore/core/NodeContext.h
Normal file
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include <WiFiUdp.h>
|
||||
#include <map>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <initializer_list>
|
||||
#include <memory>
|
||||
#include "spore/types/NodeInfo.h"
|
||||
#include "spore/types/Config.h"
|
||||
#include "spore/types/ApiTypes.h"
|
||||
#include "spore/core/Memberlist.h"
|
||||
|
||||
class NodeContext {
|
||||
public:
|
||||
NodeContext();
|
||||
NodeContext(std::initializer_list<std::pair<String, String>> initialLabels);
|
||||
~NodeContext();
|
||||
WiFiUDP* udp;
|
||||
String hostname;
|
||||
IPAddress localIP;
|
||||
NodeInfo self;
|
||||
std::unique_ptr<Memberlist> memberList;
|
||||
::Config config;
|
||||
std::map<String, String> constructorLabels; // Labels passed to constructor (not persisted)
|
||||
|
||||
using EventCallback = std::function<void(void*)>;
|
||||
std::map<std::string, std::vector<EventCallback>> eventRegistry;
|
||||
using AnyEventCallback = std::function<void(const std::string&, void*)>;
|
||||
std::vector<AnyEventCallback> anyEventSubscribers;
|
||||
|
||||
void on(const std::string& event, EventCallback cb);
|
||||
void fire(const std::string& event, void* data);
|
||||
void onAny(AnyEventCallback cb);
|
||||
void rebuildLabels(); // Rebuild self.labels from constructorLabels + config.labels
|
||||
};
|
||||
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;
|
||||
};
|
||||
@@ -5,20 +5,20 @@
|
||||
|
||||
// Cluster protocol and API constants
|
||||
namespace ClusterProtocol {
|
||||
constexpr const char* DISCOVERY_MSG = "CLUSTER_DISCOVERY";
|
||||
constexpr const char* RESPONSE_MSG = "CLUSTER_RESPONSE";
|
||||
constexpr const char* HEARTBEAT_MSG = "cluster/heartbeat";
|
||||
constexpr const char* NODE_UPDATE_MSG = "node/update";
|
||||
constexpr const char* CLUSTER_EVENT_MSG = "cluster/event";
|
||||
constexpr const char* RAW_MSG = "RAW";
|
||||
constexpr uint16_t UDP_PORT = 4210;
|
||||
constexpr size_t UDP_BUF_SIZE = 64;
|
||||
// Increased buffer to accommodate larger RAW pixel streams and node info JSON over UDP
|
||||
constexpr size_t UDP_BUF_SIZE = 2048;
|
||||
constexpr const char* API_NODE_STATUS = "/api/node/status";
|
||||
}
|
||||
|
||||
namespace TaskIntervals {
|
||||
constexpr unsigned long SEND_DISCOVERY = 1000;
|
||||
constexpr unsigned long LISTEN_FOR_DISCOVERY = 100;
|
||||
constexpr unsigned long UPDATE_STATUS = 1000;
|
||||
constexpr unsigned long PRINT_MEMBER_LIST = 5000;
|
||||
constexpr unsigned long HEARTBEAT = 2000;
|
||||
constexpr unsigned long UPDATE_ALL_MEMBERS_INFO = 10000;
|
||||
}
|
||||
|
||||
constexpr unsigned long NODE_ACTIVE_THRESHOLD = 10000;
|
||||
17
include/spore/services/ClusterService.h
Normal file
17
include/spore/services/ClusterService.h
Normal file
@@ -0,0 +1,17 @@
|
||||
#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;
|
||||
void registerTasks(TaskManager& taskManager) override;
|
||||
const char* getName() const override { return "Cluster"; }
|
||||
|
||||
private:
|
||||
NodeContext& ctx;
|
||||
|
||||
void handleMembersRequest(AsyncWebServerRequest* request);
|
||||
};
|
||||
43
include/spore/services/MonitoringService.h
Normal file
43
include/spore/services/MonitoringService.h
Normal file
@@ -0,0 +1,43 @@
|
||||
#pragma once
|
||||
#include "spore/Service.h"
|
||||
#include <functional>
|
||||
|
||||
class MonitoringService : public Service {
|
||||
public:
|
||||
MonitoringService();
|
||||
void registerEndpoints(ApiServer& api) override;
|
||||
void registerTasks(TaskManager& taskManager) override;
|
||||
const char* getName() const override { return "Monitoring"; }
|
||||
|
||||
// System resource information
|
||||
struct SystemResources {
|
||||
// CPU information
|
||||
float currentCpuUsage;
|
||||
float averageCpuUsage;
|
||||
unsigned long measurementCount;
|
||||
bool isMeasuring;
|
||||
|
||||
// Memory information
|
||||
size_t freeHeap;
|
||||
size_t totalHeap;
|
||||
|
||||
// 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
|
||||
void getFilesystemInfo(size_t& totalBytes, size_t& usedBytes) const;
|
||||
};
|
||||
23
include/spore/services/NetworkService.h
Normal file
23
include/spore/services/NetworkService.h
Normal file
@@ -0,0 +1,23 @@
|
||||
#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;
|
||||
void registerTasks(TaskManager& taskManager) 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);
|
||||
};
|
||||
25
include/spore/services/NodeService.h
Normal file
25
include/spore/services/NodeService.h
Normal file
@@ -0,0 +1,25 @@
|
||||
#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;
|
||||
void registerTasks(TaskManager& taskManager) 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);
|
||||
void handleConfigRequest(AsyncWebServerRequest* request);
|
||||
void handleGetConfigRequest(AsyncWebServerRequest* request);
|
||||
};
|
||||
19
include/spore/services/StaticFileService.h
Normal file
19
include/spore/services/StaticFileService.h
Normal file
@@ -0,0 +1,19 @@
|
||||
#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;
|
||||
void registerTasks(TaskManager& taskManager) override;
|
||||
const char* getName() const override { return name.c_str(); }
|
||||
|
||||
private:
|
||||
static const String name;
|
||||
NodeContext& ctx;
|
||||
ApiServer& apiServer;
|
||||
};
|
||||
18
include/spore/services/TaskService.h
Normal file
18
include/spore/services/TaskService.h
Normal file
@@ -0,0 +1,18 @@
|
||||
#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;
|
||||
void registerTasks(TaskManager& taskManager) override;
|
||||
const char* getName() const override { return "Task"; }
|
||||
|
||||
private:
|
||||
TaskManager& taskManager;
|
||||
|
||||
void handleStatusRequest(AsyncWebServerRequest* request);
|
||||
void handleControlRequest(AsyncWebServerRequest* request);
|
||||
};
|
||||
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) {}
|
||||
};
|
||||
75
include/spore/types/Config.h
Normal file
75
include/spore/types/Config.h
Normal file
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <LittleFS.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <map>
|
||||
|
||||
class Config {
|
||||
public:
|
||||
// Default Configuration Constants
|
||||
static constexpr const char* DEFAULT_WIFI_SSID = "shroud";
|
||||
static constexpr const char* DEFAULT_WIFI_PASSWORD = "th3r31sn0sp00n";
|
||||
static constexpr uint16_t DEFAULT_UDP_PORT = 4210;
|
||||
static constexpr uint16_t DEFAULT_API_SERVER_PORT = 80;
|
||||
static constexpr unsigned long DEFAULT_CLUSTER_LISTEN_INTERVAL_MS = 10;
|
||||
static constexpr unsigned long DEFAULT_HEARTBEAT_INTERVAL_MS = 5000;
|
||||
static constexpr unsigned long DEFAULT_STATUS_UPDATE_INTERVAL_MS = 1000;
|
||||
static constexpr unsigned long DEFAULT_NODE_UPDATE_BROADCAST_INTERVAL_MS = 5000;
|
||||
static constexpr unsigned long DEFAULT_PRINT_INTERVAL_MS = 5000;
|
||||
static constexpr unsigned long DEFAULT_NODE_ACTIVE_THRESHOLD_MS = 10000;
|
||||
static constexpr unsigned long DEFAULT_NODE_INACTIVE_THRESHOLD_MS = 60000;
|
||||
static constexpr unsigned long DEFAULT_NODE_DEAD_THRESHOLD_MS = 120000;
|
||||
static constexpr unsigned long DEFAULT_WIFI_CONNECT_TIMEOUT_MS = 15000;
|
||||
static constexpr unsigned long DEFAULT_WIFI_RETRY_DELAY_MS = 500;
|
||||
static constexpr unsigned long DEFAULT_RESTART_DELAY_MS = 10;
|
||||
static constexpr uint16_t DEFAULT_JSON_DOC_SIZE = 1024;
|
||||
static constexpr uint32_t DEFAULT_LOW_MEMORY_THRESHOLD_BYTES = 10000;
|
||||
static constexpr uint32_t DEFAULT_CRITICAL_MEMORY_THRESHOLD_BYTES = 5000;
|
||||
static constexpr size_t DEFAULT_MAX_CONCURRENT_HTTP_REQUESTS = 3;
|
||||
|
||||
// WiFi Configuration
|
||||
String wifi_ssid;
|
||||
String wifi_password;
|
||||
|
||||
// Network Configuration
|
||||
uint16_t udp_port;
|
||||
uint16_t api_server_port;
|
||||
|
||||
// Cluster Configuration
|
||||
unsigned long heartbeat_interval_ms;
|
||||
unsigned long cluster_listen_interval_ms;
|
||||
unsigned long status_update_interval_ms;
|
||||
|
||||
// Node Status Thresholds
|
||||
unsigned long node_active_threshold_ms;
|
||||
unsigned long node_inactive_threshold_ms;
|
||||
unsigned long node_dead_threshold_ms;
|
||||
|
||||
// WiFi Connection
|
||||
unsigned long wifi_connect_timeout_ms;
|
||||
unsigned long wifi_retry_delay_ms;
|
||||
|
||||
// System Configuration
|
||||
unsigned long restart_delay_ms;
|
||||
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;
|
||||
|
||||
// Custom Labels
|
||||
std::map<String, String> labels;
|
||||
|
||||
// Constructor
|
||||
Config();
|
||||
|
||||
// Persistence methods
|
||||
bool saveToFile(const String& filename = "/config.json");
|
||||
bool loadFromFile(const String& filename = "/config.json");
|
||||
void setDefaults();
|
||||
|
||||
private:
|
||||
static const char* CONFIG_FILE_PATH;
|
||||
};
|
||||
@@ -1,13 +1,15 @@
|
||||
#pragma once
|
||||
#include "Globals.h"
|
||||
#include <IPAddress.h>
|
||||
#include <vector>
|
||||
#include <tuple>
|
||||
#include <map>
|
||||
#include "ApiTypes.h"
|
||||
|
||||
struct NodeInfo {
|
||||
String hostname;
|
||||
IPAddress ip;
|
||||
unsigned long lastSeen;
|
||||
unsigned long uptime = 0; // milliseconds since node started
|
||||
enum Status { ACTIVE, INACTIVE, DEAD } status;
|
||||
struct Resources {
|
||||
uint32_t freeHeap = 0;
|
||||
@@ -16,9 +18,11 @@ struct NodeInfo {
|
||||
uint32_t cpuFreqMHz = 0;
|
||||
uint32_t flashChipSize = 0;
|
||||
} resources;
|
||||
unsigned long latency = 0; // ms since lastSeen
|
||||
std::vector<std::tuple<String, int>> apiEndpoints; // List of registered endpoints
|
||||
unsigned long latency = 0; // ms from heartbeat broadcast to NODE_UPDATE receipt
|
||||
std::vector<EndpointInfo> endpoints; // List of registered endpoints
|
||||
std::map<String, String> labels; // Arbitrary node labels (key -> value)
|
||||
};
|
||||
|
||||
const char* statusToStr(NodeInfo::Status status);
|
||||
void updateNodeStatus(NodeInfo &node, unsigned long now = millis());
|
||||
void updateNodeStatus(NodeInfo &node, unsigned long now, unsigned long inactive_threshold, unsigned long dead_threshold);
|
||||
void updateNodeStatus(NodeInfo &node, unsigned long now = millis()); // Legacy overload for backward compatibility
|
||||
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,
|
||||
|
173
platformio.ini
173
platformio.ini
@@ -8,16 +8,173 @@
|
||||
; Please visit documentation for the other options and examples
|
||||
; 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
|
||||
build_flags =
|
||||
-Os ; Optimize for size
|
||||
-ffunction-sections ; Place each function in its own section
|
||||
-fdata-sections ; Place data in separate sections
|
||||
-Wl,--gc-sections ; Remove unused sections at link time
|
||||
-DNDEBUG ; Disable debug assertions
|
||||
-DVTABLES_IN_FLASH ; Move virtual tables to flash
|
||||
-fno-exceptions ; Disable C++ exceptions
|
||||
-fno-rtti ; Disable runtime type information
|
||||
|
||||
[env:base]
|
||||
platform = platformio/espressif8266@^4.2.1
|
||||
board = esp01_1m
|
||||
framework = arduino
|
||||
upload_speed = 115200
|
||||
monitor_speed = 115200
|
||||
board_build.partitions = partitions_ota_1M.csv
|
||||
board_build.flash_mode = dout ; ESP‑01S uses DOUT on 1 Mbit flash
|
||||
board_build.flash_size = 1M
|
||||
lib_deps =
|
||||
esp32async/ESPAsyncWebServer@^3.8.0
|
||||
bblanchon/ArduinoJson@^7.4.2
|
||||
arkhipenko/TaskScheduler@^3.8.5
|
||||
board_build.f_cpu = 80000000L
|
||||
board_build.flash_mode = qio
|
||||
board_build.filesystem = littlefs
|
||||
; note: somehow partition table is not working, so we need to use the ldscript
|
||||
board_build.ldscript = eagle.flash.1m64.ld ; 64KB -> FS Size
|
||||
lib_deps = ${common.lib_deps}
|
||||
build_flags = ${common.build_flags}
|
||||
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_flags = ${common.build_flags}
|
||||
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_flags = ${common.build_flags}
|
||||
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 ;${common.build_flags}
|
||||
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>
|
||||
|
||||
[env:pixelstream]
|
||||
platform = platformio/espressif8266@^4.2.1
|
||||
board = esp01_1m
|
||||
framework = arduino
|
||||
upload_speed = 115200
|
||||
monitor_speed = 115200
|
||||
board_build.filesystem = littlefs
|
||||
board_build.flash_mode = dout
|
||||
board_build.ldscript = eagle.flash.1m64.ld
|
||||
lib_deps = ${common.lib_deps}
|
||||
adafruit/Adafruit NeoPixel@^1.15.1
|
||||
build_flags = ${common.build_flags}
|
||||
build_src_filter =
|
||||
+<examples/pixelstream/*.cpp>
|
||||
+<src/spore/*.cpp>
|
||||
+<src/spore/core/*.cpp>
|
||||
+<src/spore/services/*.cpp>
|
||||
+<src/spore/types/*.cpp>
|
||||
+<src/spore/util/*.cpp>
|
||||
+<src/internal/*.cpp>
|
||||
|
||||
[env:pixelstream_d1]
|
||||
platform = platformio/espressif8266@^4.2.1
|
||||
board = d1_mini
|
||||
framework = arduino
|
||||
upload_speed = 115200
|
||||
monitor_speed = 115200
|
||||
board_build.filesystem = littlefs
|
||||
board_build.flash_mode = dout
|
||||
board_build.ldscript = eagle.flash.4m1m.ld
|
||||
lib_deps = ${common.lib_deps}
|
||||
adafruit/Adafruit NeoPixel@^1.15.1
|
||||
build_flags = -DPIXEL_PIN=TX -DPIXEL_COUNT=256 -DMATRIX_WIDTH=16 ${common.build_flags}
|
||||
build_src_filter =
|
||||
+<examples/pixelstream/*.cpp>
|
||||
+<src/spore/*.cpp>
|
||||
+<src/spore/core/*.cpp>
|
||||
+<src/spore/services/*.cpp>
|
||||
+<src/spore/types/*.cpp>
|
||||
+<src/spore/util/*.cpp>
|
||||
+<src/internal/*.cpp>
|
||||
|
||||
[env:multimatrix]
|
||||
platform = platformio/espressif8266@^4.2.1
|
||||
board = d1_mini
|
||||
framework = arduino
|
||||
upload_speed = 115200
|
||||
monitor_speed = 115200
|
||||
board_build.filesystem = littlefs
|
||||
board_build.flash_mode = dio
|
||||
board_build.flash_size = 4M
|
||||
board_build.ldscript = eagle.flash.4m1m.ld
|
||||
lib_deps = ${common.lib_deps}
|
||||
adafruit/Adafruit NeoPixel@^1.15.1
|
||||
dfrobot/DFRobotDFPlayerMini@^1.0.6
|
||||
build_flags = ${common.build_flags}
|
||||
build_src_filter =
|
||||
+<examples/multimatrix/*.cpp>
|
||||
+<examples/pixelstream/PixelStreamController.cpp>
|
||||
+<src/spore/*.cpp>
|
||||
+<src/spore/core/*.cpp>
|
||||
+<src/spore/services/*.cpp>
|
||||
+<src/spore/types/*.cpp>
|
||||
+<src/spore/util/*.cpp>
|
||||
+<src/internal/*.cpp>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
#include "ClusterContext.h"
|
||||
|
||||
ClusterContext::ClusterContext() {
|
||||
scheduler = new Scheduler();
|
||||
udp = new WiFiUDP();
|
||||
memberList = new std::vector<NodeInfo>();
|
||||
hostname = "";
|
||||
}
|
||||
|
||||
ClusterContext::~ClusterContext() {
|
||||
delete scheduler;
|
||||
delete udp;
|
||||
delete memberList;
|
||||
}
|
||||
|
||||
void ClusterContext::registerEvent(const std::string& event, EventCallback cb) {
|
||||
eventRegistry[event].push_back(cb);
|
||||
}
|
||||
|
||||
void ClusterContext::triggerEvent(const std::string& event, void* data) {
|
||||
for (auto& cb : eventRegistry[event]) {
|
||||
cb(data);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
#pragma once
|
||||
#include <TaskSchedulerDeclarations.h>
|
||||
#include <WiFiUdp.h>
|
||||
#include <vector>
|
||||
#include "NodeInfo.h"
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
class ClusterContext {
|
||||
public:
|
||||
ClusterContext();
|
||||
~ClusterContext();
|
||||
Scheduler* scheduler;
|
||||
WiFiUDP* udp;
|
||||
String hostname;
|
||||
IPAddress localIP;
|
||||
std::vector<NodeInfo>* memberList;
|
||||
|
||||
using EventCallback = std::function<void(void*)>;
|
||||
std::map<std::string, std::vector<EventCallback>> eventRegistry;
|
||||
|
||||
void registerEvent(const std::string& event, EventCallback cb);
|
||||
void triggerEvent(const std::string& event, void* data);
|
||||
};
|
||||
@@ -1,175 +0,0 @@
|
||||
#include "ClusterManager.h"
|
||||
|
||||
ClusterManager::ClusterManager(ClusterContext& ctx) : ctx(ctx) {
|
||||
// Register callback for node_discovered event
|
||||
ctx.registerEvent("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", ClusterProtocol::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(), ClusterProtocol::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;
|
||||
for (auto& node : memberList) {
|
||||
if (node.hostname == nodeHost) {
|
||||
node.ip = nodeIP;
|
||||
//fetchNodeInfo(nodeIP); // Do not fetch here, handled by periodic task
|
||||
return;
|
||||
}
|
||||
}
|
||||
NodeInfo newNode;
|
||||
newNode.hostname = nodeHost;
|
||||
newNode.ip = nodeIP;
|
||||
newNode.lastSeen = millis();
|
||||
updateNodeStatus(newNode, newNode.lastSeen);
|
||||
memberList.push_back(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;
|
||||
for (auto& node : memberList) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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() {
|
||||
for (auto& node : *ctx.memberList) {
|
||||
if (node.hostname == ctx.hostname) {
|
||||
node.lastSeen = millis();
|
||||
node.status = NodeInfo::ACTIVE;
|
||||
updateLocalNodeResources();
|
||||
ctx.triggerEvent("node_discovered", &node);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ClusterManager::updateAllMembersInfoTaskCallback() {
|
||||
auto& memberList = *ctx.memberList;
|
||||
for (const auto& node : memberList) {
|
||||
if (node.ip != ctx.localIP) {
|
||||
fetchNodeInfo(node.ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ClusterManager::updateAllNodeStatuses() {
|
||||
auto& memberList = *ctx.memberList;
|
||||
unsigned long now = millis();
|
||||
for (auto& node : memberList) {
|
||||
updateNodeStatus(node, now);
|
||||
node.latency = now - node.lastSeen;
|
||||
}
|
||||
}
|
||||
|
||||
void ClusterManager::removeDeadNodes() {
|
||||
auto& memberList = *ctx.memberList;
|
||||
unsigned long now = millis();
|
||||
for (size_t i = 0; i < memberList.size(); ) {
|
||||
unsigned long diff = now - memberList[i].lastSeen;
|
||||
if (memberList[i].status == NodeInfo::DEAD && diff > NODE_DEAD_THRESHOLD) {
|
||||
Serial.printf("[Cluster] Removing node: %s\n", memberList[i].hostname.c_str());
|
||||
memberList.erase(memberList.begin() + i);
|
||||
} else {
|
||||
++i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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& node : memberList) {
|
||||
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() {
|
||||
for (auto& node : *ctx.memberList) {
|
||||
if (node.hostname == ctx.hostname) {
|
||||
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();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
#pragma once
|
||||
#include "Globals.h"
|
||||
#include "ClusterContext.h"
|
||||
#include "NodeInfo.h"
|
||||
#include <Arduino.h>
|
||||
#include <ESP8266WiFi.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <ESP8266HTTPClient.h>
|
||||
|
||||
class ClusterManager {
|
||||
public:
|
||||
ClusterManager(ClusterContext& ctx);
|
||||
void sendDiscovery();
|
||||
void listenForDiscovery();
|
||||
void addOrUpdateNode(const String& nodeHost, IPAddress nodeIP);
|
||||
void updateAllNodeStatuses();
|
||||
void removeDeadNodes();
|
||||
void printMemberList();
|
||||
const std::vector<NodeInfo>& getMemberList() const { return *ctx.memberList; }
|
||||
void fetchNodeInfo(const IPAddress& ip);
|
||||
void updateLocalNodeResources();
|
||||
void heartbeatTaskCallback();
|
||||
void updateAllMembersInfoTaskCallback();
|
||||
private:
|
||||
ClusterContext& ctx;
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
#include "NetworkManager.h"
|
||||
|
||||
const char* STA_SSID = "shroud";
|
||||
const char* STA_PASS = "th3r31sn0sp00n";
|
||||
|
||||
NetworkManager::NetworkManager(ClusterContext& 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(STA_SSID, STA_PASS);
|
||||
Serial.println("[WiFi] Connecting to AP...");
|
||||
unsigned long startAttemptTime = millis();
|
||||
while (WiFi.status() != WL_CONNECTED && millis() - startAttemptTime < 15000) {
|
||||
delay(500);
|
||||
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(STA_SSID, STA_PASS);
|
||||
Serial.print("[WiFi] AP created, IP: ");
|
||||
Serial.println(WiFi.softAPIP());
|
||||
}
|
||||
setHostnameFromMac();
|
||||
ctx.udp->begin(4210);
|
||||
ctx.localIP = WiFi.localIP();
|
||||
ctx.hostname = WiFi.hostname();
|
||||
Serial.print("[WiFi] Hostname set to: ");
|
||||
Serial.println(ctx.hostname);
|
||||
Serial.print("[WiFi] UDP listening on port 4210\n");
|
||||
|
||||
// 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.triggerEvent("node_discovered", &self);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
#pragma once
|
||||
#include "ClusterContext.h"
|
||||
#include <ESP8266WiFi.h>
|
||||
|
||||
class NetworkManager {
|
||||
public:
|
||||
NetworkManager(ClusterContext& ctx);
|
||||
void setupWiFi();
|
||||
void setHostnameFromMac();
|
||||
private:
|
||||
ClusterContext& ctx;
|
||||
};
|
||||
@@ -1,169 +0,0 @@
|
||||
#include "RestApiServer.h"
|
||||
|
||||
RestApiServer::RestApiServer(ClusterContext& ctx, uint16_t port) : server(port), ctx(ctx) {}
|
||||
|
||||
void RestApiServer::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()) {
|
||||
(*ctx.memberList)[0].apiEndpoints.push_back(std::make_tuple(uri, method));
|
||||
}
|
||||
server.on(uri.c_str(), method, requestHandler);
|
||||
}
|
||||
|
||||
void RestApiServer::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()) {
|
||||
(*ctx.memberList)[0].apiEndpoints.push_back(std::make_tuple(uri, method));
|
||||
}
|
||||
server.on(uri.c_str(), method, requestHandler, uploadHandler);
|
||||
}
|
||||
|
||||
void RestApiServer::begin() {
|
||||
addEndpoint("/api/node/status", HTTP_GET,
|
||||
std::bind(&RestApiServer::getSystemStatusJson, this, std::placeholders::_1));
|
||||
addEndpoint("/api/cluster/members", HTTP_GET,
|
||||
std::bind(&RestApiServer::getClusterMembersJson, this, std::placeholders::_1));
|
||||
addEndpoint("/api/node/update", HTTP_POST,
|
||||
std::bind(&RestApiServer::onFirmwareUpdateRequest, this, std::placeholders::_1),
|
||||
std::bind(&RestApiServer::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(&RestApiServer::onRestartRequest, this, std::placeholders::_1));
|
||||
server.begin();
|
||||
}
|
||||
|
||||
void RestApiServer::getSystemStatusJson(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 RestApiServer::getClusterMembersJson(AsyncWebServerRequest *request) {
|
||||
JsonDocument doc;
|
||||
JsonArray arr = doc["members"].to<JsonArray>();
|
||||
for (const auto& node : *ctx.memberList) {
|
||||
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 RestApiServer::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 RestApiServer::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 RestApiServer::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 RestApiServer::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,35 +0,0 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <ESPAsyncWebServer.h>
|
||||
#include <Updater.h>
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
#include <tuple>
|
||||
|
||||
#include "ClusterContext.h"
|
||||
#include "NodeInfo.h"
|
||||
|
||||
#define DEBUG_UPDATER 1
|
||||
|
||||
using namespace std;
|
||||
using namespace std::placeholders;
|
||||
|
||||
class RestApiServer {
|
||||
public:
|
||||
RestApiServer(ClusterContext& 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;
|
||||
ClusterContext& ctx;
|
||||
std::vector<std::tuple<String, int>> serviceRegistry;
|
||||
void getClusterMembersJson(AsyncWebServerRequest *request);
|
||||
void methodToStr(const std::tuple<String, int> &endpoint, ArduinoJson::V742PB22::JsonObject &apiObj);
|
||||
void getSystemStatusJson(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,5 +0,0 @@
|
||||
/*
|
||||
* https://github.com/arkhipenko/TaskScheduler/tree/master/examples/Scheduler_example16_Multitab
|
||||
*/
|
||||
|
||||
#include <TaskScheduler.h>
|
||||
40
src/main.cpp
40
src/main.cpp
@@ -1,40 +0,0 @@
|
||||
#include <Arduino.h>
|
||||
#include "Globals.h"
|
||||
#include "ClusterContext.h"
|
||||
#include "NetworkManager.h"
|
||||
#include "ClusterManager.h"
|
||||
#include "RestApiServer.h"
|
||||
|
||||
ClusterContext ctx;
|
||||
NetworkManager network(ctx);
|
||||
ClusterManager cluster(ctx);
|
||||
RestApiServer apiServer(ctx);
|
||||
|
||||
Task tSendDiscovery(TaskIntervals::SEND_DISCOVERY, TASK_FOREVER, [](){ cluster.sendDiscovery(); });
|
||||
Task tListenForDiscovery(TaskIntervals::LISTEN_FOR_DISCOVERY, TASK_FOREVER, [](){ cluster.listenForDiscovery(); });
|
||||
Task tUpdateStatus(TaskIntervals::UPDATE_STATUS, TASK_FOREVER, [](){ cluster.updateAllNodeStatuses(); cluster.removeDeadNodes(); });
|
||||
Task tPrintMemberList(TaskIntervals::PRINT_MEMBER_LIST, TASK_FOREVER, [](){ cluster.printMemberList(); });
|
||||
Task tHeartbeat(TaskIntervals::HEARTBEAT, TASK_FOREVER, [](){ cluster.heartbeatTaskCallback(); });
|
||||
Task tUpdateAllMembersInfo(TaskIntervals::UPDATE_ALL_MEMBERS_INFO, TASK_FOREVER, [](){ cluster.updateAllMembersInfoTaskCallback(); });
|
||||
|
||||
void setup() {
|
||||
network.setupWiFi();
|
||||
ctx.scheduler->init();
|
||||
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();
|
||||
}
|
||||
170
src/spore/Spore.cpp
Normal file
170
src/spore/Spore.cpp
Normal file
@@ -0,0 +1,170 @@
|
||||
#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),
|
||||
initialized(false), apiServerStarted(false) {
|
||||
|
||||
// Rebuild labels from constructor + config labels
|
||||
ctx.rebuildLabels();
|
||||
}
|
||||
|
||||
Spore::Spore(std::initializer_list<std::pair<String, String>> initialLabels)
|
||||
: ctx(initialLabels), network(ctx), taskManager(ctx), cluster(ctx, taskManager),
|
||||
apiServer(ctx, taskManager, ctx.config.api_server_port),
|
||||
initialized(false), apiServerStarted(false) {
|
||||
|
||||
// Rebuild labels from constructor + config labels (config takes precedence)
|
||||
ctx.rebuildLabels();
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Execute main tasks
|
||||
taskManager.execute();
|
||||
|
||||
// Yield to allow other tasks to run
|
||||
yield();
|
||||
}
|
||||
|
||||
void Spore::registerService(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.registerService(*service);
|
||||
service->registerTasks(taskManager);
|
||||
LOG_INFO("Spore", "Added service '" + String(service->getName()) + "' to running API server and task manager");
|
||||
} else {
|
||||
LOG_INFO("Spore", "Registered service '" + String(service->getName()) + "' (will be added to API server when begin() is called)");
|
||||
}
|
||||
}
|
||||
|
||||
void Spore::registerService(Service* service) {
|
||||
if (!service) {
|
||||
LOG_WARN("Spore", "Attempted to add null service");
|
||||
return;
|
||||
}
|
||||
|
||||
// Wrap raw pointer in shared_ptr with no-op deleter to avoid double-delete
|
||||
registerService(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>();
|
||||
|
||||
// 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 and task manager
|
||||
for (auto& service : services) {
|
||||
if (service) {
|
||||
apiServer.registerService(*service);
|
||||
service->registerTasks(taskManager);
|
||||
LOG_INFO("Spore", "Added service '" + String(service->getName()) + "' to API server and task manager");
|
||||
}
|
||||
}
|
||||
|
||||
// Start the API server
|
||||
apiServer.begin();
|
||||
apiServerStarted = true;
|
||||
|
||||
LOG_INFO("Spore", "API server started");
|
||||
}
|
||||
194
src/spore/core/ApiServer.cpp
Normal file
194
src/spore/core/ApiServer.cpp
Normal file
@@ -0,0 +1,194 @@
|
||||
#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});
|
||||
|
||||
// Update cluster if needed
|
||||
String localIPStr = ctx.localIP.toString();
|
||||
auto member = ctx.memberList->getMember(localIPStr.c_str());
|
||||
if (member) {
|
||||
NodeInfo updatedNode = *member;
|
||||
updatedNode.endpoints.push_back(EndpointInfo{uri, method, params, serviceName, true});
|
||||
ctx.memberList->updateMember(localIPStr.c_str(), updatedNode);
|
||||
}
|
||||
}
|
||||
|
||||
void ApiServer::registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler) {
|
||||
// Get current service name if available
|
||||
String serviceName = "unknown";
|
||||
if (!services.empty()) {
|
||||
serviceName = services.back().get().getName();
|
||||
}
|
||||
registerEndpoint(uri, method, {}, serviceName);
|
||||
server.on(uri.c_str(), method, requestHandler);
|
||||
}
|
||||
|
||||
void ApiServer::registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
std::function<void(AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool)> uploadHandler) {
|
||||
// Get current service name if available
|
||||
String serviceName = "unknown";
|
||||
if (!services.empty()) {
|
||||
serviceName = services.back().get().getName();
|
||||
}
|
||||
registerEndpoint(uri, method, {}, serviceName);
|
||||
server.on(uri.c_str(), method, requestHandler, uploadHandler);
|
||||
}
|
||||
|
||||
// Overloads that also record minimal capability specs
|
||||
void ApiServer::registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
const std::vector<ParamSpec>& params) {
|
||||
// Get current service name if available
|
||||
String serviceName = "unknown";
|
||||
if (!services.empty()) {
|
||||
serviceName = services.back().get().getName();
|
||||
}
|
||||
registerEndpoint(uri, method, params, serviceName);
|
||||
server.on(uri.c_str(), method, requestHandler);
|
||||
}
|
||||
|
||||
void ApiServer::registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
std::function<void(AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool)> uploadHandler,
|
||||
const std::vector<ParamSpec>& params) {
|
||||
// Get current service name if available
|
||||
String serviceName = "unknown";
|
||||
if (!services.empty()) {
|
||||
serviceName = services.back().get().getName();
|
||||
}
|
||||
registerEndpoint(uri, method, params, serviceName);
|
||||
server.on(uri.c_str(), method, requestHandler, uploadHandler);
|
||||
}
|
||||
|
||||
void ApiServer::registerService(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() {
|
||||
// Setup streaming API (WebSocket)
|
||||
setupWebSocket();
|
||||
server.addHandler(&ws);
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
void ApiServer::setupWebSocket() {
|
||||
ws.onEvent([this](AsyncWebSocket* server, AsyncWebSocketClient* client, AwsEventType type, void* arg, uint8_t* data, size_t len) {
|
||||
if (type == WS_EVT_DATA) {
|
||||
AwsFrameInfo* info = (AwsFrameInfo*)arg;
|
||||
if (info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) {
|
||||
// Parse directly from the raw buffer with explicit length
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, (const char*)data, len);
|
||||
if (!err) {
|
||||
LOG_DEBUG("API", "Received event: " + String(doc["event"].as<String>()));
|
||||
String eventName = doc["event"].as<String>();
|
||||
String payloadStr;
|
||||
if (doc["payload"].is<const char*>()) {
|
||||
payloadStr = doc["payload"].as<const char*>();
|
||||
} else if (!doc["payload"].isNull()) {
|
||||
// If payload is an object/array, serialize it
|
||||
String tmp; serializeJson(doc["payload"], tmp); payloadStr = tmp;
|
||||
}
|
||||
// Allow empty payload; services may treat it as defaults
|
||||
if (eventName.length() > 0) {
|
||||
// Inject origin tag into payload JSON if possible
|
||||
String enriched = payloadStr;
|
||||
if (payloadStr.length() > 0) {
|
||||
JsonDocument pd;
|
||||
if (!deserializeJson(pd, payloadStr)) {
|
||||
pd["_origin"] = String("ws:") + String(client->id());
|
||||
String tmp; serializeJson(pd, tmp); enriched = tmp;
|
||||
} else {
|
||||
// If payload is plain string, leave as-is (no origin)
|
||||
}
|
||||
}
|
||||
std::string ev = eventName.c_str();
|
||||
ctx.fire(ev, &enriched);
|
||||
// Acknowledge
|
||||
client->text("{\"ok\":true}");
|
||||
} else {
|
||||
client->text("{\"error\":\"Missing 'event'\"}");
|
||||
}
|
||||
} else {
|
||||
client->text("{\"error\":\"Invalid JSON\"}");
|
||||
}
|
||||
}
|
||||
} else if (type == WS_EVT_CONNECT) {
|
||||
client->text("{\"hello\":\"ws connected\"}");
|
||||
wsClients.push_back(client);
|
||||
} else if (type == WS_EVT_DISCONNECT) {
|
||||
wsClients.erase(std::remove(wsClients.begin(), wsClients.end(), client), wsClients.end());
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to all local events and forward to websocket clients
|
||||
ctx.onAny([this](const std::string& event, void* dataPtr) {
|
||||
// Ignore raw UDP frames
|
||||
if (event == "udp/raw") {
|
||||
return;
|
||||
}
|
||||
|
||||
String* payloadStrPtr = static_cast<String*>(dataPtr);
|
||||
String payloadStr = payloadStrPtr ? *payloadStrPtr : String("");
|
||||
|
||||
// Extract and strip origin if present
|
||||
String origin;
|
||||
String cleanedPayload = payloadStr;
|
||||
if (payloadStr.length() > 0) {
|
||||
JsonDocument pd;
|
||||
if (!deserializeJson(pd, payloadStr)) {
|
||||
if (pd["_origin"].is<const char*>()) {
|
||||
origin = pd["_origin"].as<const char*>();
|
||||
pd.remove("_origin");
|
||||
String tmp; serializeJson(pd, tmp); cleanedPayload = tmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
JsonDocument outDoc;
|
||||
outDoc["event"] = event.c_str();
|
||||
outDoc["payload"] = cleanedPayload;
|
||||
String out; serializeJson(outDoc, out);
|
||||
|
||||
if (origin.startsWith("ws:")) {
|
||||
uint32_t originId = (uint32_t)origin.substring(3).toInt();
|
||||
for (auto* c : wsClients) {
|
||||
if (c && c->id() != originId) {
|
||||
c->text(out);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ws.textAll(out);
|
||||
}
|
||||
});
|
||||
}
|
||||
493
src/spore/core/ClusterManager.cpp
Normal file
493
src/spore/core/ClusterManager.cpp
Normal file
@@ -0,0 +1,493 @@
|
||||
#include "spore/core/ClusterManager.h"
|
||||
#include "spore/internal/Globals.h"
|
||||
#include "spore/util/Logging.h"
|
||||
#include "spore/types/NodeInfo.h"
|
||||
|
||||
ClusterManager::ClusterManager(NodeContext& ctx, TaskManager& taskMgr) : ctx(ctx), taskManager(taskMgr) {
|
||||
// Register callback for node/discovered event - this fires when network is ready
|
||||
ctx.on("node/discovered", [this](void* data) {
|
||||
NodeInfo* node = static_cast<NodeInfo*>(data);
|
||||
this->addOrUpdateNode(node->hostname, node->ip);
|
||||
});
|
||||
// Centralized broadcast handler: services fire 'cluster/broadcast' with cluster/event JSON payload
|
||||
ctx.on("cluster/broadcast", [this](void* data) {
|
||||
String* jsonStr = static_cast<String*>(data);
|
||||
if (!jsonStr) {
|
||||
LOG_WARN("Cluster", "cluster/broadcast called with null data");
|
||||
return;
|
||||
}
|
||||
// Subnet-directed broadcast (more reliable than 255.255.255.255 on some networks)
|
||||
IPAddress ip = WiFi.localIP();
|
||||
IPAddress mask = WiFi.subnetMask();
|
||||
IPAddress bcast(ip[0] | ~mask[0], ip[1] | ~mask[1], ip[2] | ~mask[2], ip[3] | ~mask[3]);
|
||||
LOG_DEBUG("Cluster", String("Broadcasting cluster/event to ") + bcast.toString() + " len=" + String(jsonStr->length()));
|
||||
this->ctx.udp->beginPacket(bcast, this->ctx.config.udp_port);
|
||||
String msg = String(ClusterProtocol::CLUSTER_EVENT_MSG) + ":" + *jsonStr;
|
||||
this->ctx.udp->write(msg.c_str());
|
||||
this->ctx.udp->endPacket();
|
||||
});
|
||||
|
||||
// Handler for node update broadcasts: services fire 'cluster/node/update' when their node info changes
|
||||
ctx.on("cluster/node/update", [this](void* data) {
|
||||
// Trigger immediate NODE_UPDATE broadcast when node info changes
|
||||
broadcastNodeUpdate();
|
||||
});
|
||||
|
||||
// Handler for memberlist changes: print memberlist when it changes
|
||||
ctx.on("cluster/memberlist/changed", [this](void* data) {
|
||||
printMemberList();
|
||||
});
|
||||
// Register tasks
|
||||
registerTasks();
|
||||
initMessageHandlers();
|
||||
}
|
||||
|
||||
void ClusterManager::registerTasks() {
|
||||
taskManager.registerTask("cluster_listen", ctx.config.cluster_listen_interval_ms, [this]() { listen(); });
|
||||
taskManager.registerTask("status_update", ctx.config.status_update_interval_ms, [this]() { updateAllNodeStatuses(); removeDeadNodes(); });
|
||||
taskManager.registerTask("heartbeat", ctx.config.heartbeat_interval_ms, [this]() { heartbeatTaskCallback(); });
|
||||
LOG_INFO("ClusterManager", "Registered all cluster tasks");
|
||||
}
|
||||
|
||||
// Discovery functionality removed - using heartbeat-only approach
|
||||
|
||||
void ClusterManager::listen() {
|
||||
int packetSize = ctx.udp->parsePacket();
|
||||
if (!packetSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
char incoming[ClusterProtocol::UDP_BUF_SIZE];
|
||||
int len = ctx.udp->read(incoming, ClusterProtocol::UDP_BUF_SIZE);
|
||||
if (len <= 0) {
|
||||
return;
|
||||
}
|
||||
if (len >= (int)ClusterProtocol::UDP_BUF_SIZE) {
|
||||
incoming[ClusterProtocol::UDP_BUF_SIZE - 1] = 0;
|
||||
} else {
|
||||
incoming[len] = 0;
|
||||
}
|
||||
handleIncomingMessage(incoming);
|
||||
}
|
||||
|
||||
void ClusterManager::initMessageHandlers() {
|
||||
messageHandlers.clear();
|
||||
messageHandlers.push_back({ &ClusterManager::isRawMsg, [this](const char* msg){ this->onRawMessage(msg); }, "RAW" });
|
||||
messageHandlers.push_back({ &ClusterManager::isHeartbeatMsg, [this](const char* msg){ this->onHeartbeat(msg); }, "HEARTBEAT" });
|
||||
messageHandlers.push_back({ &ClusterManager::isNodeUpdateMsg, [this](const char* msg){ this->onNodeUpdate(msg); }, "NODE_UPDATE" });
|
||||
messageHandlers.push_back({ &ClusterManager::isClusterEventMsg, [this](const char* msg){ this->onClusterEvent(msg); }, "CLUSTER_EVENT" });
|
||||
}
|
||||
|
||||
void ClusterManager::handleIncomingMessage(const char* incoming) {
|
||||
for (const auto& h : messageHandlers) {
|
||||
if (h.predicate(incoming)) {
|
||||
h.handle(incoming);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Unknown message - log first token
|
||||
const char* colon = strchr(incoming, ':');
|
||||
String head;
|
||||
if (colon) {
|
||||
head = String(incoming).substring(0, colon - incoming);
|
||||
} else {
|
||||
head = String(incoming);
|
||||
}
|
||||
LOG_DEBUG("Cluster", String("Unknown cluster message: ") + head);
|
||||
}
|
||||
|
||||
bool ClusterManager::isHeartbeatMsg(const char* msg) {
|
||||
return strncmp(msg, ClusterProtocol::HEARTBEAT_MSG, strlen(ClusterProtocol::HEARTBEAT_MSG)) == 0;
|
||||
}
|
||||
|
||||
bool ClusterManager::isNodeUpdateMsg(const char* msg) {
|
||||
return strncmp(msg, ClusterProtocol::NODE_UPDATE_MSG, strlen(ClusterProtocol::NODE_UPDATE_MSG)) == 0;
|
||||
}
|
||||
|
||||
bool ClusterManager::isClusterEventMsg(const char* msg) {
|
||||
return strncmp(msg, ClusterProtocol::CLUSTER_EVENT_MSG, strlen(ClusterProtocol::CLUSTER_EVENT_MSG)) == 0;
|
||||
}
|
||||
|
||||
bool ClusterManager::isRawMsg(const char* msg) {
|
||||
// RAW frames must be "RAW:<payload>"; enforce the delimiter so we skip things like "RAW_HEARTBEAT".
|
||||
const std::size_t prefixLen = strlen(ClusterProtocol::RAW_MSG);
|
||||
if (strncmp(msg, ClusterProtocol::RAW_MSG, prefixLen) != 0) {
|
||||
return false;
|
||||
}
|
||||
return msg[prefixLen] == ':';
|
||||
}
|
||||
|
||||
// Discovery functionality removed - using heartbeat-only approach
|
||||
|
||||
void ClusterManager::onHeartbeat(const char* msg) {
|
||||
// Extract hostname from heartbeat message: "cluster/heartbeat:hostname"
|
||||
const char* colon = strchr(msg, ':');
|
||||
if (!colon) {
|
||||
LOG_WARN("Cluster", "Invalid heartbeat message format");
|
||||
return;
|
||||
}
|
||||
|
||||
String hostname = String(colon + 1);
|
||||
IPAddress senderIP = ctx.udp->remoteIP();
|
||||
|
||||
// Update memberlist with the heartbeat
|
||||
addOrUpdateNode(hostname, senderIP);
|
||||
|
||||
// Respond with minimal node info (hostname, ip, uptime, labels)
|
||||
sendNodeInfo(hostname, senderIP);
|
||||
}
|
||||
|
||||
void ClusterManager::onNodeUpdate(const char* msg) {
|
||||
// Message format: "node/update:hostname:{json}"
|
||||
const char* firstColon = strchr(msg, ':');
|
||||
if (!firstColon) {
|
||||
LOG_WARN("Cluster", "Invalid NODE_UPDATE message format");
|
||||
return;
|
||||
}
|
||||
|
||||
const char* secondColon = strchr(firstColon + 1, ':');
|
||||
if (!secondColon) {
|
||||
LOG_WARN("Cluster", "Invalid NODE_UPDATE message format");
|
||||
return;
|
||||
}
|
||||
|
||||
String hostnamePart = String(firstColon + 1);
|
||||
String hostname = hostnamePart.substring(0, secondColon - firstColon - 1);
|
||||
const char* jsonCStr = secondColon + 1;
|
||||
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, jsonCStr);
|
||||
if (err) {
|
||||
LOG_WARN("Cluster", String("Failed to parse NODE_UPDATE JSON from ") + ctx.udp->remoteIP().toString());
|
||||
return;
|
||||
}
|
||||
|
||||
// The NODE_UPDATE contains info about the target node (hostname from message)
|
||||
// but is sent FROM the responding node (ctx.udp->remoteIP())
|
||||
// We need to find the responding node in the memberlist, not the target node
|
||||
IPAddress respondingNodeIP = ctx.udp->remoteIP();
|
||||
String respondingIPStr = respondingNodeIP.toString();
|
||||
|
||||
// Find the responding node by IP address
|
||||
auto respondingMember = ctx.memberList->getMember(respondingIPStr.c_str());
|
||||
if (!respondingMember) {
|
||||
LOG_WARN("Cluster", String("Received NODE_UPDATE from unknown node: ") + respondingNodeIP.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate latency only if we recently sent a heartbeat (within last 1 second)
|
||||
unsigned long latency = 0;
|
||||
unsigned long now = millis();
|
||||
if (lastHeartbeatSentAt != 0 && (now - lastHeartbeatSentAt) < 1000) { // 1 second window
|
||||
latency = now - lastHeartbeatSentAt;
|
||||
lastHeartbeatSentAt = 0; // Reset for next calculation
|
||||
}
|
||||
|
||||
// Create updated node info
|
||||
NodeInfo updatedNode = *respondingMember;
|
||||
bool hostnameChanged = false;
|
||||
bool labelsChanged = false;
|
||||
|
||||
// Update hostname if provided
|
||||
if (doc["hostname"].is<const char*>()) {
|
||||
String newHostname = doc["hostname"].as<const char*>();
|
||||
if (updatedNode.hostname != newHostname) {
|
||||
updatedNode.hostname = newHostname;
|
||||
hostnameChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Update uptime if provided
|
||||
if (doc["uptime"].is<unsigned long>()) {
|
||||
updatedNode.uptime = doc["uptime"];
|
||||
}
|
||||
|
||||
// Update labels if provided
|
||||
if (doc["labels"].is<JsonObject>()) {
|
||||
JsonObject labelsObj = doc["labels"].as<JsonObject>();
|
||||
std::map<String, String> newLabels;
|
||||
for (JsonPair kvp : labelsObj) {
|
||||
const char* key = kvp.key().c_str();
|
||||
const char* value = labelsObj[kvp.key()];
|
||||
newLabels[key] = String(value);
|
||||
}
|
||||
|
||||
// Compare with existing labels
|
||||
if (newLabels != updatedNode.labels) {
|
||||
labelsChanged = true;
|
||||
updatedNode.labels = newLabels;
|
||||
}
|
||||
}
|
||||
|
||||
// Update timing and status
|
||||
updatedNode.lastSeen = now;
|
||||
updatedNode.status = NodeInfo::ACTIVE;
|
||||
|
||||
// Update latency if we calculated it (preserve existing value if not)
|
||||
if (latency > 0) {
|
||||
updatedNode.latency = latency;
|
||||
}
|
||||
|
||||
// Persist the updated node info to the memberlist
|
||||
ctx.memberList->updateMember(respondingIPStr.c_str(), updatedNode);
|
||||
|
||||
// Check if any fields changed that require broadcasting
|
||||
bool nodeInfoChanged = hostnameChanged || labelsChanged;
|
||||
|
||||
if (nodeInfoChanged) {
|
||||
// Fire cluster/node/update event to trigger broadcast
|
||||
ctx.fire("cluster/node/update", nullptr);
|
||||
}
|
||||
|
||||
LOG_DEBUG("Cluster", String("Updated responding node ") + updatedNode.hostname + " @ " + respondingNodeIP.toString() +
|
||||
" | hostname: " + (hostnameChanged ? "changed" : "unchanged") +
|
||||
" | labels: " + (labelsChanged ? "changed" : "unchanged") +
|
||||
" | latency: " + (latency > 0 ? String(latency) + "ms" : "not calculated"));
|
||||
}
|
||||
|
||||
void ClusterManager::sendNodeInfo(const String& targetHostname, const IPAddress& targetIP) {
|
||||
JsonDocument doc;
|
||||
|
||||
// Get our node info for the response (we're the responding node)
|
||||
String localIPStr = ctx.localIP.toString();
|
||||
auto member = ctx.memberList->getMember(localIPStr.c_str());
|
||||
if (member) {
|
||||
const NodeInfo& node = *member;
|
||||
|
||||
// Response contains info about ourselves (the responding node)
|
||||
doc["hostname"] = node.hostname;
|
||||
doc["ip"] = node.ip.toString();
|
||||
doc["uptime"] = node.uptime;
|
||||
|
||||
// Add labels if present
|
||||
if (!node.labels.empty()) {
|
||||
JsonObject labelsObj = doc["labels"].to<JsonObject>();
|
||||
for (const auto& kv : node.labels) {
|
||||
labelsObj[kv.first.c_str()] = kv.second;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to basic info if not in memberlist
|
||||
doc["hostname"] = ctx.hostname;
|
||||
doc["ip"] = ctx.localIP.toString();
|
||||
doc["uptime"] = millis();
|
||||
}
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
|
||||
// Send NODE_UPDATE:targetHostname:{json about responding node}
|
||||
ctx.udp->beginPacket(targetIP, ctx.config.udp_port);
|
||||
String msg = String(ClusterProtocol::NODE_UPDATE_MSG) + ":" + targetHostname + ":" + json;
|
||||
ctx.udp->write(msg.c_str());
|
||||
ctx.udp->endPacket();
|
||||
|
||||
LOG_DEBUG("Cluster", String("Sent NODE_UPDATE response to ") + targetHostname + " @ " + targetIP.toString());
|
||||
}
|
||||
|
||||
void ClusterManager::onClusterEvent(const char* msg) {
|
||||
// Message format: cluster/event:{"event":"...","data":"<json string>"}
|
||||
const char* jsonStart = msg + strlen(ClusterProtocol::CLUSTER_EVENT_MSG) + 1; // skip prefix and ':'
|
||||
if (*jsonStart == '\0') {
|
||||
LOG_DEBUG("Cluster", "cluster/event received with empty payload");
|
||||
return;
|
||||
}
|
||||
LOG_DEBUG("Cluster", String("cluster/event raw from ") + ctx.udp->remoteIP().toString() + " len=" + String(strlen(jsonStart)));
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, jsonStart);
|
||||
if (err) {
|
||||
LOG_ERROR("Cluster", String("Failed to parse cluster/event JSON from ") + ctx.udp->remoteIP().toString());
|
||||
return;
|
||||
}
|
||||
// Robust extraction of event and data
|
||||
String eventStr;
|
||||
if (doc["event"].is<const char*>()) {
|
||||
eventStr = doc["event"].as<const char*>();
|
||||
} else if (doc["event"].is<String>()) {
|
||||
eventStr = doc["event"].as<String>();
|
||||
}
|
||||
|
||||
String data;
|
||||
if (doc["data"].is<const char*>()) {
|
||||
data = doc["data"].as<const char*>();
|
||||
} else if (doc["data"].is<JsonVariantConst>()) {
|
||||
// If data is a nested JSON object/array, serialize it back to string
|
||||
String tmp;
|
||||
serializeJson(doc["data"], tmp);
|
||||
data = tmp;
|
||||
}
|
||||
|
||||
if (eventStr.length() == 0 || data.length() == 0) {
|
||||
String dbg;
|
||||
serializeJson(doc, dbg);
|
||||
LOG_WARN("Cluster", String("cluster/event missing 'event' or 'data' | payload=") + dbg);
|
||||
return;
|
||||
}
|
||||
|
||||
std::string eventKey(eventStr.c_str());
|
||||
LOG_DEBUG("Cluster", String("Firing event '") + eventStr + "' with dataLen=" + String(data.length()));
|
||||
ctx.fire(eventKey, &data);
|
||||
}
|
||||
|
||||
void ClusterManager::onRawMessage(const char* msg) {
|
||||
const std::size_t prefixLen = strlen(ClusterProtocol::RAW_MSG);
|
||||
if (msg[prefixLen] != ':') {
|
||||
LOG_WARN("Cluster", "RAW message received without payload delimiter");
|
||||
return;
|
||||
}
|
||||
|
||||
const char* payloadStart = msg + prefixLen + 1;
|
||||
if (*payloadStart == '\0') {
|
||||
LOG_WARN("Cluster", "RAW message received with empty payload");
|
||||
return;
|
||||
}
|
||||
|
||||
String payload(payloadStart);
|
||||
ctx.fire("udp/raw", &payload);
|
||||
}
|
||||
|
||||
void ClusterManager::addOrUpdateNode(const String& nodeHost, IPAddress nodeIP) {
|
||||
bool memberlistChanged = false;
|
||||
String ipStr = nodeIP.toString();
|
||||
|
||||
// Check if member exists
|
||||
auto existingMember = ctx.memberList->getMember(ipStr.c_str());
|
||||
if (existingMember) {
|
||||
// Update existing node - preserve all existing field values
|
||||
NodeInfo updatedNode = *existingMember;
|
||||
if (updatedNode.ip != nodeIP) {
|
||||
updatedNode.ip = nodeIP;
|
||||
memberlistChanged = true;
|
||||
}
|
||||
updatedNode.lastSeen = millis();
|
||||
ctx.memberList->updateMember(ipStr.c_str(), updatedNode);
|
||||
} else {
|
||||
// Add new node
|
||||
NodeInfo newNode;
|
||||
newNode.hostname = nodeHost;
|
||||
newNode.ip = nodeIP;
|
||||
newNode.lastSeen = millis();
|
||||
updateNodeStatus(newNode, newNode.lastSeen, ctx.config.node_inactive_threshold_ms, ctx.config.node_dead_threshold_ms);
|
||||
|
||||
// Initialize static resources if this is the local node being added for the first time
|
||||
if (nodeIP == ctx.localIP && nodeHost == ctx.hostname) {
|
||||
newNode.resources.chipId = ESP.getChipId();
|
||||
newNode.resources.sdkVersion = String(ESP.getSdkVersion());
|
||||
newNode.resources.cpuFreqMHz = ESP.getCpuFreqMHz();
|
||||
newNode.resources.flashChipSize = ESP.getFlashChipSize();
|
||||
LOG_DEBUG("Cluster", "Initialized static resources for local node");
|
||||
}
|
||||
|
||||
ctx.memberList->addMember(ipStr.c_str(), newNode);
|
||||
memberlistChanged = true;
|
||||
LOG_INFO("Cluster", "Added node: " + nodeHost + " @ " + newNode.ip.toString() + " | Status: " + statusToStr(newNode.status) + " | last update: 0");
|
||||
}
|
||||
|
||||
// Fire event if memberlist changed
|
||||
if (memberlistChanged) {
|
||||
ctx.fire("cluster/memberlist/changed", nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
void ClusterManager::heartbeatTaskCallback() {
|
||||
// Update local node resources and lastSeen since we're actively sending heartbeats
|
||||
String localIPStr = ctx.localIP.toString();
|
||||
auto member = ctx.memberList->getMember(localIPStr.c_str());
|
||||
if (member) {
|
||||
NodeInfo node = *member;
|
||||
updateLocalNodeResources(node);
|
||||
node.lastSeen = millis(); // Update lastSeen since we're actively participating
|
||||
ctx.memberList->updateMember(localIPStr.c_str(), node);
|
||||
}
|
||||
|
||||
// Broadcast heartbeat - peers will respond with NODE_UPDATE
|
||||
lastHeartbeatSentAt = millis();
|
||||
ctx.udp->beginPacket("255.255.255.255", ctx.config.udp_port);
|
||||
String hb = String(ClusterProtocol::HEARTBEAT_MSG) + ":" + ctx.hostname;
|
||||
ctx.udp->write(hb.c_str());
|
||||
ctx.udp->endPacket();
|
||||
|
||||
LOG_DEBUG("Cluster", String("Sent heartbeat: ") + ctx.hostname);
|
||||
}
|
||||
|
||||
void ClusterManager::updateAllMembersInfoTaskCallback() {
|
||||
// HTTP-based member info fetching disabled; node info is provided via UDP responses to heartbeats
|
||||
// No-op to reduce network and memory usage
|
||||
}
|
||||
|
||||
void ClusterManager::broadcastNodeUpdate() {
|
||||
// Broadcast our current node info as NODE_UPDATE to all cluster members
|
||||
String localIPStr = ctx.localIP.toString();
|
||||
auto member = ctx.memberList->getMember(localIPStr.c_str());
|
||||
if (!member) {
|
||||
return;
|
||||
}
|
||||
|
||||
const NodeInfo& node = *member;
|
||||
|
||||
JsonDocument doc;
|
||||
doc["hostname"] = node.hostname;
|
||||
doc["uptime"] = node.uptime;
|
||||
|
||||
// Add labels if present
|
||||
if (!node.labels.empty()) {
|
||||
JsonObject labelsObj = doc["labels"].to<JsonObject>();
|
||||
for (const auto& kv : node.labels) {
|
||||
labelsObj[kv.first.c_str()] = kv.second;
|
||||
}
|
||||
}
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
|
||||
// Broadcast to all cluster members
|
||||
ctx.udp->beginPacket("255.255.255.255", ctx.config.udp_port);
|
||||
String msg = String(ClusterProtocol::NODE_UPDATE_MSG) + ":" + ctx.hostname + ":" + json;
|
||||
ctx.udp->write(msg.c_str());
|
||||
ctx.udp->endPacket();
|
||||
|
||||
LOG_DEBUG("Cluster", String("Broadcasted NODE_UPDATE for ") + ctx.hostname);
|
||||
}
|
||||
|
||||
void ClusterManager::updateAllNodeStatuses() {
|
||||
unsigned long now = millis();
|
||||
ctx.memberList->updateAllNodeStatuses(now, ctx.config.node_inactive_threshold_ms, ctx.config.node_dead_threshold_ms);
|
||||
}
|
||||
|
||||
void ClusterManager::removeDeadNodes() {
|
||||
size_t removedCount = ctx.memberList->removeDeadMembers();
|
||||
if (removedCount > 0) {
|
||||
LOG_INFO("Cluster", String("Removed ") + removedCount + " dead nodes");
|
||||
ctx.fire("cluster/memberlist/changed", nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
void ClusterManager::printMemberList() {
|
||||
size_t count = ctx.memberList->getMemberCount();
|
||||
if (count == 0) {
|
||||
LOG_INFO("Cluster", "Member List: empty");
|
||||
return;
|
||||
}
|
||||
LOG_INFO("Cluster", "Member List:");
|
||||
ctx.memberList->forEachMember([](const std::string& ip, const NodeInfo& node) {
|
||||
LOG_INFO("Cluster", " " + node.hostname + " @ " + node.ip.toString() + " | Status: " + statusToStr(node.status) + " | last seen: " + String(millis() - node.lastSeen));
|
||||
});
|
||||
}
|
||||
|
||||
void ClusterManager::updateLocalNodeResources(NodeInfo& node) {
|
||||
// Update node status and timing
|
||||
node.lastSeen = millis();
|
||||
node.status = NodeInfo::ACTIVE;
|
||||
node.uptime = millis();
|
||||
|
||||
// Update dynamic resources (always updated)
|
||||
uint32_t freeHeap = ESP.getFreeHeap();
|
||||
node.resources.freeHeap = freeHeap;
|
||||
|
||||
// Log memory warnings if heap is getting low
|
||||
if (freeHeap < ctx.config.low_memory_threshold_bytes) {
|
||||
LOG_WARN("Cluster", "Low memory warning: " + String(freeHeap) + " bytes free");
|
||||
} else if (freeHeap < ctx.config.critical_memory_threshold_bytes) {
|
||||
LOG_ERROR("Cluster", "Critical memory warning: " + String(freeHeap) + " bytes free");
|
||||
}
|
||||
}
|
||||
114
src/spore/core/Memberlist.cpp
Normal file
114
src/spore/core/Memberlist.cpp
Normal file
@@ -0,0 +1,114 @@
|
||||
#include "spore/core/Memberlist.h"
|
||||
#include <algorithm>
|
||||
|
||||
Memberlist::Memberlist() = default;
|
||||
|
||||
Memberlist::~Memberlist() = default;
|
||||
|
||||
bool Memberlist::addOrUpdateMember(const std::string& ip, const NodeInfo& node) {
|
||||
auto it = m_members.find(ip);
|
||||
if (it != m_members.end()) {
|
||||
// Update existing member
|
||||
it->second = node;
|
||||
it->second.lastSeen = millis(); // Update last seen time
|
||||
return true;
|
||||
} else {
|
||||
// Add new member
|
||||
NodeInfo newNode = node;
|
||||
newNode.lastSeen = millis();
|
||||
m_members[ip] = newNode;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
bool Memberlist::addMember(const std::string& ip, const NodeInfo& node) {
|
||||
if (m_members.find(ip) != m_members.end()) {
|
||||
return false; // Member already exists
|
||||
}
|
||||
NodeInfo newNode = node;
|
||||
newNode.lastSeen = millis();
|
||||
m_members[ip] = newNode;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Memberlist::updateMember(const std::string& ip, const NodeInfo& node) {
|
||||
auto it = m_members.find(ip);
|
||||
if (it == m_members.end()) {
|
||||
return false; // Member doesn't exist
|
||||
}
|
||||
it->second = node;
|
||||
it->second.lastSeen = millis(); // Update last seen time
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Memberlist::removeMember(const std::string& ip) {
|
||||
auto it = m_members.find(ip);
|
||||
if (it == m_members.end()) {
|
||||
return false; // Member doesn't exist
|
||||
}
|
||||
m_members.erase(it);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::optional<NodeInfo> Memberlist::getMember(const std::string& ip) const {
|
||||
auto it = m_members.find(ip);
|
||||
if (it != m_members.end()) {
|
||||
return it->second;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void Memberlist::forEachMember(std::function<void(const std::string&, const NodeInfo&)> callback) const {
|
||||
for (const auto& pair : m_members) {
|
||||
callback(pair.first, pair.second);
|
||||
}
|
||||
}
|
||||
|
||||
bool Memberlist::forEachMemberUntil(std::function<bool(const std::string&, const NodeInfo&)> callback) const {
|
||||
for (const auto& pair : m_members) {
|
||||
if (!callback(pair.first, pair.second)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t Memberlist::getMemberCount() const {
|
||||
return m_members.size();
|
||||
}
|
||||
|
||||
void Memberlist::updateAllNodeStatuses(unsigned long currentTime,
|
||||
unsigned long staleThresholdMs,
|
||||
unsigned long deadThresholdMs,
|
||||
std::function<void(const std::string&, NodeInfo::Status, NodeInfo::Status)> onStatusChange) {
|
||||
for (auto& [ip, node] : m_members) {
|
||||
NodeInfo::Status oldStatus = node.status;
|
||||
updateNodeStatus(node, currentTime, staleThresholdMs, deadThresholdMs);
|
||||
|
||||
if (oldStatus != node.status && onStatusChange) {
|
||||
onStatusChange(ip, oldStatus, node.status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
size_t Memberlist::removeDeadMembers() {
|
||||
size_t removedCount = 0;
|
||||
auto it = m_members.begin();
|
||||
while (it != m_members.end()) {
|
||||
if (it->second.status == NodeInfo::Status::DEAD) {
|
||||
it = m_members.erase(it);
|
||||
++removedCount;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
bool Memberlist::hasMember(const std::string& ip) const {
|
||||
return m_members.find(ip) != m_members.end();
|
||||
}
|
||||
|
||||
void Memberlist::clear() {
|
||||
m_members.clear();
|
||||
}
|
||||
127
src/spore/core/NetworkManager.cpp
Normal file
127
src/spore/core/NetworkManager.cpp
Normal file
@@ -0,0 +1,127 @@
|
||||
#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;
|
||||
}
|
||||
|
||||
bool NetworkManager::saveConfig() {
|
||||
return ctx.config.saveToFile();
|
||||
}
|
||||
|
||||
void NetworkManager::restartNode() {
|
||||
LOG_INFO("NetworkManager", "Restarting node after WiFi configuration change...");
|
||||
delay(100); // Give time for response to be sent
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
void NetworkManager::setHostnameFromMac() {
|
||||
uint8_t mac[6];
|
||||
WiFi.macAddress(mac);
|
||||
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
|
||||
String localIPStr = ctx.localIP.toString();
|
||||
ctx.memberList->addOrUpdateMember(localIPStr.c_str(), ctx.self);
|
||||
|
||||
// Notify listeners that the node is (re)discovered
|
||||
ctx.fire("node/discovered", &ctx.self);
|
||||
}
|
||||
55
src/spore/core/NodeContext.cpp
Normal file
55
src/spore/core/NodeContext.cpp
Normal file
@@ -0,0 +1,55 @@
|
||||
#include "spore/core/NodeContext.h"
|
||||
|
||||
NodeContext::NodeContext() {
|
||||
udp = new WiFiUDP();
|
||||
memberList = std::make_unique<Memberlist>();
|
||||
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) {
|
||||
constructorLabels[kv.first] = kv.second;
|
||||
self.labels[kv.first] = kv.second;
|
||||
}
|
||||
}
|
||||
|
||||
NodeContext::~NodeContext() {
|
||||
delete udp;
|
||||
// memberList is a unique_ptr, so no need to delete manually
|
||||
}
|
||||
|
||||
void NodeContext::on(const std::string& event, EventCallback cb) {
|
||||
eventRegistry[event].push_back(cb);
|
||||
}
|
||||
|
||||
void NodeContext::fire(const std::string& event, void* data) {
|
||||
for (auto& cb : eventRegistry[event]) {
|
||||
cb(data);
|
||||
}
|
||||
for (auto& acb : anyEventSubscribers) {
|
||||
acb(event, data);
|
||||
}
|
||||
}
|
||||
|
||||
void NodeContext::onAny(AnyEventCallback cb) {
|
||||
anyEventSubscribers.push_back(cb);
|
||||
}
|
||||
|
||||
void NodeContext::rebuildLabels() {
|
||||
// Clear current labels
|
||||
self.labels.clear();
|
||||
|
||||
// Add constructor labels first
|
||||
for (const auto& kv : constructorLabels) {
|
||||
self.labels[kv.first] = kv.second;
|
||||
}
|
||||
|
||||
// Add config labels (these override constructor labels if same key)
|
||||
for (const auto& kv : config.labels) {
|
||||
self.labels[kv.first] = kv.second;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
63
src/spore/services/ClusterService.cpp
Normal file
63
src/spore/services/ClusterService.cpp
Normal file
@@ -0,0 +1,63 @@
|
||||
#include "spore/services/ClusterService.h"
|
||||
#include "spore/core/ApiServer.h"
|
||||
|
||||
ClusterService::ClusterService(NodeContext& ctx) : ctx(ctx) {}
|
||||
|
||||
void ClusterService::registerEndpoints(ApiServer& api) {
|
||||
api.registerEndpoint("/api/cluster/members", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleMembersRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
// Generic cluster broadcast endpoint
|
||||
api.registerEndpoint("/api/cluster/event", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) {
|
||||
if (!request->hasParam("event", true) || !request->hasParam("payload", true)) {
|
||||
request->send(400, "application/json", "{\"error\":\"Missing 'event' or 'payload'\"}");
|
||||
return;
|
||||
}
|
||||
String eventName = request->getParam("event", true)->value();
|
||||
String payloadStr = request->getParam("payload", true)->value();
|
||||
JsonDocument envelope;
|
||||
envelope["event"] = eventName;
|
||||
envelope["data"] = payloadStr; // pass payload as JSON string
|
||||
String eventJson;
|
||||
serializeJson(envelope, eventJson);
|
||||
std::string ev = "cluster/broadcast";
|
||||
ctx.fire(ev, &eventJson);
|
||||
request->send(200, "application/json", "{\"ok\":true}");
|
||||
},
|
||||
std::vector<ParamSpec>{
|
||||
ParamSpec{String("event"), true, String("body"), String("string"), {}},
|
||||
ParamSpec{String("payload"), true, String("body"), String("string"), {}}
|
||||
});
|
||||
}
|
||||
|
||||
void ClusterService::registerTasks(TaskManager& taskManager) {
|
||||
// ClusterService doesn't register any tasks itself
|
||||
}
|
||||
|
||||
void ClusterService::handleMembersRequest(AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
JsonArray arr = doc["members"].to<JsonArray>();
|
||||
|
||||
ctx.memberList->forEachMember([&arr](const std::string& ip, const NodeInfo& node) {
|
||||
JsonObject obj = arr.add<JsonObject>();
|
||||
obj["hostname"] = node.hostname;
|
||||
obj["ip"] = node.ip.toString();
|
||||
obj["lastSeen"] = node.lastSeen;
|
||||
obj["latency"] = node.latency;
|
||||
obj["status"] = statusToStr(node.status);
|
||||
|
||||
// Add labels if present
|
||||
if (!node.labels.empty()) {
|
||||
JsonObject labelsObj = obj["labels"].to<JsonObject>();
|
||||
for (const auto& kv : node.labels) {
|
||||
labelsObj[kv.first.c_str()] = kv.second;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
request->send(200, "application/json", json);
|
||||
}
|
||||
99
src/spore/services/MonitoringService.cpp
Normal file
99
src/spore/services/MonitoringService.cpp
Normal file
@@ -0,0 +1,99 @@
|
||||
#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() {
|
||||
}
|
||||
|
||||
void MonitoringService::registerEndpoints(ApiServer& api) {
|
||||
api.registerEndpoint("/api/monitoring/resources", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleResourcesRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
}
|
||||
|
||||
void MonitoringService::registerTasks(TaskManager& taskManager) {
|
||||
// MonitoringService doesn't register any tasks itself
|
||||
}
|
||||
|
||||
MonitoringService::SystemResources MonitoringService::getSystemResources() const {
|
||||
SystemResources resources;
|
||||
|
||||
// CPU information - sending fixed value of 100
|
||||
resources.currentCpuUsage = 100.0f;
|
||||
resources.averageCpuUsage = 100.0f;
|
||||
resources.measurementCount = 0;
|
||||
resources.isMeasuring = false;
|
||||
|
||||
// Memory information - ESP8266 compatible
|
||||
resources.freeHeap = ESP.getFreeHeap();
|
||||
resources.totalHeap = 81920; // ESP8266 has ~80KB RAM
|
||||
|
||||
// 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["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["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);
|
||||
}
|
||||
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
150
src/spore/services/NetworkService.cpp
Normal file
150
src/spore/services/NetworkService.cpp
Normal file
@@ -0,0 +1,150 @@
|
||||
#include "spore/services/NetworkService.h"
|
||||
#include "spore/util/Logging.h"
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
NetworkService::NetworkService(NetworkManager& networkManager)
|
||||
: networkManager(networkManager) {}
|
||||
|
||||
void NetworkService::registerEndpoints(ApiServer& api) {
|
||||
// WiFi scanning endpoints
|
||||
api.registerEndpoint("/api/network/wifi/scan", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleWifiScanRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
api.registerEndpoint("/api/network/wifi/scan", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleGetWifiNetworks(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
// Network status and configuration endpoints
|
||||
api.registerEndpoint("/api/network/status", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleNetworkStatus(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
api.registerEndpoint("/api/network/wifi/config", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleSetWifiConfig(request); },
|
||||
std::vector<ParamSpec>{
|
||||
ParamSpec{String("ssid"), true, String("body"), String("string"), {}, String("")},
|
||||
ParamSpec{String("password"), true, String("body"), String("string"), {}, String("")},
|
||||
ParamSpec{String("connect_timeout_ms"), false, String("body"), String("number"), {}, String("10000")},
|
||||
ParamSpec{String("retry_delay_ms"), false, String("body"), String("number"), {}, String("500")}
|
||||
});
|
||||
}
|
||||
|
||||
void NetworkService::registerTasks(TaskManager& taskManager) {
|
||||
// NetworkService doesn't register any tasks itself
|
||||
}
|
||||
|
||||
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 in memory
|
||||
networkManager.setWiFiConfig(ssid, password, connect_timeout_ms, retry_delay_ms);
|
||||
|
||||
// Save configuration to persistent storage
|
||||
bool configSaved = networkManager.saveConfig();
|
||||
if (!configSaved) {
|
||||
LOG_WARN("NetworkService", "Failed to save WiFi configuration to persistent storage");
|
||||
}
|
||||
|
||||
// Prepare response
|
||||
JsonDocument doc;
|
||||
doc["status"] = "success";
|
||||
doc["message"] = "WiFi configuration updated and saved";
|
||||
doc["config_saved"] = configSaved;
|
||||
doc["restarting"] = true;
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
|
||||
// Send response before restarting
|
||||
AsyncWebServerResponse* response = request->beginResponse(200, "application/json", json);
|
||||
response->addHeader("Connection", "close");
|
||||
request->send(response);
|
||||
|
||||
// Restart the node to apply new WiFi settings
|
||||
request->onDisconnect([this]() {
|
||||
LOG_INFO("NetworkService", "Restarting node to apply WiFi configuration...");
|
||||
delay(100); // Give time for response to be sent
|
||||
networkManager.restartNode();
|
||||
});
|
||||
}
|
||||
292
src/spore/services/NodeService.cpp
Normal file
292
src/spore/services/NodeService.cpp
Normal file
@@ -0,0 +1,292 @@
|
||||
#include "spore/services/NodeService.h"
|
||||
#include "spore/core/ApiServer.h"
|
||||
#include "spore/util/Logging.h"
|
||||
|
||||
NodeService::NodeService(NodeContext& ctx, ApiServer& apiServer) : ctx(ctx), apiServer(apiServer) {}
|
||||
|
||||
void NodeService::registerEndpoints(ApiServer& api) {
|
||||
// Status endpoint
|
||||
api.registerEndpoint("/api/node/status", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
// Update endpoint with file upload
|
||||
api.registerEndpoint("/api/node/update", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleUpdateRequest(request); },
|
||||
[this](AsyncWebServerRequest* request, const String& filename, size_t index, uint8_t* data, size_t len, bool final) {
|
||||
handleUpdateUpload(request, filename, index, data, len, final);
|
||||
},
|
||||
std::vector<ParamSpec>{
|
||||
ParamSpec{String("firmware"), true, String("body"), String("file"), {}, String("")}
|
||||
});
|
||||
|
||||
// Restart endpoint
|
||||
api.registerEndpoint("/api/node/restart", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleRestartRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
// Endpoints endpoint
|
||||
api.registerEndpoint("/api/node/endpoints", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleEndpointsRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
// Config endpoint for setting labels
|
||||
api.registerEndpoint("/api/node/config", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleConfigRequest(request); },
|
||||
std::vector<ParamSpec>{
|
||||
ParamSpec{String("labels"), true, String("body"), String("json"), {}, String("")}
|
||||
});
|
||||
|
||||
// Config endpoint for getting node configuration (without WiFi password)
|
||||
api.registerEndpoint("/api/node/config", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleGetConfigRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
// Generic local event endpoint
|
||||
api.registerEndpoint("/api/node/event", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) {
|
||||
if (!request->hasParam("event", true) || !request->hasParam("payload", true)) {
|
||||
request->send(400, "application/json", "{\"error\":\"Missing 'event' or 'payload'\"}");
|
||||
return;
|
||||
}
|
||||
String eventName = request->getParam("event", true)->value();
|
||||
String payloadStr = request->getParam("payload", true)->value();
|
||||
std::string ev = eventName.c_str();
|
||||
ctx.fire(ev, &payloadStr);
|
||||
request->send(200, "application/json", "{\"ok\":true}");
|
||||
},
|
||||
std::vector<ParamSpec>{
|
||||
ParamSpec{String("event"), true, String("body"), String("string"), {}},
|
||||
ParamSpec{String("payload"), true, String("body"), String("string"), {}}
|
||||
});
|
||||
}
|
||||
|
||||
void NodeService::registerTasks(TaskManager& taskManager) {
|
||||
// NodeService doesn't register any tasks itself
|
||||
}
|
||||
|
||||
void NodeService::handleStatusRequest(AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
doc["freeHeap"] = ESP.getFreeHeap();
|
||||
doc["chipId"] = ESP.getChipId();
|
||||
doc["sdkVersion"] = ESP.getSdkVersion();
|
||||
doc["cpuFreqMHz"] = ESP.getCpuFreqMHz();
|
||||
doc["flashChipSize"] = ESP.getFlashChipSize();
|
||||
|
||||
// Include local node labels if present
|
||||
auto member = ctx.memberList->getMember(ctx.hostname.c_str());
|
||||
if (member) {
|
||||
JsonObject labelsObj = doc["labels"].to<JsonObject>();
|
||||
for (const auto& kv : member->labels) {
|
||||
labelsObj[kv.first.c_str()] = kv.second;
|
||||
}
|
||||
} else if (!ctx.self.labels.empty()) {
|
||||
JsonObject labelsObj = doc["labels"].to<JsonObject>();
|
||||
for (const auto& kv : ctx.self.labels) {
|
||||
labelsObj[kv.first.c_str()] = kv.second;
|
||||
}
|
||||
}
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
request->send(200, "application/json", json);
|
||||
}
|
||||
|
||||
void NodeService::handleUpdateRequest(AsyncWebServerRequest* request) {
|
||||
bool success = !Update.hasError();
|
||||
AsyncWebServerResponse* response = request->beginResponse(200, "application/json",
|
||||
success ? "{\"status\": \"OK\"}" : "{\"status\": \"FAIL\"}");
|
||||
response->addHeader("Connection", "close");
|
||||
request->send(response);
|
||||
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\", \"message\": \"Update failed: not enough space\"}");
|
||||
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) {
|
||||
AsyncWebServerResponse* response = request->beginResponse(200, "application/json",
|
||||
"{\"status\": \"restarting\"}");
|
||||
response->addHeader("Connection", "close");
|
||||
request->send(response);
|
||||
request->onDisconnect([this]() {
|
||||
LOG_INFO("API", "Restart device");
|
||||
delay(10);
|
||||
ESP.restart();
|
||||
});
|
||||
}
|
||||
|
||||
void NodeService::handleEndpointsRequest(AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
JsonArray endpointsArr = doc["endpoints"].to<JsonArray>();
|
||||
|
||||
// Add all registered endpoints from ApiServer
|
||||
for (const auto& endpoint : apiServer.getEndpoints()) {
|
||||
JsonObject obj = endpointsArr.add<JsonObject>();
|
||||
obj["uri"] = endpoint.uri;
|
||||
obj["method"] = ApiServer::methodToStr(endpoint.method);
|
||||
if (!endpoint.params.empty()) {
|
||||
JsonArray paramsArr = obj["params"].to<JsonArray>();
|
||||
for (const auto& ps : endpoint.params) {
|
||||
JsonObject p = paramsArr.add<JsonObject>();
|
||||
p["name"] = ps.name;
|
||||
p["location"] = ps.location;
|
||||
p["required"] = ps.required;
|
||||
p["type"] = ps.type;
|
||||
if (!ps.values.empty()) {
|
||||
JsonArray allowed = p["values"].to<JsonArray>();
|
||||
for (const auto& v : ps.values) {
|
||||
allowed.add(v);
|
||||
}
|
||||
}
|
||||
if (ps.defaultValue.length() > 0) {
|
||||
p["default"] = ps.defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
request->send(200, "application/json", json);
|
||||
}
|
||||
|
||||
void NodeService::handleConfigRequest(AsyncWebServerRequest* request) {
|
||||
if (!request->hasParam("labels", true)) {
|
||||
request->send(400, "application/json", "{\"error\":\"Missing 'labels' parameter\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
String labelsJson = request->getParam("labels", true)->value();
|
||||
|
||||
// Parse the JSON
|
||||
JsonDocument doc;
|
||||
DeserializationError error = deserializeJson(doc, labelsJson);
|
||||
if (error) {
|
||||
request->send(400, "application/json", "{\"error\":\"Invalid JSON format: " + String(error.c_str()) + "\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Update config labels
|
||||
ctx.config.labels.clear();
|
||||
if (doc.is<JsonObject>()) {
|
||||
JsonObject labelsObj = doc.as<JsonObject>();
|
||||
for (JsonPair kv : labelsObj) {
|
||||
ctx.config.labels[kv.key().c_str()] = kv.value().as<String>();
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild self.labels from constructor + config labels
|
||||
ctx.rebuildLabels();
|
||||
|
||||
// Update the member list entry for the local node if it exists
|
||||
String localIPStr = ctx.localIP.toString();
|
||||
auto member = ctx.memberList->getMember(localIPStr.c_str());
|
||||
if (member) {
|
||||
// Update the labels in the member list entry
|
||||
NodeInfo updatedNode = *member;
|
||||
updatedNode.labels.clear();
|
||||
for (const auto& kv : ctx.self.labels) {
|
||||
updatedNode.labels[kv.first] = kv.second;
|
||||
}
|
||||
ctx.memberList->updateMember(localIPStr.c_str(), updatedNode);
|
||||
}
|
||||
|
||||
// Save config to file
|
||||
if (ctx.config.saveToFile()) {
|
||||
LOG_INFO("NodeService", "Labels updated and saved to config");
|
||||
request->send(200, "application/json", "{\"status\":\"success\",\"message\":\"Labels updated and saved\"}");
|
||||
} else {
|
||||
LOG_ERROR("NodeService", "Failed to save labels to config file");
|
||||
request->send(500, "application/json", "{\"error\":\"Failed to save configuration\"}");
|
||||
}
|
||||
}
|
||||
|
||||
void NodeService::handleGetConfigRequest(AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
|
||||
// WiFi Configuration (excluding password for security)
|
||||
JsonObject wifiObj = doc["wifi"].to<JsonObject>();
|
||||
wifiObj["ssid"] = ctx.config.wifi_ssid;
|
||||
wifiObj["connect_timeout_ms"] = ctx.config.wifi_connect_timeout_ms;
|
||||
wifiObj["retry_delay_ms"] = ctx.config.wifi_retry_delay_ms;
|
||||
|
||||
// Network Configuration
|
||||
JsonObject networkObj = doc["network"].to<JsonObject>();
|
||||
networkObj["udp_port"] = ctx.config.udp_port;
|
||||
networkObj["api_server_port"] = ctx.config.api_server_port;
|
||||
|
||||
// Cluster Configuration
|
||||
JsonObject clusterObj = doc["cluster"].to<JsonObject>();
|
||||
clusterObj["heartbeat_interval_ms"] = ctx.config.heartbeat_interval_ms;
|
||||
clusterObj["cluster_listen_interval_ms"] = ctx.config.cluster_listen_interval_ms;
|
||||
clusterObj["status_update_interval_ms"] = ctx.config.status_update_interval_ms;
|
||||
|
||||
// Node Status Thresholds
|
||||
JsonObject thresholdsObj = doc["thresholds"].to<JsonObject>();
|
||||
thresholdsObj["node_active_threshold_ms"] = ctx.config.node_active_threshold_ms;
|
||||
thresholdsObj["node_inactive_threshold_ms"] = ctx.config.node_inactive_threshold_ms;
|
||||
thresholdsObj["node_dead_threshold_ms"] = ctx.config.node_dead_threshold_ms;
|
||||
|
||||
// System Configuration
|
||||
JsonObject systemObj = doc["system"].to<JsonObject>();
|
||||
systemObj["restart_delay_ms"] = ctx.config.restart_delay_ms;
|
||||
systemObj["json_doc_size"] = ctx.config.json_doc_size;
|
||||
|
||||
// Memory Management
|
||||
JsonObject memoryObj = doc["memory"].to<JsonObject>();
|
||||
memoryObj["low_memory_threshold_bytes"] = ctx.config.low_memory_threshold_bytes;
|
||||
memoryObj["critical_memory_threshold_bytes"] = ctx.config.critical_memory_threshold_bytes;
|
||||
memoryObj["max_concurrent_http_requests"] = ctx.config.max_concurrent_http_requests;
|
||||
|
||||
// Custom Labels
|
||||
if (!ctx.config.labels.empty()) {
|
||||
JsonObject labelsObj = doc["labels"].to<JsonObject>();
|
||||
for (const auto& kv : ctx.config.labels) {
|
||||
labelsObj[kv.first.c_str()] = kv.second;
|
||||
}
|
||||
}
|
||||
|
||||
// Add metadata
|
||||
doc["version"] = "1.0";
|
||||
doc["retrieved_at"] = millis();
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
request->send(200, "application/json", json);
|
||||
}
|
||||
26
src/spore/services/StaticFileService.cpp
Normal file
26
src/spore/services/StaticFileService.cpp
Normal file
@@ -0,0 +1,26 @@
|
||||
#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");
|
||||
}
|
||||
|
||||
void StaticFileService::registerTasks(TaskManager& taskManager) {
|
||||
// StaticFileService doesn't register any tasks itself
|
||||
}
|
||||
|
||||
141
src/spore/services/TaskService.cpp
Normal file
141
src/spore/services/TaskService.cpp
Normal file
@@ -0,0 +1,141 @@
|
||||
#include "spore/services/TaskService.h"
|
||||
#include "spore/core/ApiServer.h"
|
||||
#include <algorithm>
|
||||
|
||||
TaskService::TaskService(TaskManager& taskManager) : taskManager(taskManager) {}
|
||||
|
||||
void TaskService::registerEndpoints(ApiServer& api) {
|
||||
api.registerEndpoint("/api/tasks/status", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
api.registerEndpoint("/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("")
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void TaskService::registerTasks(TaskManager& taskManager) {
|
||||
// TaskService doesn't register any tasks itself - it manages other tasks
|
||||
}
|
||||
|
||||
void TaskService::handleStatusRequest(AsyncWebServerRequest* request) {
|
||||
JsonDocument scratch;
|
||||
auto taskStatuses = taskManager.getAllTaskStatuses(scratch);
|
||||
|
||||
JsonDocument doc;
|
||||
JsonObject summaryObj = doc["summary"].to<JsonObject>();
|
||||
summaryObj["totalTasks"] = taskStatuses.size();
|
||||
summaryObj["activeTasks"] = std::count_if(taskStatuses.begin(), taskStatuses.end(),
|
||||
[](const auto& pair) { return pair.second["enabled"]; });
|
||||
|
||||
JsonArray tasksArr = doc["tasks"].to<JsonArray>();
|
||||
for (const auto& taskPair : taskStatuses) {
|
||||
JsonObject taskObj = tasksArr.add<JsonObject>();
|
||||
taskObj["name"] = taskPair.first;
|
||||
taskObj["interval"] = taskPair.second["interval"];
|
||||
taskObj["enabled"] = taskPair.second["enabled"];
|
||||
taskObj["running"] = taskPair.second["running"];
|
||||
taskObj["autoStart"] = taskPair.second["autoStart"];
|
||||
}
|
||||
|
||||
JsonObject systemObj = doc["system"].to<JsonObject>();
|
||||
systemObj["freeHeap"] = ESP.getFreeHeap();
|
||||
systemObj["uptime"] = millis();
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
request->send(200, "application/json", json);
|
||||
}
|
||||
|
||||
void TaskService::handleControlRequest(AsyncWebServerRequest* request) {
|
||||
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";
|
||||
|
||||
JsonDocument statusDoc;
|
||||
statusDoc["success"] = success;
|
||||
statusDoc["message"] = message;
|
||||
statusDoc["task"] = taskName;
|
||||
statusDoc["action"] = action;
|
||||
|
||||
statusDoc["taskDetails"] = JsonObject();
|
||||
JsonObject taskDetails = statusDoc["taskDetails"];
|
||||
taskDetails["name"] = taskName;
|
||||
taskDetails["enabled"] = taskManager.isTaskEnabled(taskName.c_str());
|
||||
taskDetails["running"] = taskManager.isTaskRunning(taskName.c_str());
|
||||
taskDetails["interval"] = taskManager.getTaskInterval(taskName.c_str());
|
||||
|
||||
taskDetails["system"] = JsonObject();
|
||||
JsonObject systemInfo = taskDetails["system"];
|
||||
systemInfo["freeHeap"] = ESP.getFreeHeap();
|
||||
systemInfo["uptime"] = millis();
|
||||
|
||||
String statusJson;
|
||||
serializeJson(statusDoc, statusJson);
|
||||
request->send(200, "application/json", statusJson);
|
||||
return;
|
||||
} else {
|
||||
success = false;
|
||||
message = "Invalid action. Use: enable, disable, start, stop, or status";
|
||||
}
|
||||
|
||||
JsonDocument doc;
|
||||
doc["success"] = success;
|
||||
doc["message"] = message;
|
||||
doc["task"] = taskName;
|
||||
doc["action"] = action;
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
request->send(success ? 200 : 400, "application/json", json);
|
||||
} else {
|
||||
JsonDocument doc;
|
||||
doc["success"] = false;
|
||||
doc["message"] = "Missing parameters. Required: task, action";
|
||||
doc["example"] = "{\"task\": \"discovery_send\", \"action\": \"status\"}";
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
request->send(400, "application/json", json);
|
||||
}
|
||||
}
|
||||
194
src/spore/types/Config.cpp
Normal file
194
src/spore/types/Config.cpp
Normal file
@@ -0,0 +1,194 @@
|
||||
#include "spore/types/Config.h"
|
||||
#include "spore/util/Logging.h"
|
||||
|
||||
const char* Config::CONFIG_FILE_PATH = "/config.json";
|
||||
|
||||
Config::Config() {
|
||||
// Initialize LittleFS
|
||||
if (!LittleFS.begin()) {
|
||||
LOG_WARN("Config", "Failed to initialize LittleFS, using defaults");
|
||||
setDefaults();
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to load configuration from file
|
||||
if (!loadFromFile()) {
|
||||
LOG_INFO("Config", "No config file found, using defaults");
|
||||
setDefaults();
|
||||
// Save defaults to file for future use
|
||||
saveToFile();
|
||||
} else {
|
||||
LOG_INFO("Config", "Configuration loaded from file");
|
||||
}
|
||||
}
|
||||
|
||||
void Config::setDefaults() {
|
||||
// WiFi Configuration
|
||||
wifi_ssid = DEFAULT_WIFI_SSID;
|
||||
wifi_password = DEFAULT_WIFI_PASSWORD;
|
||||
|
||||
// Network Configuration
|
||||
udp_port = DEFAULT_UDP_PORT;
|
||||
api_server_port = DEFAULT_API_SERVER_PORT;
|
||||
|
||||
// Cluster Configuration
|
||||
cluster_listen_interval_ms = DEFAULT_CLUSTER_LISTEN_INTERVAL_MS;
|
||||
heartbeat_interval_ms = DEFAULT_HEARTBEAT_INTERVAL_MS;
|
||||
status_update_interval_ms = DEFAULT_STATUS_UPDATE_INTERVAL_MS;
|
||||
|
||||
// Node Status Thresholds
|
||||
node_active_threshold_ms = DEFAULT_NODE_ACTIVE_THRESHOLD_MS;
|
||||
node_inactive_threshold_ms = DEFAULT_NODE_INACTIVE_THRESHOLD_MS;
|
||||
node_dead_threshold_ms = DEFAULT_NODE_DEAD_THRESHOLD_MS;
|
||||
|
||||
// WiFi Connection
|
||||
wifi_connect_timeout_ms = DEFAULT_WIFI_CONNECT_TIMEOUT_MS;
|
||||
wifi_retry_delay_ms = DEFAULT_WIFI_RETRY_DELAY_MS;
|
||||
|
||||
// System Configuration
|
||||
restart_delay_ms = DEFAULT_RESTART_DELAY_MS;
|
||||
json_doc_size = DEFAULT_JSON_DOC_SIZE;
|
||||
|
||||
// Memory Management
|
||||
low_memory_threshold_bytes = DEFAULT_LOW_MEMORY_THRESHOLD_BYTES; // 10KB
|
||||
critical_memory_threshold_bytes = DEFAULT_CRITICAL_MEMORY_THRESHOLD_BYTES; // 5KB
|
||||
max_concurrent_http_requests = DEFAULT_MAX_CONCURRENT_HTTP_REQUESTS;
|
||||
|
||||
// Custom Labels - start empty by default
|
||||
labels.clear();
|
||||
}
|
||||
|
||||
bool Config::saveToFile(const String& filename) {
|
||||
if (!LittleFS.begin()) {
|
||||
LOG_ERROR("Config", "LittleFS not initialized, cannot save config");
|
||||
return false;
|
||||
}
|
||||
|
||||
File file = LittleFS.open(filename, "w");
|
||||
if (!file) {
|
||||
LOG_ERROR("Config", "Failed to open config file for writing: " + filename);
|
||||
return false;
|
||||
}
|
||||
|
||||
JsonDocument doc;
|
||||
|
||||
// WiFi Configuration
|
||||
doc["wifi"]["ssid"] = wifi_ssid;
|
||||
doc["wifi"]["password"] = wifi_password;
|
||||
doc["wifi"]["connect_timeout_ms"] = wifi_connect_timeout_ms;
|
||||
doc["wifi"]["retry_delay_ms"] = wifi_retry_delay_ms;
|
||||
|
||||
// Network Configuration
|
||||
doc["network"]["udp_port"] = udp_port;
|
||||
doc["network"]["api_server_port"] = api_server_port;
|
||||
|
||||
// Cluster Configuration
|
||||
doc["cluster"]["heartbeat_interval_ms"] = heartbeat_interval_ms;
|
||||
doc["cluster"]["cluster_listen_interval_ms"] = cluster_listen_interval_ms;
|
||||
doc["cluster"]["status_update_interval_ms"] = status_update_interval_ms;
|
||||
|
||||
// Node Status Thresholds
|
||||
doc["thresholds"]["node_active_threshold_ms"] = node_active_threshold_ms;
|
||||
doc["thresholds"]["node_inactive_threshold_ms"] = node_inactive_threshold_ms;
|
||||
doc["thresholds"]["node_dead_threshold_ms"] = node_dead_threshold_ms;
|
||||
|
||||
// System Configuration
|
||||
doc["system"]["restart_delay_ms"] = restart_delay_ms;
|
||||
doc["system"]["json_doc_size"] = json_doc_size;
|
||||
|
||||
// Memory Management
|
||||
doc["memory"]["low_memory_threshold_bytes"] = low_memory_threshold_bytes;
|
||||
doc["memory"]["critical_memory_threshold_bytes"] = critical_memory_threshold_bytes;
|
||||
doc["memory"]["max_concurrent_http_requests"] = max_concurrent_http_requests;
|
||||
|
||||
// Custom Labels
|
||||
JsonObject labelsObj = doc["labels"].to<JsonObject>();
|
||||
for (const auto& kv : labels) {
|
||||
labelsObj[kv.first] = kv.second;
|
||||
}
|
||||
|
||||
// Add metadata
|
||||
doc["_meta"]["version"] = "1.0";
|
||||
doc["_meta"]["saved_at"] = millis();
|
||||
|
||||
size_t bytesWritten = serializeJson(doc, file);
|
||||
file.close();
|
||||
|
||||
if (bytesWritten > 0) {
|
||||
LOG_INFO("Config", "Configuration saved to " + filename + " (" + String(bytesWritten) + " bytes)");
|
||||
return true;
|
||||
} else {
|
||||
LOG_ERROR("Config", "Failed to write configuration to file");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool Config::loadFromFile(const String& filename) {
|
||||
if (!LittleFS.begin()) {
|
||||
LOG_ERROR("Config", "LittleFS not initialized, cannot load config");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!LittleFS.exists(filename)) {
|
||||
LOG_DEBUG("Config", "Config file does not exist: " + filename);
|
||||
return false;
|
||||
}
|
||||
|
||||
File file = LittleFS.open(filename, "r");
|
||||
if (!file) {
|
||||
LOG_ERROR("Config", "Failed to open config file for reading: " + filename);
|
||||
return false;
|
||||
}
|
||||
|
||||
JsonDocument doc;
|
||||
DeserializationError error = deserializeJson(doc, file);
|
||||
file.close();
|
||||
|
||||
if (error) {
|
||||
LOG_ERROR("Config", "Failed to parse config file: " + String(error.c_str()));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load WiFi Configuration with defaults
|
||||
wifi_ssid = doc["wifi"]["ssid"] | DEFAULT_WIFI_SSID;
|
||||
wifi_password = doc["wifi"]["password"] | DEFAULT_WIFI_PASSWORD;
|
||||
wifi_connect_timeout_ms = doc["wifi"]["connect_timeout_ms"] | DEFAULT_WIFI_CONNECT_TIMEOUT_MS;
|
||||
wifi_retry_delay_ms = doc["wifi"]["retry_delay_ms"] | DEFAULT_WIFI_RETRY_DELAY_MS;
|
||||
|
||||
// Load Network Configuration with defaults
|
||||
udp_port = doc["network"]["udp_port"] | DEFAULT_UDP_PORT;
|
||||
api_server_port = doc["network"]["api_server_port"] | DEFAULT_API_SERVER_PORT;
|
||||
|
||||
// Load Cluster Configuration with defaults
|
||||
heartbeat_interval_ms = doc["cluster"]["heartbeat_interval_ms"] | DEFAULT_HEARTBEAT_INTERVAL_MS;
|
||||
cluster_listen_interval_ms = doc["cluster"]["cluster_listen_interval_ms"] | DEFAULT_CLUSTER_LISTEN_INTERVAL_MS;
|
||||
status_update_interval_ms = doc["cluster"]["status_update_interval_ms"] | DEFAULT_STATUS_UPDATE_INTERVAL_MS;
|
||||
|
||||
// Load Node Status Thresholds with defaults
|
||||
node_active_threshold_ms = doc["thresholds"]["node_active_threshold_ms"] | DEFAULT_NODE_ACTIVE_THRESHOLD_MS;
|
||||
node_inactive_threshold_ms = doc["thresholds"]["node_inactive_threshold_ms"] | DEFAULT_NODE_INACTIVE_THRESHOLD_MS;
|
||||
node_dead_threshold_ms = doc["thresholds"]["node_dead_threshold_ms"] | DEFAULT_NODE_DEAD_THRESHOLD_MS;
|
||||
|
||||
// Load System Configuration with defaults
|
||||
restart_delay_ms = doc["system"]["restart_delay_ms"] | DEFAULT_RESTART_DELAY_MS;
|
||||
json_doc_size = doc["system"]["json_doc_size"] | DEFAULT_JSON_DOC_SIZE;
|
||||
|
||||
// Load Memory Management with defaults
|
||||
low_memory_threshold_bytes = doc["memory"]["low_memory_threshold_bytes"] | DEFAULT_LOW_MEMORY_THRESHOLD_BYTES;
|
||||
critical_memory_threshold_bytes = doc["memory"]["critical_memory_threshold_bytes"] | DEFAULT_CRITICAL_MEMORY_THRESHOLD_BYTES;
|
||||
max_concurrent_http_requests = doc["memory"]["max_concurrent_http_requests"] | DEFAULT_MAX_CONCURRENT_HTTP_REQUESTS;
|
||||
|
||||
// Load Custom Labels
|
||||
labels.clear();
|
||||
if (doc["labels"].is<JsonObject>()) {
|
||||
JsonObject labelsObj = doc["labels"].as<JsonObject>();
|
||||
for (JsonPair kv : labelsObj) {
|
||||
labels[kv.key().c_str()] = kv.value().as<String>();
|
||||
}
|
||||
}
|
||||
|
||||
LOG_DEBUG("Config", "Loaded WiFi SSID: " + wifi_ssid);
|
||||
LOG_DEBUG("Config", "Config file version: " + String(doc["_meta"]["version"] | "unknown"));
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "NodeInfo.h"
|
||||
#include "spore/types/NodeInfo.h"
|
||||
#include "spore/internal/Globals.h"
|
||||
|
||||
const char* statusToStr(NodeInfo::Status status) {
|
||||
switch (status) {
|
||||
@@ -9,13 +10,18 @@ const char* statusToStr(NodeInfo::Status status) {
|
||||
}
|
||||
}
|
||||
|
||||
void updateNodeStatus(NodeInfo &node, unsigned long now) {
|
||||
void updateNodeStatus(NodeInfo &node, unsigned long now, unsigned long inactive_threshold, unsigned long dead_threshold) {
|
||||
unsigned long diff = now - node.lastSeen;
|
||||
if (diff < NODE_INACTIVE_THRESHOLD) {
|
||||
if (diff < inactive_threshold) {
|
||||
node.status = NodeInfo::ACTIVE;
|
||||
} else if (diff < NODE_INACTIVE_THRESHOLD) {
|
||||
} else if (diff < dead_threshold) {
|
||||
node.status = NodeInfo::INACTIVE;
|
||||
} else {
|
||||
node.status = NodeInfo::DEAD;
|
||||
}
|
||||
}
|
||||
|
||||
void updateNodeStatus(NodeInfo &node, unsigned long now) {
|
||||
// Legacy implementation using hardcoded values
|
||||
updateNodeStatus(node, now, NODE_INACTIVE_THRESHOLD, NODE_DEAD_THRESHOLD);
|
||||
}
|
||||
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));
|
||||
}
|
||||
1
test/.gitignore
vendored
Normal file
1
test/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
69
test/README
69
test/README
@@ -1,11 +1,64 @@
|
||||
# Test Scripts
|
||||
|
||||
This directory is intended for PlatformIO Test Runner and project tests.
|
||||
This directory contains JavaScript test scripts to interact with the Spore device, primarily for testing cluster event broadcasting.
|
||||
|
||||
Unit Testing is a software testing method by which individual units of
|
||||
source code, sets of one or more MCU program modules together with associated
|
||||
control data, usage procedures, and operating procedures, are tested to
|
||||
determine whether they are fit for use. Unit testing finds problems early
|
||||
in the development cycle.
|
||||
## Prerequisites
|
||||
|
||||
These scripts require [Node.js](https://nodejs.org/) to be installed on your system.
|
||||
|
||||
## How to Run
|
||||
|
||||
### 1. HTTP Cluster Broadcast Color (`test/http-cluster-broadcast-color.js`)
|
||||
|
||||
This script sends HTTP POST requests to the `/api/cluster/event` endpoint on your Spore device. It broadcasts NeoPattern color changes across the cluster every 5 seconds.
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
node test/http-cluster-broadcast-color.js <device-ip>
|
||||
```
|
||||
Example:
|
||||
```
|
||||
node test/http-cluster-broadcast-color.js 10.0.1.53
|
||||
```
|
||||
This will broadcast `{ event: "api/neopattern/color", data: { color: "#RRGGBB", brightness: 128 } }` every 5 seconds to the cluster via `/api/cluster/event`.
|
||||
|
||||
### 2. WS Local Color Setter (`test/ws-color-client.js`)
|
||||
|
||||
Connects to the device WebSocket (`/ws`) and sets a solid color locally (non-broadcast) every 5 seconds by firing `api/neopattern/color`.
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
node test/ws-color-client.js ws://<device-ip>/ws
|
||||
```
|
||||
Example:
|
||||
```
|
||||
node test/ws-color-client.js ws://10.0.1.53/ws
|
||||
```
|
||||
|
||||
### 3. WS Cluster Broadcast Color (`test/ws-cluster-broadcast-color.js`)
|
||||
|
||||
Connects to the device WebSocket (`/ws`) and broadcasts a color change to all peers every 5 seconds by firing `cluster/broadcast` with the proper envelope.
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
node test/ws-cluster-broadcast-color.js ws://<device-ip>/ws
|
||||
```
|
||||
Example:
|
||||
```
|
||||
node test/ws-cluster-broadcast-color.js ws://10.0.1.53/ws
|
||||
```
|
||||
|
||||
### 4. WS Cluster Broadcast Rainbow (`test/ws-cluster-broadcast-rainbow.js`)
|
||||
|
||||
Broadcasts a smooth rainbow color transition over WebSocket using `cluster/broadcast` and the `api/neopattern/color` event. Update rate defaults to `UPDATE_RATE` in the script (e.g., 100 ms).
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
node test/ws-cluster-broadcast-rainbow.js ws://<device-ip>/ws
|
||||
```
|
||||
Example:
|
||||
```
|
||||
node test/ws-cluster-broadcast-rainbow.js ws://10.0.1.53/ws
|
||||
```
|
||||
Note: Very fast update intervals (e.g., 10 ms) may saturate links or the device.
|
||||
|
||||
More information about PlatformIO Unit Testing:
|
||||
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html
|
||||
|
||||
52
test/neopattern/http-cluster-broadcast-color.js
Normal file
52
test/neopattern/http-cluster-broadcast-color.js
Normal file
@@ -0,0 +1,52 @@
|
||||
// Simple HTTP client to broadcast a neopattern color change to the cluster
|
||||
// Usage: node cluster-broadcast-color.js 10.0.1.53
|
||||
|
||||
const http = require('http');
|
||||
|
||||
const host = process.argv[2] || '127.0.0.1';
|
||||
const port = 80;
|
||||
|
||||
const colors = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF'];
|
||||
let idx = 0;
|
||||
|
||||
function postClusterEvent(event, payloadObj) {
|
||||
const payload = encodeURIComponent(JSON.stringify(payloadObj));
|
||||
const body = `event=${encodeURIComponent(event)}&payload=${payload}`;
|
||||
|
||||
const options = {
|
||||
host,
|
||||
port,
|
||||
path: '/api/cluster/event',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': Buffer.byteLength(body)
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
console.log('Response:', res.statusCode, data);
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
console.error('Request error:', err.message);
|
||||
});
|
||||
|
||||
req.write(body);
|
||||
req.end();
|
||||
}
|
||||
|
||||
console.log(`Broadcasting color changes to http://${host}/api/cluster/event ...`);
|
||||
setInterval(() => {
|
||||
const color = colors[idx % colors.length];
|
||||
idx++;
|
||||
const payload = { color, brightness: 80 };
|
||||
console.log('Broadcasting color:', payload);
|
||||
postClusterEvent('api/neopattern/color', payload);
|
||||
}, 5000);
|
||||
|
||||
|
||||
46
test/neopattern/ws-cluster-broadcast-color.js
Normal file
46
test/neopattern/ws-cluster-broadcast-color.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// WebSocket client to broadcast neopattern color changes across the cluster
|
||||
// Usage: node ws-cluster-broadcast-color.js ws://<device-ip>/ws
|
||||
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const url = process.argv[2] || 'ws://127.0.0.1/ws';
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
const colors = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF'];
|
||||
let idx = 0;
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('Connected to', url);
|
||||
// Broadcast color change every 5 seconds via cluster/broadcast
|
||||
setInterval(() => {
|
||||
const color = colors[idx % colors.length];
|
||||
idx++;
|
||||
const payload = { color, brightness: 80 };
|
||||
const envelope = {
|
||||
event: 'api/neopattern/color',
|
||||
data: payload // server will serialize object payloads
|
||||
};
|
||||
const msg = { event: 'cluster/broadcast', payload: envelope };
|
||||
ws.send(JSON.stringify(msg));
|
||||
console.log('Broadcasted color event', payload);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
console.log('Received:', msg);
|
||||
} catch (e) {
|
||||
console.log('Received raw:', data.toString());
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('WebSocket error:', err.message);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('WebSocket closed');
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user