261 lines
8.6 KiB
C++
261 lines
8.6 KiB
C++
/**
|
|
* @file ConfigManager.cpp
|
|
* @brief Implementation of persistent configuration management via LittleFS.
|
|
*/
|
|
|
|
#include "ConfigManager.h"
|
|
#include <LittleFS.h>
|
|
#include <ArduinoJson.h>
|
|
|
|
// 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<JsonArray>();
|
|
for (uint8_t p = 0; p < _cfg.pageCount; p++) {
|
|
JsonObject page = pages.add<JsonObject>();
|
|
page["name"] = _cfg.pages[p].name;
|
|
|
|
JsonArray tiles = page["tiles"].to<JsonArray>();
|
|
for (uint8_t t = 0; t < Display::TILE_COUNT; t++) {
|
|
JsonObject tile = tiles.add<JsonObject>();
|
|
tile["field"] = static_cast<uint8_t>(_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<JsonArray>();
|
|
for (uint8_t i = 0; i < static_cast<uint8_t>(DataField::_COUNT); i++) {
|
|
JsonObject f = fields.add<JsonObject>();
|
|
f["id"] = i;
|
|
f["name"] = fieldName(static_cast<DataField>(i));
|
|
f["unit"] = fieldUnit(static_cast<DataField>(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<JsonArray>();
|
|
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<JsonArray>()) {
|
|
if (tIdx >= Display::TILE_COUNT) break;
|
|
uint8_t fid = tile["field"] | 0;
|
|
if (fid < static_cast<uint8_t>(DataField::_COUNT)) {
|
|
_cfg.pages[pCount].tiles[tIdx].field = static_cast<DataField>(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<JsonArray>();
|
|
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<JsonArray>()) {
|
|
if (tIdx >= Display::TILE_COUNT) break;
|
|
uint8_t fid = tile["field"] | 0;
|
|
_cfg.pages[pIdx].tiles[tIdx].field =
|
|
(fid < static_cast<uint8_t>(DataField::_COUNT))
|
|
? static_cast<DataField>(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<JsonArray>();
|
|
for (uint8_t p = 0; p < _cfg.pageCount; p++) {
|
|
JsonObject page = pages.add<JsonObject>();
|
|
page["name"] = _cfg.pages[p].name;
|
|
JsonArray tiles = page["tiles"].to<JsonArray>();
|
|
for (uint8_t t = 0; t < Display::TILE_COUNT; t++) {
|
|
JsonObject tile = tiles.add<JsonObject>();
|
|
tile["field"] = static_cast<uint8_t>(_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));
|
|
}
|