diff --git a/ctl.sh b/ctl.sh index b776441..682d7e2 100755 --- a/ctl.sh +++ b/ctl.sh @@ -15,6 +15,9 @@ source .env ## ota all - OTA update all nodes in cluster ## cluster members - List cluster members ## node wifi [ip] - Configure WiFi on node +## node label set [ip] - Set a label on node +## node label delete [ip] - Delete a label from node +## node status [ip] - Get node status and information ## monitor - Monitor serial output ## ## Examples: @@ -22,6 +25,11 @@ source .env ## ./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 status +## ./ctl.sh node status 192.168.1.100 function info { sed -n 's/^##//p' ctl.sh @@ -161,6 +169,212 @@ function node { return 1 fi } + + function label { + function set { + if [ $# -lt 1 ]; then + echo "Usage: $0 node label set [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 [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 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} } diff --git a/docs/ConfigurationManagement.md b/docs/ConfigurationManagement.md index 72c7ec9..50bf912 100644 --- a/docs/ConfigurationManagement.md +++ b/docs/ConfigurationManagement.md @@ -2,7 +2,7 @@ ## Overview -SPORE implements a comprehensive 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. +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 diff --git a/include/spore/core/NodeContext.h b/include/spore/core/NodeContext.h index 49dc455..2e1e2b4 100644 --- a/include/spore/core/NodeContext.h +++ b/include/spore/core/NodeContext.h @@ -20,6 +20,7 @@ public: NodeInfo self; std::map* memberList; ::Config config; + std::map constructorLabels; // Labels passed to constructor (not persisted) using EventCallback = std::function; std::map> eventRegistry; @@ -29,4 +30,5 @@ public: 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 }; diff --git a/include/spore/services/NodeService.h b/include/spore/services/NodeService.h index 7829d6e..748840b 100644 --- a/include/spore/services/NodeService.h +++ b/include/spore/services/NodeService.h @@ -20,4 +20,5 @@ private: 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); }; diff --git a/include/spore/types/Config.h b/include/spore/types/Config.h index 99b3601..97d5d28 100644 --- a/include/spore/types/Config.h +++ b/include/spore/types/Config.h @@ -3,6 +3,7 @@ #include #include #include +#include class Config { public: @@ -62,6 +63,9 @@ public: uint32_t critical_memory_threshold_bytes; size_t max_concurrent_http_requests; + // Custom Labels + std::map labels; + // Constructor Config(); diff --git a/src/spore/Spore.cpp b/src/spore/Spore.cpp index 9fbfed1..706671c 100644 --- a/src/spore/Spore.cpp +++ b/src/spore/Spore.cpp @@ -10,12 +10,18 @@ Spore::Spore() : ctx(), network(ctx), taskManager(ctx), cluster(ctx, taskManager), apiServer(ctx, taskManager, ctx.config.api_server_port), cpuUsage(), initialized(false), apiServerStarted(false) { + + // Rebuild labels from constructor + config labels + ctx.rebuildLabels(); } Spore::Spore(std::initializer_list> initialLabels) : ctx(initialLabels), network(ctx), taskManager(ctx), cluster(ctx, taskManager), apiServer(ctx, taskManager, ctx.config.api_server_port), cpuUsage(), initialized(false), apiServerStarted(false) { + + // Rebuild labels from constructor + config labels (config takes precedence) + ctx.rebuildLabels(); } Spore::~Spore() { diff --git a/src/spore/core/NodeContext.cpp b/src/spore/core/NodeContext.cpp index cd7b574..5520994 100644 --- a/src/spore/core/NodeContext.cpp +++ b/src/spore/core/NodeContext.cpp @@ -12,6 +12,7 @@ NodeContext::NodeContext() { NodeContext::NodeContext(std::initializer_list> initialLabels) : NodeContext() { for (const auto& kv : initialLabels) { + constructorLabels[kv.first] = kv.second; self.labels[kv.first] = kv.second; } } @@ -37,3 +38,18 @@ void NodeContext::fire(const std::string& event, void* 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; + } +} diff --git a/src/spore/services/NodeService.cpp b/src/spore/services/NodeService.cpp index 6af2650..520462d 100644 --- a/src/spore/services/NodeService.cpp +++ b/src/spore/services/NodeService.cpp @@ -30,6 +30,13 @@ void NodeService::registerEndpoints(ApiServer& api) { [this](AsyncWebServerRequest* request) { handleEndpointsRequest(request); }, std::vector{}); + // Config endpoint for setting labels + api.registerEndpoint("/api/node/config", HTTP_POST, + [this](AsyncWebServerRequest* request) { handleConfigRequest(request); }, + std::vector{ + ParamSpec{String("labels"), true, String("body"), String("json"), {}, String("")} + }); + // Generic local event endpoint api.registerEndpoint("/api/node/event", HTTP_POST, [this](AsyncWebServerRequest* request) { @@ -175,3 +182,54 @@ void NodeService::handleEndpointsRequest(AsyncWebServerRequest* request) { 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 labelsObj = doc.as(); + for (JsonPair kv : labelsObj) { + ctx.config.labels[kv.key().c_str()] = kv.value().as(); + } + } + + // Rebuild self.labels from constructor + config labels + ctx.rebuildLabels(); + + // TODO think of a better way to update the member list entry for the local node + // Update the member list entry for this node if it exists + if (ctx.memberList) { + auto it = ctx.memberList->find(ctx.hostname); + if (it != ctx.memberList->end()) { + // Update the labels in the member list entry + it->second.labels.clear(); + for (const auto& kv : ctx.self.labels) { + it->second.labels[kv.first] = kv.second; + } + } + } + + // 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\"}"); + } +} diff --git a/src/spore/types/Config.cpp b/src/spore/types/Config.cpp index 014cdd2..681cc73 100644 --- a/src/spore/types/Config.cpp +++ b/src/spore/types/Config.cpp @@ -56,6 +56,9 @@ void Config::setDefaults() { 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) { @@ -104,6 +107,12 @@ bool Config::saveToFile(const String& filename) { 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(); + for (const auto& kv : labels) { + labelsObj[kv.first] = kv.second; + } + // Add metadata doc["_meta"]["version"] = "1.0"; doc["_meta"]["saved_at"] = millis(); @@ -178,6 +187,15 @@ bool Config::loadFromFile(const String& filename) { 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 labelsObj = doc["labels"].as(); + for (JsonPair kv : labelsObj) { + labels[kv.key().c_str()] = kv.value().as(); + } + } + LOG_DEBUG("Config", "Loaded WiFi SSID: " + wifi_ssid); LOG_DEBUG("Config", "Config file version: " + String(doc["_meta"]["version"] | "unknown"));