Files
EggDuino/src/Config_Web.cpp
2026-02-26 20:29:37 +01:00

541 lines
14 KiB
C++

#include "EggDuino.h"
#include <ArduinoJson.h>
#include <string.h>
namespace
{
const char *kConfigPath = "/config.json";
const size_t kConfigJsonCapacity = 4096;
WebServer server(80);
bool configStoreReady = false;
bool isIntType(const ConfigParameter &param)
{
return strcmp(param.type, "int") == 0;
}
int *asIntPtr(ConfigParameter &param)
{
return static_cast<int *>(param.value);
}
String *asStringPtr(ConfigParameter &param)
{
return static_cast<String *>(param.value);
}
ConfigParameter *findParameter(const String &key)
{
for (size_t i = 0; i < configParameterCount; ++i)
{
if (key.equals(configParameters[i].key))
{
return &configParameters[i];
}
}
return nullptr;
}
void applyDefaults()
{
for (size_t i = 0; i < configParameterCount; ++i)
{
if (isIntType(configParameters[i]))
{
*asIntPtr(configParameters[i]) = configParameters[i].defaultValue;
}
else
{
const char *fallback = configParameters[i].defaultText == nullptr ? "" : configParameters[i].defaultText;
*asStringPtr(configParameters[i]) = String(fallback);
}
}
}
void handleRoot()
{
static const char kPage[] PROGMEM = R"HTML(
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>EggDuino Konfiguration</title>
<style>
body { font-family: "Segoe UI", sans-serif; margin: 20px; background: #f3f6fb; color: #1a1a1a; }
main { max-width: 760px; margin: 0 auto; background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 8px 24px rgba(0,0,0,0.08); }
h1 { margin-top: 0; font-size: 1.35rem; }
label { display: block; margin: 14px 0 6px; font-weight: 600; }
input[type='number'], input[type='text'], input[type='password'] { width: 100%; padding: 10px; border: 1px solid #c7d2e5; border-radius: 8px; box-sizing: border-box; }
button { margin-top: 18px; border: 0; background: #0b5ed7; color: white; padding: 10px 14px; border-radius: 8px; cursor: pointer; }
#status { margin-top: 12px; min-height: 1.2em; }
#log { margin-top: 20px; border: 1px solid #d6dfef; border-radius: 8px; background: #0f172a; color: #d2e3ff; padding: 10px; height: 220px; overflow-y: auto; white-space: pre-wrap; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 0.9rem; }
#logTitle { margin-top: 24px; margin-bottom: 8px; font-weight: 700; }
</style>
</head>
<body>
<main>
<h1>EggDuino Parameter</h1>
<form id="cfgForm"></form>
<button id="saveBtn" type="button">Speichern</button>
<div id="status"></div>
<div id="logTitle">Logs</div>
<div id="log"></div>
</main>
<script>
let lastSeq = 0;
async function loadConfig() {
const resp = await fetch('/api/config', { cache: 'no-store' });
if (!resp.ok) throw new Error('Konfiguration konnte nicht geladen werden');
return resp.json();
}
function renderForm(config) {
const form = document.getElementById('cfgForm');
form.innerHTML = '';
(config.parameters || []).forEach(p => {
const label = document.createElement('label');
label.textContent = p.description || p.key;
const input = document.createElement('input');
const paramType = p.type || 'int';
input.type = paramType === 'int' ? 'number' : paramType;
if (paramType === 'password') {
input.value = '';
if (p.hasValue) {
input.placeholder = '(gespeichert)';
}
} else {
input.value = p.value ?? '';
}
input.dataset.key = p.key;
input.dataset.ptype = paramType;
input.dataset.secret = p.secret ? '1' : '0';
input.dataset.touched = '0';
input.addEventListener('input', () => {
input.dataset.touched = '1';
});
form.appendChild(label);
form.appendChild(input);
});
}
async function saveConfig() {
const status = document.getElementById('status');
const inputs = [...document.querySelectorAll('input[data-key]')];
const payload = {
parameters: inputs
.map(i => {
const type = i.dataset.ptype || 'int';
const secret = i.dataset.secret === '1';
if (type === 'int') {
return { key: i.dataset.key, value: Number(i.value) };
}
if (secret && i.dataset.touched !== '1') {
return null;
}
return { key: i.dataset.key, value: i.value };
})
.filter(Boolean)
};
const resp = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(text || 'Speichern fehlgeschlagen');
}
const saved = await resp.json();
renderForm(saved);
status.textContent = 'Gespeichert';
}
async function pollLogs() {
const box = document.getElementById('log');
const resp = await fetch('/api/logs?since=' + lastSeq);
if (!resp.ok) {
return;
}
const payload = await resp.json();
(payload.logs || []).forEach(entry => {
box.textContent += entry.text + '\n';
});
if (typeof payload.lastSeq === 'number') {
lastSeq = payload.lastSeq;
}
box.scrollTop = box.scrollHeight;
}
(async function init() {
const status = document.getElementById('status');
try {
const cfg = await loadConfig();
renderForm(cfg);
status.textContent = 'Bereit';
} catch (e) {
status.textContent = e.message;
}
document.getElementById('saveBtn').addEventListener('click', async () => {
try {
await saveConfig();
} catch (e) {
status.textContent = e.message;
}
});
pollLogs();
setInterval(pollLogs, 800);
})();
</script>
</body>
</html>
)HTML";
server.send(200, "text/html", kPage);
}
void handleGetConfig()
{
if (!configStoreReady && !initConfigStore())
{
server.send(500, "text/plain", "Config storage not available");
return;
}
server.sendHeader("Cache-Control", "no-store, no-cache, must-revalidate");
server.sendHeader("Pragma", "no-cache");
server.sendHeader("Expires", "0");
server.send(200, "application/json", buildConfigJson());
}
void handlePostConfig()
{
if (!configStoreReady && !initConfigStore())
{
server.send(500, "text/plain", "Config storage not available");
return;
}
if (!server.hasArg("plain"))
{
server.send(400, "text/plain", "Missing JSON body");
return;
}
String error;
if (!applyConfigJson(server.arg("plain"), error))
{
Log(String("Config JSON fehlerhaft: ") + error);
server.send(400, "text/plain", error);
return;
}
if (!saveConfigToFile())
{
Log("Config konnte nicht gespeichert werden");
server.send(500, "text/plain", "Could not save config");
return;
}
server.send(200, "application/json", buildConfigJson());
}
void handleGetLogs()
{
uint32_t since = 0;
if (server.hasArg("since"))
{
since = static_cast<uint32_t>(server.arg("since").toInt());
}
server.send(200, "application/json", buildLogsJson(since));
}
void handleNotFound()
{
if (server.uri().startsWith("/api/"))
{
server.send(404, "text/plain", "API endpoint not found");
return;
}
handleRoot();
}
} // namespace
ConfigParameter configParameters[] = {
{"int", "penUpPos", &g_iPenUpPos, "Pen Up Position", 40, "", false},
{"int", "penDownPos", &g_iPenDownPos, "Pen Down Position", 10, "", false},
{"int", "rotStepPin", &g_iRotStepPin, "Rotational Stepper Step Pin", kDefaultRotStepPin, "", false},
{"int", "rotDirPin", &g_iRotDirPin, "Rotational Stepper Direction Pin", kDefaultRotDirPin, "", false},
{"int", "rotEnablePin", &g_iRotEnablePin, "Rotational Stepper Enable Pin", kDefaultRotEnablePin, "", false},
{"int", "penStepPin", &g_iPenStepPin, "Pen Stepper Step Pin", kDefaultPenStepPin, "", false},
{"int", "penDirPin", &g_iPenDirPin, "Pen Stepper Direction Pin", kDefaultPenDirPin, "", false},
{"int", "penEnablePin", &g_iPenEnablePin, "Pen Stepper Enable Pin", kDefaultPenEnablePin, "", false},
{"int", "rotMicrostep", &g_iRotMicrostep, "Rotational Stepper Microsteps", kDefaultRotMicrostep, "", false},
{"int", "penMicrostep", &g_iPenMicrostep, "Pen Stepper Microsteps", kDefaultPenMicrostep, "", false},
{"int", "servoPin", &g_iServoPin, "Servo Pin", kDefaultServoPin, "", false},
{"text", "wifiSsid", &g_sWifiSsid, "WLAN SSID", 0, "", false},
{"password", "wifiPassword", &g_sWifiPassword, "WLAN Passwort", 0, "", true},
};
const size_t configParameterCount = sizeof(configParameters) / sizeof(configParameters[0]);
bool initConfigStore()
{
if (!SPIFFS.begin(true))
{
configStoreReady = false;
return false;
}
configStoreReady = loadConfigFromFile();
return configStoreReady;
}
bool loadConfigFromFile()
{
applyDefaults();
File file = SPIFFS.open(kConfigPath, "r");
if (!file)
{
Log("config.json fehlt, defaults werden gespeichert");
return saveConfigToFile();
}
StaticJsonDocument<kConfigJsonCapacity> doc;
DeserializationError err = deserializeJson(doc, file);
file.close();
if (err)
{
Log(String("config.json ist ungueltig (") + err.c_str() + "), defaults werden gespeichert");
return saveConfigToFile();
}
JsonArray params = doc["parameters"].as<JsonArray>();
for (JsonObject item : params)
{
const char *key = item["key"];
if (key == nullptr)
{
continue;
}
ConfigParameter *param = findParameter(String(key));
if (param == nullptr)
{
continue;
}
if (item.containsKey("value"))
{
if (isIntType(*param))
{
*asIntPtr(*param) = item["value"].as<int>();
}
else
{
*asStringPtr(*param) = item["value"].as<String>();
}
}
if (item.containsKey("description"))
{
param->description = item["description"].as<String>();
}
}
updateStepCorrectionFactors();
Log(String("Config geladen: penUpPos=") + g_iPenUpPos + ", penDownPos=" + g_iPenDownPos);
return true;
}
bool saveConfigToFile()
{
File file = SPIFFS.open(kConfigPath, "w");
if (!file)
{
Log("SPIFFS open write failed");
return false;
}
StaticJsonDocument<kConfigJsonCapacity> doc;
JsonArray params = doc.createNestedArray("parameters");
for (size_t i = 0; i < configParameterCount; ++i)
{
JsonObject item = params.createNestedObject();
item["key"] = configParameters[i].key;
item["type"] = configParameters[i].type;
item["secret"] = configParameters[i].secret;
if (isIntType(configParameters[i]))
{
item["value"] = *asIntPtr(configParameters[i]);
}
else
{
item["value"] = *asStringPtr(configParameters[i]);
}
item["description"] = configParameters[i].description;
}
if (doc.overflowed())
{
Log("Config JSON Dokument zu klein beim Speichern");
file.close();
return false;
}
bool ok = serializeJsonPretty(doc, file) > 0;
file.flush();
file.close();
if (!ok)
{
Log("serializeJsonPretty failed");
}
return ok;
}
String buildConfigJson()
{
StaticJsonDocument<kConfigJsonCapacity> doc;
JsonArray params = doc.createNestedArray("parameters");
for (size_t i = 0; i < configParameterCount; ++i)
{
JsonObject item = params.createNestedObject();
item["key"] = configParameters[i].key;
item["type"] = configParameters[i].type;
item["secret"] = configParameters[i].secret;
if (isIntType(configParameters[i]))
{
item["value"] = *asIntPtr(configParameters[i]);
}
else if (configParameters[i].secret)
{
item["value"] = "";
item["hasValue"] = !asStringPtr(configParameters[i])->isEmpty();
}
else
{
item["value"] = *asStringPtr(configParameters[i]);
}
item["description"] = configParameters[i].description;
}
if (doc.overflowed())
{
Log("Config JSON Dokument zu klein beim Lesen");
return String("{\"parameters\":[]}");
}
String output;
serializeJson(doc, output);
return output;
}
bool applyConfigJson(const String &payload, String &errorMessage)
{
StaticJsonDocument<kConfigJsonCapacity> doc;
DeserializationError err = deserializeJson(doc, payload);
if (err)
{
errorMessage = "Invalid JSON payload";
return false;
}
JsonArray params = doc["parameters"].as<JsonArray>();
if (params.isNull())
{
errorMessage = "JSON must contain 'parameters' array";
return false;
}
for (JsonObject item : params)
{
const char *key = item["key"];
if (key == nullptr)
{
continue;
}
ConfigParameter *param = findParameter(String(key));
if (param == nullptr)
{
continue;
}
if (!item.containsKey("value"))
{
continue;
}
if (isIntType(*param))
{
*asIntPtr(*param) = item["value"].as<int>();
}
else
{
*asStringPtr(*param) = item["value"].as<String>();
}
if (item.containsKey("description"))
{
param->description = item["description"].as<String>();
}
}
updateStepCorrectionFactors();
return true;
}
void startWebInterface()
{
initConfigStore();
bool staConnected = false;
if (!g_sWifiSsid.isEmpty())
{
WiFi.mode(WIFI_STA);
WiFi.begin(g_sWifiSsid.c_str(), g_sWifiPassword.c_str());
const unsigned long connectStart = millis();
const unsigned long connectTimeoutMs = 10000;
while (WiFi.status() != WL_CONNECTED && millis() - connectStart < connectTimeoutMs)
{
delay(250);
}
staConnected = (WiFi.status() == WL_CONNECTED);
if (staConnected)
{
Serial.println(String("http://") + WiFi.localIP().toString());
}
else
{
Serial.println("WLAN Verbindung fehlgeschlagen");
}
server.on("/", HTTP_GET, handleRoot);
server.on("/api/config", HTTP_GET, handleGetConfig);
server.on("/api/config", HTTP_POST, handlePostConfig);
server.on("/api/logs", HTTP_GET, handleGetLogs);
server.onNotFound(handleNotFound);
server.begin();
startWifiProtocolInterface();
}
if (!staConnected)
{
WiFi.mode(WIFI_AP);
if (WiFi.softAP("EggDuino"))
{
Serial.println(String("AP aktiv: EggDuino / http://") + WiFi.softAPIP().toString());
}
else
{
Serial.println("AP konnte nicht gestartet werden");
}
}
server.on("/", HTTP_GET, handleRoot);
server.on("/api/config", HTTP_GET, handleGetConfig);
server.on("/api/config", HTTP_POST, handlePostConfig);
server.on("/api/logs", HTTP_GET, handleGetLogs);
server.onNotFound(handleNotFound);
server.begin();
}
void handleWebInterface()
{
server.handleClient();
}