feat: persistent custom labels

This commit is contained in:
2025-10-15 22:23:00 +02:00
parent 1a74d1fe90
commit 7063b1ab16
9 changed files with 320 additions and 1 deletions

214
ctl.sh
View File

@@ -15,6 +15,9 @@ source .env
## ota all <target> - OTA update all nodes in cluster
## cluster members - List cluster members
## node wifi <ssid> <password> [ip] - Configure WiFi on node
## node label set <key=value> [ip] - Set a label on node
## node label delete <key> [ip] - Delete a label from node
## node 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 <key=value> [node_ip]"
echo " key=value: Label key and value in format 'key=value'"
echo " node_ip: Optional IP address (defaults to API_NODE from .env)"
return 1
fi
local key_value="$1"
local node_ip="${2:-$API_NODE}"
# Parse key=value format
if [[ ! "$key_value" =~ ^[^=]+=.+$ ]]; then
echo "Error: Label must be in format 'key=value'"
echo "Example: environment=production"
return 1
fi
local key="${key_value%%=*}"
local value="${key_value#*=}"
echo "Setting label '$key=$value' on node $node_ip..."
# First get current labels
current_labels=$(curl -s "http://$node_ip/api/node/status" | jq -r '.labels // {}')
# Add/update the new label
updated_labels=$(echo "$current_labels" | jq --arg key "$key" --arg value "$value" '. + {($key): $value}')
# Send updated labels to the node
response=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "labels=$updated_labels" \
"http://$node_ip/api/node/config" 2>/dev/null || echo -e "\n000")
# Extract HTTP status code and response body
http_code=$(echo "$response" | tail -n1)
response_body=$(echo "$response" | head -n -1)
# Check if curl succeeded
if [ "$http_code" = "000" ] || [ -z "$response_body" ]; then
echo "Error: Failed to connect to node at $node_ip"
echo "Please check:"
echo " - Node is powered on and connected to network"
echo " - IP address is correct"
echo " - Node is running Spore firmware"
return 1
fi
# Check HTTP status code
if [ "$http_code" != "200" ]; then
echo "Error: HTTP $http_code - Server error"
echo "Response: $response_body"
return 1
fi
# Parse and display the response
status=$(echo "$response_body" | jq -r '.status // "unknown"')
message=$(echo "$response_body" | jq -r '.message // "No message"')
echo "Status: $status"
echo "Message: $message"
# Return appropriate exit code
if [ "$status" = "success" ]; then
echo "Label '$key=$value' set successfully!"
return 0
else
echo "Failed to set label!"
return 1
fi
}
function delete {
if [ $# -lt 1 ]; then
echo "Usage: $0 node label delete <key> [node_ip]"
echo " key: Label key to delete"
echo " node_ip: Optional IP address (defaults to API_NODE from .env)"
return 1
fi
local key="$1"
local node_ip="${2:-$API_NODE}"
echo "Deleting label '$key' from node $node_ip..."
# First get current labels
current_labels=$(curl -s "http://$node_ip/api/node/status" | jq -r '.labels // {}')
# Check if key exists
if [ "$(echo "$current_labels" | jq -r --arg key "$key" 'has($key)')" != "true" ]; then
echo "Warning: Label '$key' does not exist on node"
return 0
fi
# Remove the key
updated_labels=$(echo "$current_labels" | jq --arg key "$key" 'del(.[$key])')
# Send updated labels to the node
response=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "labels=$updated_labels" \
"http://$node_ip/api/node/config" 2>/dev/null || echo -e "\n000")
# Extract HTTP status code and response body
http_code=$(echo "$response" | tail -n1)
response_body=$(echo "$response" | head -n -1)
# Check if curl succeeded
if [ "$http_code" = "000" ] || [ -z "$response_body" ]; then
echo "Error: Failed to connect to node at $node_ip"
echo "Please check:"
echo " - Node is powered on and connected to network"
echo " - IP address is correct"
echo " - Node is running Spore firmware"
return 1
fi
# Check HTTP status code
if [ "$http_code" != "200" ]; then
echo "Error: HTTP $http_code - Server error"
echo "Response: $response_body"
return 1
fi
# Parse and display the response
status=$(echo "$response_body" | jq -r '.status // "unknown"')
message=$(echo "$response_body" | jq -r '.message // "No message"')
echo "Status: $status"
echo "Message: $message"
# Return appropriate exit code
if [ "$status" = "success" ]; then
echo "Label '$key' deleted successfully!"
return 0
else
echo "Failed to delete label!"
return 1
fi
}
${@:-info}
}
function 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}
}

View File

@@ -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

View File

@@ -20,6 +20,7 @@ public:
NodeInfo self;
std::map<String, NodeInfo>* memberList;
::Config config;
std::map<String, String> constructorLabels; // Labels passed to constructor (not persisted)
using EventCallback = std::function<void(void*)>;
std::map<std::string, std::vector<EventCallback>> eventRegistry;
@@ -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
};

View File

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

View File

@@ -3,6 +3,7 @@
#include <Arduino.h>
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <map>
class Config {
public:
@@ -62,6 +63,9 @@ public:
uint32_t critical_memory_threshold_bytes;
size_t max_concurrent_http_requests;
// Custom Labels
std::map<String, String> labels;
// Constructor
Config();

View File

@@ -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<std::pair<String, String>> 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() {

View File

@@ -12,6 +12,7 @@ NodeContext::NodeContext() {
NodeContext::NodeContext(std::initializer_list<std::pair<String, String>> initialLabels) : NodeContext() {
for (const auto& kv : initialLabels) {
constructorLabels[kv.first] = kv.second;
self.labels[kv.first] = kv.second;
}
}
@@ -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;
}
}

View File

@@ -30,6 +30,13 @@ void NodeService::registerEndpoints(ApiServer& api) {
[this](AsyncWebServerRequest* request) { handleEndpointsRequest(request); },
std::vector<ParamSpec>{});
// Config endpoint for setting labels
api.registerEndpoint("/api/node/config", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleConfigRequest(request); },
std::vector<ParamSpec>{
ParamSpec{String("labels"), true, String("body"), String("json"), {}, String("")}
});
// 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>()) {
JsonObject labelsObj = doc.as<JsonObject>();
for (JsonPair kv : labelsObj) {
ctx.config.labels[kv.key().c_str()] = kv.value().as<String>();
}
}
// Rebuild self.labels from constructor + config labels
ctx.rebuildLabels();
// 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\"}");
}
}

View File

@@ -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<JsonObject>();
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>()) {
JsonObject labelsObj = doc["labels"].as<JsonObject>();
for (JsonPair kv : labelsObj) {
labels[kv.key().c_str()] = kv.value().as<String>();
}
}
LOG_DEBUG("Config", "Loaded WiFi SSID: " + wifi_ssid);
LOG_DEBUG("Config", "Config file version: " + String(doc["_meta"]["version"] | "unknown"));