Subversion Repositories ESP8266_P1_Meter

Rev

Details | Last modification | View Log | RSS feed

Rev Author Line No. Line
2 raymond 1
#ifndef CONFIGURATOR_HPP
2
#define CONFIGURATOR_HPP
3
#include <type_traits>
4
#include <FS.h>
5
 
6
#include "Json.h"
7
#include "SerialLog.h"
8
#include "ConfigUpgrader.hpp"
9
 
10
#define MIN_F -3.4028235E+38
11
#define MAX_F 3.4028235E+38
12
 
13
// Public dropdown definition type, available only when /setup is enabled
14
namespace AsyncFSWebServer {
15
    struct DropdownList {
16
        const char* label;                 // JSON key / UI label id
17
        const char* const* values;         // Static array of values (null-terminated strings)
18
        size_t size;                       // Number of items in values
19
        size_t selectedIndex;              // Currently selected item index
20
    };
21
 
22
    struct Slider {
23
        const char* label;                 // JSON key / UI label id
24
        double min;                        // Minimum value
25
        double max;                        // Maximum value
26
        double step;                       // Step increment
27
        double value;                      // Current value
28
    };
29
}
30
 
31
class SetupConfigurator
32
{
33
    protected:
34
        uint8_t numOptions = 0;
35
        fs::FS* m_filesystem = nullptr;
36
        CJSON::Json* m_doc = nullptr;
37
        CJSON::Json* m_savedDoc = nullptr;  // Temporary storage for saved file values
38
 
39
        // Builders for v2 hierarchical schema (sections / elements)
40
        CJSON::Json m_sectionsArray;        // Root array of sections for current session
41
        CJSON::Json m_currentSection;       // Currently open section
42
        CJSON::Json m_currentElements;      // Elements array for current section
43
        bool m_hasCurrentSection = false;   // True if a section is open
44
 
45
        uint16_t& m_port;         
46
        String& m_host;
47
        bool m_opened = false;
48
 
49
        // --------- Helpers for v2 hierarchical schema ---------
50
 
51
        // Ensure there is an open section to which options can be added
52
        void ensureActiveSection() {
53
            if (m_hasCurrentSection) return;
54
            // Default section when user doesn't call addOptionBox explicitly
55
            m_currentSection.createObject();
56
            m_currentSection.setString("title", "General Options");
57
            m_currentElements.createArray();
58
            m_hasCurrentSection = true;
59
        }
60
 
61
        // Start a new named section (used by addOptionBox)
62
        void startNewSection(const char* title) {
63
            // Finalize previous section first
64
            if (m_hasCurrentSection) {
65
                m_currentSection.set("elements", m_currentElements);
66
                m_sectionsArray.add(m_currentSection);
67
                m_currentSection.createObject();
68
                m_currentElements.createArray();
69
            }
70
            String t = String(title);
71
            m_currentSection.setString("title", t);
72
            m_hasCurrentSection = true;
73
        }
74
 
75
        // Finalize any open section and attach sections array to root document
76
        void finalizeSectionsToRoot() {
77
            if (m_doc == nullptr) return;
78
            if (m_hasCurrentSection) {
79
                m_currentSection.set("elements", m_currentElements);
80
                m_sectionsArray.add(m_currentSection);
81
                m_hasCurrentSection = false;
82
            }
83
            m_doc->set("sections", m_sectionsArray);
84
        }
85
 
86
        bool isOpened() {
87
            return m_opened;
88
        }
89
 
90
        bool openConfiguration() {
91
            if (checkConfigFile()) {
92
                // Check if config needs upgrade from v1 to v2
93
                upgradeConfigIfNeeded();
94
 
95
                // Read existing file into m_savedDoc (background copy for value lookup)
96
                if (m_filesystem->exists(ESP_FS_WS_CONFIG_FILE)) {
97
                    File file = m_filesystem->open(ESP_FS_WS_CONFIG_FILE, "r");
98
                    if (file) {
99
                        String content = file.readString();
100
                        file.close();
101
 
102
                        m_savedDoc = new CJSON::Json();
103
                        if (!m_savedDoc->parse(content)) {
104
                            log_error("Failed to parse existing configuration");
105
                            delete m_savedDoc;
106
                            m_savedDoc = nullptr;
107
                            // Don't continue if parsing fails
108
                            return false;
109
                        }
110
                    }
111
                }
112
 
113
                // Create fresh v2 root document for this session
114
                m_doc = new CJSON::Json();
115
                m_doc->createObject();
116
 
117
                // Version tag
118
                m_doc->setString("_version", "2.0");
119
 
120
                // Metadata section
121
                m_doc->ensureObject("_meta");
122
                String appTitle = "Custom HTML Web Server";
123
                String logoPath = String(ESP_FS_WS_CONFIG_FOLDER) + "/logo.svg";
124
                if (m_savedDoc) {
125
                    String tmp;
126
                    if (m_savedDoc->getString("_meta", "app_title", tmp)) appTitle = tmp;
127
                    if (m_savedDoc->getString("_meta", "logo", tmp)) logoPath = tmp;
128
                }
129
                m_doc->setString("_meta", "app_title", appTitle);
130
                m_doc->setString("_meta", "logo", logoPath);
131
                m_doc->setNumber("_meta", "port", static_cast<double>(m_port));
132
                m_doc->setString("_meta", "host", m_host);
133
 
134
                // State section (object; can be extended externally if needed)
135
                m_doc->ensureObject("_state");
136
 
137
                // Assets section: carry over existing lists when present
138
                m_doc->ensureObject("_assets");
139
                std::vector<String> cssList;
140
                std::vector<String> jsList;
141
                std::vector<String> htmlList;
142
                if (m_savedDoc) {
143
                    auto copyArray = [](CJSON::Json* src, const char* obj, const char* key, std::vector<String>& out) {
144
                        if (!src) return;
145
                        const cJSON* root = src->getRoot();
146
                        if (!root) return;
147
                        const cJSON* scope = cJSON_GetObjectItemCaseSensitive(root, obj);
148
                        if (!scope || !cJSON_IsObject(scope)) return;
149
                        const cJSON* arr = cJSON_GetObjectItemCaseSensitive(scope, key);
150
                        if (!arr || !cJSON_IsArray(arr)) return;
151
                        for (const cJSON* it = arr->child; it; it = it->next) {
152
                            if (cJSON_IsString(it) && it->valuestring) {
153
                                out.emplace_back(String(it->valuestring));
154
                            }
155
                        }
156
                    };
157
                    copyArray(m_savedDoc, "_assets", "css", cssList);
158
                    // Prefer new key "js" but also accept legacy "javascript" for backward compatibility
159
                    copyArray(m_savedDoc, "_assets", "js", jsList);
160
                    if (jsList.empty()) {
161
                        copyArray(m_savedDoc, "_assets", "javascript", jsList);
162
                    }
163
                    copyArray(m_savedDoc, "_assets", "html", htmlList);
164
                }
165
                std::vector<String> empty;
166
                m_doc->setArray("_assets", "css", cssList.empty() ? empty : cssList);
167
                m_doc->setArray("_assets", "js", jsList.empty() ? empty : jsList);
168
                m_doc->setArray("_assets", "html", htmlList.empty() ? empty : htmlList);
169
 
170
                // Initialize sections builder (will be attached to m_doc on close)
171
                m_sectionsArray.createArray();
172
                m_currentSection.createObject();
173
                m_currentElements.createArray();
174
                m_hasCurrentSection = false;
175
 
176
                m_opened = true;
177
                return true;
178
            }
179
            return false;
180
        }
181
 
182
        /**
183
         * @brief Check if config needs upgrade and perform it if necessary
184
         * Uses ConfigUpgrader to migrate from v1 to v2 format
185
         */
186
        void upgradeConfigIfNeeded() {
187
            if (m_filesystem == nullptr) return;
188
 
189
            ConfigUpgrader upgrader(m_filesystem, ESP_FS_WS_CONFIG_FILE);
190
            upgrader.upgrade();
191
        }
192
 
193
        // If config file or folder doesn't exist, create them. If config file exists, do nothing.
194
        // Returns true if config file is ready for use (exists or created successfully), false on failure.
195
        // Some keys might be necessary for the setup page to work properly, so this function ensures that 
196
        // the config file exists and is initialized with a valid JSON object if it was missing.
197
        bool checkConfigFile() {
198
            File file = m_filesystem->open(ESP_FS_WS_CONFIG_FOLDER, "r");
199
            if (!file) {
200
                log_error("Failed to open /setup directory. Create new folder\n");
201
                if (!m_filesystem->mkdir(ESP_FS_WS_CONFIG_FOLDER)) {
202
                    log_error("Error. Folder %s not created", ESP_FS_WS_CONFIG_FOLDER);
203
                    return false;
204
                }
205
            }
206
 
207
            // Check if config file exist, and create if necessary
208
            if (!m_filesystem->exists(ESP_FS_WS_CONFIG_FILE)) {
209
                file = m_filesystem->open(ESP_FS_WS_CONFIG_FILE, "w");
210
                if (!file) {
211
                    log_error("Error. File %s not created", ESP_FS_WS_CONFIG_FILE);
212
                    return false;
213
                }
214
                // Create pure v2 config (no legacy flat keys)
215
                CJSON::Json initDoc;
216
                initDoc.createObject();
217
                initDoc.setString("_version", "2.0");
218
 
219
                // Metadata
220
                initDoc.ensureObject("_meta");
221
                initDoc.setString("_meta", "app_title", String("Custom HTML Web Server"));
222
                initDoc.setString("_meta", "logo", String(ESP_FS_WS_CONFIG_FOLDER) + "/logo.svg");
223
                initDoc.setNumber("_meta", "port", static_cast<double>(m_port));
224
                initDoc.setString("_meta", "host", m_host);
225
 
226
                // Empty _state object
227
                initDoc.ensureObject("_state");
228
 
229
                // _assets with empty lists
230
                initDoc.ensureObject("_assets");
231
                {
232
                    std::vector<String> empty;
233
                    initDoc.setArray("_assets", "css", empty);
234
                    initDoc.setArray("_assets", "js", empty);
235
                    initDoc.setArray("_assets", "html", empty);
236
                }
237
 
238
                // Empty sections array (as real array node)
239
                {
240
                    CJSON::Json sections;
241
                    sections.createArray();
242
                    initDoc.set("sections", sections);
243
                }
244
 
245
                String json = initDoc.serialize(true);
246
                file.print(json);
247
                file.close();
248
            }
249
            log_debug("Config file %s OK", ESP_FS_WS_CONFIG_FILE);
250
            return true;
251
        }
252
 
253
    public:
254
        friend class AsyncFsWebServer;
255
        SetupConfigurator(fs::FS *fs, uint16_t& port, String& host) 
256
            : m_filesystem(fs), m_port(port), m_host(host) { ; }
257
 
258
        bool closeConfiguration() {            
259
 
260
            // If no options were added in this session, skip writing to avoid overwriting
261
            if (numOptions == 0) {
262
                log_debug("No options added; skipping config write");
263
                if (m_doc) { delete m_doc; m_doc = nullptr; }
264
                if (m_savedDoc) { delete m_savedDoc; m_savedDoc = nullptr; }
265
                return true;
266
            }
267
 
268
            // Finalize sections into root _v2 schema
269
            finalizeSectionsToRoot();
270
 
271
            // Write configuration to file only if content has changed
272
            // Serialize the new content
273
            String newContent = m_doc->serialize(true);
274
 
275
            // Read existing file content
276
            String oldContent;
277
            if (m_filesystem->exists(ESP_FS_WS_CONFIG_FILE)) {
278
                File readFile = m_filesystem->open(ESP_FS_WS_CONFIG_FILE, "r");
279
                if (readFile) {
280
                    oldContent = readFile.readString();
281
                    readFile.close();
282
                }
283
            }
284
 
285
            // Write only if content is different
286
            if (oldContent != newContent) {
287
                File file = m_filesystem->open(ESP_FS_WS_CONFIG_FILE, "w");
288
                if (file) {
289
                    file.print(newContent);
290
                    file.close();
291
                    log_debug("Config file written (content changed)");
292
                } 
293
                else {
294
                    log_error("Error opening config file for write");
295
                    delete (m_doc);
296
                    m_doc = nullptr;
297
                    if (m_savedDoc) { 
298
                        delete (m_savedDoc); 
299
                        m_savedDoc = nullptr; 
300
                    }
301
                    m_opened = false;
302
                    return false;
303
                }
304
            } 
305
            else {
306
                log_debug("Config file unchanged, skipping write");
307
            }
308
 
309
            delete (m_doc);
310
            m_doc = nullptr;
311
            if (m_savedDoc) { 
312
                delete (m_savedDoc); 
313
                m_savedDoc = nullptr; 
314
            }
315
 
316
            m_opened = false;
317
            numOptions = 0;
318
            return true;
319
        }
320
 
321
        // Save logo image from binary data (uint8_t array)
322
        // Supports: PNG, JPEG, GIF, SVG (plain or gzipped)
323
        // Automatically detects gzip compression (magic bytes 0x1f 0x8b)
324
        void setSetupPageLogo(const uint8_t* imageData, size_t imageSize, const char* mimeType = "image/png", bool overwrite = false) {
325
            // Ensure configuration document is open so we can update _meta
326
            if (m_doc == nullptr) {
327
                if (!openConfiguration()) {
328
                    log_error("Error! /setup configuration not possible");
329
                    return;
330
                }
331
            }
332
 
333
            // Determine file extension from MIME type
334
            String extension = ".png";
335
            if (strcmp(mimeType, "image/jpeg") == 0 || strcmp(mimeType, "image/jpg") == 0) {
336
                extension = ".jpg";
337
            } else if (strcmp(mimeType, "image/gif") == 0) {
338
                extension = ".gif";
339
            } else if (strcmp(mimeType, "image/svg+xml") == 0) {
340
                extension = ".svg";
341
            }
342
 
343
            String filename = ESP_FS_WS_CONFIG_FOLDER;
344
            filename += "/logo";
345
            filename += extension;
346
 
347
            // Auto-detect gzip compression by checking magic bytes
348
            if (imageSize >= 2 && imageData[0] == 0x1f && imageData[1] == 0x8b) {
349
                filename += ".gz";
350
            }
351
 
352
            // Save binary logo file
353
            if (optionToFileBinary(filename.c_str(), imageData, imageSize, overwrite)) {
354
                // Store path in _meta.logo instead of creating an "img-logo" element
355
                m_doc->ensureObject("_meta");
356
                m_doc->setString("_meta", "logo", filename);
357
                // Mark configuration as changed so closeConfiguration() will persist it
358
                numOptions++;
359
            }
360
        }
361
 
362
        // Overload for string literals (e.g., SVG text)
363
        void setSetupPageLogo(const char* svgText, bool overwrite = false) {
364
            setSetupPageLogo((const uint8_t*)svgText, strlen(svgText), "image/svg+xml", overwrite);
365
        }
366
 
367
        // Set page title as metadata (_meta.app_title) instead of a normal option element
368
        void setSetupPageTitle(const char* title) {
369
            if (m_doc == nullptr) {
370
                if (!openConfiguration()) {
371
                    log_error("Error! /setup configuration not possible");
372
                    return;
373
                }
374
            }
375
 
376
            m_doc->ensureObject("_meta");
377
            m_doc->setString("_meta", "app_title", String(title));
378
            // Mark configuration as changed so closeConfiguration() will persist it
379
            numOptions++;
380
        }
381
 
382
        bool optionToFile(const char* filename, const char* str, bool overWrite) {
383
            // Check if file is already saved
384
            if (m_filesystem->exists(filename) && !overWrite) {
385
                return true;
386
            }
387
            // Create or overwrite option file
388
            else {
389
                File file = m_filesystem->open(filename, "w");
390
                if (file) {
391
                    #if defined(ESP8266)
392
                    String _str = str;
393
                    file.print(_str);
394
                    #else
395
                    file.print(str);
396
                    #endif
397
                    file.close();
398
                    log_debug("File %s saved", filename);
399
                    return true;
400
                }
401
                else {
402
                    log_debug("Error writing file %s", filename);
403
                }
404
            }
405
            return false;
406
        }
407
 
408
        // Save binary data to file (e.g., pre-compressed gzip data)
409
        bool optionToFileBinary(const char* filename, const uint8_t* data, size_t len, bool overWrite) {
410
            if (m_filesystem->exists(filename) && !overWrite) {
411
                return true;
412
            }
413
            File file = m_filesystem->open(filename, "w");
414
            if (file) {
415
                size_t written = file.write(data, len);
416
                file.close();
417
                log_debug("Binary file %s saved (%d bytes)", filename, written);
418
                return written == len;
419
            }
420
            log_debug("Error writing binary file %s", filename);
421
            return false;
422
        }
423
 
424
        // Save binary data to file (for pre-compressed gzip data)
425
        bool optionToFileGzip(const char* filename, const uint8_t* data, size_t len, bool overWrite) {
426
            if (m_filesystem->exists(filename) && !overWrite) {
427
                return true;
428
            }
429
            File file = m_filesystem->open(filename, "w");
430
            if (file) {
431
                size_t written = file.write(data, len);
432
                file.close();
433
                log_debug("Binary file %s saved (%d bytes)", filename, written);
434
                return written == len;
435
            }
436
            log_debug("Error writing binary file %s", filename);
437
            return false;
438
        }
439
 
440
        void addSource(const String& source, const String& id, const String& extension, bool overWrite) {
441
            if (m_doc == nullptr) {
442
                if (!openConfiguration()) {
443
                    log_error("Error! /setup configuration not possible");
444
                }
445
            }
446
 
447
            String path = ESP_FS_WS_CONFIG_FOLDER;
448
            path += "/";
449
            path += id;
450
            path += extension;
451
 
452
            bool isCss = extension.equals(".css");
453
            bool isJs = extension.equals(".js");
454
 
455
            if (optionToFile(path.c_str(), source.c_str(), overWrite)) {
456
                // Register asset path inside _assets.{css,js,html} instead of flat raw-* keys
457
                m_doc->ensureObject("_assets");
458
                cJSON* root = m_doc->getRoot();
459
                if (!root) return;
460
                cJSON* assets = cJSON_GetObjectItemCaseSensitive(root, "_assets");
461
                if (!assets || !cJSON_IsObject(assets)) return;
462
 
463
                const char* arrayKey = nullptr;
464
                if (isCss) arrayKey = "css";
465
                else if (isJs) arrayKey = "js";
466
 
467
                if (arrayKey) {
468
                    cJSON* arr = cJSON_GetObjectItemCaseSensitive(assets, arrayKey);
469
                    if (!arr || !cJSON_IsArray(arr)) {
470
                        arr = cJSON_CreateArray();
471
                        cJSON_AddItemToObject(assets, arrayKey, arr);
472
                    }
473
 
474
                    // Avoid duplicates
475
                    bool found = false;
476
                    for (cJSON* it = arr->child; it; it = it->next) {
477
                        if (cJSON_IsString(it) && it->valuestring && path.equals(String(it->valuestring))) {
478
                            found = true;
479
                            break;
480
                        }
481
                    }
482
                    if (!found) {
483
                        cJSON_AddItemToArray(arr, cJSON_CreateString(path.c_str()));
484
                    }
485
                }
486
            }
487
            else {
488
                log_error("Source option not saved");
489
            }
490
 
491
        }
492
 
493
        void addHTML(const char* html, const char* id, bool overWrite) {
494
            String path = String(ESP_FS_WS_CONFIG_FOLDER) + "/" + id + ".htm";
495
            optionToFile(path.c_str(), html, overWrite);
496
 
497
            // Add HTML as an element in the current section
498
            CJSON::Json elem;
499
            elem.createObject();
500
            elem.setString("type", "html");
501
            elem.setString("label", "");
502
            elem.setString("value", path);
503
 
504
            m_currentElements.add(elem);
505
            numOptions++;
506
        }
507
 
508
        void addCSS(const char* css,  const char* id, bool overWrite) {
509
            String source = css;
510
            addSource(source, id, ".css", overWrite);
511
        }
512
 
513
        void addJavascript(const char* script,  const char* id, bool overWrite) {
514
            String source = script;
515
            addSource(source, id, ".js", overWrite);
516
        }
517
 
518
        /*
519
            Add a new dropdown input element
520
        */
521
        void addDropdownList(const char *label, const char** array, size_t size) {
522
            if (m_doc == nullptr) {
523
                if (!openConfiguration()) {
524
                    log_error("Error! /setup configuration not possible");
525
                }
526
            }
527
 
528
            ensureActiveSection();
529
 
530
            // Determine selected value: prefer saved, otherwise first item
531
            String selectedValue = (size > 0) ? String(array[0]) : String("");
532
            if (m_savedDoc) {
533
                const cJSON* root = m_savedDoc->getRoot();
534
                if (root) {
535
                    const cJSON* sections = cJSON_GetObjectItemCaseSensitive(root, "sections");
536
                    if (sections && cJSON_IsArray(sections)) {
537
                        const cJSON* sec = sections->child;
538
                        while (sec) {
539
                            const cJSON* elems = cJSON_GetObjectItemCaseSensitive(sec, "elements");
540
                            if (elems && cJSON_IsArray(elems)) {
541
                                const cJSON* el = elems->child;
542
                                while (el) {
543
                                    const cJSON* lbl = cJSON_GetObjectItemCaseSensitive(el, "label");
544
                                    if (lbl && cJSON_IsString(lbl) && lbl->valuestring && String(lbl->valuestring).equals(label)) {
545
                                        const cJSON* val = cJSON_GetObjectItemCaseSensitive(el, "value");
546
                                        if (val && cJSON_IsString(val) && val->valuestring) {
547
                                            selectedValue = String(val->valuestring);
548
                                        }
549
                                        break;
550
                                    }
551
                                    el = el->next;
552
                                }
553
                            }
554
                            sec = sec->next;
555
                        }
556
                    }
557
                }
558
            }
559
 
560
            CJSON::Json elem;
561
            elem.createObject();
562
            elem.setString("label", label);
563
            elem.setString("type", "select");
564
            elem.setString("value", selectedValue);
565
            std::vector<String> vals; vals.reserve(size);
566
            for (size_t i = 0; i < size; i++) vals.emplace_back(String(array[i]));
567
            elem.setArray("options", vals);
568
 
569
            m_currentElements.add(elem);
570
            numOptions++;
571
        }
572
 
573
        /*
574
            Add a new dropdown using a static definition that tracks current index
575
        */
576
        void addDropdownList(AsyncFSWebServer::DropdownList &def) {
577
            if (m_doc == nullptr) {
578
                if (!openConfiguration()) {
579
                    log_error("Error! /setup configuration not possible");
580
                }
581
            }
582
 
583
            const char* label = def.label;
584
            ensureActiveSection();
585
 
586
            // Determine selected value: prefer saved, otherwise provided index, otherwise first
587
            String selectedValue = (def.size > 0) ? String(def.values[(def.selectedIndex < def.size) ? def.selectedIndex : 0]) : String("");
588
            if (m_savedDoc) {
589
                const cJSON* root = m_savedDoc->getRoot();
590
                if (root) {
591
                    const cJSON* sections = cJSON_GetObjectItemCaseSensitive(root, "sections");
592
                    if (sections && cJSON_IsArray(sections)) {
593
                        const cJSON* sec = sections->child;
594
                        while (sec) {
595
                            const cJSON* elems = cJSON_GetObjectItemCaseSensitive(sec, "elements");
596
                            if (elems && cJSON_IsArray(elems)) {
597
                                const cJSON* el = elems->child;
598
                                while (el) {
599
                                    const cJSON* lbl = cJSON_GetObjectItemCaseSensitive(el, "label");
600
                                    if (lbl && cJSON_IsString(lbl) && lbl->valuestring && String(lbl->valuestring).equals(label)) {
601
                                        const cJSON* val = cJSON_GetObjectItemCaseSensitive(el, "value");
602
                                        if (val && cJSON_IsString(val) && val->valuestring) {
603
                                            selectedValue = String(val->valuestring);
604
                                        }
605
                                        break;
606
                                    }
607
                                    el = el->next;
608
                                }
609
                            }
610
                            sec = sec->next;
611
                        }
612
                    }
613
                }
614
            }
615
 
616
            CJSON::Json elem;
617
            elem.createObject();
618
            elem.setString("label", label);
619
            elem.setString("type", "select");
620
            elem.setString("value", selectedValue);
621
            std::vector<String> vals; vals.reserve(def.size);
622
            for (size_t i = 0; i < def.size; i++) { vals.emplace_back(String(def.values[i])); }
623
            elem.setArray("options", vals);
624
 
625
            // Update def.selectedIndex from selectedValue
626
            for (size_t i = 0; i < def.size; i++) {
627
                if (selectedValue.equals(String(def.values[i]))) {
628
                    def.selectedIndex = i;
629
                    break;
630
                }
631
            }
632
 
633
            m_currentElements.add(elem);
634
            numOptions++;
635
        }
636
 
637
        /*
638
            Update a dropdown definition's selectedIndex from persisted config
639
            Returns true if a matching value was found
640
        */
641
        bool getDropdownSelection(AsyncFSWebServer::DropdownList &def) {
642
            // Ensure we have a doc to read from
643
            if (m_doc == nullptr && !openConfiguration()) {
644
                log_error("Error! /setup configuration not possible");
645
                return false;
646
            }
647
 
648
            CJSON::Json* sourceDoc = (m_savedDoc != nullptr) ? m_savedDoc : m_doc;
649
            if (sourceDoc == nullptr) {
650
                log_error("No configuration document available for reading");
651
                return false;
652
            }
653
 
654
            const cJSON* root = sourceDoc->getRoot();
655
            if (!root) return false;
656
            const cJSON* sections = cJSON_GetObjectItemCaseSensitive(root, "sections");
657
            if (!sections || !cJSON_IsArray(sections)) return false;
658
 
659
            String sel;
660
            const cJSON* sec = sections->child;
661
            while (sec) {
662
                const cJSON* elems = cJSON_GetObjectItemCaseSensitive(sec, "elements");
663
                if (elems && cJSON_IsArray(elems)) {
664
                    const cJSON* el = elems->child;
665
                    while (el) {
666
                        const cJSON* lbl = cJSON_GetObjectItemCaseSensitive(el, "label");
667
                        if (lbl && cJSON_IsString(lbl) && lbl->valuestring && String(lbl->valuestring).equals(def.label)) {
668
                            const cJSON* val = cJSON_GetObjectItemCaseSensitive(el, "value");
669
                            if (val && cJSON_IsString(val) && val->valuestring) {
670
                                sel = String(val->valuestring);
671
                            }
672
                            break;
673
                        }
674
                        el = el->next;
675
                    }
676
                }
677
                sec = sec->next;
678
            }
679
 
680
            if (!sel.length()) return false;
681
 
682
            for (size_t i = 0; i < def.size; i++) {
683
                if (sel.equals(String(def.values[i]))) {
684
                    def.selectedIndex = i;
685
                    return true;
686
                }
687
            }
688
            return false;
689
        }
690
 
691
        /*
692
            Add a new slider using a static definition that tracks current value
693
        */
694
        void addSlider(AsyncFSWebServer::Slider &def) {
695
            if (m_doc == nullptr) {
696
                if (!openConfiguration()) {
697
                    log_error("Error! /setup configuration not possible");
698
                }
699
            }
700
 
701
            const char* label = def.label;
702
            ensureActiveSection();
703
 
704
            // Prefer saved value when available; else use def.value
705
            double current = def.value;
706
            if (m_savedDoc) {
707
                const cJSON* root = m_savedDoc->getRoot();
708
                if (root) {
709
                    const cJSON* sections = cJSON_GetObjectItemCaseSensitive(root, "sections");
710
                    if (sections && cJSON_IsArray(sections)) {
711
                        const cJSON* sec = sections->child;
712
                        while (sec) {
713
                            const cJSON* elems = cJSON_GetObjectItemCaseSensitive(sec, "elements");
714
                            if (elems && cJSON_IsArray(elems)) {
715
                                const cJSON* el = elems->child;
716
                                while (el) {
717
                                    const cJSON* lbl = cJSON_GetObjectItemCaseSensitive(el, "label");
718
                                    if (lbl && cJSON_IsString(lbl) && lbl->valuestring && String(lbl->valuestring).equals(label)) {
719
                                        const cJSON* val = cJSON_GetObjectItemCaseSensitive(el, "value");
720
                                        if (val && cJSON_IsNumber(val)) {
721
                                            current = val->valuedouble;
722
                                        }
723
                                        break;
724
                                    }
725
                                    el = el->next;
726
                                }
727
                            }
728
                            sec = sec->next;
729
                        }
730
                    }
731
                }
732
            }
733
 
734
            def.value = current;
735
 
736
            CJSON::Json elem;
737
            elem.createObject();
738
            elem.setString("label", label);
739
            elem.setString("type", "slider");
740
            elem.setNumber("value", current);
741
            elem.setNumber("min", def.min);
742
            elem.setNumber("max", def.max);
743
            elem.setNumber("step", def.step);
744
 
745
            m_currentElements.add(elem);
746
            numOptions++;
747
        }
748
 
749
        /*
750
            Read slider value into the provided struct from persisted config
751
            Returns true if a value was found
752
        */
753
        bool getSliderValue(AsyncFSWebServer::Slider &def) {
754
            if (m_doc == nullptr && !openConfiguration()) {
755
                log_error("Error! /setup configuration not possible");
756
                return false;
757
            }
758
 
759
            CJSON::Json* sourceDoc = (m_savedDoc != nullptr) ? m_savedDoc : m_doc;
760
            if (sourceDoc == nullptr) return false;
761
 
762
            const cJSON* root = sourceDoc->getRoot();
763
            if (!root) return false;
764
            const cJSON* sections = cJSON_GetObjectItemCaseSensitive(root, "sections");
765
            if (!sections || !cJSON_IsArray(sections)) return false;
766
 
767
            const cJSON* sec = sections->child;
768
            while (sec) {
769
                const cJSON* elems = cJSON_GetObjectItemCaseSensitive(sec, "elements");
770
                if (elems && cJSON_IsArray(elems)) {
771
                    const cJSON* el = elems->child;
772
                    while (el) {
773
                        const cJSON* lbl = cJSON_GetObjectItemCaseSensitive(el, "label");
774
                        if (lbl && cJSON_IsString(lbl) && lbl->valuestring && String(lbl->valuestring).equals(def.label)) {
775
                            const cJSON* val = cJSON_GetObjectItemCaseSensitive(el, "value");
776
                            if (val && cJSON_IsNumber(val)) {
777
                                def.value = val->valuedouble;
778
                                return true;
779
                            }
780
                            return false;
781
                        }
782
                        el = el->next;
783
                    }
784
                }
785
                sec = sec->next;
786
            }
787
            return false;
788
        }
789
 
790
        /*
791
            Add a new option box with custom label
792
        */
793
        void addOptionBox(const char* boxTitle) {
794
            if (m_doc == nullptr) {
795
                if (!openConfiguration()) {
796
                    log_error("Error! /setup configuration not possible");
797
                    return;
798
                }
799
            }
800
            startNewSection(boxTitle);
801
        }
802
 
803
        /**
804
         * @brief Add a comment string associated with an existing element
805
         * The comment will be stored in the element object under key "comment".
806
         * When the frontend renders the option it will append a <div class="cmt">.
807
         */
808
        void addComment(const char *tag, const char *comment) {
809
            if (m_doc == nullptr) {
810
                if (!openConfiguration()) {
811
                    log_error("Error! /setup configuration not possible");
812
                    return;
813
                }
814
            }
815
            String ct = String(comment);
816
            bool found = false;
817
            // search in current elements (active section)
818
            {
819
                cJSON* arr = m_currentElements.getRoot();
820
                if (arr && cJSON_IsArray(arr)) {
821
                    for (cJSON* el = arr->child; el; el = el->next) {
822
                        cJSON* lbl = cJSON_GetObjectItemCaseSensitive(el, "label");
823
                        if (lbl && cJSON_IsString(lbl) && String(lbl->valuestring) == tag) {
824
                            cJSON_DeleteItemFromObjectCaseSensitive(el, "comment");
825
                            cJSON_AddStringToObject(el, "comment", comment);
826
                            found = true;
827
                            break;
828
                        }
829
                    }
830
                }
831
            }
832
            // also search in previously built sections
833
            if (!found) {
834
                cJSON* secs = m_sectionsArray.getRoot();
835
                if (secs && cJSON_IsArray(secs)) {
836
                    for (cJSON* sec = secs->child; sec && !found; sec = sec->next) {
837
                        cJSON* elems = cJSON_GetObjectItemCaseSensitive(sec, "elements");
838
                        if (elems && cJSON_IsArray(elems)) {
839
                            for (cJSON* el = elems->child; el; el = el->next) {
840
                                cJSON* lbl = cJSON_GetObjectItemCaseSensitive(el, "label");
841
                                if (lbl && cJSON_IsString(lbl) && String(lbl->valuestring) == tag) {
842
                                    cJSON_DeleteItemFromObjectCaseSensitive(el, "comment");
843
                                    cJSON_AddStringToObject(el, "comment", comment);
844
                                    found = true;
845
                                    break;
846
                                }
847
                            }
848
                        }
849
                    }
850
                }
851
            }
852
            // fallback: write to root document so it won't be lost
853
            if (!found) {
854
                m_doc->setString(tag, "comment", ct);
855
            }
856
        }
857
 
858
 
859
        /*
860
            Add custom option to config webpage (float values)
861
        */
862
        template <typename T>
863
        void addOption(const char *label, T val, double d_min, double d_max, double step) {
864
            addOption(label, val, false, d_min, d_max, step);
865
        }
866
 
867
        /*
868
        Add custom option to config webpage (type of parameter will be deduced from variable itself)
869
        */
870
        // bool-specific overload with grouping flag (grouped last to avoid breaking existing code)
871
        void addOption(const char *label, bool val, bool hidden = false, bool grouped = true) {
872
            if (m_doc == nullptr) {
873
                if (!openConfiguration()) {
874
                    log_error("Error! /setup configuration not possible");
875
                }
876
            }
877
 
878
            ensureActiveSection();
879
            String lbl = label;
880
            bool valueFromSaved = false;
881
            // read saved as before
882
            auto readSavedBool = [&](bool& out) -> bool {
883
                if (!m_savedDoc) return false;
884
                const cJSON* root = m_savedDoc->getRoot();
885
                if (!root) return false;
886
                const cJSON* sections = cJSON_GetObjectItemCaseSensitive(root, "sections");
887
                if (!sections || !cJSON_IsArray(sections)) return false;
888
                const cJSON* sec = sections->child;
889
                while (sec) {
890
                    const cJSON* elems = cJSON_GetObjectItemCaseSensitive(sec, "elements");
891
                    if (elems && cJSON_IsArray(elems)) {
892
                        const cJSON* el = elems->child;
893
                        while (el) {
894
                            const cJSON* lblNode = cJSON_GetObjectItemCaseSensitive(el, "label");
895
                            if (lblNode && cJSON_IsString(lblNode) && lblNode->valuestring && String(lblNode->valuestring).equals(lbl)) {
896
                                const cJSON* v = cJSON_GetObjectItemCaseSensitive(el, "value");
897
                                if (v && cJSON_IsBool(v)) {
898
                                    out = cJSON_IsTrue(v);
899
                                    return true;
900
                                }
901
                                return false;
902
                            }
903
                            el = el->next;
904
                        }
905
                    }
906
                    sec = sec->next;
907
                }
908
                return false;
909
            };
910
 
911
            CJSON::Json elem;
912
            elem.createObject();
913
            elem.setString("label", lbl);
914
 
915
            bool current = val;
916
            if (readSavedBool(current)) valueFromSaved = true;
917
            elem.setString("type", "boolean");
918
            elem.setBool("value", current);
919
 
920
            if (!grouped) {
921
                elem.setBool("group", false);
922
            }
923
            if (hidden) {
924
                elem.setBool("hidden", true);
925
            }
926
 
927
            log_debug("Option \"%s\" using %s value", lbl.c_str(), valueFromSaved ? "saved" : "default");
928
            m_currentElements.add(elem);
929
            numOptions++;
930
        }
931
 
932
        // generic template for all other types
933
        template <typename T>
934
        void addOption(const char *label, T val, bool hidden = false,
935
                            double d_min = MIN_F, double d_max = MAX_F, double step = 1.0)
936
        {
937
 
938
            if (m_doc == nullptr) {
939
                if (!openConfiguration()) {
940
                    log_error("Error! /setup configuration not possible");
941
                }
942
            }
943
 
944
            ensureActiveSection();
945
 
946
            String lbl = label;
947
            bool valueFromSaved = false;
948
 
949
            // Resolve current value: check saved v2 sections first
950
            auto readSavedNumber = [&](double& out) -> bool {
951
                if (!m_savedDoc) return false;
952
                const cJSON* root = m_savedDoc->getRoot();
953
                if (!root) return false;
954
                const cJSON* sections = cJSON_GetObjectItemCaseSensitive(root, "sections");
955
                if (!sections || !cJSON_IsArray(sections)) return false;
956
                const cJSON* sec = sections->child;
957
                while (sec) {
958
                    const cJSON* elems = cJSON_GetObjectItemCaseSensitive(sec, "elements");
959
                    if (elems && cJSON_IsArray(elems)) {
960
                        const cJSON* el = elems->child;
961
                        while (el) {
962
                            const cJSON* lblNode = cJSON_GetObjectItemCaseSensitive(el, "label");
963
                            if (lblNode && cJSON_IsString(lblNode) && lblNode->valuestring && String(lblNode->valuestring).equals(lbl)) {
964
                                const cJSON* v = cJSON_GetObjectItemCaseSensitive(el, "value");
965
                                if (v && cJSON_IsNumber(v)) {
966
                                    out = v->valuedouble;
967
                                    return true;
968
                                }
969
                                return false;
970
                            }
971
                            el = el->next;
972
                        }
973
                    }
974
                    sec = sec->next;
975
                }
976
                return false;
977
            };
978
 
979
            auto readSavedString = [&](String& out) -> bool {
980
                if (!m_savedDoc) return false;
981
                const cJSON* root = m_savedDoc->getRoot();
982
                if (!root) return false;
983
                const cJSON* sections = cJSON_GetObjectItemCaseSensitive(root, "sections");
984
                if (!sections || !cJSON_IsArray(sections)) return false;
985
                const cJSON* sec = sections->child;
986
                while (sec) {
987
                    const cJSON* elems = cJSON_GetObjectItemCaseSensitive(sec, "elements");
988
                    if (elems && cJSON_IsArray(elems)) {
989
                        const cJSON* el = elems->child;
990
                        while (el) {
991
                            const cJSON* lblNode = cJSON_GetObjectItemCaseSensitive(el, "label");
992
                            if (lblNode && cJSON_IsString(lblNode) && lblNode->valuestring && String(lblNode->valuestring).equals(lbl)) {
993
                                const cJSON* v = cJSON_GetObjectItemCaseSensitive(el, "value");
994
                                if (v && cJSON_IsString(v) && v->valuestring) {
995
                                    out = String(v->valuestring);
996
                                    return true;
997
                                }
998
                                return false;
999
                            }
1000
                            el = el->next;
1001
                        }
1002
                    }
1003
                    sec = sec->next;
1004
                }
1005
                return false;
1006
            };
1007
 
1008
            CJSON::Json elem;
1009
            elem.createObject();
1010
            elem.setString("label", lbl);
1011
 
1012
            if constexpr (std::is_same<T, String>::value) {
1013
                String current = val;
1014
                if (readSavedString(current)) valueFromSaved = true;
1015
                elem.setString("type", "text");
1016
                elem.setString("value", current);
1017
            } else if constexpr (std::is_same<T, const char*>::value || std::is_same<T, char*>::value) {
1018
                String current = String(val);
1019
                if (readSavedString(current)) valueFromSaved = true;
1020
                elem.setString("type", "text");
1021
                elem.setString("value", current);
1022
            } else {
1023
                double current = static_cast<double>(val);
1024
                if (readSavedNumber(current)) valueFromSaved = true;
1025
                elem.setString("type", "number");
1026
                elem.setNumber("value", current);
1027
                if (d_min != MIN_F) elem.setNumber("min", d_min);
1028
                if (d_max != MAX_F) elem.setNumber("max", d_max);
1029
                if (step != 1.0) elem.setNumber("step", step);
1030
            }
1031
 
1032
            if (hidden) {
1033
                elem.setBool("hidden", true);
1034
            }
1035
 
1036
            log_debug("Option \"%s\" using %s value", lbl.c_str(), valueFromSaved ? "saved" : "default");
1037
            m_currentElements.add(elem);
1038
            numOptions++;
1039
        }
1040
 
1041
        /*
1042
            Get current value for a specific custom option (true on success)
1043
            Reads from m_doc if open, or reloads from file if closed
1044
        */
1045
        template <typename T>
1046
        bool getOptionValue(const char *label, T &var) {
1047
            // If m_doc is nullptr, reload configuration from file
1048
            if (m_doc == nullptr) {
1049
                if (!openConfiguration()) {
1050
                    log_error("Error! /setup configuration not possible");
1051
                    return false;
1052
                }
1053
            }
1054
 
1055
            // Prefer persisted values when available; fall back to current session doc
1056
            CJSON::Json* sourceDoc = (m_savedDoc != nullptr) ? m_savedDoc : m_doc;
1057
 
1058
            if (sourceDoc == nullptr) {
1059
                log_error("No configuration document available for reading");
1060
                return false;
1061
            }
1062
 
1063
            const cJSON* root = sourceDoc->getRoot();
1064
            if (!root) return false;
1065
 
1066
            // Special case: port/host are stored in _meta
1067
            if constexpr (!std::is_same<T, String>::value && !std::is_same<T, const char*>::value && !std::is_same<T, char*>::value) {
1068
                if (strcmp(label, "port") == 0) {
1069
                    const cJSON* meta = cJSON_GetObjectItemCaseSensitive(root, "_meta");
1070
                    const cJSON* p = meta ? cJSON_GetObjectItemCaseSensitive(meta, "port") : nullptr;
1071
                    if (p && cJSON_IsNumber(p)) {
1072
                        var = static_cast<T>(p->valuedouble);
1073
                        return true;
1074
                    }
1075
                }
1076
            }
1077
 
1078
            const cJSON* sections = cJSON_GetObjectItemCaseSensitive(root, "sections");
1079
            if (!sections || !cJSON_IsArray(sections)) return false;
1080
 
1081
            const cJSON* sec = sections->child;
1082
            while (sec) {
1083
                const cJSON* elems = cJSON_GetObjectItemCaseSensitive(sec, "elements");
1084
                if (elems && cJSON_IsArray(elems)) {
1085
                    const cJSON* el = elems->child;
1086
                    while (el) {
1087
                        const cJSON* lblNode = cJSON_GetObjectItemCaseSensitive(el, "label");
1088
                        if (lblNode && cJSON_IsString(lblNode) && lblNode->valuestring && String(lblNode->valuestring).equals(label)) {
1089
                            const cJSON* valNode = cJSON_GetObjectItemCaseSensitive(el, "value");
1090
                            if constexpr (std::is_same<T, String>::value) {
1091
                                if (valNode && cJSON_IsString(valNode) && valNode->valuestring) {
1092
                                    var = String(valNode->valuestring);
1093
                                    return true;
1094
                                }
1095
                            } else if constexpr (std::is_same<T, const char*>::value || std::is_same<T, char*>::value) {
1096
                                if (valNode && cJSON_IsString(valNode) && valNode->valuestring) {
1097
                                    static String tmp; // Note: lifetime tied to process; acceptable for config reads
1098
                                    tmp = String(valNode->valuestring);
1099
                                    var = tmp.c_str();
1100
                                    return true;
1101
                                }
1102
                            } else if constexpr (std::is_same<T, bool>::value) {
1103
                                if (valNode && cJSON_IsBool(valNode)) {
1104
                                    var = cJSON_IsTrue(valNode);
1105
                                    return true;
1106
                                }
1107
                            } else {
1108
                                if (valNode && cJSON_IsNumber(valNode)) {
1109
                                    var = static_cast<T>(valNode->valuedouble);
1110
                                    return true;
1111
                                }
1112
                            }
1113
                            return false;
1114
                        }
1115
                        el = el->next;
1116
                    }
1117
                }
1118
                sec = sec->next;
1119
            }
1120
            return false;
1121
        }
1122
 
1123
        template <typename T>
1124
        bool saveOptionValue(const char *label, T val) {
1125
            if (m_doc == nullptr) {
1126
                if (!openConfiguration()) {
1127
                    log_error("Error! /setup configuration not possible");
1128
                    return false;
1129
                }
1130
            }
1131
 
1132
            // Ensure sections are attached before modifying (so we always work on the same tree)
1133
            finalizeSectionsToRoot();
1134
 
1135
            cJSON* root = m_doc->getRoot();
1136
            if (!root) return false;
1137
            cJSON* sections = cJSON_GetObjectItemCaseSensitive(root, "sections");
1138
            if (!sections || !cJSON_IsArray(sections)) return false;
1139
 
1140
            cJSON* targetElement = nullptr;
1141
            for (cJSON* sec = sections->child; sec; sec = sec->next) {
1142
                cJSON* elems = cJSON_GetObjectItemCaseSensitive(sec, "elements");
1143
                if (!elems || !cJSON_IsArray(elems)) continue;
1144
                for (cJSON* el = elems->child; el; el = el->next) {
1145
                    cJSON* lblNode = cJSON_GetObjectItemCaseSensitive(el, "label");
1146
                    if (lblNode && cJSON_IsString(lblNode) && lblNode->valuestring && String(lblNode->valuestring).equals(label)) {
1147
                        targetElement = el;
1148
                        break;
1149
                    }
1150
                }
1151
                if (targetElement) break;
1152
            }
1153
 
1154
            if (!targetElement) return false;
1155
 
1156
            // Replace or create "value" field inside target element
1157
            cJSON_DeleteItemFromObjectCaseSensitive(targetElement, "value");
1158
 
1159
            if constexpr (std::is_same<T, String>::value) {
1160
                cJSON_AddItemToObject(targetElement, "value", cJSON_CreateString(val.c_str()));
1161
            } else if constexpr (std::is_same<T, const char*>::value || std::is_same<T, char*>::value) {
1162
                cJSON_AddItemToObject(targetElement, "value", cJSON_CreateString(String(val).c_str()));
1163
            } else if constexpr (std::is_same<T, bool>::value) {
1164
                cJSON_AddItemToObject(targetElement, "value", cJSON_CreateBool(val));
1165
            } else {
1166
                cJSON_AddItemToObject(targetElement, "value", cJSON_CreateNumber(static_cast<double>(val)));
1167
            }
1168
            return true;
1169
        }
1170
 
1171
};
1172
 
1173
#endif