knxdisplay/main/WidgetManager.cpp
2026-02-01 21:48:36 +01:00

2186 lines
80 KiB
C++

#include "WidgetManager.hpp"
#include "widgets/WidgetFactory.hpp"
#include "widgets/RoomCardWidget.hpp"
#include "HistoryStore.hpp"
#include "SdCard.hpp"
#include "esp_lv_adapter.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "esp_heap_caps.h"
#include "cJSON.h"
#include <memory>
#include <new>
#include <cstdio>
#include <cstdint>
#include <cstring>
#include <sys/stat.h>
#include <cstdlib>
static const char* TAG = "WidgetMgr";
static constexpr uint8_t SCREEN_ID_NONE = 0xFF;
#if LV_USE_OBJ_NAME
static void dump_flex_objects(lv_obj_t* obj, uint8_t depth) {
if (!obj) return;
if (lv_obj_get_style_layout(obj, LV_PART_MAIN) == LV_LAYOUT_FLEX) {
char name[64];
lv_obj_get_name_resolved(obj, name, sizeof(name));
ESP_LOGI("FlexMap", "flex obj=%p name=%s depth=%u", obj, name, depth);
}
uint32_t count = lv_obj_get_child_count(obj);
for (uint32_t i = 0; i < count; ++i) {
dump_flex_objects(lv_obj_get_child(obj, static_cast<int32_t>(i)), depth + 1);
}
}
#endif
static bool is_valid_utf8(const char* text, size_t len) {
size_t i = 0;
while (i < len) {
uint8_t c = static_cast<uint8_t>(text[i]);
if (c < 0x80) {
i++;
continue;
}
if ((c & 0xE0) == 0xC0) {
if (i + 1 >= len) return false;
uint8_t c1 = static_cast<uint8_t>(text[i + 1]);
if ((c1 & 0xC0) != 0x80) return false;
i += 2;
continue;
}
if ((c & 0xF0) == 0xE0) {
if (i + 2 >= len) return false;
uint8_t c1 = static_cast<uint8_t>(text[i + 1]);
uint8_t c2 = static_cast<uint8_t>(text[i + 2]);
if ((c1 & 0xC0) != 0x80 || (c2 & 0xC0) != 0x80) return false;
i += 3;
continue;
}
if ((c & 0xF8) == 0xF0) {
if (i + 3 >= len) return false;
uint8_t c1 = static_cast<uint8_t>(text[i + 1]);
uint8_t c2 = static_cast<uint8_t>(text[i + 2]);
uint8_t c3 = static_cast<uint8_t>(text[i + 3]);
if ((c1 & 0xC0) != 0x80 || (c2 & 0xC0) != 0x80 || (c3 & 0xC0) != 0x80) return false;
i += 4;
continue;
}
return false;
}
return true;
}
static void latin1_to_utf8(const char* src, size_t src_len, char* dst, size_t dst_size) {
if (dst_size == 0) return;
size_t di = 0;
for (size_t si = 0; si < src_len; ++si) {
uint8_t c = static_cast<uint8_t>(src[si]);
if (c < 0x80) {
if (di + 1 >= dst_size) break;
dst[di++] = static_cast<char>(c);
} else {
if (di + 2 >= dst_size) break;
dst[di++] = static_cast<char>(0xC0 | (c >> 6));
dst[di++] = static_cast<char>(0x80 | (c & 0x3F));
}
}
dst[di] = '\0';
}
static WidgetConfig makeButtonLabelChild(const WidgetConfig& button) {
WidgetConfig label = WidgetConfig::createLabel(0, 0, 0, button.text);
label.parentId = button.id;
if (button.width > 0) label.width = button.width;
if (button.height > 0) label.height = button.height;
label.fontSize = button.fontSize;
label.textAlign = button.textAlign;
label.textColor = button.textColor;
label.textSource = TextSource::STATIC;
label.bgOpacity = 0;
label.borderRadius = 0;
label.shadow.enabled = false;
// Preserve existing icon config if any
label.iconCodepoint = button.iconCodepoint;
label.iconPosition = button.iconPosition;
label.iconSize = button.iconSize;
label.iconGap = button.iconGap;
if (label.text[0] == '\0') {
strncpy(label.text, "Button", MAX_TEXT_LEN - 1);
label.text[MAX_TEXT_LEN - 1] = '\0';
}
return label;
}
static void ensureButtonLabels(ScreenConfig& screen) {
bool hasLabelChild[MAX_WIDGETS] = {};
for (uint8_t i = 0; i < screen.widgetCount; i++) {
const WidgetConfig& w = screen.widgets[i];
if (w.type == WidgetType::LABEL && w.parentId >= 0 && w.parentId < MAX_WIDGETS) {
hasLabelChild[w.parentId] = true;
}
}
const uint8_t initialCount = screen.widgetCount;
for (uint8_t i = 0; i < initialCount; i++) {
WidgetConfig& w = screen.widgets[i];
if (w.type != WidgetType::BUTTON) continue;
w.isContainer = true;
if (w.id < MAX_WIDGETS && hasLabelChild[w.id]) continue;
WidgetConfig label = makeButtonLabelChild(w);
int newId = screen.addWidget(label);
if (newId < 0) {
ESP_LOGW(TAG, "No space to add label child for button %d", w.id);
w.isContainer = false;
continue;
}
if (w.id < MAX_WIDGETS) {
hasLabelChild[w.id] = true;
}
}
}
// WidgetManager implementation
WidgetManager& WidgetManager::instance() {
static WidgetManager inst;
return inst;
}
WidgetManager::WidgetManager() {
// Allocate GuiConfig in PSRAM to save internal RAM
config_ = static_cast<GuiConfig*>(heap_caps_malloc(sizeof(GuiConfig), MALLOC_CAP_SPIRAM));
if (!config_) {
ESP_LOGE(TAG, "Failed to allocate GuiConfig in PSRAM, trying internal RAM");
config_ = new GuiConfig();
} else {
new (config_) GuiConfig(); // Placement new to call constructor
}
// widgets_ is default-initialized to nullptr
portMUX_INITIALIZE(&knxCacheMux_);
uiQueue_ = xQueueCreate(UI_EVENT_QUEUE_LEN, sizeof(UiEvent));
if (!uiQueue_) {
ESP_LOGE(TAG, "Failed to create UI event queue");
}
createDefaultConfig();
activeScreenId_ = config_->startScreenId;
lastActivityUs_ = esp_timer_get_time();
}
void WidgetManager::createDefaultConfig() {
config_->clear();
config_->screenCount = 1;
ScreenConfig& screen = config_->screens[0];
screen.clear(0, "Screen 1");
// Default: Temperature label
auto tempLabel = WidgetConfig::createKnxLabel(0, 50, 20,
TextSource::KNX_DPT_TEMP, 1, "%.1f °C");
tempLabel.fontSize = 3; // 28pt
screen.addWidget(tempLabel);
// Default: KNX Prog button
auto progBtn = WidgetConfig::createButton(1, 50, 100, "KNX Prog", 0, true);
progBtn.bgColor = {200, 50, 50}; // Red
screen.addWidget(progBtn);
ensureButtonLabels(screen);
config_->startScreenId = screen.id;
config_->standbyEnabled = false;
config_->standbyScreenId = 0xFF;
config_->standbyMinutes = 0;
activeScreenId_ = screen.id;
}
void WidgetManager::init() {
loadFromSdCard();
HistoryStore::instance().configureFromConfig(*config_);
HistoryStore::instance().loadFromSdCard();
if (config_->findScreen(config_->startScreenId)) {
activeScreenId_ = config_->startScreenId;
} else if (config_->screenCount > 0) {
activeScreenId_ = config_->screens[0].id;
} else {
activeScreenId_ = 0;
}
lastActivityUs_ = esp_timer_get_time();
ESP_LOGI(TAG, "WidgetManager initialized with %d screens", config_->screenCount);
}
void WidgetManager::loadFromSdCard() {
if (!SdCard::instance().isMounted()) {
ESP_LOGI(TAG, "SD card not mounted, using defaults");
return;
}
FILE* f = fopen(CONFIG_FILE, "r");
if (!f) {
ESP_LOGI(TAG, "No config file found, using defaults");
return;
}
fseek(f, 0, SEEK_END);
long size = ftell(f);
fseek(f, 0, SEEK_SET);
if (size <= 0 || size > 32768) {
ESP_LOGE(TAG, "Invalid config file size: %ld", size);
fclose(f);
return;
}
char* json = new char[size + 1];
if (!json) {
ESP_LOGE(TAG, "Failed to allocate memory for config");
fclose(f);
return;
}
size_t read = fread(json, 1, size, f);
fclose(f);
json[read] = '\0';
bool success = updateConfigFromJson(json);
delete[] json;
if (success) {
ESP_LOGI(TAG, "Loaded %d screens from SD card", config_->screenCount);
} else {
ESP_LOGE(TAG, "Failed to parse config file");
}
}
void WidgetManager::saveToSdCard() {
if (!SdCard::instance().isMounted()) {
ESP_LOGE(TAG, "SD card not mounted, cannot save config");
return;
}
char* json = new char[32768];
if (!json) {
ESP_LOGE(TAG, "Failed to allocate memory for JSON");
return;
}
getConfigJson(json, 32768);
FILE* f = fopen(CONFIG_FILE, "w");
if (!f) {
ESP_LOGE(TAG, "Failed to open config file for writing");
delete[] json;
return;
}
size_t written = fwrite(json, 1, strlen(json), f);
fclose(f);
delete[] json;
if (written > 0) {
ESP_LOGI(TAG, "Saved %d screens to SD card", config_->screenCount);
} else {
ESP_LOGE(TAG, "Failed to write config file");
}
}
void WidgetManager::applyConfig() {
HistoryStore::instance().configureFromConfig(*config_);
if (!config_->findScreen(activeScreenId_)) {
if (config_->findScreen(config_->startScreenId)) {
activeScreenId_ = config_->startScreenId;
} else if (config_->screenCount > 0) {
activeScreenId_ = config_->screens[0].id;
}
}
applyScreen(activeScreenId_);
}
void WidgetManager::saveAndApply() {
saveToSdCard();
applyConfig();
ESP_LOGI(TAG, "Config saved and applied");
}
void WidgetManager::resetToDefaults() {
closeModal();
standbyActive_ = false;
standbyWakePending_ = false;
standbyReturnScreenId_ = SCREEN_ID_NONE;
previousScreenId_ = SCREEN_ID_NONE;
createDefaultConfig();
saveAndApply();
ESP_LOGI(TAG, "Reset to defaults");
}
ScreenConfig* WidgetManager::activeScreen() {
if (modalContainer_ && modalScreenId_ != SCREEN_ID_NONE) {
return config_->findScreen(modalScreenId_);
}
return config_->findScreen(activeScreenId_);
}
const ScreenConfig* WidgetManager::activeScreen() const {
if (modalContainer_ && modalScreenId_ != SCREEN_ID_NONE) {
return config_->findScreen(modalScreenId_);
}
return config_->findScreen(activeScreenId_);
}
const ScreenConfig* WidgetManager::currentScreen() const {
return activeScreen();
}
void WidgetManager::applyScreen(uint8_t screenId) {
if (esp_lv_adapter_lock(-1) != ESP_OK) {
ESP_LOGE(TAG, "Failed to acquire LVGL lock!");
return;
}
applyScreenLocked(screenId);
esp_lv_adapter_unlock();
}
void WidgetManager::applyScreenLocked(uint8_t screenId) {
ESP_LOGI(TAG, "applyScreen(%d) - start", screenId);
ScreenConfig* screen = config_->findScreen(screenId);
if (!screen) {
ESP_LOGW(TAG, "Screen %d not found", screenId);
return;
}
ensureButtonLabels(*screen);
if (modalContainer_) {
ESP_LOGI(TAG, "Closing modal first");
closeModalLocked();
}
lv_display_t* disp = lv_display_get_default();
if (disp) {
lv_display_enable_invalidation(disp, false);
}
// SAFE DESTRUCTION:
// 1. Mark all C++ widgets as "LVGL object already gone"
for (auto& widget : widgets_) {
if (widget) widget->clearLvglObject();
}
// 2. Delete all LVGL objects on layers we use
lv_obj_clean(lv_scr_act());
lv_obj_clean(lv_layer_top());
// 3. Now destroy C++ objects (their destructors won't call lv_obj_delete)
destroyAllWidgets();
ESP_LOGI(TAG, "Creating new widgets for screen '%s' (%d widgets)...",
screen->name, screen->widgetCount);
lv_obj_t* root = lv_scr_act();
createAllWidgets(*screen, root);
ESP_LOGI(TAG, "Widgets created");
applyCachedValuesToWidgets();
if (disp) {
lv_display_enable_invalidation(disp, true);
}
lv_obj_invalidate(lv_scr_act());
ESP_LOGI(TAG, "applyScreen(%d) - complete", screenId);
}
void WidgetManager::showModalScreen(const ScreenConfig& screen) {
if (esp_lv_adapter_lock(-1) != ESP_OK) return;
showModalScreenLocked(screen);
esp_lv_adapter_unlock();
}
void WidgetManager::showModalScreenLocked(const ScreenConfig& screen) {
if (modalContainer_) {
closeModalLocked();
}
// SAFE DESTRUCTION
for (auto& widget : widgets_) {
if (widget) widget->clearLvglObject();
}
lv_obj_clean(lv_scr_act()); // Should we clean screen when showing modal?
// Actually, usually modal is ON TOP of screen.
// But our current WidgetManager destroys screen widgets when showing modal!
// That's why we clean.
lv_obj_clean(lv_layer_top());
destroyAllWidgets();
lv_disp_t* disp = lv_disp_get_default();
int32_t dispWidth = disp ? lv_disp_get_hor_res(disp) : 1280;
int32_t dispHeight = disp ? lv_disp_get_ver_res(disp) : 800;
// Create semi-transparent background overlay if dimming enabled
lv_obj_t* dimmer = nullptr;
if (screen.modalDimBackground) {
dimmer = lv_obj_create(lv_layer_top());
if (dimmer) {
lv_obj_remove_style_all(dimmer);
lv_obj_set_size(dimmer, dispWidth, dispHeight);
lv_obj_set_style_bg_color(dimmer, lv_color_black(), 0);
lv_obj_set_style_bg_opa(dimmer, LV_OPA_50, 0);
lv_obj_clear_flag(dimmer, LV_OBJ_FLAG_SCROLLABLE);
}
}
// Create modal container
lv_obj_t* modal = lv_obj_create(lv_layer_top());
lv_obj_clear_flag(modal, LV_OBJ_FLAG_SCROLLABLE);
// Calculate modal size
int32_t modalWidth = screen.modalWidth;
int32_t modalHeight = screen.modalHeight;
// Auto-size: calculate from widget bounds if not specified
if (modalWidth <= 0 || modalHeight <= 0) {
int32_t maxX = 0, maxY = 0;
for (uint8_t i = 0; i < screen.widgetCount; i++) {
const WidgetConfig& w = screen.widgets[i];
if (w.visible) {
int32_t right = w.x + w.width;
int32_t bottom = w.y + w.height;
if (right > maxX) maxX = right;
if (bottom > maxY) maxY = bottom;
}
}
if (modalWidth <= 0) modalWidth = maxX + 40; // Add padding
if (modalHeight <= 0) modalHeight = maxY + 40;
}
lv_obj_set_size(modal, modalWidth, modalHeight);
// Position modal (0 = centered)
if (screen.modalX == 0 && screen.modalY == 0) {
lv_obj_center(modal);
} else {
lv_obj_set_pos(modal, screen.modalX, screen.modalY);
}
// Style modal
lv_obj_set_style_bg_color(modal, lv_color_make(
screen.backgroundColor.r,
screen.backgroundColor.g,
screen.backgroundColor.b), 0);
lv_obj_set_style_bg_opa(modal, LV_OPA_COVER, 0);
lv_obj_set_style_radius(modal, screen.modalBorderRadius, 0);
lv_obj_set_style_border_width(modal, 0, 0);
lv_obj_set_style_pad_all(modal, 0, 0);
// Add shadow for modal
lv_obj_set_style_shadow_color(modal, lv_color_black(), 0);
lv_obj_set_style_shadow_opa(modal, LV_OPA_30, 0);
lv_obj_set_style_shadow_width(modal, 20, 0);
lv_obj_set_style_shadow_spread(modal, 5, 0);
modalContainer_ = modal;
modalDimmer_ = dimmer;
modalScreenId_ = screen.id;
// Create widgets inside modal (not on full screen)
screen_ = modal;
for (uint8_t i = 0; i < screen.widgetCount; i++) {
const WidgetConfig& cfg = screen.widgets[i];
auto widget = WidgetFactory::create(cfg);
if (widget && cfg.id < 256) {
widget->create(modal);
widget->applyStyle();
widgets_[cfg.id] = std::move(widget);
}
}
applyCachedValuesToWidgets();
ESP_LOGI(TAG, "Modal screen %d opened (%ldx%ld)", screen.id, modalWidth, modalHeight);
}
void WidgetManager::closeModal() {
if (esp_lv_adapter_lock(-1) != ESP_OK) return;
closeModalLocked();
esp_lv_adapter_unlock();
}
void WidgetManager::closeModalLocked() {
printf("WM: closeModal Start. Container=%p\n", (void*)modalContainer_);
fflush(stdout);
if (!modalContainer_) {
return;
}
// SAFE DESTRUCTION
for (auto& widget : widgets_) {
if (widget) widget->clearLvglObject();
}
lv_obj_clean(lv_layer_top());
// Destroy widgets first
printf("WM: closeModal destroying widgets...\n");
fflush(stdout);
destroyAllWidgets();
modalDimmer_ = nullptr;
modalContainer_ = nullptr;
modalScreenId_ = SCREEN_ID_NONE;
printf("WM: closeModal Complete\n");
fflush(stdout);
}
void WidgetManager::showScreenLocked(uint8_t screenId) {
ESP_LOGI(TAG, "showScreen(%d) called", screenId);
ScreenConfig* screen = config_->findScreen(screenId);
if (!screen) {
ESP_LOGW(TAG, "Screen %d not found", screenId);
return;
}
ESP_LOGI(TAG, "Found screen '%s', mode=%d", screen->name,
static_cast<int>(screen->mode));
if (screen->mode == ScreenMode::MODAL) {
showModalScreenLocked(*screen);
return;
}
previousScreenId_ = activeScreenId_;
activeScreenId_ = screen->id;
standbyActive_ = false;
applyScreenLocked(activeScreenId_);
}
void WidgetManager::handleButtonAction(const WidgetConfig& cfg, lv_obj_t* target) {
printf("WM: handleButtonAction btn=%d act=%d type=%d\n", cfg.id, (int)cfg.action, (int)cfg.type);
fflush(stdout);
if (cfg.type != WidgetType::BUTTON) {
printf("WM: Not a button!\n");
fflush(stdout);
return;
}
onUserActivity();
switch (cfg.action) {
case ButtonAction::JUMP:
printf("WM: Action JUMP to %d\n", cfg.targetScreen);
fflush(stdout);
navAction_ = ButtonAction::JUMP;
navTargetScreen_ = cfg.targetScreen;
navPending_ = true;
navRequestUs_ = esp_timer_get_time();
break;
case ButtonAction::BACK:
printf("WM: Action BACK\n");
fflush(stdout);
navAction_ = ButtonAction::BACK;
navTargetScreen_ = SCREEN_ID_NONE;
navPending_ = true;
navRequestUs_ = esp_timer_get_time();
break;
case ButtonAction::KNX:
default: {
if (cfg.knxAddressWrite > 0) {
bool state = false;
if (target) {
state = (lv_obj_get_state(target) & LV_STATE_CHECKED) != 0;
}
ESP_LOGI(TAG, "Button %d clicked, KNX write to %d, state=%d",
cfg.id, cfg.knxAddressWrite, state);
// TODO: Send KNX telegram
}
break;
}
}
}
void WidgetManager::goBackLocked() {
printf("WM: goBack called. Modal=%p Active=%d Prev=%d\n",
(void*)modalContainer_, activeScreenId_, previousScreenId_);
fflush(stdout);
if (modalContainer_) {
printf("WM: Closing modal...\n");
fflush(stdout);
closeModalLocked();
printf("WM: Modal closed. Restoring screen %d\n", activeScreenId_);
fflush(stdout);
// Restore the active screen (which was in background)
if (config_->findScreen(activeScreenId_)) {
applyScreenLocked(activeScreenId_);
} else {
ESP_LOGE(TAG, "Active screen %d not found after closing modal!", activeScreenId_);
if (config_->findScreen(config_->startScreenId)) {
activeScreenId_ = config_->startScreenId;
applyScreenLocked(activeScreenId_);
}
}
return;
}
if (previousScreenId_ != SCREEN_ID_NONE && previousScreenId_ != activeScreenId_) {
printf("WM: Going back to screen %d\n", previousScreenId_);
fflush(stdout);
if (config_->findScreen(previousScreenId_)) {
activeScreenId_ = previousScreenId_;
previousScreenId_ = SCREEN_ID_NONE;
applyScreenLocked(activeScreenId_);
} else {
ESP_LOGW(TAG, "Previous screen %d not found", previousScreenId_);
previousScreenId_ = SCREEN_ID_NONE;
}
} else {
printf("WM: No previous screen to go back to\n");
fflush(stdout);
}
}
void WidgetManager::goBack() {
if (esp_lv_adapter_lock(-1) != ESP_OK) return;
goBackLocked();
esp_lv_adapter_unlock();
}
void WidgetManager::navigateToScreen(uint8_t screenId) {
navAction_ = ButtonAction::JUMP;
navTargetScreen_ = screenId;
navPending_ = true;
navRequestUs_ = esp_timer_get_time();
}
void WidgetManager::navigateBack() {
navAction_ = ButtonAction::BACK;
navTargetScreen_ = SCREEN_ID_NONE;
navPending_ = true;
navRequestUs_ = esp_timer_get_time();
}
void WidgetManager::sendKnxSwitch(uint16_t groupAddr, bool value) {
ESP_LOGI(TAG, "sendKnxSwitch: GA=%d, value=%d", groupAddr, value);
// TODO: Send actual KNX telegram via KnxWorker
// For now, just log and update cache so UI reflects the change
cacheKnxSwitch(groupAddr, value);
}
void WidgetManager::enterStandby() {
if (!config_->standbyEnabled || config_->standbyMinutes == 0) return;
if (standbyActive_) return;
if (config_->standbyScreenId == SCREEN_ID_NONE) return;
ScreenConfig* standbyScreen = config_->findScreen(config_->standbyScreenId);
if (!standbyScreen) return;
standbyReturnScreenId_ = activeScreenId_;
standbyActive_ = true;
activeScreenId_ = standbyScreen->id;
applyScreenLocked(activeScreenId_);
}
void WidgetManager::loop() {
static uint32_t loopCount = 0;
loopCount++;
if (loopCount % 40 == 0) {
ESP_LOGI(TAG, "Heap: %lu | Internal: %lu",
esp_get_free_heap_size(),
heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
}
bool didUiNav = false;
if (navPending_) {
int64_t now = esp_timer_get_time();
if (now - navRequestUs_ >= NAV_DELAY_US) {
navPending_ = false;
printf("WM: [TRACE] Nav start\n"); fflush(stdout);
if (navAction_ == ButtonAction::JUMP) {
showScreenLocked(navTargetScreen_);
} else if (navAction_ == ButtonAction::BACK) {
goBackLocked();
}
didUiNav = true;
printf("WM: [TRACE] Nav end\n"); fflush(stdout);
}
}
// printf("WM: [TRACE] Queue start\n"); fflush(stdout);
processUiQueue();
// printf("WM: [TRACE] Queue end\n"); fflush(stdout);
// printf("WM: [TRACE] Tick start\n"); fflush(stdout);
if (HistoryStore::instance().tick()) {
refreshChartWidgets();
}
// printf("WM: [TRACE] Tick end\n"); fflush(stdout);
// printf("WM: [TRACE] Time start\n"); fflush(stdout);
updateSystemTimeWidgets();
// printf("WM: [TRACE] Time end\n"); fflush(stdout);
if (didUiNav) return;
if (!config_->standbyEnabled || config_->standbyMinutes == 0) return;
if (standbyActive_) return;
if (config_->standbyScreenId == SCREEN_ID_NONE) return;
int64_t now = esp_timer_get_time();
int64_t idleUs = now - lastActivityUs_;
int64_t timeoutUs = static_cast<int64_t>(config_->standbyMinutes) * 60 * 1000000LL;
if (idleUs >= timeoutUs) {
enterStandby();
}
}
void WidgetManager::onUserActivity() {
lastActivityUs_ = esp_timer_get_time();
if (standbyActive_) {
standbyActive_ = false;
uint8_t returnId = standbyReturnScreenId_;
if (returnId == SCREEN_ID_NONE) {
returnId = config_->startScreenId;
}
standbyWakeTarget_ = returnId;
standbyWakePending_ = true;
}
}
void WidgetManager::destroyAllWidgets() {
ESP_LOGI(TAG, "destroyAllWidgets: Start (%d widgets)", widgets_.size());
// Destroy in reverse order (last created first)
for (int i = static_cast<int>(widgets_.size()) - 1; i >= 0; i--) {
if (widgets_[i]) {
ESP_LOGD(TAG, "Destroying widget %d", i);
widgets_[i].reset();
}
}
ESP_LOGI(TAG, "destroyAllWidgets: Complete");
}
void WidgetManager::createAllWidgets(const ScreenConfig& screen, lv_obj_t* parent) {
screen_ = parent;
lv_obj_set_style_bg_color(parent, lv_color_make(
screen.backgroundColor.r,
screen.backgroundColor.g,
screen.backgroundColor.b), 0);
lv_obj_set_style_bg_opa(parent, LV_OPA_COVER, 0);
// Background image (if set)
// Note: Requires LV_USE_FS_POSIX=y in sdkconfig with LV_FS_POSIX_LETTER='S' (83)
if (screen.bgImagePath[0] != '\0') {
char fullPath[64];
snprintf(fullPath, sizeof(fullPath), "S:/sdcard%s", screen.bgImagePath);
// Check if file exists, try uppercase IMAGES as fallback
struct stat st;
char checkPath[64];
snprintf(checkPath, sizeof(checkPath), "/sdcard%s", screen.bgImagePath);
if (stat(checkPath, &st) != 0) {
// Try uppercase /IMAGES/ instead of /images/
if (strncmp(screen.bgImagePath, "/images/", 8) == 0) {
snprintf(fullPath, sizeof(fullPath), "S:/sdcard/IMAGES%s", screen.bgImagePath + 7);
ESP_LOGI(TAG, "Trying uppercase path: %s", fullPath);
}
}
ESP_LOGI(TAG, "Loading background image: %s", fullPath);
lv_obj_t* bgImg = lv_image_create(parent);
lv_image_set_src(bgImg, fullPath);
// Get image dimensions
lv_image_header_t header;
lv_result_t res = lv_image_decoder_get_info(fullPath, &header);
if (res == LV_RESULT_OK && header.w > 0 && header.h > 0) {
ESP_LOGI(TAG, "Image size: %dx%d", header.w, header.h);
// Get display/canvas size
int32_t dispW = lv_obj_get_width(parent);
int32_t dispH = lv_obj_get_height(parent);
if (dispW <= 0) dispW = LV_HOR_RES;
if (dispH <= 0) dispH = LV_VER_RES;
switch (screen.bgImageMode) {
case BgImageMode::STRETCH: {
// Calculate scale to fill display (in 1/256 units for LVGL)
int32_t scaleX = (dispW * 256) / header.w;
int32_t scaleY = (dispH * 256) / header.h;
lv_image_set_scale_x(bgImg, scaleX);
lv_image_set_scale_y(bgImg, scaleY);
lv_obj_set_pos(bgImg, 0, 0);
ESP_LOGI(TAG, "Stretch scale: %ldx%ld", scaleX, scaleY);
break;
}
case BgImageMode::CENTER:
lv_obj_center(bgImg);
break;
case BgImageMode::TILE:
lv_image_set_inner_align(bgImg, LV_IMAGE_ALIGN_TILE);
lv_obj_set_size(bgImg, lv_pct(100), lv_pct(100));
break;
default:
break;
}
} else {
ESP_LOGW(TAG, "Could not get image info, using default position");
lv_obj_set_pos(bgImg, 0, 0);
}
// Send to background (behind all widgets)
lv_obj_move_to_index(bgImg, 0);
}
// Pass 1: Create root widgets (parentId == -1)
for (uint8_t i = 0; i < screen.widgetCount; i++) {
const WidgetConfig& cfg = screen.widgets[i];
if (cfg.parentId != -1) continue;
auto widget = WidgetFactory::create(cfg);
if (widget && cfg.id < 256) {
widget->create(parent);
widget->applyStyle();
widgets_[cfg.id] = std::move(widget);
}
}
// Pass 2: Create child widgets
// Simple 1-level depth support for now. For deeper nesting, we'd need a topological sort or multiple passes.
bool madeProgress = true;
int remainingPasses = 10; // Prevent infinite loops
while (madeProgress && remainingPasses > 0) {
madeProgress = false;
remainingPasses--;
for (uint8_t i = 0; i < screen.widgetCount; i++) {
const WidgetConfig& cfg = screen.widgets[i];
// Skip if already created
if (cfg.id < 256 && widgets_[cfg.id]) continue;
// Skip if it's a root widget (should be created in Pass 1, but if failed/skipped, ignore)
if (cfg.parentId == -1) continue;
// Check if parent exists
if (cfg.parentId >= 0 && cfg.parentId < 256 && widgets_[cfg.parentId]) {
// Parent exists! Get its LVGL object
lv_obj_t* parentObj = widgets_[cfg.parentId]->getObj();
if (parentObj) {
auto widget = WidgetFactory::create(cfg);
if (widget && cfg.id < 256) {
widget->create(parentObj);
widget->applyStyle();
widgets_[cfg.id] = std::move(widget);
madeProgress = true;
}
}
}
}
}
}
void WidgetManager::onKnxValue(uint16_t groupAddr, float value, TextSource source) {
UiEvent event = {};
event.type = UiEventType::KNX_VALUE;
event.groupAddr = groupAddr;
event.textSource = source;
event.value = value;
cacheKnxValue(groupAddr, source, value);
enqueueUiEvent(event);
}
void WidgetManager::onKnxValue(uint16_t groupAddr, float value) {
onKnxValue(groupAddr, value, TextSource::KNX_DPT_TEMP);
}
void WidgetManager::onKnxSwitch(uint16_t groupAddr, bool value) {
UiEvent event = {};
event.type = UiEventType::KNX_SWITCH;
event.groupAddr = groupAddr;
event.state = value;
cacheKnxSwitch(groupAddr, value);
enqueueUiEvent(event);
}
void WidgetManager::onKnxText(uint16_t groupAddr, const char* text) {
UiEvent event = {};
event.type = UiEventType::KNX_TEXT;
event.groupAddr = groupAddr;
if (text) {
const size_t max_len = sizeof(event.text) - 1;
const size_t src_len = strnlen(text, max_len);
if (is_valid_utf8(text, src_len)) {
memcpy(event.text, text, src_len);
event.text[src_len] = '\0';
} else {
latin1_to_utf8(text, src_len, event.text, sizeof(event.text));
}
} else {
event.text[0] = '\0';
}
cacheKnxText(groupAddr, event.text);
enqueueUiEvent(event);
}
void WidgetManager::onKnxTime(uint16_t groupAddr, const struct tm& value, KnxTimeType type) {
UiEvent event = {};
event.type = UiEventType::KNX_TIME;
event.groupAddr = groupAddr;
event.timeType = type;
event.timeValue = value;
cacheKnxTime(groupAddr, type, value);
enqueueUiEvent(event);
}
bool WidgetManager::enqueueUiEvent(const UiEvent& event) {
if (!uiQueue_) return false;
return xQueueSend(uiQueue_, &event, 0) == pdTRUE;
}
void WidgetManager::processUiQueue() {
if (!uiQueue_) return;
if (uxQueueMessagesWaiting(uiQueue_) == 0) return;
// Call from loop() which is ALREADY LOCKED by LVGL timer
// DO NOT take esp_lv_adapter_lock() here.
UiEvent event = {};
static constexpr size_t kMaxEventsPerLoop = 8;
size_t processed = 0;
while (processed < kMaxEventsPerLoop &&
xQueueReceive(uiQueue_, &event, 0) == pdTRUE) {
switch (event.type) {
case UiEventType::KNX_VALUE:
applyKnxValue(event.groupAddr, event.value, event.textSource);
break;
case UiEventType::KNX_SWITCH:
applyKnxSwitch(event.groupAddr, event.state);
break;
case UiEventType::KNX_TEXT:
applyKnxText(event.groupAddr, event.text);
break;
case UiEventType::KNX_TIME:
applyKnxTime(event.groupAddr, event.timeValue, event.timeType);
break;
}
processed++;
}
if (chartRefreshPending_) {
refreshChartWidgetsLocked();
chartRefreshPending_ = false;
}
}
void WidgetManager::applyCachedValuesToWidgets() {
for (auto& widget : widgets_) {
if (!widget) continue;
// Primary address
uint16_t addr = widget->getKnxAddress();
TextSource source = widget->getTextSource();
if (addr != 0 && source != TextSource::STATIC) {
if (source == TextSource::KNX_DPT_TIME ||
source == TextSource::KNX_DPT_DATE ||
source == TextSource::KNX_DPT_DATETIME) {
KnxTimeType type = KnxTimeType::TIME;
if (source == TextSource::KNX_DPT_DATE) {
type = KnxTimeType::DATE;
} else if (source == TextSource::KNX_DPT_DATETIME) {
type = KnxTimeType::DATETIME;
}
struct tm tmValue = {};
if (getCachedKnxTime(addr, type, &tmValue)) {
widget->onKnxTime(tmValue, source);
}
} else if (source == TextSource::KNX_DPT_SWITCH) {
bool state = false;
if (getCachedKnxSwitch(addr, &state)) {
widget->onKnxSwitch(state);
}
} else if (source == TextSource::KNX_DPT_TEXT) {
char text[MAX_TEXT_LEN] = {};
if (getCachedKnxText(addr, text, sizeof(text))) {
widget->onKnxText(text);
}
} else if (isNumericTextSource(source)) {
float value = 0.0f;
if (getCachedKnxValue(addr, source, &value)) {
widget->onKnxValue(value);
}
}
}
// Secondary address (left value)
uint16_t addr2 = widget->getKnxAddress2();
TextSource source2 = widget->getTextSource2();
if (addr2 != 0 && source2 != TextSource::STATIC && isNumericTextSource(source2)) {
float value = 0.0f;
if (getCachedKnxValue(addr2, source2, &value)) {
widget->onKnxValue2(value);
}
}
// Tertiary address (right value)
uint16_t addr3 = widget->getKnxAddress3();
TextSource source3 = widget->getTextSource3();
if (addr3 != 0 && source3 != TextSource::STATIC && isNumericTextSource(source3)) {
float value = 0.0f;
if (getCachedKnxValue(addr3, source3, &value)) {
widget->onKnxValue3(value);
}
}
}
}
void WidgetManager::refreshChartWidgetsLocked() {
for (auto& widget : widgets_) {
if (!widget) continue;
if (widget->getType() == WidgetType::CHART) {
widget->onHistoryUpdate();
}
}
}
void WidgetManager::refreshChartWidgets() {
// Call from loop() which is ALREADY LOCKED by LVGL timer
// DO NOT take esp_lv_adapter_lock() here.
refreshChartWidgetsLocked();
}
void WidgetManager::updateSystemTimeWidgets() {
int64_t nowUs = esp_timer_get_time();
if (nowUs - lastSystemTimeUpdateUs_ < 500000) { // Update every 500ms
return;
}
lastSystemTimeUpdateUs_ = nowUs;
time_t now;
time(&now);
struct tm timeinfo;
localtime_r(&now, &timeinfo);
// Call from loop() which is ALREADY LOCKED by LVGL timer
for (auto& widget : widgets_) {
if (!widget) continue;
TextSource src = widget->getTextSource();
if (src == TextSource::SYSTEM_TIME ||
src == TextSource::SYSTEM_DATE ||
src == TextSource::SYSTEM_DATETIME) {
widget->onKnxTime(timeinfo, src);
}
}
}
void WidgetManager::applyKnxValue(uint16_t groupAddr, float value, TextSource source) {
for (auto& widget : widgets_) {
if (!widget) continue;
// Primary address (bottom value)
if (widget->getKnxAddress() == groupAddr &&
widget->getTextSource() == source) {
widget->onKnxValue(value);
}
// Secondary address (left value)
if (widget->getKnxAddress2() == groupAddr &&
widget->getTextSource2() == source) {
widget->onKnxValue2(value);
}
// Tertiary address (right value)
if (widget->getKnxAddress3() == groupAddr &&
widget->getTextSource3() == source) {
widget->onKnxValue3(value);
}
}
if (HistoryStore::instance().updateLatest(groupAddr, source, value)) {
chartRefreshPending_ = true;
}
}
void WidgetManager::applyKnxSwitch(uint16_t groupAddr, bool value) {
for (auto& widget : widgets_) {
if (!widget) continue;
if (widget->getKnxAddress() == groupAddr) {
widget->onKnxSwitch(value);
}
// RoomCard sub-button status updates
if (widget->getType() == WidgetType::ROOMCARD) {
const WidgetConfig& cfg = widget->getConfig();
for (uint8_t i = 0; i < cfg.subButtonCount && i < MAX_SUBBUTTONS; ++i) {
if (cfg.subButtons[i].enabled && cfg.subButtons[i].knxAddrRead == groupAddr) {
static_cast<RoomCardWidget*>(widget.get())->onSubButtonStatus(i, value);
}
}
}
}
if (config_->knxNightModeAddress != 0 && groupAddr == config_->knxNightModeAddress) {
nightMode_ = value;
}
}
void WidgetManager::applyKnxText(uint16_t groupAddr, const char* text) {
for (auto& widget : widgets_) {
if (widget && widget->getKnxAddress() == groupAddr) {
widget->onKnxText(text);
}
}
}
void WidgetManager::applyKnxTime(uint16_t groupAddr, const struct tm& value, KnxTimeType type) {
// Simplified system time synchronization
bool isGlobalTime = false;
if (type == KnxTimeType::TIME && config_->knxTimeAddress != 0 && groupAddr == config_->knxTimeAddress) isGlobalTime = true;
if (type == KnxTimeType::DATE && config_->knxDateAddress != 0 && groupAddr == config_->knxDateAddress) isGlobalTime = true;
if (type == KnxTimeType::DATETIME && config_->knxDateTimeAddress != 0 && groupAddr == config_->knxDateTimeAddress) isGlobalTime = true;
if (isGlobalTime) {
time_t now;
time(&now);
struct tm t_new;
localtime_r(&now, &t_new);
if (type == KnxTimeType::TIME) {
t_new.tm_hour = value.tm_hour;
t_new.tm_min = value.tm_min;
t_new.tm_sec = value.tm_sec;
} else if (type == KnxTimeType::DATE) {
t_new.tm_year = value.tm_year;
if (t_new.tm_year < 1900) t_new.tm_year += 1900;
t_new.tm_mon = value.tm_mon;
t_new.tm_mday = value.tm_mday;
} else if (type == KnxTimeType::DATETIME) {
t_new = value;
if (t_new.tm_year < 1900) t_new.tm_year += 1900;
}
// Final normalization for mktime (expects year - 1900)
if (t_new.tm_year >= 1900) t_new.tm_year -= 1900;
time_t t = mktime(&t_new);
if (t != -1) {
struct timeval tv = { .tv_sec = t, .tv_usec = 0 };
settimeofday(&tv, NULL);
}
}
TextSource source = TextSource::STATIC;
if (type == KnxTimeType::TIME) source = TextSource::KNX_DPT_TIME;
else if (type == KnxTimeType::DATE) source = TextSource::KNX_DPT_DATE;
else if (type == KnxTimeType::DATETIME) source = TextSource::KNX_DPT_DATETIME;
if (source != TextSource::STATIC) {
for (auto& widget : widgets_) {
if (widget && widget->getKnxAddress() == groupAddr &&
widget->getTextSource() == source) {
widget->onKnxTime(value, source);
}
}
}
chartRefreshPending_ = true;
}
void WidgetManager::cacheKnxValue(uint16_t groupAddr, TextSource source, float value) {
if (groupAddr == 0) return;
portENTER_CRITICAL(&knxCacheMux_);
size_t freeIndex = KNX_CACHE_SIZE;
for (size_t i = 0; i < KNX_CACHE_SIZE; ++i) {
auto& entry = knxNumericCache_[i];
if (entry.valid) {
if (entry.groupAddr == groupAddr && entry.source == source) {
entry.value = value;
portEXIT_CRITICAL(&knxCacheMux_);
return;
}
} else if (freeIndex == KNX_CACHE_SIZE) {
freeIndex = i;
}
}
size_t index = freeIndex;
if (index == KNX_CACHE_SIZE) {
index = knxNumericCacheNext_;
knxNumericCacheNext_ = (knxNumericCacheNext_ + 1) % KNX_CACHE_SIZE;
}
auto& entry = knxNumericCache_[index];
entry.groupAddr = groupAddr;
entry.source = source;
entry.value = value;
entry.valid = true;
portEXIT_CRITICAL(&knxCacheMux_);
}
void WidgetManager::cacheKnxSwitch(uint16_t groupAddr, bool value) {
if (groupAddr == 0) return;
portENTER_CRITICAL(&knxCacheMux_);
size_t freeIndex = KNX_CACHE_SIZE;
for (size_t i = 0; i < KNX_CACHE_SIZE; ++i) {
auto& entry = knxSwitchCache_[i];
if (entry.valid) {
if (entry.groupAddr == groupAddr) {
entry.value = value;
portEXIT_CRITICAL(&knxCacheMux_);
return;
}
} else if (freeIndex == KNX_CACHE_SIZE) {
freeIndex = i;
}
}
size_t index = freeIndex;
if (index == KNX_CACHE_SIZE) {
index = knxSwitchCacheNext_;
knxSwitchCacheNext_ = (knxSwitchCacheNext_ + 1) % KNX_CACHE_SIZE;
}
auto& entry = knxSwitchCache_[index];
entry.groupAddr = groupAddr;
entry.value = value;
entry.valid = true;
portEXIT_CRITICAL(&knxCacheMux_);
}
void WidgetManager::cacheKnxText(uint16_t groupAddr, const char* text) {
if (groupAddr == 0) return;
portENTER_CRITICAL(&knxCacheMux_);
size_t freeIndex = KNX_CACHE_SIZE;
for (size_t i = 0; i < KNX_CACHE_SIZE; ++i) {
auto& entry = knxTextCache_[i];
if (entry.valid) {
if (entry.groupAddr == groupAddr) {
if (text) {
strncpy(entry.text, text, MAX_TEXT_LEN - 1);
entry.text[MAX_TEXT_LEN - 1] = '\0';
} else {
entry.text[0] = '\0';
}
portEXIT_CRITICAL(&knxCacheMux_);
return;
}
} else if (freeIndex == KNX_CACHE_SIZE) {
freeIndex = i;
}
}
size_t index = freeIndex;
if (index == KNX_CACHE_SIZE) {
index = knxTextCacheNext_;
knxTextCacheNext_ = (knxTextCacheNext_ + 1) % KNX_CACHE_SIZE;
}
auto& entry = knxTextCache_[index];
entry.groupAddr = groupAddr;
if (text) {
strncpy(entry.text, text, MAX_TEXT_LEN - 1);
entry.text[MAX_TEXT_LEN - 1] = '\0';
} else {
entry.text[0] = '\0';
}
entry.valid = true;
portEXIT_CRITICAL(&knxCacheMux_);
}
void WidgetManager::cacheKnxTime(uint16_t groupAddr, KnxTimeType type, const struct tm& value) {
if (groupAddr == 0) return;
portENTER_CRITICAL(&knxCacheMux_);
size_t freeIndex = KNX_CACHE_SIZE;
for (size_t i = 0; i < KNX_CACHE_SIZE; ++i) {
auto& entry = knxTimeCache_[i];
if (entry.valid) {
if (entry.groupAddr == groupAddr && entry.type == type) {
entry.value = value;
portEXIT_CRITICAL(&knxCacheMux_);
return;
}
} else if (freeIndex == KNX_CACHE_SIZE) {
freeIndex = i;
}
}
size_t index = freeIndex;
if (index == KNX_CACHE_SIZE) {
index = knxTimeCacheNext_;
knxTimeCacheNext_ = (knxTimeCacheNext_ + 1) % KNX_CACHE_SIZE;
}
auto& entry = knxTimeCache_[index];
entry.groupAddr = groupAddr;
entry.type = type;
entry.value = value;
entry.valid = true;
portEXIT_CRITICAL(&knxCacheMux_);
}
bool WidgetManager::getCachedKnxValue(uint16_t groupAddr, TextSource source, float* out) const {
if (groupAddr == 0 || out == nullptr) return false;
bool found = false;
portENTER_CRITICAL(&knxCacheMux_);
for (const auto& entry : knxNumericCache_) {
if (entry.valid && entry.groupAddr == groupAddr && entry.source == source) {
*out = entry.value;
found = true;
break;
}
}
portEXIT_CRITICAL(&knxCacheMux_);
return found;
}
bool WidgetManager::getCachedKnxSwitch(uint16_t groupAddr, bool* out) const {
if (groupAddr == 0 || out == nullptr) return false;
bool found = false;
portENTER_CRITICAL(&knxCacheMux_);
for (const auto& entry : knxSwitchCache_) {
if (entry.valid && entry.groupAddr == groupAddr) {
*out = entry.value;
found = true;
break;
}
}
portEXIT_CRITICAL(&knxCacheMux_);
return found;
}
bool WidgetManager::getCachedKnxText(uint16_t groupAddr, char* out, size_t outSize) const {
if (groupAddr == 0 || out == nullptr || outSize == 0) return false;
bool found = false;
portENTER_CRITICAL(&knxCacheMux_);
for (const auto& entry : knxTextCache_) {
if (entry.valid && entry.groupAddr == groupAddr) {
strncpy(out, entry.text, outSize - 1);
out[outSize - 1] = '\0';
found = true;
break;
}
}
portEXIT_CRITICAL(&knxCacheMux_);
return found;
}
bool WidgetManager::getCachedKnxTime(uint16_t groupAddr, KnxTimeType type, struct tm* out) const {
if (groupAddr == 0 || out == nullptr) return false;
bool found = false;
portENTER_CRITICAL(&knxCacheMux_);
for (const auto& entry : knxTimeCache_) {
if (entry.valid && entry.groupAddr == groupAddr && entry.type == type) {
*out = entry.value;
found = true;
break;
}
}
portEXIT_CRITICAL(&knxCacheMux_);
return found;
}
bool WidgetManager::isNumericTextSource(TextSource source) {
return source == TextSource::KNX_DPT_TEMP ||
source == TextSource::KNX_DPT_PERCENT ||
source == TextSource::KNX_DPT_POWER ||
source == TextSource::KNX_DPT_ENERGY ||
source == TextSource::KNX_DPT_DECIMALFACTOR;
}
// Helper function to parse hex color string
static uint32_t parseHexColor(const char* colorStr) {
if (!colorStr || colorStr[0] != '#') return 0;
return strtoul(colorStr + 1, nullptr, 16);
}
static Color defaultChartColor(size_t index) {
static const Color kChartColors[CHART_MAX_SERIES] = {
{239, 99, 81},
{125, 211, 176},
{94, 162, 239}
};
if (index >= CHART_MAX_SERIES) return kChartColors[0];
return kChartColors[index];
}
void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
cJSON* root = cJSON_CreateObject();
if (!root) {
snprintf(buf, bufSize, "{}");
return;
}
cJSON_AddNumberToObject(root, "startScreen", config_->startScreenId);
cJSON* standby = cJSON_AddObjectToObject(root, "standby");
cJSON_AddBoolToObject(standby, "enabled", config_->standbyEnabled);
cJSON_AddNumberToObject(standby, "screen", config_->standbyScreenId);
cJSON_AddNumberToObject(standby, "minutes", config_->standbyMinutes);
cJSON* knx = cJSON_AddObjectToObject(root, "knx");
cJSON_AddNumberToObject(knx, "time", config_->knxTimeAddress);
cJSON_AddNumberToObject(knx, "date", config_->knxDateAddress);
cJSON_AddNumberToObject(knx, "dateTime", config_->knxDateTimeAddress);
cJSON_AddNumberToObject(knx, "night", config_->knxNightModeAddress);
cJSON* screens = cJSON_AddArrayToObject(root, "screens");
for (uint8_t s = 0; s < config_->screenCount; s++) {
const ScreenConfig& screen = config_->screens[s];
cJSON* screenJson = cJSON_CreateObject();
cJSON_AddNumberToObject(screenJson, "id", screen.id);
cJSON_AddStringToObject(screenJson, "name", screen.name);
cJSON_AddNumberToObject(screenJson, "mode", static_cast<int>(screen.mode));
char bgColorStr[8];
snprintf(bgColorStr, sizeof(bgColorStr), "#%02X%02X%02X",
screen.backgroundColor.r, screen.backgroundColor.g, screen.backgroundColor.b);
cJSON_AddStringToObject(screenJson, "bgColor", bgColorStr);
// Background image
if (screen.bgImagePath[0] != '\0') {
cJSON_AddStringToObject(screenJson, "bgImage", screen.bgImagePath);
cJSON_AddNumberToObject(screenJson, "bgImageMode", static_cast<int>(screen.bgImageMode));
}
// Modal-specific properties
if (screen.mode == ScreenMode::MODAL) {
cJSON* modal = cJSON_AddObjectToObject(screenJson, "modal");
cJSON_AddNumberToObject(modal, "x", screen.modalX);
cJSON_AddNumberToObject(modal, "y", screen.modalY);
cJSON_AddNumberToObject(modal, "w", screen.modalWidth);
cJSON_AddNumberToObject(modal, "h", screen.modalHeight);
cJSON_AddNumberToObject(modal, "radius", screen.modalBorderRadius);
cJSON_AddBoolToObject(modal, "dim", screen.modalDimBackground);
}
cJSON* widgets = cJSON_AddArrayToObject(screenJson, "widgets");
for (uint8_t i = 0; i < screen.widgetCount; i++) {
const WidgetConfig& w = screen.widgets[i];
cJSON* widget = cJSON_CreateObject();
cJSON_AddNumberToObject(widget, "id", w.id);
cJSON_AddNumberToObject(widget, "type", static_cast<int>(w.type));
cJSON_AddNumberToObject(widget, "x", w.x);
cJSON_AddNumberToObject(widget, "y", w.y);
cJSON_AddNumberToObject(widget, "w", w.width);
cJSON_AddNumberToObject(widget, "h", w.height);
cJSON_AddBoolToObject(widget, "visible", w.visible);
cJSON_AddNumberToObject(widget, "textSrc", static_cast<int>(w.textSource));
cJSON_AddStringToObject(widget, "text", w.text);
cJSON_AddNumberToObject(widget, "knxAddr", w.knxAddress);
cJSON_AddNumberToObject(widget, "fontSize", w.fontSize);
cJSON_AddNumberToObject(widget, "textAlign", w.textAlign);
cJSON_AddBoolToObject(widget, "isContainer", w.isContainer);
char textColorStr[8];
snprintf(textColorStr, sizeof(textColorStr), "#%02X%02X%02X",
w.textColor.r, w.textColor.g, w.textColor.b);
cJSON_AddStringToObject(widget, "textColor", textColorStr);
char widgetBgColorStr[8];
snprintf(widgetBgColorStr, sizeof(widgetBgColorStr), "#%02X%02X%02X",
w.bgColor.r, w.bgColor.g, w.bgColor.b);
cJSON_AddStringToObject(widget, "bgColor", widgetBgColorStr);
cJSON_AddNumberToObject(widget, "bgOpacity", w.bgOpacity);
cJSON_AddNumberToObject(widget, "radius", w.borderRadius);
cJSON* shadow = cJSON_AddObjectToObject(widget, "shadow");
cJSON_AddBoolToObject(shadow, "enabled", w.shadow.enabled);
cJSON_AddNumberToObject(shadow, "x", w.shadow.offsetX);
cJSON_AddNumberToObject(shadow, "y", w.shadow.offsetY);
cJSON_AddNumberToObject(shadow, "blur", w.shadow.blur);
cJSON_AddNumberToObject(shadow, "spread", w.shadow.spread);
char shadowColorStr[8];
snprintf(shadowColorStr, sizeof(shadowColorStr), "#%02X%02X%02X",
w.shadow.color.r, w.shadow.color.g, w.shadow.color.b);
cJSON_AddStringToObject(shadow, "color", shadowColorStr);
cJSON_AddBoolToObject(widget, "isToggle", w.isToggle);
cJSON_AddNumberToObject(widget, "knxAddrWrite", w.knxAddressWrite);
cJSON_AddNumberToObject(widget, "action", static_cast<int>(w.action));
cJSON_AddNumberToObject(widget, "targetScreen", w.targetScreen);
// Icon properties
cJSON_AddNumberToObject(widget, "iconCodepoint", w.iconCodepoint);
cJSON_AddNumberToObject(widget, "iconPosition", w.iconPosition);
cJSON_AddNumberToObject(widget, "iconSize", w.iconSize);
cJSON_AddNumberToObject(widget, "iconGap", w.iconGap);
cJSON_AddNumberToObject(widget, "parentId", w.parentId);
// Secondary KNX address (left value)
if (w.knxAddress2 > 0) {
cJSON_AddNumberToObject(widget, "knxAddr2", w.knxAddress2);
cJSON_AddNumberToObject(widget, "textSrc2", static_cast<int>(w.textSource2));
cJSON_AddStringToObject(widget, "text2", w.text2);
}
// Tertiary KNX address (right value)
if (w.knxAddress3 > 0) {
cJSON_AddNumberToObject(widget, "knxAddr3", w.knxAddress3);
cJSON_AddNumberToObject(widget, "textSrc3", static_cast<int>(w.textSource3));
cJSON_AddStringToObject(widget, "text3", w.text3);
}
// Conditions
if (w.conditionCount > 0) {
cJSON* conditions = cJSON_AddArrayToObject(widget, "conditions");
for (uint8_t ci = 0; ci < w.conditionCount && ci < MAX_CONDITIONS; ++ci) {
const StyleCondition& cond = w.conditions[ci];
if (!cond.enabled) continue;
cJSON* condJson = cJSON_CreateObject();
// Source
const char* sourceStr = "primary";
if (cond.source == ConditionSource::SECONDARY) sourceStr = "secondary";
else if (cond.source == ConditionSource::TERTIARY) sourceStr = "tertiary";
cJSON_AddStringToObject(condJson, "source", sourceStr);
cJSON_AddNumberToObject(condJson, "threshold", cond.threshold);
// Operator
const char* opStr = "lt";
switch (cond.op) {
case ConditionOp::LESS: opStr = "lt"; break;
case ConditionOp::LESS_EQUAL: opStr = "lte"; break;
case ConditionOp::EQUAL: opStr = "eq"; break;
case ConditionOp::GREATER_EQUAL: opStr = "gte"; break;
case ConditionOp::GREATER: opStr = "gt"; break;
case ConditionOp::NOT_EQUAL: opStr = "neq"; break;
}
cJSON_AddStringToObject(condJson, "op", opStr);
cJSON_AddNumberToObject(condJson, "priority", cond.priority);
// Style
if (cond.style.iconCodepoint != 0) {
cJSON_AddNumberToObject(condJson, "icon", cond.style.iconCodepoint);
}
if (cond.style.flags & ConditionStyle::FLAG_USE_TEXT_COLOR) {
char colorStr[8];
snprintf(colorStr, sizeof(colorStr), "#%02X%02X%02X",
cond.style.textColor.r, cond.style.textColor.g, cond.style.textColor.b);
cJSON_AddStringToObject(condJson, "textColor", colorStr);
}
if (cond.style.flags & ConditionStyle::FLAG_USE_BG_COLOR) {
char colorStr[8];
snprintf(colorStr, sizeof(colorStr), "#%02X%02X%02X",
cond.style.bgColor.r, cond.style.bgColor.g, cond.style.bgColor.b);
cJSON_AddStringToObject(condJson, "bgColor", colorStr);
}
if (cond.style.flags & ConditionStyle::FLAG_HIDE) {
cJSON_AddBoolToObject(condJson, "hide", true);
}
cJSON_AddItemToArray(conditions, condJson);
}
}
if (w.type == WidgetType::CHART) {
cJSON* chart = cJSON_AddObjectToObject(widget, "chart");
cJSON_AddNumberToObject(chart, "period", w.chartPeriod);
cJSON* series = cJSON_AddArrayToObject(chart, "series");
uint8_t seriesCount = w.chartSeriesCount;
if (seriesCount > CHART_MAX_SERIES) seriesCount = CHART_MAX_SERIES;
for (uint8_t si = 0; si < seriesCount; ++si) {
cJSON* s = cJSON_CreateObject();
cJSON_AddNumberToObject(s, "knxAddr", w.chartKnxAddress[si]);
cJSON_AddNumberToObject(s, "textSrc", static_cast<int>(w.chartTextSource[si]));
char colorStr[8];
snprintf(colorStr, sizeof(colorStr), "#%02X%02X%02X",
w.chartSeriesColor[si].r, w.chartSeriesColor[si].g, w.chartSeriesColor[si].b);
cJSON_AddStringToObject(s, "color", colorStr);
cJSON_AddItemToArray(series, s);
}
}
// RoomCard sub-buttons
if (w.type == WidgetType::ROOMCARD) {
cJSON_AddNumberToObject(widget, "subButtonSize", w.subButtonSize);
cJSON_AddNumberToObject(widget, "subButtonDistance", w.subButtonDistance);
}
if (w.type == WidgetType::ROOMCARD && w.subButtonCount > 0) {
cJSON* subButtons = cJSON_AddArrayToObject(widget, "subButtons");
for (uint8_t si = 0; si < w.subButtonCount && si < MAX_SUBBUTTONS; ++si) {
const SubButtonConfig& sb = w.subButtons[si];
if (!sb.enabled) continue;
cJSON* sbJson = cJSON_CreateObject();
cJSON_AddNumberToObject(sbJson, "pos", static_cast<int>(sb.position));
cJSON_AddNumberToObject(sbJson, "icon", sb.iconCodepoint);
cJSON_AddNumberToObject(sbJson, "knxRead", sb.knxAddrRead);
cJSON_AddNumberToObject(sbJson, "knxWrite", sb.knxAddrWrite);
cJSON_AddNumberToObject(sbJson, "action", static_cast<int>(sb.action));
cJSON_AddNumberToObject(sbJson, "target", sb.targetScreen);
char colorOnStr[8], colorOffStr[8];
snprintf(colorOnStr, sizeof(colorOnStr), "#%02X%02X%02X",
sb.colorOn.r, sb.colorOn.g, sb.colorOn.b);
snprintf(colorOffStr, sizeof(colorOffStr), "#%02X%02X%02X",
sb.colorOff.r, sb.colorOff.g, sb.colorOff.b);
cJSON_AddStringToObject(sbJson, "colorOn", colorOnStr);
cJSON_AddStringToObject(sbJson, "colorOff", colorOffStr);
cJSON_AddItemToArray(subButtons, sbJson);
}
}
cJSON_AddItemToArray(widgets, widget);
}
cJSON_AddItemToArray(screens, screenJson);
}
char* jsonStr = cJSON_PrintUnformatted(root);
if (jsonStr) {
strncpy(buf, jsonStr, bufSize - 1);
buf[bufSize - 1] = '\0';
free(jsonStr);
} else {
snprintf(buf, bufSize, "{}");
}
cJSON_Delete(root);
}
bool WidgetManager::updateConfigFromJson(const char* json) {
cJSON* root = cJSON_Parse(json);
if (!root) {
ESP_LOGE(TAG, "Failed to parse JSON");
return false;
}
std::unique_ptr<GuiConfig> newConfig(new (std::nothrow) GuiConfig());
if (!newConfig) {
ESP_LOGE(TAG, "Out of memory for config");
cJSON_Delete(root);
return false;
}
newConfig->clear();
auto parseWidgets = [&](cJSON* widgets, ScreenConfig& screen) -> bool {
if (!cJSON_IsArray(widgets)) return false;
screen.widgetCount = 0;
cJSON* widget = nullptr;
cJSON_ArrayForEach(widget, widgets) {
if (screen.widgetCount >= MAX_WIDGETS) break;
WidgetConfig& w = screen.widgets[screen.widgetCount];
memset(&w, 0, sizeof(w));
w.visible = true;
w.action = ButtonAction::KNX;
w.targetScreen = 0;
w.textAlign = static_cast<uint8_t>(TextAlign::CENTER);
w.isContainer = false;
w.chartPeriod = static_cast<uint8_t>(ChartPeriod::HOUR_1);
w.chartSeriesCount = 1;
for (size_t i = 0; i < CHART_MAX_SERIES; ++i) {
w.chartKnxAddress[i] = 0;
w.chartTextSource[i] = TextSource::KNX_DPT_TEMP;
w.chartSeriesColor[i] = defaultChartColor(i);
}
cJSON* id = cJSON_GetObjectItem(widget, "id");
if (cJSON_IsNumber(id)) w.id = id->valueint;
cJSON* type = cJSON_GetObjectItem(widget, "type");
if (cJSON_IsNumber(type)) w.type = static_cast<WidgetType>(type->valueint);
cJSON* x = cJSON_GetObjectItem(widget, "x");
if (cJSON_IsNumber(x)) w.x = x->valueint;
cJSON* y = cJSON_GetObjectItem(widget, "y");
if (cJSON_IsNumber(y)) w.y = y->valueint;
cJSON* width = cJSON_GetObjectItem(widget, "w");
if (cJSON_IsNumber(width)) w.width = width->valueint;
cJSON* height = cJSON_GetObjectItem(widget, "h");
if (cJSON_IsNumber(height)) w.height = height->valueint;
cJSON* visible = cJSON_GetObjectItem(widget, "visible");
if (cJSON_IsBool(visible)) w.visible = cJSON_IsTrue(visible);
cJSON* textSrc = cJSON_GetObjectItem(widget, "textSrc");
if (cJSON_IsNumber(textSrc)) w.textSource = static_cast<TextSource>(textSrc->valueint);
cJSON* text = cJSON_GetObjectItem(widget, "text");
if (cJSON_IsString(text)) {
strncpy(w.text, text->valuestring, MAX_TEXT_LEN - 1);
w.text[MAX_TEXT_LEN - 1] = '\0';
}
cJSON* knxAddr = cJSON_GetObjectItem(widget, "knxAddr");
if (cJSON_IsNumber(knxAddr)) w.knxAddress = knxAddr->valueint;
cJSON* fontSize = cJSON_GetObjectItem(widget, "fontSize");
if (cJSON_IsNumber(fontSize)) w.fontSize = fontSize->valueint;
cJSON* textAlign = cJSON_GetObjectItem(widget, "textAlign");
if (cJSON_IsNumber(textAlign)) w.textAlign = textAlign->valueint;
cJSON* isContainer = cJSON_GetObjectItem(widget, "isContainer");
if (cJSON_IsBool(isContainer)) w.isContainer = cJSON_IsTrue(isContainer);
cJSON* textColor = cJSON_GetObjectItem(widget, "textColor");
if (cJSON_IsString(textColor)) {
w.textColor = Color::fromHex(parseHexColor(textColor->valuestring));
}
cJSON* widgetBgColor = cJSON_GetObjectItem(widget, "bgColor");
if (cJSON_IsString(widgetBgColor)) {
w.bgColor = Color::fromHex(parseHexColor(widgetBgColor->valuestring));
}
cJSON* bgOpacity = cJSON_GetObjectItem(widget, "bgOpacity");
if (cJSON_IsNumber(bgOpacity)) w.bgOpacity = bgOpacity->valueint;
cJSON* radius = cJSON_GetObjectItem(widget, "radius");
if (cJSON_IsNumber(radius)) w.borderRadius = radius->valueint;
cJSON* shadow = cJSON_GetObjectItem(widget, "shadow");
if (cJSON_IsObject(shadow)) {
cJSON* enabled = cJSON_GetObjectItem(shadow, "enabled");
if (cJSON_IsBool(enabled)) w.shadow.enabled = cJSON_IsTrue(enabled);
cJSON* sx = cJSON_GetObjectItem(shadow, "x");
if (cJSON_IsNumber(sx)) w.shadow.offsetX = sx->valueint;
cJSON* sy = cJSON_GetObjectItem(shadow, "y");
if (cJSON_IsNumber(sy)) w.shadow.offsetY = sy->valueint;
cJSON* blur = cJSON_GetObjectItem(shadow, "blur");
if (cJSON_IsNumber(blur)) w.shadow.blur = blur->valueint;
cJSON* spread = cJSON_GetObjectItem(shadow, "spread");
if (cJSON_IsNumber(spread)) w.shadow.spread = spread->valueint;
cJSON* shadowColor = cJSON_GetObjectItem(shadow, "color");
if (cJSON_IsString(shadowColor)) {
w.shadow.color = Color::fromHex(parseHexColor(shadowColor->valuestring));
}
}
cJSON* isToggle = cJSON_GetObjectItem(widget, "isToggle");
if (cJSON_IsBool(isToggle)) w.isToggle = cJSON_IsTrue(isToggle);
cJSON* knxAddrWrite = cJSON_GetObjectItem(widget, "knxAddrWrite");
if (cJSON_IsNumber(knxAddrWrite)) w.knxAddressWrite = knxAddrWrite->valueint;
cJSON* action = cJSON_GetObjectItem(widget, "action");
if (cJSON_IsNumber(action)) w.action = static_cast<ButtonAction>(action->valueint);
cJSON* targetScreen = cJSON_GetObjectItem(widget, "targetScreen");
if (cJSON_IsNumber(targetScreen)) w.targetScreen = targetScreen->valueint;
// Icon properties
cJSON* iconCodepoint = cJSON_GetObjectItem(widget, "iconCodepoint");
if (cJSON_IsNumber(iconCodepoint)) w.iconCodepoint = static_cast<uint32_t>(iconCodepoint->valuedouble);
cJSON* iconPosition = cJSON_GetObjectItem(widget, "iconPosition");
if (cJSON_IsNumber(iconPosition)) w.iconPosition = iconPosition->valueint;
cJSON* iconSize = cJSON_GetObjectItem(widget, "iconSize");
if (cJSON_IsNumber(iconSize)) w.iconSize = iconSize->valueint;
cJSON* iconGap = cJSON_GetObjectItem(widget, "iconGap");
if (cJSON_IsNumber(iconGap)) w.iconGap = iconGap->valueint;
cJSON* parentId = cJSON_GetObjectItem(widget, "parentId");
if (cJSON_IsNumber(parentId)) {
w.parentId = static_cast<int8_t>(parentId->valueint);
} else {
w.parentId = -1; // Default to root
}
// Secondary KNX address (left value)
cJSON* knxAddr2 = cJSON_GetObjectItem(widget, "knxAddr2");
if (cJSON_IsNumber(knxAddr2)) w.knxAddress2 = knxAddr2->valueint;
cJSON* textSrc2 = cJSON_GetObjectItem(widget, "textSrc2");
if (cJSON_IsNumber(textSrc2)) w.textSource2 = static_cast<TextSource>(textSrc2->valueint);
cJSON* text2 = cJSON_GetObjectItem(widget, "text2");
if (cJSON_IsString(text2)) {
strncpy(w.text2, text2->valuestring, MAX_FORMAT_LEN - 1);
w.text2[MAX_FORMAT_LEN - 1] = '\0';
}
// Tertiary KNX address (right value)
cJSON* knxAddr3 = cJSON_GetObjectItem(widget, "knxAddr3");
if (cJSON_IsNumber(knxAddr3)) w.knxAddress3 = knxAddr3->valueint;
cJSON* textSrc3 = cJSON_GetObjectItem(widget, "textSrc3");
if (cJSON_IsNumber(textSrc3)) w.textSource3 = static_cast<TextSource>(textSrc3->valueint);
cJSON* text3 = cJSON_GetObjectItem(widget, "text3");
if (cJSON_IsString(text3)) {
strncpy(w.text3, text3->valuestring, MAX_FORMAT_LEN - 1);
w.text3[MAX_FORMAT_LEN - 1] = '\0';
}
// Conditions
cJSON* conditions = cJSON_GetObjectItem(widget, "conditions");
if (cJSON_IsArray(conditions)) {
uint8_t condIdx = 0;
cJSON* condItem = nullptr;
cJSON_ArrayForEach(condItem, conditions) {
if (condIdx >= MAX_CONDITIONS) break;
StyleCondition& cond = w.conditions[condIdx];
memset(&cond, 0, sizeof(cond));
cond.enabled = true;
// Source
cJSON* source = cJSON_GetObjectItem(condItem, "source");
if (cJSON_IsString(source)) {
if (strcmp(source->valuestring, "secondary") == 0) {
cond.source = ConditionSource::SECONDARY;
} else if (strcmp(source->valuestring, "tertiary") == 0) {
cond.source = ConditionSource::TERTIARY;
} else {
cond.source = ConditionSource::PRIMARY;
}
}
// Threshold
cJSON* threshold = cJSON_GetObjectItem(condItem, "threshold");
if (cJSON_IsNumber(threshold)) cond.threshold = static_cast<float>(threshold->valuedouble);
// Operator
cJSON* op = cJSON_GetObjectItem(condItem, "op");
if (cJSON_IsString(op)) {
if (strcmp(op->valuestring, "lt") == 0) cond.op = ConditionOp::LESS;
else if (strcmp(op->valuestring, "lte") == 0) cond.op = ConditionOp::LESS_EQUAL;
else if (strcmp(op->valuestring, "eq") == 0) cond.op = ConditionOp::EQUAL;
else if (strcmp(op->valuestring, "gte") == 0) cond.op = ConditionOp::GREATER_EQUAL;
else if (strcmp(op->valuestring, "gt") == 0) cond.op = ConditionOp::GREATER;
else if (strcmp(op->valuestring, "neq") == 0) cond.op = ConditionOp::NOT_EQUAL;
}
// Priority
cJSON* priority = cJSON_GetObjectItem(condItem, "priority");
if (cJSON_IsNumber(priority)) cond.priority = priority->valueint;
// Icon
cJSON* icon = cJSON_GetObjectItem(condItem, "icon");
if (cJSON_IsNumber(icon)) cond.style.iconCodepoint = static_cast<uint32_t>(icon->valuedouble);
// Text color
cJSON* textColor = cJSON_GetObjectItem(condItem, "textColor");
if (cJSON_IsString(textColor)) {
cond.style.textColor = Color::fromHex(parseHexColor(textColor->valuestring));
cond.style.flags |= ConditionStyle::FLAG_USE_TEXT_COLOR;
}
// Background color
cJSON* bgColor = cJSON_GetObjectItem(condItem, "bgColor");
if (cJSON_IsString(bgColor)) {
cond.style.bgColor = Color::fromHex(parseHexColor(bgColor->valuestring));
cond.style.flags |= ConditionStyle::FLAG_USE_BG_COLOR;
}
// Hide
cJSON* hide = cJSON_GetObjectItem(condItem, "hide");
if (cJSON_IsBool(hide) && cJSON_IsTrue(hide)) {
cond.style.flags |= ConditionStyle::FLAG_HIDE;
}
condIdx++;
}
w.conditionCount = condIdx;
}
cJSON* chart = cJSON_GetObjectItem(widget, "chart");
if (cJSON_IsObject(chart)) {
cJSON* period = cJSON_GetObjectItem(chart, "period");
if (cJSON_IsNumber(period)) {
int periodVal = period->valueint;
if (periodVal < 0) periodVal = 0;
if (periodVal > static_cast<int>(ChartPeriod::MONTH_1)) {
periodVal = static_cast<int>(ChartPeriod::MONTH_1);
}
w.chartPeriod = static_cast<uint8_t>(periodVal);
}
cJSON* series = cJSON_GetObjectItem(chart, "series");
if (cJSON_IsArray(series)) {
uint8_t idx = 0;
cJSON* item = nullptr;
cJSON_ArrayForEach(item, series) {
if (idx >= CHART_MAX_SERIES) break;
cJSON* sAddr = cJSON_GetObjectItem(item, "knxAddr");
if (cJSON_IsNumber(sAddr)) {
w.chartKnxAddress[idx] = sAddr->valueint;
}
cJSON* sSrc = cJSON_GetObjectItem(item, "textSrc");
if (cJSON_IsNumber(sSrc)) {
TextSource src = static_cast<TextSource>(sSrc->valueint);
if (isNumericTextSource(src)) {
w.chartTextSource[idx] = src;
}
}
cJSON* sColor = cJSON_GetObjectItem(item, "color");
if (cJSON_IsString(sColor)) {
w.chartSeriesColor[idx] = Color::fromHex(parseHexColor(sColor->valuestring));
}
idx++;
}
if (idx > 0) {
w.chartSeriesCount = idx;
}
}
}
// RoomCard sub-button size and distance
cJSON* subButtonSize = cJSON_GetObjectItem(widget, "subButtonSize");
if (cJSON_IsNumber(subButtonSize)) {
w.subButtonSize = subButtonSize->valueint;
} else {
w.subButtonSize = 40; // Default
}
cJSON* subButtonDistance = cJSON_GetObjectItem(widget, "subButtonDistance");
if (cJSON_IsNumber(subButtonDistance)) {
w.subButtonDistance = subButtonDistance->valueint;
} else {
w.subButtonDistance = 80; // Default 80px
}
// RoomCard sub-buttons
cJSON* subButtons = cJSON_GetObjectItem(widget, "subButtons");
if (cJSON_IsArray(subButtons)) {
uint8_t sbIdx = 0;
cJSON* sbItem = nullptr;
cJSON_ArrayForEach(sbItem, subButtons) {
if (sbIdx >= MAX_SUBBUTTONS) break;
SubButtonConfig& sb = w.subButtons[sbIdx];
memset(&sb, 0, sizeof(sb));
sb.enabled = true;
cJSON* pos = cJSON_GetObjectItem(sbItem, "pos");
if (cJSON_IsNumber(pos)) {
sb.position = static_cast<SubButtonPosition>(pos->valueint);
}
cJSON* icon = cJSON_GetObjectItem(sbItem, "icon");
if (cJSON_IsNumber(icon)) {
sb.iconCodepoint = static_cast<uint32_t>(icon->valuedouble);
}
cJSON* knxRead = cJSON_GetObjectItem(sbItem, "knxRead");
if (cJSON_IsNumber(knxRead)) {
sb.knxAddrRead = knxRead->valueint;
}
cJSON* knxWrite = cJSON_GetObjectItem(sbItem, "knxWrite");
if (cJSON_IsNumber(knxWrite)) {
sb.knxAddrWrite = knxWrite->valueint;
}
cJSON* action = cJSON_GetObjectItem(sbItem, "action");
if (cJSON_IsNumber(action)) {
sb.action = static_cast<SubButtonAction>(action->valueint);
}
cJSON* target = cJSON_GetObjectItem(sbItem, "target");
if (cJSON_IsNumber(target)) {
sb.targetScreen = target->valueint;
}
cJSON* colorOn = cJSON_GetObjectItem(sbItem, "colorOn");
if (cJSON_IsString(colorOn)) {
sb.colorOn = Color::fromHex(parseHexColor(colorOn->valuestring));
}
cJSON* colorOff = cJSON_GetObjectItem(sbItem, "colorOff");
if (cJSON_IsString(colorOff)) {
sb.colorOff = Color::fromHex(parseHexColor(colorOff->valuestring));
}
sbIdx++;
}
w.subButtonCount = sbIdx;
}
screen.widgetCount++;
}
return true;
};
cJSON* screens = cJSON_GetObjectItem(root, "screens");
if (cJSON_IsArray(screens)) {
cJSON* screenJson = nullptr;
cJSON_ArrayForEach(screenJson, screens) {
if (newConfig->screenCount >= MAX_SCREENS) break;
uint8_t screenId = newConfig->screenCount;
const char* screenName = nullptr;
cJSON* id = cJSON_GetObjectItem(screenJson, "id");
if (cJSON_IsNumber(id)) {
int idVal = id->valueint;
if (idVal < 0) idVal = 0;
screenId = static_cast<uint8_t>(idVal);
}
cJSON* name = cJSON_GetObjectItem(screenJson, "name");
if (cJSON_IsString(name)) screenName = name->valuestring;
ScreenConfig& screen = newConfig->screens[newConfig->screenCount];
screen.clear(screenId, screenName);
if (!screen.name[0]) {
char fallback[16];
snprintf(fallback, sizeof(fallback), "Screen %d", screenId);
strncpy(screen.name, fallback, sizeof(screen.name) - 1);
}
cJSON* mode = cJSON_GetObjectItem(screenJson, "mode");
if (cJSON_IsNumber(mode)) screen.mode = static_cast<ScreenMode>(mode->valueint);
cJSON* bgColor = cJSON_GetObjectItem(screenJson, "bgColor");
if (cJSON_IsString(bgColor)) {
screen.backgroundColor = Color::fromHex(parseHexColor(bgColor->valuestring));
}
// Parse background image
cJSON* bgImage = cJSON_GetObjectItem(screenJson, "bgImage");
if (cJSON_IsString(bgImage)) {
strncpy(screen.bgImagePath, bgImage->valuestring, MAX_BG_IMAGE_PATH_LEN - 1);
screen.bgImagePath[MAX_BG_IMAGE_PATH_LEN - 1] = '\0';
} else {
screen.bgImagePath[0] = '\0';
}
cJSON* bgImageMode = cJSON_GetObjectItem(screenJson, "bgImageMode");
if (cJSON_IsNumber(bgImageMode)) {
screen.bgImageMode = static_cast<BgImageMode>(bgImageMode->valueint);
} else {
screen.bgImageMode = BgImageMode::STRETCH;
}
// Parse modal-specific properties
cJSON* modal = cJSON_GetObjectItem(screenJson, "modal");
if (cJSON_IsObject(modal)) {
cJSON* mx = cJSON_GetObjectItem(modal, "x");
if (cJSON_IsNumber(mx)) screen.modalX = mx->valueint;
cJSON* my = cJSON_GetObjectItem(modal, "y");
if (cJSON_IsNumber(my)) screen.modalY = my->valueint;
cJSON* mw = cJSON_GetObjectItem(modal, "w");
if (cJSON_IsNumber(mw)) screen.modalWidth = mw->valueint;
cJSON* mh = cJSON_GetObjectItem(modal, "h");
if (cJSON_IsNumber(mh)) screen.modalHeight = mh->valueint;
cJSON* mr = cJSON_GetObjectItem(modal, "radius");
if (cJSON_IsNumber(mr)) screen.modalBorderRadius = mr->valueint;
cJSON* dim = cJSON_GetObjectItem(modal, "dim");
if (cJSON_IsBool(dim)) screen.modalDimBackground = cJSON_IsTrue(dim);
}
cJSON* widgets = cJSON_GetObjectItem(screenJson, "widgets");
if (!parseWidgets(widgets, screen)) {
screen.widgetCount = 0;
}
ensureButtonLabels(screen);
newConfig->screenCount++;
}
} else {
newConfig->screenCount = 1;
ScreenConfig& screen = newConfig->screens[0];
screen.clear(0, "Screen 1");
cJSON* bgColor = cJSON_GetObjectItem(root, "bgColor");
if (cJSON_IsString(bgColor)) {
screen.backgroundColor = Color::fromHex(parseHexColor(bgColor->valuestring));
}
cJSON* widgets = cJSON_GetObjectItem(root, "widgets");
if (!parseWidgets(widgets, screen)) {
cJSON_Delete(root);
return false;
}
ensureButtonLabels(screen);
}
cJSON* startScreen = cJSON_GetObjectItem(root, "startScreen");
if (cJSON_IsNumber(startScreen)) {
int val = startScreen->valueint;
if (val < 0) val = 0;
newConfig->startScreenId = static_cast<uint8_t>(val);
}
cJSON* standby = cJSON_GetObjectItem(root, "standby");
if (cJSON_IsObject(standby)) {
cJSON* enabled = cJSON_GetObjectItem(standby, "enabled");
if (cJSON_IsBool(enabled)) newConfig->standbyEnabled = cJSON_IsTrue(enabled);
cJSON* screen = cJSON_GetObjectItem(standby, "screen");
if (cJSON_IsNumber(screen)) {
int val = screen->valueint;
if (val < 0) {
newConfig->standbyScreenId = SCREEN_ID_NONE;
} else {
newConfig->standbyScreenId = static_cast<uint8_t>(val);
}
}
cJSON* minutes = cJSON_GetObjectItem(standby, "minutes");
if (cJSON_IsNumber(minutes)) {
int val = minutes->valueint;
if (val < 0) val = 0;
newConfig->standbyMinutes = static_cast<uint16_t>(val);
}
}
cJSON* knx = cJSON_GetObjectItem(root, "knx");
if (cJSON_IsObject(knx)) {
cJSON* timeAddr = cJSON_GetObjectItem(knx, "time");
if (cJSON_IsNumber(timeAddr)) newConfig->knxTimeAddress = timeAddr->valueint;
cJSON* dateAddr = cJSON_GetObjectItem(knx, "date");
if (cJSON_IsNumber(dateAddr)) newConfig->knxDateAddress = dateAddr->valueint;
cJSON* dateTimeAddr = cJSON_GetObjectItem(knx, "dateTime");
if (cJSON_IsNumber(dateTimeAddr)) newConfig->knxDateTimeAddress = dateTimeAddr->valueint;
cJSON* nightAddr = cJSON_GetObjectItem(knx, "night");
if (cJSON_IsNumber(nightAddr)) newConfig->knxNightModeAddress = nightAddr->valueint;
}
if (newConfig->screenCount == 0) {
cJSON_Delete(root);
return false;
}
if (!newConfig->findScreen(newConfig->startScreenId)) {
newConfig->startScreenId = newConfig->screens[0].id;
}
if (!newConfig->findScreen(newConfig->standbyScreenId)) {
newConfig->standbyEnabled = false;
newConfig->standbyScreenId = SCREEN_ID_NONE;
}
*config_ = *newConfig;
cJSON_Delete(root);
ESP_LOGI(TAG, "Parsed %d screens from JSON", config_->screenCount);
return true;
}