basic functionality

This commit is contained in:
2025-08-21 15:54:05 +02:00
commit fc015e8958
25 changed files with 1138 additions and 0 deletions

1
.env Normal file
View File

@@ -0,0 +1 @@
export API_NODE=10.0.1.53

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch

10
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
]
}

63
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,63 @@
{
"files.associations": {
"chrono": "cpp",
"functional": "cpp",
"array": "cpp",
"atomic": "cpp",
"bit": "cpp",
"bitset": "cpp",
"cctype": "cpp",
"clocale": "cpp",
"cmath": "cpp",
"compare": "cpp",
"concepts": "cpp",
"condition_variable": "cpp",
"cstdarg": "cpp",
"cstddef": "cpp",
"cstdint": "cpp",
"cstdio": "cpp",
"cstdlib": "cpp",
"cstring": "cpp",
"ctime": "cpp",
"cwchar": "cpp",
"cwctype": "cpp",
"deque": "cpp",
"list": "cpp",
"map": "cpp",
"set": "cpp",
"unordered_map": "cpp",
"vector": "cpp",
"exception": "cpp",
"algorithm": "cpp",
"iterator": "cpp",
"memory": "cpp",
"memory_resource": "cpp",
"numeric": "cpp",
"optional": "cpp",
"random": "cpp",
"ratio": "cpp",
"regex": "cpp",
"string": "cpp",
"string_view": "cpp",
"system_error": "cpp",
"tuple": "cpp",
"type_traits": "cpp",
"utility": "cpp",
"initializer_list": "cpp",
"iosfwd": "cpp",
"istream": "cpp",
"limits": "cpp",
"mutex": "cpp",
"new": "cpp",
"ostream": "cpp",
"ranges": "cpp",
"sstream": "cpp",
"stdexcept": "cpp",
"stop_token": "cpp",
"streambuf": "cpp",
"thread": "cpp",
"cinttypes": "cpp",
"typeinfo": "cpp",
"variant": "cpp"
}
}

51
README.md Normal file
View File

