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

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();
}