/** * @file ConfigManager.cpp * @brief Implementation of persistent configuration management via LittleFS. */ #include "ConfigManager.h" #include #include // Singleton instance ConfigManager configManager; // ============================================================================= // Public API // ============================================================================= bool ConfigManager::begin() { if (!LittleFS.begin(true /* format on fail */)) { Serial.println("[Config] ERROR: LittleFS mount failed"); return false; } Serial.println("[Config] LittleFS mounted"); if (!_load()) { Serial.println("[Config] No valid config found — applying defaults"); _applyDefaults(); _write(); } return true; } bool ConfigManager::save(const AppConfig& cfg) { _cfg = cfg; return _write(); } bool ConfigManager::persist() { return _write(); } bool ConfigManager::setActivePage(uint8_t index) { if (index >= _cfg.pageCount) return false; _cfg.activePage = index; return _write(); } void ConfigManager::nextPage() { _cfg.activePage = (_cfg.activePage + 1) % _cfg.pageCount; _write(); } void ConfigManager::prevPage() { _cfg.activePage = (_cfg.activePage == 0) ? _cfg.pageCount - 1 : _cfg.activePage - 1; _write(); } bool ConfigManager::setTile(uint8_t page, uint8_t tile, DataField field, const char* label) { if (page >= MAX_PAGES || tile >= Display::TILE_COUNT) return false; _cfg.pages[page].tiles[tile].field = field; if (label && strlen(label) < sizeof(TileConfig::label)) { strlcpy(_cfg.pages[page].tiles[tile].label, label, sizeof(TileConfig::label)); } return _write(); } void ConfigManager::toJson(String& out) const { // Use a JsonDocument sized for the whole config JsonDocument doc; doc["activePage"] = _cfg.activePage; doc["pageCount"] = _cfg.pageCount; doc["apSSID"] = _cfg.apSSID; // Do not serialise AP password to the web UI for security JsonArray pages = doc["pages"].to(); for (uint8_t p = 0; p < _cfg.pageCount; p++) { JsonObject page = pages.add(); page["name"] = _cfg.pages[p].name; JsonArray tiles = page["tiles"].to(); for (uint8_t t = 0; t < Display::TILE_COUNT; t++) { JsonObject tile = tiles.add(); tile["field"] = static_cast(_cfg.pages[p].tiles[t].field); tile["fieldName"] = fieldName(_cfg.pages[p].tiles[t].field); tile["label"] = _cfg.pages[p].tiles[t].label; } } // Append available fields list so the web UI can build dropdowns JsonArray fields = doc["availableFields"].to(); for (uint8_t i = 0; i < static_cast(DataField::_COUNT); i++) { JsonObject f = fields.add(); f["id"] = i; f["name"] = fieldName(static_cast(i)); f["unit"] = fieldUnit(static_cast(i)); } serializeJson(doc, out); } bool ConfigManager::fromJson(const String& json) { JsonDocument doc; DeserializationError err = deserializeJson(doc, json); if (err) { Serial.printf("[Config] JSON parse error: %s\n", err.c_str()); return false; } if (doc.containsKey("apSSID")) { strlcpy(_cfg.apSSID, doc["apSSID"] | _cfg.apSSID, sizeof(_cfg.apSSID)); } if (doc.containsKey("apPassword")) { strlcpy(_cfg.apPassword, doc["apPassword"] | _cfg.apPassword, sizeof(_cfg.apPassword)); } JsonArray pages = doc["pages"].as(); if (pages) { uint8_t pCount = 0; for (JsonObject page : pages) { if (pCount >= MAX_PAGES) break; strlcpy(_cfg.pages[pCount].name, page["name"] | "", sizeof(PageConfig::name)); uint8_t tIdx = 0; for (JsonObject tile : page["tiles"].as()) { if (tIdx >= Display::TILE_COUNT) break; uint8_t fid = tile["field"] | 0; if (fid < static_cast(DataField::_COUNT)) { _cfg.pages[pCount].tiles[tIdx].field = static_cast(fid); } strlcpy(_cfg.pages[pCount].tiles[tIdx].label, tile["label"] | "", sizeof(TileConfig::label)); tIdx++; } pCount++; } if (pCount > 0) _cfg.pageCount = pCount; } if (doc.containsKey("activePage")) { uint8_t ap = doc["activePage"]; if (ap < _cfg.pageCount) _cfg.activePage = ap; } return _write(); } void ConfigManager::resetToDefaults() { _applyDefaults(); _write(); } // ============================================================================= // Private helpers // ============================================================================= bool ConfigManager::_load() { if (!LittleFS.exists(CONFIG_PATH)) return false; File f = LittleFS.open(CONFIG_PATH, "r"); if (!f) return false; String json = f.readString(); f.close(); // Re-use fromJson but do not re-persist to avoid re-entry JsonDocument doc; if (deserializeJson(doc, json) != DeserializationError::Ok) return false; // --- activePage / pageCount --- _cfg.activePage = doc["activePage"] | 0; _cfg.pageCount = doc["pageCount"] | 1; if (_cfg.pageCount == 0 || _cfg.pageCount > MAX_PAGES) _cfg.pageCount = 1; if (_cfg.activePage >= _cfg.pageCount) _cfg.activePage = 0; strlcpy(_cfg.apSSID, doc["apSSID"] | "MarineDisplay", sizeof(_cfg.apSSID)); strlcpy(_cfg.apPassword, doc["apPassword"] | "marine123", sizeof(_cfg.apPassword)); JsonArray pages = doc["pages"].as(); uint8_t pIdx = 0; for (JsonObject page : pages) { if (pIdx >= MAX_PAGES) break; strlcpy(_cfg.pages[pIdx].name, page["name"] | "", sizeof(PageConfig::name)); uint8_t tIdx = 0; for (JsonObject tile : page["tiles"].as()) { if (tIdx >= Display::TILE_COUNT) break; uint8_t fid = tile["field"] | 0; _cfg.pages[pIdx].tiles[tIdx].field = (fid < static_cast(DataField::_COUNT)) ? static_cast(fid) : DataField::NONE; strlcpy(_cfg.pages[pIdx].tiles[tIdx].label, tile["label"] | "", sizeof(TileConfig::label)); tIdx++; } pIdx++; } Serial.printf("[Config] Loaded %u page(s), active=%u\n", _cfg.pageCount, _cfg.activePage); return true; } bool ConfigManager::_write() { File f = LittleFS.open(CONFIG_PATH, "w"); if (!f) { Serial.println("[Config] ERROR: Cannot open config file for writing"); return false; } JsonDocument doc; doc["activePage"] = _cfg.activePage; doc["pageCount"] = _cfg.pageCount; doc["apSSID"] = _cfg.apSSID; doc["apPassword"] = _cfg.apPassword; JsonArray pages = doc["pages"].to(); for (uint8_t p = 0; p < _cfg.pageCount; p++) { JsonObject page = pages.add(); page["name"] = _cfg.pages[p].name; JsonArray tiles = page["tiles"].to(); for (uint8_t t = 0; t < Display::TILE_COUNT; t++) { JsonObject tile = tiles.add(); tile["field"] = static_cast(_cfg.pages[p].tiles[t].field); tile["label"] = _cfg.pages[p].tiles[t].label; } } size_t written = serializeJson(doc, f); f.close(); Serial.printf("[Config] Saved %u bytes to %s\n", written, CONFIG_PATH); return written > 0; } void ConfigManager::_applyDefaults() { _cfg = AppConfig{}; // zero-init // Page 0 — Essential sailing data _cfg.pageCount = 2; _cfg.activePage = 0; strlcpy(_cfg.pages[0].name, "Sailing", sizeof(PageConfig::name)); _cfg.pages[0].tiles[0] = { DataField::SOG, "SOG" }; _cfg.pages[0].tiles[1] = { DataField::TWA, "TWA" }; _cfg.pages[0].tiles[2] = { DataField::TWS, "TWS" }; _cfg.pages[0].tiles[3] = { DataField::VMG, "VMG" }; // Page 1 — Performance strlcpy(_cfg.pages[1].name, "Performance", sizeof(PageConfig::name)); _cfg.pages[1].tiles[0] = { DataField::POLAR_PCT, "Polar %" }; _cfg.pages[1].tiles[1] = { DataField::TARGET_STW, "Tgt STW" }; _cfg.pages[1].tiles[2] = { DataField::AWA, "AWA" }; _cfg.pages[1].tiles[3] = { DataField::DEPTH, "Depth" }; strlcpy(_cfg.apSSID, "MarineDisplay", sizeof(_cfg.apSSID)); strlcpy(_cfg.apPassword, "marine123", sizeof(_cfg.apPassword)); }