@@ -0,0 +1,51 @@
# SPORE
SProcket ORchestration Engine
## Features
- WiFi STA / AP
- node auto discovery over UDP
- service registry
- pub/sub event system
- Over-The-Air updates
## Supported Hardware
- ESP8266
## 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
```
## Build
Build the firmware:
```sh
./ctl.sh build
```
## Flash
Flash firmware to a connected device:
```sh
./ctl.sh flash
```
## OTA
Update one nodes:
```sh
./ctl.sh ota update 10.0.1.x
```
Update all nodes:
```sh
./ctl.sh ota all
```

204
bastel/main.cpp Normal file
View File

@@ -0,0 +1,204 @@
#include <Arduino.h>
#include <ESP8266WiFi.h> // or #include <WiFi.h> for ESP32
#include <ESP8266mDNS.h> // or #include <ESPmDNS.h> for ESP32
#include <ESPAsyncWebServer.h>
#include <AsyncWebSocket.h>
#include <ArduinoJson.h>
// Node metadata structure
struct Node {
String name;
IPAddress ip;
unsigned long lastSeen;
String status;
};
// Service structure
struct Service {
String name;
String endpoint;
void (*callback)();
};
// MemberList and ServiceRegistry
Node memberList[10]; // Example size
Service serviceRegistry[10];
AsyncWebServer server(80);
AsyncWebSocket ws("/ws");
void onServiceCall() {
// Placeholder for service callback
}
// Helper to update node status
String getNodeStatus(unsigned long lastSeen) {
unsigned long now = millis();
if (now - lastSeen < 10000) return "active";
if (now - lastSeen < 60000) return "inactive";
return "dead";
}
// Discover other nodes via mDNS
void discoverNodes() {
int n = MDNS.queryService("_ws", "tcp");
if (n == 0) {
Serial.println("No mDNS services found");
return;
}
for (int i = 0; i < n && i < 10; i++) {
IPAddress ip = MDNS.answerIP(i);
String name = MDNS.answerHostname(i);
if (ip == INADDR_NONE || name.isEmpty()) continue; // Skip invalid entries
Serial.printf("Discovered node: %s (%s)\n", name.c_str(), ip.toString().c_str());
// Add/update node in memberList
bool found = false;
for (int j = 0; j < 10; j++) {
if (memberList[j].ip == ip) {
memberList[j].lastSeen = millis();
String oldStatus = memberList[j].status;
memberList[j].status = getNodeStatus(memberList[j].lastSeen);
found = true;
if (oldStatus != memberList[j].status) {
Serial.printf("Node %s (%s) status changed: %s -> %s\n", memberList[j].name.c_str(), memberList[j].ip.toString().c_str(), oldStatus.c_str(), memberList[j].status.c_str());
}
break;
}
}
if (!found) {
for (int j = 0; j < 10; j++) {
if (memberList[j].name == "") {
memberList[j].name = name;
memberList[j].ip = ip;
memberList[j].lastSeen = millis();
memberList[j].status = "active";
Serial.printf("Discovered new node: %s (%s)\n", name.c_str(), ip.toString().c_str());
break;
}
}
}
}
}
// Broadcast event to all nodes
void broadcastEvent(const String& event) {
DynamicJsonDocument doc(256);
doc["type"] = "event";
doc["data"] = event;
String msg;
serializeJson(doc, msg);
ws.textAll(msg);
}
// Handle incoming WebSocket events
void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len) {
if (type == WS_EVT_DATA) {
String msg = String((char*)data);
DynamicJsonDocument doc(256);
DeserializationError err = deserializeJson(doc, msg);
if (!err) {
String type = doc["type"];
if (type == "event") {
// Handle pub/sub event
Serial.println("Received event: " + doc["data"].as<String>());
} else if (type == "service_call") {
// Handle service call
String endpoint = doc["endpoint"];
for (int i = 0; i < 10; i++) {
if (serviceRegistry[i].endpoint == endpoint && serviceRegistry[i].callback) {
serviceRegistry[i].callback();
break;
}
}
}
}
}
}
extern "C" {
#include <user_interface.h>
}
void onNewClient(System_Event_t *event) {
if (event->event == EVENT_SOFTAPMODE_STACONNECTED) {
uint8_t *mac = event->event_info.sta_connected.mac;
Serial.printf("New WiFi client connected: MAC %02x:%02x:%02x:%02x:%02x:%02x\n",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
}
}
String getMacHostname() {
uint8_t mac[6];
WiFi.macAddress(mac);
char hostname[32];
snprintf(hostname, sizeof(hostname), "esp-%02x%02x%02x%02x%02x%02x",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
return String(hostname);
}
void setup() {
Serial.begin(115200);
delay(100);
String myHostname = getMacHostname();
WiFi.hostname(myHostname);
WiFi.mode(WIFI_STA);
WiFi.begin("contraption", "sprocket");
Serial.printf("Attempting to connect to AP 'contraption' as %s...\n", myHostname.c_str());
unsigned long startAttemptTime = millis();
bool connected = false;
while (millis() - startAttemptTime < 10000) { // try for 10 seconds
if (WiFi.status() == WL_CONNECTED) {
connected = true;
break;
}
delay(500);
Serial.print(".");
}
if (connected) {
Serial.printf("\nConnected to AP 'contraption' as %s.\n", myHostname.c_str());
} else {
Serial.println("\nFailed to connect. Starting AP mode.");
WiFi.mode(WIFI_AP);
WiFi.softAP("contraption", "sprocket");
WiFi.hostname(myHostname.c_str());
Serial.printf("Access Point 'contraption' started with password 'sprocket' and hostname %s.\n", myHostname.c_str());
wifi_set_event_handler_cb(onNewClient);
}
// mDNS setup
if (MDNS.begin(myHostname)) {
Serial.println("mDNS responder started");
// Register WebSocket service on mDNS
MDNS.addService("_ws", "tcp", 80); // 80 is the default WebSocket port
Serial.println("mDNS WebSocket service '_ws._tcp' registered on port 80");
}
// WebSocket server setup
ws.onEvent(onWsEvent);
server.addHandler(&ws);
server.begin();
// Register example service
serviceRegistry[0] = {"status", "/api/status", onServiceCall};
}
void loop() {
// Periodically discover nodes
static unsigned long lastDiscovery = 0;
if (millis() - lastDiscovery > 5000) {
discoverNodes();
lastDiscovery = millis();
}
// Update node statuses
for (int i = 0; i < 10; i++) {
if (memberList[i].name != "") {
String oldStatus = memberList[i].status;
memberList[i].status = getNodeStatus(memberList[i].lastSeen);
if (oldStatus != memberList[i].status) {
Serial.printf("Node %s (%s) status changed: %s -> %s\n", memberList[i].name.c_str(), memberList[i].ip.toString().c_str(), oldStatus.c_str(), memberList[i].status.c_str());
}
}
}
// No need for webSocket.loop() with ESPAsyncWebServer
// Node discovery, memberlist update, service registry logic to be added
}

44
ctl.sh Executable file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -e
source .env
function info {
sed -n 's/^##//p' ctl.sh
}
function build {
echo "Building project..."
pio run
}
function flash {
echo "Flashing firmware..."
pio run --target upload
}
function ota {
function update {
echo "Updating node at $1"
curl -X POST \
-F "file=@.pio/build/esp01_1m/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
done
}
${@:-info}
}
function cluster {
function members {
curl -s http://$API_NODE/api/cluster/members | jq -r '.members[] | "\(.hostname) \(.ip)"'
}
${@:-info}
}
${@:-info}

27
include/Globals.h Normal file
View File

@@ -0,0 +1,27 @@
#pragma once
#include <cstddef>
#include <cstdint>
// Cluster protocol and API constants
namespace ClusterProtocol {
constexpr const char* DISCOVERY_MSG = "CLUSTER_DISCOVERY";
constexpr const char* RESPONSE_MSG = "CLUSTER_RESPONSE";
constexpr uint16_t UDP_PORT = 4210;
constexpr size_t UDP_BUF_SIZE = 64;
constexpr const char* API_NODE_STATUS = "/api/node/status";
}
namespace TaskIntervals {
constexpr unsigned long SEND_DISCOVERY = 1000;
constexpr unsigned long LISTEN_FOR_DISCOVERY = 100;
constexpr unsigned long UPDATE_STATUS = 1000;
constexpr unsigned long PRINT_MEMBER_LIST = 5000;
constexpr unsigned long HEARTBEAT = 2000;
constexpr unsigned long UPDATE_ALL_MEMBERS_INFO = 10000;
}
constexpr unsigned long NODE_ACTIVE_THRESHOLD = 10000;
constexpr unsigned long NODE_INACTIVE_THRESHOLD = 60000;
constexpr unsigned long NODE_DEAD_THRESHOLD = 120000;

37
include/README Normal file
View File

@@ -0,0 +1,37 @@
This directory is intended for project header files.
A header file is a file containing C declarations and macro definitions
to be shared between several project source files. You request the use of a
header file in your project source file (C, C++, etc) located in `src` folder
by including it, with the C preprocessing directive `#include'.
```src/main.c
#include "header.h"
int main (void)
{
...
}
```
Including a header file produces the same results as copying the header file
into each source file that needs it. Such copying would be time-consuming
and error-prone. With a header file, the related declarations appear
in only one place. If they need to be changed, they can be changed in one
place, and programs that include the header file will automatically use the
new version when next recompiled. The header file eliminates the labor of
finding and changing all the copies as well as the risk that a failure to
find one copy will result in inconsistencies within a program.
In C, the convention is to give header files names that end with `.h'.
Read more about using header files in official GCC documentation:
* Include Syntax
* Include Operation
* Once-Only Headers
* Computed Includes
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html

46
lib/README Normal file
View File

@@ -0,0 +1,46 @@
This directory is intended for project specific (private) libraries.
PlatformIO will compile them to static libraries and link into the executable file.
The source code of each library should be placed in a separate directory
("lib/your_library_name/[Code]").
For example, see the structure of the following example libraries `Foo` and `Bar`:
|--lib
| |
| |--Bar
| | |--docs
| | |--examples
| | |--src
| | |- Bar.c
| | |- Bar.h
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
| |
| |--Foo
| | |- Foo.c
| | |- Foo.h
| |
| |- README --> THIS FILE
|
|- platformio.ini
|--src
|- main.c
Example contents of `src/main.c` using Foo and Bar:
```
#include <Foo.h>
#include <Bar.h>
int main (void)
{
...
}
```
The PlatformIO Library Dependency Finder will find automatically dependent
libraries by scanning project source files.
More information about PlatformIO Library Dependency Finder
- https://docs.platformio.org/page/librarymanager/ldf.html

6
partitions_ota_1M.csv Normal file
View File

@@ -0,0 +1,6 @@
# 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,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x4000
3 otadata data ota 0x2000
4 app0 app ota_0 0x3E000
5 app1 app ota_1 0x3E000
6 spiffs data spiffs 0x1C000

23
platformio.ini Normal file
View File

@@ -0,0 +1,23 @@
; PlatformIO Project Configuration File
;
; Build options: build flags, source filter
; Upload options: custom upload port, speed and extra flags
; Library options: dependencies, extra library storages
; Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html
[env:esp01_1m]
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 ; ESP01S uses DOUT on 1Mbit flash
board_build.flash_size = 1M
lib_deps =
esp32async/ESPAsyncWebServer@^3.8.0
bblanchon/ArduinoJson@^7.4.2
arkhipenko/TaskScheduler@^3.8.5

24
src/ClusterContext.cpp Normal file
View File

@@ -0,0 +1,24 @@
#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);
}
}

25
src/ClusterContext.h Normal file
View File

@@ -0,0 +1,25 @@
#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);
};

175
src/ClusterManager.cpp Normal file
View File

@@ -0,0 +1,175 @@
#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;
}
}
}

26
src/ClusterManager.h Normal file
View File

@@ -0,0 +1,26 @@
#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;
};

54
src/NetworkManager.cpp Normal file
View File

@@ -0,0 +1,54 @@
#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;
self.ip = WiFi.localIP();
self.lastSeen = millis();
self.status = NodeInfo::ACTIVE;
ctx.triggerEvent("node_discovered", &self);
}

12
src/NetworkManager.h Normal file
View File

@@ -0,0 +1,12 @@
#pragma once
#include "ClusterContext.h"
#include <ESP8266WiFi.h>
class NetworkManager {
public:
NetworkManager(ClusterContext& ctx);
void setupWiFi();
void setHostnameFromMac();
private:
ClusterContext& ctx;
};

21
src/NodeInfo.cpp Normal file
View File

@@ -0,0 +1,21 @@
#include "NodeInfo.h"
const char* statusToStr(NodeInfo::Status status) {
switch (status) {
case NodeInfo::ACTIVE: return "active";
case NodeInfo::INACTIVE: return "inactive";
case NodeInfo::DEAD: return "dead";
default: return "unknown";
}
}
void updateNodeStatus(NodeInfo &node, unsigned long now) {
unsigned long diff = now - node.lastSeen;
if (diff < NODE_INACTIVE_THRESHOLD) {
node.status = NodeInfo::ACTIVE;
} else if (diff < NODE_INACTIVE_THRESHOLD) {
node.status = NodeInfo::INACTIVE;
} else {
node.status = NodeInfo::DEAD;
}
}

24
src/NodeInfo.h Normal file
View File

@@ -0,0 +1,24 @@
#pragma once
#include "Globals.h"
#include <IPAddress.h>
#include <vector>
#include <tuple>
struct NodeInfo {
String hostname;
IPAddress ip;
unsigned long lastSeen;
enum Status { ACTIVE, INACTIVE, DEAD } status;
struct Resources {
uint32_t freeHeap = 0;
uint32_t chipId = 0;
String sdkVersion;
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
};
const char* statusToStr(NodeInfo::Status status);
void updateNodeStatus(NodeInfo &node, unsigned long now = millis());

169
src/RestApiServer.cpp Normal file
View File

@@ -0,0 +1,169 @@
#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();
});
}

35
src/RestApiServer.h Normal file
View File

@@ -0,0 +1,35 @@
#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);
};

5
src/TaskScheduler.cpp Normal file
View File

@@ -0,0 +1,5 @@
/*
* https://github.com/arkhipenko/TaskScheduler/tree/master/examples/Scheduler_example16_Multitab
*/
#include <TaskScheduler.h>

40
src/main.cpp Normal file
View File

@@ -0,0 +1,40 @@
#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();
}

11
test/README Normal file
View File

@@ -0,0 +1,11 @@
This directory is intended for PlatformIO Test Runner and project tests.
Unit Testing is a software testing method by which individual units of
source code, sets of one or more MCU program modules together with associated
control data, usage procedures, and operating procedures, are tested to
determine whether they are fit for use. Unit testing finds problems early
in the development cycle.
More information about PlatformIO Unit Testing:
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html