commit fc015e89584a2b75716e76348709ebde5175fdc4 Author: Patrick Balsiger Date: Thu Aug 21 15:54:05 2025 +0200 basic functionality diff --git a/.env b/.env new file mode 100644 index 0000000..02668c4 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +export API_NODE=10.0.1.53 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89cc49c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..080e70d --- /dev/null +++ b/.vscode/extensions.json @@ -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" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..981a3e3 --- /dev/null +++ b/.vscode/settings.json @@ -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" + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ddb77fa --- /dev/null +++ b/README.md @@ -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 +``` \ No newline at end of file diff --git a/bastel/main.cpp b/bastel/main.cpp new file mode 100644 index 0000000..705a838 --- /dev/null +++ b/bastel/main.cpp @@ -0,0 +1,204 @@ +#include +#include // or #include for ESP32 +#include // or #include for ESP32 +#include +#include +#include + +// 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()); + } 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 +} + +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 +} \ No newline at end of file diff --git a/ctl.sh b/ctl.sh new file mode 100755 index 0000000..92e6a85 --- /dev/null +++ b/ctl.sh @@ -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} diff --git a/include/Globals.h b/include/Globals.h new file mode 100644 index 0000000..d7ad5fb --- /dev/null +++ b/include/Globals.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +// 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; + \ No newline at end of file diff --git a/include/README b/include/README new file mode 100644 index 0000000..49819c0 --- /dev/null +++ b/include/README @@ -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 diff --git a/lib/README b/lib/README new file mode 100644 index 0000000..9379397 --- /dev/null +++ b/lib/README @@ -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 +#include + +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 diff --git a/partitions_ota_1M.csv b/partitions_ota_1M.csv new file mode 100644 index 0000000..54f204c --- /dev/null +++ b/partitions_ota_1M.csv @@ -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, \ No newline at end of file diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..96d5ed1 --- /dev/null +++ b/platformio.ini @@ -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 ; 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 diff --git a/src/ClusterContext.cpp b/src/ClusterContext.cpp new file mode 100644 index 0000000..b95c7dc --- /dev/null +++ b/src/ClusterContext.cpp @@ -0,0 +1,24 @@ +#include "ClusterContext.h" + +ClusterContext::ClusterContext() { + scheduler = new Scheduler(); + udp = new WiFiUDP(); + memberList = new std::vector(); + 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); + } +} diff --git a/src/ClusterContext.h b/src/ClusterContext.h new file mode 100644 index 0000000..e5f3cd2 --- /dev/null +++ b/src/ClusterContext.h @@ -0,0 +1,25 @@ +#pragma once +#include +#include +#include +#include "NodeInfo.h" +#include +#include +#include + +class ClusterContext { +public: + ClusterContext(); + ~ClusterContext(); + Scheduler* scheduler; + WiFiUDP* udp; + String hostname; + IPAddress localIP; + std::vector* memberList; + + using EventCallback = std::function; + std::map> eventRegistry; + + void registerEvent(const std::string& event, EventCallback cb); + void triggerEvent(const std::string& event, void* data); +}; diff --git a/src/ClusterManager.cpp b/src/ClusterManager.cpp new file mode 100644 index 0000000..e87bda5 --- /dev/null +++ b/src/ClusterManager.cpp @@ -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(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 apiArr = doc["api"].as(); + 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; + } + } +} diff --git a/src/ClusterManager.h b/src/ClusterManager.h new file mode 100644 index 0000000..54c0b1d --- /dev/null +++ b/src/ClusterManager.h @@ -0,0 +1,26 @@ +#pragma once +#include "Globals.h" +#include "ClusterContext.h" +#include "NodeInfo.h" +#include +#include +#include +#include + +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& getMemberList() const { return *ctx.memberList; } + void fetchNodeInfo(const IPAddress& ip); + void updateLocalNodeResources(); + void heartbeatTaskCallback(); + void updateAllMembersInfoTaskCallback(); +private: + ClusterContext& ctx; +}; diff --git a/src/NetworkManager.cpp b/src/NetworkManager.cpp new file mode 100644 index 0000000..a3f6452 --- /dev/null +++ b/src/NetworkManager.cpp @@ -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); +} diff --git a/src/NetworkManager.h b/src/NetworkManager.h new file mode 100644 index 0000000..aaef026 --- /dev/null +++ b/src/NetworkManager.h @@ -0,0 +1,12 @@ +#pragma once +#include "ClusterContext.h" +#include + +class NetworkManager { +public: + NetworkManager(ClusterContext& ctx); + void setupWiFi(); + void setHostnameFromMac(); +private: + ClusterContext& ctx; +}; diff --git a/src/NodeInfo.cpp b/src/NodeInfo.cpp new file mode 100644 index 0000000..25fd67f --- /dev/null +++ b/src/NodeInfo.cpp @@ -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; + } +} diff --git a/src/NodeInfo.h b/src/NodeInfo.h new file mode 100644 index 0000000..37a3ed0 --- /dev/null +++ b/src/NodeInfo.h @@ -0,0 +1,24 @@ +#pragma once +#include "Globals.h" +#include +#include +#include + +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> apiEndpoints; // List of registered endpoints +}; + +const char* statusToStr(NodeInfo::Status status); +void updateNodeStatus(NodeInfo &node, unsigned long now = millis()); diff --git a/src/RestApiServer.cpp b/src/RestApiServer.cpp new file mode 100644 index 0000000..3470622 --- /dev/null +++ b/src/RestApiServer.cpp @@ -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 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 requestHandler, + std::function 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(); + for (const auto& entry : serviceRegistry) { + JsonObject apiObj = apiArr.add(); + 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(); + for (const auto& node : *ctx.memberList) { + JsonObject obj = arr.add(); + 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(); + for (const auto& endpoint : node.apiEndpoints) { + JsonObject apiObj = apiArr.add(); + 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 &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(); + }); +} \ No newline at end of file diff --git a/src/RestApiServer.h b/src/RestApiServer.h new file mode 100644 index 0000000..a554a9f --- /dev/null +++ b/src/RestApiServer.h @@ -0,0 +1,35 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +#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 requestHandler); + void addEndpoint(const String& uri, int method, std::function requestHandler, + std::function uploadHandler); +private: + AsyncWebServer server; + ClusterContext& ctx; + std::vector> serviceRegistry; + void getClusterMembersJson(AsyncWebServerRequest *request); + void methodToStr(const std::tuple &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); +}; diff --git a/src/TaskScheduler.cpp b/src/TaskScheduler.cpp new file mode 100644 index 0000000..c8b13c6 --- /dev/null +++ b/src/TaskScheduler.cpp @@ -0,0 +1,5 @@ +/* + * https://github.com/arkhipenko/TaskScheduler/tree/master/examples/Scheduler_example16_Multitab + */ + +#include \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..c6e5b16 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,40 @@ +#include +#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(); +} diff --git a/test/README b/test/README new file mode 100644 index 0000000..9b1e87b --- /dev/null +++ b/test/README @@ -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