feat: persistent custom labels
This commit is contained in:
214
ctl.sh
214
ctl.sh
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\"}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user