Blame | Last modification | View Log | RSS feed
#ifndef CONFIGURATOR_HPP#define CONFIGURATOR_HPP#include <type_traits>#include <FS.h>#include "Json.h"#include "SerialLog.h"#include "ConfigUpgrader.hpp"#define MIN_F -3.4028235E+38#define MAX_F 3.4028235E+38// Public dropdown definition type, available only when /setup is enablednamespace AsyncFSWebServer {struct DropdownList {const char* label; // JSON key / UI label idconst char* const* values; // Static array of values (null-terminated strings)size_t size; // Number of items in valuessize_t selectedIndex; // Currently selected item index};struct Slider {const char* label; // JSON key / UI label iddouble min; // Minimum valuedouble max; // Maximum valuedouble step; // Step incrementdouble value; // Current value};}class SetupConfigurator{protected:uint8_t numOptions = 0;fs::FS* m_filesystem = nullptr;CJSON::Json* m_doc = nullptr;CJSON::Json* m_savedDoc = nullptr; // Temporary storage for saved file values// Builders for v2 hierarchical schema (sections / elements)CJSON::Json m_sectionsArray; // Root array of sections for current sessionCJSON::Json m_currentSection; // Currently open sectionCJSON::Json m_currentElements; // Elements array for current sectionbool m_hasCurrentSection = false; // True if a section is openuint16_t& m_port;String& m_host;bool m_opened = false;// --------- Helpers for v2 hierarchical schema ---------// Ensure there is an open section to which options can be addedvoid ensureActiveSection() {if (m_hasCurrentSection) return;// Default section when user doesn't call addOptionBox explicitlym_currentSection.createObject();m_currentSection.setString("title", "General Options");m_currentElements.createArray();m_hasCurrentSection = true;}// Start a new named section (used by addOptionBox)void startNewSection(const char* title) {// Finalize previous section firstif (m_hasCurrentSection) {m_currentSection.set("elements", m_currentElements);m_sectionsArray.add(m_currentSection);m_currentSection.createObject();m_currentElements.createArray();}String t = String(title);m_currentSection.setString("title", t);m_hasCurrentSection = true;}// Finalize any open section and attach sections array to root documentvoid finalizeSectionsToRoot() {if (m_doc == nullptr) return;if (m_hasCurrentSection) {m_currentSection.set("elements", m_currentElements);m_sectionsArray.add(m_currentSection);m_hasCurrentSection = false;}m_doc->set("sections", m_sectionsArray);}bool isOpened() {return m_opened;}bool openConfiguration() {if (checkConfigFile()) {// Check if config needs upgrade from v1 to v2upgradeConfigIfNeeded();// Read existing file into m_savedDoc (background copy for value lookup)if (m_filesystem->exists(ESP_FS_WS_CONFIG_FILE)) {File file = m_filesystem->open(ESP_FS_WS_CONFIG_FILE, "r");if (file) {String content = file.readString();file.close();m_savedDoc = new CJSON::Json();if (!m_savedDoc->parse(content)) {log_error("Failed to parse existing configuration");delete m_savedDoc;m_savedDoc = nullptr;// Don't continue if parsing failsreturn false;}}}// Create fresh v2 root document for this sessionm_doc = new CJSON::Json();m_doc->createObject();// Version tagm_doc->setString("_version", "2.0");// Metadata sectionm_doc->ensureObject("_meta");String appTitle = "Custom HTML Web Server";String logoPath = String(ESP_FS_WS_CONFIG_FOLDER) + "/logo.svg";if (m_savedDoc) {String tmp;if (m_savedDoc->getString("_meta", "app_title", tmp)) appTitle = tmp;if (m_savedDoc->getString("_meta", "logo", tmp)) logoPath = tmp;}m_doc->setString("_meta", "app_title", appTitle);m_doc->setString("_meta", "logo", logoPath);m_doc->setNumber("_meta", "port", static_cast<double>(m_port));m_doc->setString("_meta", "host", m_host);// State section (object; can be extended externally if needed)m_doc->ensureObject("_state");// Assets section: carry over existing lists when presentm_doc->ensureObject("_assets");std::vector<String> cssList;std::vector<String> jsList;std::vector<String> htmlList;if (m_savedDoc) {auto copyArray = [](CJSON::Json* src, const char* obj, const char* key, std::vector<String>& out) {if (!src) return;const cJSON* root = src->getRoot();if (!root) return;const cJSON* scope = cJSON_GetObjectItemCaseSensitive(root, obj);if (!scope || !cJSON_IsObject(scope)) return;const cJSON* arr = cJSON_GetObjectItemCaseSensitive(scope, key);if (!arr || !cJSON_IsArray(arr)) return;for (const cJSON* it = arr->child; it; it = it->next) {if (cJSON_IsString(it) && it->valuestring) {out.emplace_back(String(it->valuestring));}}};copyArray(m_savedDoc, "_assets", "css", cssList);// Prefer new key "js" but also accept legacy "javascript" for backward compatibilitycopyArray(m_savedDoc, "_assets", "js", jsList);if (jsList.empty()) {copyArray(m_savedDoc, "_assets", "javascript", jsList);}copyArray(m_savedDoc, "_assets", "html", htmlList);}std::vector<String> empty;m_doc->setArray("_assets", "css", cssList.empty() ? empty : cssList);m_doc->setArray("_assets", "js", jsList.empty() ? empty : jsList);m_doc->setArray("_assets", "html", htmlList.empty() ? empty : htmlList);// Initialize sections builder (will be attached to m_doc on close)m_sectionsArray.createArray();m_currentSection.createObject();m_currentElements.createArray();m_hasCurrentSection = false;m_opened = true;return true;}return false;}/*** @brief Check if config needs upgrade and perform it if necessary* Uses ConfigUpgrader to migrate from v1 to v2 format*/void upgradeConfigIfNeeded() {if (m_filesystem == nullptr) return;ConfigUpgrader upgrader(m_filesystem, ESP_FS_WS_CONFIG_FILE);upgrader.upgrade();}// If config file or folder doesn't exist, create them. If config file exists, do nothing.// Returns true if config file is ready for use (exists or created successfully), false on failure.// Some keys might be necessary for the setup page to work properly, so this function ensures that// the config file exists and is initialized with a valid JSON object if it was missing.bool checkConfigFile() {File file = m_filesystem->open(ESP_FS_WS_CONFIG_FOLDER, "r");if (!file) {log_error("Failed to open /setup directory. Create new folder\n");if (!m_filesystem->mkdir(ESP_FS_WS_CONFIG_FOLDER)) {log_error("Error. Folder %s not created", ESP_FS_WS_CONFIG_FOLDER);return false;}}// Check if config file exist, and create if necessaryif (!m_filesystem->exists(ESP_FS_WS_CONFIG_FILE)) {file = m_filesystem->open(ESP_FS_WS_CONFIG_FILE, "w");if (!file) {log_error("Error. File %s not created", ESP_FS_WS_CONFIG_FILE);return false;}// Create pure v2 config (no legacy flat keys)CJSON::Json initDoc;initDoc.createObject();initDoc.setString("_version", "2.0");// MetadatainitDoc.ensureObject("_meta");initDoc.setString("_meta", "app_title", String("Custom HTML Web Server"));initDoc.setString("_meta", "logo", String(ESP_FS_WS_CONFIG_FOLDER) + "/logo.svg");initDoc.setNumber("_meta", "port", static_cast<double>(m_port));initDoc.setString("_meta", "host", m_host);// Empty _state objectinitDoc.ensureObject("_state");// _assets with empty listsinitDoc.ensureObject("_assets");{std::vector<String> empty;initDoc.setArray("_assets", "css", empty);initDoc.setArray("_assets", "js", empty);initDoc.setArray("_assets", "html", empty);}// Empty sections array (as real array node){CJSON::Json sections;sections.createArray();initDoc.set("sections", sections);}String json = initDoc.serialize(true);file.print(json);file.close();}log_debug("Config file %s OK", ESP_FS_WS_CONFIG_FILE);return true;}public:friend class AsyncFsWebServer;SetupConfigurator(fs::FS *fs, uint16_t& port, String& host): m_filesystem(fs), m_port(port), m_host(host) { ; }bool closeConfiguration() {// If no options were added in this session, skip writing to avoid overwritingif (numOptions == 0) {log_debug("No options added; skipping config write");if (m_doc) { delete m_doc; m_doc = nullptr; }if (m_savedDoc) { delete m_savedDoc; m_savedDoc = nullptr; }return true;}// Finalize sections into root _v2 schemafinalizeSectionsToRoot();// Write configuration to file only if content has changed// Serialize the new contentString newContent = m_doc->serialize(true);// Read existing file contentString oldContent;if (m_filesystem->exists(ESP_FS_WS_CONFIG_FILE)) {File readFile = m_filesystem->open(ESP_FS_WS_CONFIG_FILE, "r");if (readFile) {oldContent = readFile.readString();readFile.close();}}// Write only if content is differentif (oldContent != newContent) {File file = m_filesystem->open(ESP_FS_WS_CONFIG_FILE, "w");if (file) {file.print(newContent);file.close();log_debug("Config file written (content changed)");}else {log_error("Error opening config file for write");delete (m_doc);m_doc = nullptr;if (m_savedDoc) {delete (m_savedDoc);m_savedDoc = nullptr;}m_opened = false;return false;}}else {log_debug("Config file unchanged, skipping write");}delete (m_doc);m_doc = nullptr;if (m_savedDoc) {delete (m_savedDoc);m_savedDoc = nullptr;}m_opened = false;numOptions = 0;return true;}// Save logo image from binary data (uint8_t array)// Supports: PNG, JPEG, GIF, SVG (plain or gzipped)// Automatically detects gzip compression (magic bytes 0x1f 0x8b)void setSetupPageLogo(const uint8_t* imageData, size_t imageSize, const char* mimeType = "image/png", bool overwrite = false) {// Ensure configuration document is open so we can update _metaif (m_doc == nullptr) {if (!openConfiguration()) {log_error("Error! /setup configuration not possible");return;}}// Determine file extension from MIME typeString extension = ".png";if (strcmp(mimeType, "image/jpeg") == 0 || strcmp(mimeType, "image/jpg") == 0) {extension = ".jpg";} else if (strcmp(mimeType, "image/gif") == 0) {extension = ".gif";} else if (strcmp(mimeType, "image/svg+xml") == 0) {extension = ".svg";}String filename = ESP_FS_WS_CONFIG_FOLDER;filename += "/logo";filename += extension;// Auto-detect gzip compression by checking magic bytesif (imageSize >= 2 && imageData[0] == 0x1f && imageData[1] == 0x8b) {filename += ".gz";}// Save binary logo fileif (optionToFileBinary(filename.c_str(), imageData, imageSize, overwrite)) {// Store path in _meta.logo instead of creating an "img-logo" elementm_doc->ensureObject("_meta");m_doc->setString("_meta", "logo", filename);// Mark configuration as changed so closeConfiguration() will persist itnumOptions++;}}// Overload for string literals (e.g., SVG text)void setSetupPageLogo(const char* svgText, bool overwrite = false) {setSetupPageLogo((const uint8_t*)svgText, strlen(svgText), "image/svg+xml", overwrite);}// Set page title as metadata (_meta.app_title) instead of a normal option elementvoid setSetupPageTitle(const char* title) {if (m_doc == nullptr) {if (!openConfiguration()) {log_error("Error! /setup configuration not possible");return;}}m_doc->ensureObject("_meta");m_doc->setString("_meta", "app_title", String(title));// Mark configuration as changed so closeConfiguration() will persist itnumOptions++;}bool optionToFile(const char* filename, const char* str, bool overWrite) {// Check if file is already savedif (m_filesystem->exists(filename) && !overWrite) {return true;}// Create or overwrite option fileelse {File file = m_filesystem->open(filename, "w");if (file) {#if defined(ESP8266)String _str = str;file.print(_str);#elsefile.print(str);#endiffile.close();log_debug("File %s saved", filename);return true;}else {log_debug("Error writing file %s", filename);}}return false;}// Save binary data to file (e.g., pre-compressed gzip data)bool optionToFileBinary(const char* filename, const uint8_t* data, size_t len, bool overWrite) {if (m_filesystem->exists(filename) && !overWrite) {return true;}File file = m_filesystem->open(filename, "w");if (file) {size_t written = file.write(data, len);file.close();log_debug("Binary file %s saved (%d bytes)", filename, written);return written == len;}log_debug("Error writing binary file %s", filename);return false;}// Save binary data to file (for pre-compressed gzip data)bool optionToFileGzip(const char* filename, const uint8_t* data, size_t len, bool overWrite) {if (m_filesystem->exists(filename) && !overWrite) {return true;}File file = m_filesystem->open(filename, "w");if (file) {size_t written = file.write(data, len);file.close();log_debug("Binary file %s saved (%d bytes)", filename, written);return written == len;}log_debug("Error writing binary file %s", filename);return false;}void addSource(const String& source, const String& id, const String& extension, bool overWrite) {if (m_doc == nullptr) {if (!openConfiguration()) {log_error("Error! /setup configuration not possible");}}String path = ESP_FS_WS_CONFIG_FOLDER;path += "/";path += id;path += extension;bool isCss = extension.equals(".css");bool isJs = extension.equals(".js");if (optionToFile(path.c_str(), source.c_str(), overWrite)) {// Register asset path inside _assets.{css,js,html} instead of flat raw-* keysm_doc->ensureObject("_assets");cJSON* root = m_doc->getRoot();if (!root) return;cJSON* assets = cJSON_GetObjectItemCaseSensitive(root, "_assets");if (!assets || !cJSON_IsObject(assets)) return;const char* arrayKey = nullptr;if (isCss) arrayKey = "css";else if (isJs) arrayKey = "js";if (arrayKey) {cJSON* arr = cJSON_GetObjectItemCaseSensitive(assets, arrayKey);if (!arr || !cJSON_IsArray(arr)) {arr = cJSON_CreateArray();cJSON_AddItemToObject(assets, arrayKey, arr);}// Avoid duplicatesbool found = false;for (cJSON* it = arr->child; it; it = it->next) {if (cJSON_IsString(it) && it->valuestring && path.equals(String(it->valuestring))) {found = true;break;}}if (!found) {cJSON_AddItemToArray(arr, cJSON_CreateString(path.c_str()));}}}else {log_error("Source option not saved");}}void addHTML(const char* html, const char* id, bool overWrite) {String path = String(ESP_FS_WS_CONFIG_FOLDER) + "/" + id + ".htm";optionToFile(path.c_str(), html, overWrite);// Add HTML as an element in the current sectionCJSON::Json elem;elem.createObject();elem.setString("type", "html");elem.setString("label", "");elem.setString("value", path);m_currentElements.add(elem);numOptions++;}void addCSS(const char* css, const char* id, bool overWrite) {String source = css;addSource(source, id, ".css", overWrite);}void addJavascript(const char* script, const char* id, bool overWrite) {String source = script;addSource(source, id, ".js", overWrite);}/*Add a new dropdown input element*/void addDropdownList(const char *label, const char** array, size_t size) {if (m_doc == nullptr) {if (!openConfiguration()) {log_error("Error! /setup configuration not possible");}}ensureActiveSection();// Determine selected value: prefer saved, otherwise first itemString selectedValue = (size > 0) ? String(array[0]) : String("");if (m_savedDoc) {const cJSON* root = m_savedDoc->getRoot();if (root) {const cJSON* sections = cJSON_GetObjectItemCaseSensitive(root, "sections");if (sections && cJSON_IsArray(sections)) {const cJSON* sec = sections->child;while (sec) {const cJSON* elems = cJSON_GetObjectItemCaseSensitive(sec, "elements");if (elems && cJSON_IsArray(elems)) {const cJSON* el = elems->child;while (el) {const cJSON* lbl = cJSON_GetObjectItemCaseSensitive(el, "label");if (lbl && cJSON_IsString(lbl) && lbl->valuestring && String(lbl->valuestring).equals(label)) {const cJSON* val = cJSON_GetObjectItemCaseSensitive(el, "value");if (val && cJSON_IsString(val) && val->valuestring) {selectedValue = String(val->valuestring);}break;}el = el->next;}}sec = sec->next;}}}}CJSON::Json elem;elem.createObject();elem.setString("label", label);elem.setString("type", "select");elem.setString("value", selectedValue);std::vector<String> vals; vals.reserve(size);for (size_t i = 0; i < size; i++) vals.emplace_back(String(array[i]));elem.setArray("options", vals);m_currentElements.add(elem);numOptions++;}/*Add a new dropdown using a static definition that tracks current index*/void addDropdownList(AsyncFSWebServer::DropdownList &def) {if (m_doc == nullptr) {if (!openConfiguration()) {log_error("Error! /setup configuration not possible");}}const char* label = def.label;ensureActiveSection();// Determine selected value: prefer saved, otherwise provided index, otherwise firstString selectedValue = (def.size > 0) ? String(def.values[(def.selectedIndex < def.size) ? def.selectedIndex : 0]) : String("");if (m_savedDoc) {const cJSON* root = m_savedDoc->getRoot();if (root) {const cJSON* sections = cJSON_GetObjectItemCaseSensitive(root, "sections");if (sections && cJSON_IsArray(sections)) {const cJSON* sec = sections->child;while (sec) {const cJSON* elems = cJSON_GetObjectItemCaseSensitive(sec, "elements");if (elems && cJSON_IsArray(elems)) {const cJSON* el = elems->child;while (el) {const cJSON* lbl = cJSON_GetObjectItemCaseSensitive(el, "label");if (lbl && cJSON_IsString(lbl) && lbl->valuestring && String(lbl->valuestring).equals(label)) {const cJSON* val = cJSON_GetObjectItemCaseSensitive(el, "value");if (val && cJSON_IsString(val) && val->valuestring) {selectedValue = String(val->valuestring);}break;}el = el->next;}}sec = sec->next;}}}}CJSON::Json elem;elem.createObject();elem.setString("label", label);elem.setString("type", "select");elem.setString("value", selectedValue);std::vector<String> vals; vals.reserve(def.size);for (size_t i = 0; i < def.size; i++) { vals.emplace_back(String(def.values[i])); }elem.setArray("options", vals);// Update def.selectedIndex from selectedValuefor (size_t i = 0; i < def.size; i++) {if (selectedValue.equals(String(def.values[i]))) {def.selectedIndex = i;break;}}m_currentElements.add(elem);numOptions++;}/*Update a dropdown definition's selectedIndex from persisted configReturns true if a matching value was found*/bool getDropdownSelection(AsyncFSWebServer::DropdownList &def) {// Ensure we have a doc to read fromif (m_doc == nullptr && !openConfiguration()) {log_error("Error! /setup configuration not possible");return false;}CJSON::Json* sourceDoc = (m_savedDoc != nullptr) ? m_savedDoc : m_doc;if (sourceDoc == nullptr) {log_error("No configuration document available for reading");return false;}const cJSON* root = sourceDoc->getRoot();if (!root) return false;const cJSON* sections = cJSON_GetObjectItemCaseSensitive(root, "sections");if (!sections || !cJSON_IsArray(sections)) return false;String sel;const cJSON* sec = sections->child;while (sec) {const cJSON* elems = cJSON_GetObjectItemCaseSensitive(sec, "elements");if (elems && cJSON_IsArray(elems)) {const cJSON* el = elems->child;while (el) {const cJSON* lbl = cJSON_GetObjectItemCaseSensitive(el, "label");if (lbl && cJSON_IsString(lbl) && lbl->valuestring && String(lbl->valuestring).equals(def.label)) {const cJSON* val = cJSON_GetObjectItemCaseSensitive(el, "value");if (val && cJSON_IsString(val) && val->valuestring) {sel = String(val->valuestring);}break;}el = el->next;}}sec = sec->next;}if (!sel.length()) return false;for (size_t i = 0; i < def.size; i++) {if (sel.equals(String(def.values[i]))) {def.selectedIndex = i;return true;}}return false;}/*Add a new slider using a static definition that tracks current value*/void addSlider(AsyncFSWebServer::Slider &def) {if (m_doc == nullptr) {if (!openConfiguration()) {log_error("Error! /setup configuration not possible");}}const char* label = def.label;ensureActiveSection();// Prefer saved value when available; else use def.valuedouble current = def.value;if (m_savedDoc) {const cJSON* root = m_savedDoc->getRoot();if (root) {const cJSON* sections = cJSON_GetObjectItemCaseSensitive(root, "sections");if (sections && cJSON_IsArray(sections)) {const cJSON* sec = sections->child;while (sec) {const cJSON* elems = cJSON_GetObjectItemCaseSensitive(sec, "elements");if (elems && cJSON_IsArray(elems)) {const cJSON* el = elems->child;while (el) {const cJSON* lbl = cJSON_GetObjectItemCaseSensitive(el, "label");if (lbl && cJSON_IsString(lbl) && lbl->valuestring && String(lbl->valuestring).equals(label)) {const cJSON* val = cJSON_GetObjectItemCaseSensitive(el, "value");if (val && cJSON_IsNumber(val)) {current = val->valuedouble;}break;}el = el->next;}}sec = sec->next;}}}}def.value = current;CJSON::Json elem;elem.createObject();elem.setString("label", label);elem.setString("type", "slider");elem.setNumber("value", current);elem.setNumber("min", def.min);elem.setNumber("max", def.max);elem.setNumber("step", def.step);m_currentElements.add(elem);numOptions++;}/*Read slider value into the provided struct from persisted configReturns true if a value was found*/bool getSliderValue(AsyncFSWebServer::Slider &def) {if (m_doc == nullptr && !openConfiguration()) {log_error("Error! /setup configuration not possible");return false;}CJSON::Json* sourceDoc = (m_savedDoc != nullptr) ? m_savedDoc : m_doc;if (sourceDoc == nullptr) return false;const cJSON* root = sourceDoc->getRoot();if (!root) return false;const cJSON* sections = cJSON_GetObjectItemCaseSensitive(root, "sections");if (!sections || !cJSON_IsArray(sections)) return false;const cJSON* sec = sections->child;while (sec) {const cJSON* elems = cJSON_GetObjectItemCaseSensitive(sec, "elements");if (elems && cJSON_IsArray(elems)) {const cJSON* el = elems->child;while (el) {const cJSON* lbl = cJSON_GetObjectItemCaseSensitive(el, "label");if (lbl && cJSON_IsString(lbl) && lbl->valuestring && String(lbl->valuestring).equals(def.label)) {const cJSON* val = cJSON_GetObjectItemCaseSensitive(el, "value");if (val && cJSON_IsNumber(val)) {def.value = val->valuedouble;return true;}return false;}el = el->next;}}sec = sec->next;}return false;}/*Add a new option box with custom label*/void addOptionBox(const char* boxTitle) {if (m_doc == nullptr) {if (!openConfiguration()) {log_error("Error! /setup configuration not possible");return;}}startNewSection(boxTitle);}/*** @brief Add a comment string associated with an existing element* The comment will be stored in the element object under key "comment".* When the frontend renders the option it will append a <div class="cmt">.*/void addComment(const char *tag, const char *comment) {if (m_doc == nullptr) {if (!openConfiguration()) {log_error("Error! /setup configuration not possible");return;}}String ct = String(comment);bool found = false;// search in current elements (active section){cJSON* arr = m_currentElements.getRoot();if (arr && cJSON_IsArray(arr)) {for (cJSON* el = arr->child; el; el = el->next) {cJSON* lbl = cJSON_GetObjectItemCaseSensitive(el, "label");if (lbl && cJSON_IsString(lbl) && String(lbl->valuestring) == tag) {cJSON_DeleteItemFromObjectCaseSensitive(el, "comment");cJSON_AddStringToObject(el, "comment", comment);found = true;break;}}}}// also search in previously built sectionsif (!found) {cJSON* secs = m_sectionsArray.getRoot();if (secs && cJSON_IsArray(secs)) {for (cJSON* sec = secs->child; sec && !found; sec = sec->next) {cJSON* elems = cJSON_GetObjectItemCaseSensitive(sec, "elements");if (elems && cJSON_IsArray(elems)) {for (cJSON* el = elems->child; el; el = el->next) {cJSON* lbl = cJSON_GetObjectItemCaseSensitive(el, "label");if (lbl && cJSON_IsString(lbl) && String(lbl->valuestring) == tag) {cJSON_DeleteItemFromObjectCaseSensitive(el, "comment");cJSON_AddStringToObject(el, "comment", comment);found = true;break;}}}}}}// fallback: write to root document so it won't be lostif (!found) {m_doc->setString(tag, "comment", ct);}}/*Add custom option to config webpage (float values)*/template <typename T>void addOption(const char *label, T val, double d_min, double d_max, double step) {addOption(label, val, false, d_min, d_max, step);}/*Add custom option to config webpage (type of parameter will be deduced from variable itself)*/// bool-specific overload with grouping flag (grouped last to avoid breaking existing code)void addOption(const char *label, bool val, bool hidden = false, bool grouped = true) {if (m_doc == nullptr) {if (!openConfiguration()) {log_error("Error! /setup configuration not possible");}}ensureActiveSection();String lbl = label;bool valueFromSaved = false;// read saved as beforeauto readSavedBool = [&](bool& out) -> bool {if (!m_savedDoc) return false;const cJSON* root = m_savedDoc->getRoot();if (!root) return false;const cJSON* sections = cJSON_GetObjectItemCaseSensitive(root, "sections");if (!sections || !cJSON_IsArray(sections)) return false;const cJSON* sec = sections->child;while (sec) {const cJSON* elems = cJSON_GetObjectItemCaseSensitive(sec, "elements");if (elems && cJSON_IsArray(elems)) {const cJSON* el = elems->child;while (el) {const cJSON* lblNode = cJSON_GetObjectItemCaseSensitive(el, "label");if (lblNode && cJSON_IsString(lblNode) && lblNode->valuestring && String(lblNode->valuestring).equals(lbl)) {const cJSON* v = cJSON_GetObjectItemCaseSensitive(el, "value");if (v && cJSON_IsBool(v)) {out = cJSON_IsTrue(v);return true;}return false;}el = el->next;}}sec = sec->next;}return false;};CJSON::Json elem;elem.createObject();elem.setString("label", lbl);bool current = val;if (readSavedBool(current)) valueFromSaved = true;elem.setString("type", "boolean");elem.setBool("value", current);if (!grouped) {elem.setBool("group", false);}if (hidden) {elem.setBool("hidden", true);}log_debug("Option \"%s\" using %s value", lbl.c_str(), valueFromSaved ? "saved" : "default");m_currentElements.add(elem);numOptions++;}// generic template for all other typestemplate <typename T>void addOption(const char *label, T val, bool hidden = false,double d_min = MIN_F, double d_max = MAX_F, double step = 1.0){if (m_doc == nullptr) {if (!openConfiguration()) {log_error("Error! /setup configuration not possible");}}ensureActiveSection();String lbl = label;bool valueFromSaved = false;// Resolve current value: check saved v2 sections firstauto readSavedNumber = [&](double& out) -> bool {if (!m_savedDoc) return false;const cJSON* root = m_savedDoc->getRoot();if (!root) return false;const cJSON* sections = cJSON_GetObjectItemCaseSensitive(root, "sections");if (!sections || !cJSON_IsArray(sections)) return false;const cJSON* sec = sections->child;while (sec) {const cJSON* elems = cJSON_GetObjectItemCaseSensitive(sec, "elements");if (elems && cJSON_IsArray(elems)) {const cJSON* el = elems->child;while (el) {const cJSON* lblNode = cJSON_GetObjectItemCaseSensitive(el, "label");if (lblNode && cJSON_IsString(lblNode) && lblNode->valuestring && String(lblNode->valuestring).equals(lbl)) {const cJSON* v = cJSON_GetObjectItemCaseSensitive(el, "value");if (v && cJSON_IsNumber(v)) {out = v->valuedouble;return true;}return false;}el = el->next;}}sec = sec->next;}return false;};auto readSavedString = [&](String& out) -> bool {if (!m_savedDoc) return false;const cJSON* root = m_savedDoc->getRoot();if (!root) return false;const cJSON* sections = cJSON_GetObjectItemCaseSensitive(root, "sections");if (!sections || !cJSON_IsArray(sections)) return false;const cJSON* sec = sections->child;while (sec) {const cJSON* elems = cJSON_GetObjectItemCaseSensitive(sec, "elements");if (elems && cJSON_IsArray(elems)) {const cJSON* el = elems->child;while (el) {const cJSON* lblNode = cJSON_GetObjectItemCaseSensitive(el, "label");if (lblNode && cJSON_IsString(lblNode) && lblNode->valuestring && String(lblNode->valuestring).equals(lbl)) {const cJSON* v = cJSON_GetObjectItemCaseSensitive(el, "value");if (v && cJSON_IsString(v) && v->valuestring) {out = String(v->valuestring);return true;}return false;}el = el->next;}}sec = sec->next;}return false;};CJSON::Json elem;elem.createObject();elem.setString("label", lbl);if constexpr (std::is_same<T, String>::value) {String current = val;if (readSavedString(current)) valueFromSaved = true;elem.setString("type", "text");elem.setString("value", current);} else if constexpr (std::is_same<T, const char*>::value || std::is_same<T, char*>::value) {String current = String(val);if (readSavedString(current)) valueFromSaved = true;elem.setString("type", "text");elem.setString("value", current);} else {double current = static_cast<double>(val);if (readSavedNumber(current)) valueFromSaved = true;elem.setString("type", "number");elem.setNumber("value", current);if (d_min != MIN_F) elem.setNumber("min", d_min);if (d_max != MAX_F) elem.setNumber("max", d_max);if (step != 1.0) elem.setNumber("step", step);}if (hidden) {elem.setBool("hidden", true);}log_debug("Option \"%s\" using %s value", lbl.c_str(), valueFromSaved ? "saved" : "default");m_currentElements.add(elem);numOptions++;}/*Get current value for a specific custom option (true on success)Reads from m_doc if open, or reloads from file if closed*/template <typename T>bool getOptionValue(const char *label, T &var) {// If m_doc is nullptr, reload configuration from fileif (m_doc == nullptr) {if (!openConfiguration()) {log_error("Error! /setup configuration not possible");return false;}}// Prefer persisted values when available; fall back to current session docCJSON::Json* sourceDoc = (m_savedDoc != nullptr) ? m_savedDoc : m_doc;if (sourceDoc == nullptr) {log_error("No configuration document available for reading");return false;}const cJSON* root = sourceDoc->getRoot();if (!root) return false;// Special case: port/host are stored in _metaif constexpr (!std::is_same<T, String>::value && !std::is_same<T, const char*>::value && !std::is_same<T, char*>::value) {if (strcmp(label, "port") == 0) {const cJSON* meta = cJSON_GetObjectItemCaseSensitive(root, "_meta");const cJSON* p = meta ? cJSON_GetObjectItemCaseSensitive(meta, "port") : nullptr;if (p && cJSON_IsNumber(p)) {var = static_cast<T>(p->valuedouble);return true;}}}const cJSON* sections = cJSON_GetObjectItemCaseSensitive(root, "sections");if (!sections || !cJSON_IsArray(sections)) return false;const cJSON* sec = sections->child;while (sec) {const cJSON* elems = cJSON_GetObjectItemCaseSensitive(sec, "elements");if (elems && cJSON_IsArray(elems)) {const cJSON* el = elems->child;while (el) {const cJSON* lblNode = cJSON_GetObjectItemCaseSensitive(el, "label");if (lblNode && cJSON_IsString(lblNode) && lblNode->valuestring && String(lblNode->valuestring).equals(label)) {const cJSON* valNode = cJSON_GetObjectItemCaseSensitive(el, "value");if constexpr (std::is_same<T, String>::value) {if (valNode && cJSON_IsString(valNode) && valNode->valuestring) {var = String(valNode->valuestring);return true;}} else if constexpr (std::is_same<T, const char*>::value || std::is_same<T, char*>::value) {if (valNode && cJSON_IsString(valNode) && valNode->valuestring) {static String tmp; // Note: lifetime tied to process; acceptable for config readstmp = String(valNode->valuestring);var = tmp.c_str();return true;}} else if constexpr (std::is_same<T, bool>::value) {if (valNode && cJSON_IsBool(valNode)) {var = cJSON_IsTrue(valNode);return true;}} else {if (valNode && cJSON_IsNumber(valNode)) {var = static_cast<T>(valNode->valuedouble);return true;}}return false;}el = el->next;}}sec = sec->next;}return false;}template <typename T>bool saveOptionValue(const char *label, T val) {if (m_doc == nullptr) {if (!openConfiguration()) {log_error("Error! /setup configuration not possible");return false;}}// Ensure sections are attached before modifying (so we always work on the same tree)finalizeSectionsToRoot();cJSON* root = m_doc->getRoot();if (!root) return false;cJSON* sections = cJSON_GetObjectItemCaseSensitive(root, "sections");if (!sections || !cJSON_IsArray(sections)) return false;cJSON* targetElement = nullptr;for (cJSON* sec = sections->child; sec; sec = sec->next) {cJSON* elems = cJSON_GetObjectItemCaseSensitive(sec, "elements");if (!elems || !cJSON_IsArray(elems)) continue;for (cJSON* el = elems->child; el; el = el->next) {cJSON* lblNode = cJSON_GetObjectItemCaseSensitive(el, "label");if (lblNode && cJSON_IsString(lblNode) && lblNode->valuestring && String(lblNode->valuestring).equals(label)) {targetElement = el;break;}}if (targetElement) break;}if (!targetElement) return false;// Replace or create "value" field inside target elementcJSON_DeleteItemFromObjectCaseSensitive(targetElement, "value");if constexpr (std::is_same<T, String>::value) {cJSON_AddItemToObject(targetElement, "value", cJSON_CreateString(val.c_str()));} else if constexpr (std::is_same<T, const char*>::value || std::is_same<T, char*>::value) {cJSON_AddItemToObject(targetElement, "value", cJSON_CreateString(String(val).c_str()));} else if constexpr (std::is_same<T, bool>::value) {cJSON_AddItemToObject(targetElement, "value", cJSON_CreateBool(val));} else {cJSON_AddItemToObject(targetElement, "value", cJSON_CreateNumber(static_cast<double>(val)));}return true;}};#endif