diff --git a/.cache/clangd/index/ButtonWidget.cpp.6932614AE5FC71F9.idx b/.cache/clangd/index/ButtonWidget.cpp.6932614AE5FC71F9.idx index 5bc490e..842133a 100644 Binary files a/.cache/clangd/index/ButtonWidget.cpp.6932614AE5FC71F9.idx and b/.cache/clangd/index/ButtonWidget.cpp.6932614AE5FC71F9.idx differ diff --git a/.cache/clangd/index/PowerNodeWidget.cpp.D068C7972720D9A3.idx b/.cache/clangd/index/PowerNodeWidget.cpp.D068C7972720D9A3.idx index d70f321..f2bc892 100644 Binary files a/.cache/clangd/index/PowerNodeWidget.cpp.D068C7972720D9A3.idx and b/.cache/clangd/index/PowerNodeWidget.cpp.D068C7972720D9A3.idx differ diff --git a/.cache/clangd/index/PowerNodeWidget.hpp.EFF78DEAFB845F91.idx b/.cache/clangd/index/PowerNodeWidget.hpp.EFF78DEAFB845F91.idx index 8da357b..f85231c 100644 Binary files a/.cache/clangd/index/PowerNodeWidget.hpp.EFF78DEAFB845F91.idx and b/.cache/clangd/index/PowerNodeWidget.hpp.EFF78DEAFB845F91.idx differ diff --git a/.cache/clangd/index/RoomCardWidget.cpp.FA613078500AD19C.idx b/.cache/clangd/index/RoomCardWidget.cpp.FA613078500AD19C.idx new file mode 100644 index 0000000..b333027 Binary files /dev/null and b/.cache/clangd/index/RoomCardWidget.cpp.FA613078500AD19C.idx differ diff --git a/.cache/clangd/index/RoomCardWidget.hpp.DE0E3E44BD9BC30A.idx b/.cache/clangd/index/RoomCardWidget.hpp.DE0E3E44BD9BC30A.idx new file mode 100644 index 0000000..9264b46 Binary files /dev/null and b/.cache/clangd/index/RoomCardWidget.hpp.DE0E3E44BD9BC30A.idx differ diff --git a/.cache/clangd/index/Widget.cpp.63DB7B9186B85891.idx b/.cache/clangd/index/Widget.cpp.63DB7B9186B85891.idx index fcdc0f4..a38a563 100644 Binary files a/.cache/clangd/index/Widget.cpp.63DB7B9186B85891.idx and b/.cache/clangd/index/Widget.cpp.63DB7B9186B85891.idx differ diff --git a/.cache/clangd/index/Widget.hpp.63E5D98A9B23E60F.idx b/.cache/clangd/index/Widget.hpp.63E5D98A9B23E60F.idx index 47cb7c4..58387ac 100644 Binary files a/.cache/clangd/index/Widget.hpp.63E5D98A9B23E60F.idx and b/.cache/clangd/index/Widget.hpp.63E5D98A9B23E60F.idx differ diff --git a/.cache/clangd/index/WidgetConfig.cpp.FD56F9F36C29A5DA.idx b/.cache/clangd/index/WidgetConfig.cpp.FD56F9F36C29A5DA.idx index ed3b598..d42d31f 100644 Binary files a/.cache/clangd/index/WidgetConfig.cpp.FD56F9F36C29A5DA.idx and b/.cache/clangd/index/WidgetConfig.cpp.FD56F9F36C29A5DA.idx differ diff --git a/.cache/clangd/index/WidgetConfig.hpp.CAEFE2EEEB2A6996.idx b/.cache/clangd/index/WidgetConfig.hpp.CAEFE2EEEB2A6996.idx index f50850c..374d28d 100644 Binary files a/.cache/clangd/index/WidgetConfig.hpp.CAEFE2EEEB2A6996.idx and b/.cache/clangd/index/WidgetConfig.hpp.CAEFE2EEEB2A6996.idx differ diff --git a/.cache/clangd/index/WidgetManager.cpp.D8CE609DC911F13E.idx b/.cache/clangd/index/WidgetManager.cpp.D8CE609DC911F13E.idx index 416328f..6a7ec3f 100644 Binary files a/.cache/clangd/index/WidgetManager.cpp.D8CE609DC911F13E.idx and b/.cache/clangd/index/WidgetManager.cpp.D8CE609DC911F13E.idx differ diff --git a/.cache/clangd/index/WidgetManager.hpp.8559171B7B8A010C.idx b/.cache/clangd/index/WidgetManager.hpp.8559171B7B8A010C.idx index 4501744..72e374e 100644 Binary files a/.cache/clangd/index/WidgetManager.hpp.8559171B7B8A010C.idx and b/.cache/clangd/index/WidgetManager.hpp.8559171B7B8A010C.idx differ diff --git a/.cache/clangd/index/main.cpp.7C677863E2582AB3.idx b/.cache/clangd/index/main.cpp.7C677863E2582AB3.idx index 40ef825..4344402 100644 Binary files a/.cache/clangd/index/main.cpp.7C677863E2582AB3.idx and b/.cache/clangd/index/main.cpp.7C677863E2582AB3.idx differ diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 339fd49..b3ca56b 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -12,6 +12,7 @@ idf_component_register(SRCS "HistoryStore.cpp" "KnxWorker.cpp" "Nvs.cpp" "main.c "widgets/PowerLinkWidget.cpp" "widgets/ChartWidget.cpp" "widgets/ClockWidget.cpp" + "widgets/RoomCardWidget.cpp" "webserver/WebServer.cpp" "webserver/StaticFileHandlers.cpp" "webserver/ConfigHandlers.cpp" diff --git a/main/WidgetConfig.cpp b/main/WidgetConfig.cpp index bd2ac4a..173bc58 100644 --- a/main/WidgetConfig.cpp +++ b/main/WidgetConfig.cpp @@ -66,6 +66,71 @@ void WidgetConfig::serialize(uint8_t* buf) const { buf[pos++] = chartSeriesColor[i].g; buf[pos++] = chartSeriesColor[i].b; } + + // Secondary KNX address (left value) + buf[pos++] = knxAddress2 & 0xFF; + buf[pos++] = (knxAddress2 >> 8) & 0xFF; + buf[pos++] = static_cast(textSource2); + memcpy(&buf[pos], text2, MAX_FORMAT_LEN); pos += MAX_FORMAT_LEN; + + // Tertiary KNX address (right value) + buf[pos++] = knxAddress3 & 0xFF; + buf[pos++] = (knxAddress3 >> 8) & 0xFF; + buf[pos++] = static_cast(textSource3); + memcpy(&buf[pos], text3, MAX_FORMAT_LEN); pos += MAX_FORMAT_LEN; + + // Conditions + buf[pos++] = conditionCount; + for (size_t i = 0; i < MAX_CONDITIONS; ++i) { + const StyleCondition& cond = conditions[i]; + // threshold (4 bytes as float) + memcpy(&buf[pos], &cond.threshold, sizeof(float)); pos += sizeof(float); + buf[pos++] = static_cast(cond.op); + buf[pos++] = static_cast(cond.source); + buf[pos++] = cond.priority; + // ConditionStyle + buf[pos++] = cond.style.iconCodepoint & 0xFF; + buf[pos++] = (cond.style.iconCodepoint >> 8) & 0xFF; + buf[pos++] = (cond.style.iconCodepoint >> 16) & 0xFF; + buf[pos++] = (cond.style.iconCodepoint >> 24) & 0xFF; + buf[pos++] = cond.style.textColor.r; + buf[pos++] = cond.style.textColor.g; + buf[pos++] = cond.style.textColor.b; + buf[pos++] = cond.style.bgColor.r; + buf[pos++] = cond.style.bgColor.g; + buf[pos++] = cond.style.bgColor.b; + buf[pos++] = cond.style.bgOpacity; + buf[pos++] = cond.style.flags; + buf[pos++] = cond.enabled ? 1 : 0; + } + + // RoomCard sub-buttons + buf[pos++] = subButtonCount; + buf[pos++] = subButtonSize; + buf[pos++] = subButtonDistance; + for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) { + const SubButtonConfig& sb = subButtons[i]; + buf[pos++] = sb.iconCodepoint & 0xFF; + buf[pos++] = (sb.iconCodepoint >> 8) & 0xFF; + buf[pos++] = (sb.iconCodepoint >> 16) & 0xFF; + buf[pos++] = (sb.iconCodepoint >> 24) & 0xFF; + buf[pos++] = sb.knxAddrRead & 0xFF; + buf[pos++] = (sb.knxAddrRead >> 8) & 0xFF; + buf[pos++] = sb.knxAddrWrite & 0xFF; + buf[pos++] = (sb.knxAddrWrite >> 8) & 0xFF; + buf[pos++] = sb.colorOn.r; + buf[pos++] = sb.colorOn.g; + buf[pos++] = sb.colorOn.b; + buf[pos++] = sb.colorOff.r; + buf[pos++] = sb.colorOff.g; + buf[pos++] = sb.colorOff.b; + buf[pos++] = static_cast(sb.position); + buf[pos++] = static_cast(sb.action); + buf[pos++] = sb.targetScreen; + buf[pos++] = sb.enabled ? 1 : 0; + buf[pos++] = 0; // padding + buf[pos++] = 0; // padding + } } void WidgetConfig::deserialize(const uint8_t* buf) { @@ -130,6 +195,100 @@ void WidgetConfig::deserialize(const uint8_t* buf) { chartSeriesColor[i].g = buf[pos++]; chartSeriesColor[i].b = buf[pos++]; } + + // Secondary KNX address (left value) - check bounds for backward compatibility + if (pos + 19 <= SERIALIZED_SIZE) { + knxAddress2 = buf[pos] | (buf[pos + 1] << 8); pos += 2; + textSource2 = static_cast(buf[pos++]); + memcpy(text2, &buf[pos], MAX_FORMAT_LEN); pos += MAX_FORMAT_LEN; + text2[MAX_FORMAT_LEN - 1] = '\0'; + } else { + knxAddress2 = 0; + textSource2 = TextSource::STATIC; + text2[0] = '\0'; + } + + // Tertiary KNX address (right value) + if (pos + 19 <= SERIALIZED_SIZE) { + knxAddress3 = buf[pos] | (buf[pos + 1] << 8); pos += 2; + textSource3 = static_cast(buf[pos++]); + memcpy(text3, &buf[pos], MAX_FORMAT_LEN); pos += MAX_FORMAT_LEN; + text3[MAX_FORMAT_LEN - 1] = '\0'; + } else { + knxAddress3 = 0; + textSource3 = TextSource::STATIC; + text3[0] = '\0'; + } + + // Conditions + if (pos + 1 <= SERIALIZED_SIZE) { + conditionCount = buf[pos++]; + if (conditionCount > MAX_CONDITIONS) conditionCount = MAX_CONDITIONS; + } else { + conditionCount = 0; + } + + for (size_t i = 0; i < MAX_CONDITIONS; ++i) { + StyleCondition& cond = conditions[i]; + if (pos + 20 <= SERIALIZED_SIZE) { + memcpy(&cond.threshold, &buf[pos], sizeof(float)); pos += sizeof(float); + cond.op = static_cast(buf[pos++]); + cond.source = static_cast(buf[pos++]); + cond.priority = buf[pos++]; + cond.style.iconCodepoint = buf[pos] | (buf[pos + 1] << 8) | + (buf[pos + 2] << 16) | (buf[pos + 3] << 24); + pos += 4; + cond.style.textColor.r = buf[pos++]; + cond.style.textColor.g = buf[pos++]; + cond.style.textColor.b = buf[pos++]; + cond.style.bgColor.r = buf[pos++]; + cond.style.bgColor.g = buf[pos++]; + cond.style.bgColor.b = buf[pos++]; + cond.style.bgOpacity = buf[pos++]; + cond.style.flags = buf[pos++]; + cond.enabled = buf[pos++] != 0; + } else { + cond = StyleCondition{}; + } + } + + // RoomCard sub-buttons + if (pos + 3 <= SERIALIZED_SIZE) { + subButtonCount = buf[pos++]; + if (subButtonCount > MAX_SUBBUTTONS) subButtonCount = MAX_SUBBUTTONS; + subButtonSize = buf[pos++]; + if (subButtonSize == 0) subButtonSize = 40; // Default + subButtonDistance = buf[pos++]; + if (subButtonDistance == 0) subButtonDistance = 80; // Default 80px + } else { + subButtonCount = 0; + subButtonSize = 40; + subButtonDistance = 80; + } + + for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) { + SubButtonConfig& sb = subButtons[i]; + if (pos + 20 <= SERIALIZED_SIZE) { + sb.iconCodepoint = buf[pos] | (buf[pos + 1] << 8) | + (buf[pos + 2] << 16) | (buf[pos + 3] << 24); + pos += 4; + sb.knxAddrRead = buf[pos] | (buf[pos + 1] << 8); pos += 2; + sb.knxAddrWrite = buf[pos] | (buf[pos + 1] << 8); pos += 2; + sb.colorOn.r = buf[pos++]; + sb.colorOn.g = buf[pos++]; + sb.colorOn.b = buf[pos++]; + sb.colorOff.r = buf[pos++]; + sb.colorOff.g = buf[pos++]; + sb.colorOff.b = buf[pos++]; + sb.position = static_cast(buf[pos++]); + sb.action = static_cast(buf[pos++]); + sb.targetScreen = buf[pos++]; + sb.enabled = buf[pos++] != 0; + pos += 2; // padding + } else { + sb = SubButtonConfig{}; + } + } } WidgetConfig WidgetConfig::createLabel(uint8_t id, int16_t x, int16_t y, const char* labelText) { @@ -157,6 +316,19 @@ WidgetConfig WidgetConfig::createLabel(uint8_t id, int16_t x, int16_t y, const c cfg.iconPosition = 0; cfg.iconSize = 1; cfg.iconGap = 8; + // Secondary/tertiary address defaults + cfg.knxAddress2 = 0; + cfg.textSource2 = TextSource::STATIC; + cfg.text2[0] = '\0'; + cfg.knxAddress3 = 0; + cfg.textSource3 = TextSource::STATIC; + cfg.text3[0] = '\0'; + // Conditions + cfg.conditionCount = 0; + // Sub-buttons + cfg.subButtonCount = 0; + cfg.subButtonSize = 40; + cfg.subButtonDistance = 80; return cfg; } @@ -203,6 +375,19 @@ WidgetConfig WidgetConfig::createButton(uint8_t id, int16_t x, int16_t y, cfg.iconPosition = 0; cfg.iconSize = 1; cfg.iconGap = 8; + // Secondary/tertiary address defaults + cfg.knxAddress2 = 0; + cfg.textSource2 = TextSource::STATIC; + cfg.text2[0] = '\0'; + cfg.knxAddress3 = 0; + cfg.textSource3 = TextSource::STATIC; + cfg.text3[0] = '\0'; + // Conditions + cfg.conditionCount = 0; + // Sub-buttons + cfg.subButtonCount = 0; + cfg.subButtonSize = 40; + cfg.subButtonDistance = 80; return cfg; } @@ -211,6 +396,8 @@ void ScreenConfig::clear(uint8_t newId, const char* newName) { id = newId; mode = ScreenMode::FULLSCREEN; backgroundColor = {26, 26, 46}; // Dark blue background + bgImagePath[0] = '\0'; // No background image + bgImageMode = BgImageMode::STRETCH; widgetCount = 0; memset(widgets, 0, sizeof(widgets)); memset(name, 0, sizeof(name)); diff --git a/main/WidgetConfig.hpp b/main/WidgetConfig.hpp index 6c0c7ee..0bf58b4 100644 --- a/main/WidgetConfig.hpp +++ b/main/WidgetConfig.hpp @@ -9,7 +9,11 @@ static constexpr size_t MAX_WIDGETS = 64; static constexpr size_t MAX_SCREENS = 8; static constexpr size_t MAX_TEXT_LEN = 32; static constexpr size_t MAX_SCREEN_NAME_LEN = 24; +static constexpr size_t MAX_BG_IMAGE_PATH_LEN = 48; static constexpr size_t CHART_MAX_SERIES = 3; +static constexpr size_t MAX_CONDITIONS = 3; +static constexpr size_t MAX_FORMAT_LEN = 16; // Shorter format strings for left/right values +static constexpr size_t MAX_SUBBUTTONS = 6; // Sub-buttons for RoomCard enum class WidgetType : uint8_t { LABEL = 0, @@ -23,6 +27,7 @@ enum class WidgetType : uint8_t { POWERLINK = 8, CHART = 9, CLOCK = 10, + ROOMCARD = 11, }; enum class IconPosition : uint8_t { @@ -37,6 +42,13 @@ enum class ScreenMode : uint8_t { MODAL = 1, }; +enum class BgImageMode : uint8_t { + NONE = 0, + STRETCH = 1, + CENTER = 2, + TILE = 3, +}; + enum class ButtonAction : uint8_t { KNX = 0, JUMP = 1, @@ -93,6 +105,81 @@ struct Color { } }; +// Condition operator for conditional styling +enum class ConditionOp : uint8_t { + LESS = 0, // value < threshold + LESS_EQUAL = 1, // value <= threshold + EQUAL = 2, // value == threshold + GREATER_EQUAL = 3, // value >= threshold + GREATER = 4, // value > threshold + NOT_EQUAL = 5, // value != threshold +}; + +// Which KNX address to use for condition evaluation +enum class ConditionSource : uint8_t { + PRIMARY = 0, // knxAddress (bottom value) + SECONDARY = 1, // knxAddress2 (left value) + TERTIARY = 2, // knxAddress3 (right value) +}; + +// Style to apply when condition is met +struct ConditionStyle { + uint32_t iconCodepoint; // 0 = don't change icon + Color textColor; + Color bgColor; + uint8_t bgOpacity; + uint8_t flags; // Bits: 0=useTextColor, 1=useBgColor, 2=useBgOpacity, 3=hide + + static constexpr uint8_t FLAG_USE_TEXT_COLOR = 0x01; + static constexpr uint8_t FLAG_USE_BG_COLOR = 0x02; + static constexpr uint8_t FLAG_USE_BG_OPACITY = 0x04; + static constexpr uint8_t FLAG_HIDE = 0x08; +}; + +// A single style condition +struct StyleCondition { + float threshold; // 4 bytes + ConditionOp op; // 1 byte + ConditionSource source; // 1 byte - which value to check + uint8_t priority; // 1 byte (lower = higher priority) + ConditionStyle style; // 12 bytes + bool enabled; // 1 byte + // Total: 20 bytes per condition +}; + +// Sub-button position around RoomCard bubble (8 positions, 45° apart) +enum class SubButtonPosition : uint8_t { + TOP = 0, + TOP_RIGHT = 1, + RIGHT = 2, + BOTTOM_RIGHT = 3, + BOTTOM = 4, + BOTTOM_LEFT = 5, + LEFT = 6, + TOP_LEFT = 7, +}; + +// Sub-button action type +enum class SubButtonAction : uint8_t { + TOGGLE_KNX = 0, // Toggle KNX switch + NAVIGATE = 1, // Navigate to screen +}; + +// Sub-button configuration for RoomCard (20 bytes) +struct SubButtonConfig { + uint32_t iconCodepoint; // 4 bytes - Icon codepoint + uint16_t knxAddrRead; // 2 bytes - KNX address to read status + uint16_t knxAddrWrite; // 2 bytes - KNX address to write on click + Color colorOn; // 3 bytes - Color when ON + Color colorOff; // 3 bytes - Color when OFF + SubButtonPosition position; // 1 byte - Position around bubble + SubButtonAction action; // 1 byte - Action type + uint8_t targetScreen; // 1 byte - Target screen for navigate + bool enabled; // 1 byte - Is this sub-button active? + uint8_t _padding[2]; // 2 bytes - Alignment padding + // Total: 20 bytes per SubButton +}; + // Shadow configuration struct ShadowConfig { int8_t offsetX; @@ -151,8 +238,29 @@ struct WidgetConfig { TextSource chartTextSource[CHART_MAX_SERIES]; Color chartSeriesColor[CHART_MAX_SERIES]; + // Secondary KNX address (for PowerNode LEFT value) + uint16_t knxAddress2; + TextSource textSource2; + char text2[MAX_FORMAT_LEN]; // Format string for left value (short) + + // Tertiary KNX address (for PowerNode RIGHT value) + uint16_t knxAddress3; + TextSource textSource3; + char text3[MAX_FORMAT_LEN]; // Format string for right value (short) + + // Conditional styling + uint8_t conditionCount; + StyleCondition conditions[MAX_CONDITIONS]; + + // RoomCard sub-buttons + uint8_t subButtonCount; + uint8_t subButtonSize; // Sub-button size in pixels (default 40) + uint8_t subButtonDistance; // Distance from center in pixels (default 80) + SubButtonConfig subButtons[MAX_SUBBUTTONS]; + // Serialization size (fixed for NVS storage) - static constexpr size_t SERIALIZED_SIZE = 98; + // 197 + 1 (subButtonCount) + 1 (subButtonSize) + 1 (subButtonDistance) + 120 (6 subButtons * 20) = 320 + static constexpr size_t SERIALIZED_SIZE = 320; void serialize(uint8_t* buf) const; void deserialize(const uint8_t* buf); @@ -175,6 +283,8 @@ struct ScreenConfig { char name[MAX_SCREEN_NAME_LEN]; ScreenMode mode; Color backgroundColor; + char bgImagePath[MAX_BG_IMAGE_PATH_LEN]; // Background image path (e.g., "/images/bg.png") + BgImageMode bgImageMode; // 0=none, 1=stretch, 2=center, 3=tile uint8_t widgetCount; WidgetConfig widgets[MAX_WIDGETS]; diff --git a/main/WidgetManager.cpp b/main/WidgetManager.cpp index 7d83f36..cb7ce5a 100644 --- a/main/WidgetManager.cpp +++ b/main/WidgetManager.cpp @@ -1,5 +1,6 @@ #include "WidgetManager.hpp" #include "widgets/WidgetFactory.hpp" +#include "widgets/RoomCardWidget.hpp" #include "HistoryStore.hpp" #include "SdCard.hpp" #include "esp_lv_adapter.h" @@ -12,6 +13,7 @@ #include #include #include +#include #include static const char* TAG = "WidgetMgr"; @@ -148,6 +150,15 @@ WidgetManager& WidgetManager::instance() { } WidgetManager::WidgetManager() { + // Allocate GuiConfig in PSRAM to save internal RAM + config_ = static_cast(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)); @@ -155,14 +166,14 @@ WidgetManager::WidgetManager() { ESP_LOGE(TAG, "Failed to create UI event queue"); } createDefaultConfig(); - activeScreenId_ = config_.startScreenId; + activeScreenId_ = config_->startScreenId; lastActivityUs_ = esp_timer_get_time(); } void WidgetManager::createDefaultConfig() { - config_.clear(); - config_.screenCount = 1; - ScreenConfig& screen = config_.screens[0]; + config_->clear(); + config_->screenCount = 1; + ScreenConfig& screen = config_->screens[0]; screen.clear(0, "Screen 1"); // Default: Temperature label @@ -178,26 +189,26 @@ void WidgetManager::createDefaultConfig() { ensureButtonLabels(screen); - config_.startScreenId = screen.id; - config_.standbyEnabled = false; - config_.standbyScreenId = 0xFF; - config_.standbyMinutes = 0; + 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().configureFromConfig(*config_); HistoryStore::instance().loadFromSdCard(); - if (config_.findScreen(config_.startScreenId)) { - activeScreenId_ = config_.startScreenId; - } else if (config_.screenCount > 0) { - activeScreenId_ = config_.screens[0].id; + 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); + ESP_LOGI(TAG, "WidgetManager initialized with %d screens", config_->screenCount); } void WidgetManager::loadFromSdCard() { @@ -237,7 +248,7 @@ void WidgetManager::loadFromSdCard() { delete[] json; if (success) { - ESP_LOGI(TAG, "Loaded %d screens from SD card", config_.screenCount); + ESP_LOGI(TAG, "Loaded %d screens from SD card", config_->screenCount); } else { ESP_LOGE(TAG, "Failed to parse config file"); } @@ -269,19 +280,19 @@ void WidgetManager::saveToSdCard() { delete[] json; if (written > 0) { - ESP_LOGI(TAG, "Saved %d screens to SD card", config_.screenCount); + 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; + 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_); @@ -306,16 +317,16 @@ void WidgetManager::resetToDefaults() { ScreenConfig* WidgetManager::activeScreen() { if (modalContainer_ && modalScreenId_ != SCREEN_ID_NONE) { - return config_.findScreen(modalScreenId_); + return config_->findScreen(modalScreenId_); } - return config_.findScreen(activeScreenId_); + return config_->findScreen(activeScreenId_); } const ScreenConfig* WidgetManager::activeScreen() const { if (modalContainer_ && modalScreenId_ != SCREEN_ID_NONE) { - return config_.findScreen(modalScreenId_); + return config_->findScreen(modalScreenId_); } - return config_.findScreen(activeScreenId_); + return config_->findScreen(activeScreenId_); } const ScreenConfig* WidgetManager::currentScreen() const { @@ -334,7 +345,7 @@ void WidgetManager::applyScreen(uint8_t screenId) { void WidgetManager::applyScreenLocked(uint8_t screenId) { ESP_LOGI(TAG, "applyScreen(%d) - start", screenId); - ScreenConfig* screen = config_.findScreen(screenId); + ScreenConfig* screen = config_->findScreen(screenId); if (!screen) { ESP_LOGW(TAG, "Screen %d not found", screenId); return; @@ -525,7 +536,7 @@ void WidgetManager::closeModalLocked() { void WidgetManager::showScreenLocked(uint8_t screenId) { ESP_LOGI(TAG, "showScreen(%d) called", screenId); - ScreenConfig* screen = config_.findScreen(screenId); + ScreenConfig* screen = config_->findScreen(screenId); if (!screen) { ESP_LOGW(TAG, "Screen %d not found", screenId); return; @@ -602,12 +613,12 @@ void WidgetManager::goBackLocked() { printf("WM: Modal closed. Restoring screen %d\n", activeScreenId_); fflush(stdout); // Restore the active screen (which was in background) - if (config_.findScreen(activeScreenId_)) { + 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; + if (config_->findScreen(config_->startScreenId)) { + activeScreenId_ = config_->startScreenId; applyScreenLocked(activeScreenId_); } } @@ -617,7 +628,7 @@ void WidgetManager::goBackLocked() { if (previousScreenId_ != SCREEN_ID_NONE && previousScreenId_ != activeScreenId_) { printf("WM: Going back to screen %d\n", previousScreenId_); fflush(stdout); - if (config_.findScreen(previousScreenId_)) { + if (config_->findScreen(previousScreenId_)) { activeScreenId_ = previousScreenId_; previousScreenId_ = SCREEN_ID_NONE; applyScreenLocked(activeScreenId_); @@ -637,12 +648,33 @@ void WidgetManager::goBack() { esp_lv_adapter_unlock(); } -void WidgetManager::enterStandby() { - if (!config_.standbyEnabled || config_.standbyMinutes == 0) return; - if (standbyActive_) return; - if (config_.standbyScreenId == SCREEN_ID_NONE) return; +void WidgetManager::navigateToScreen(uint8_t screenId) { + navAction_ = ButtonAction::JUMP; + navTargetScreen_ = screenId; + navPending_ = true; + navRequestUs_ = esp_timer_get_time(); +} - ScreenConfig* standbyScreen = config_.findScreen(config_.standbyScreenId); +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_; @@ -692,13 +724,13 @@ void WidgetManager::loop() { if (didUiNav) return; - if (!config_.standbyEnabled || config_.standbyMinutes == 0) return; + if (!config_->standbyEnabled || config_->standbyMinutes == 0) return; if (standbyActive_) return; - if (config_.standbyScreenId == SCREEN_ID_NONE) 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(config_.standbyMinutes) * 60 * 1000000LL; + int64_t timeoutUs = static_cast(config_->standbyMinutes) * 60 * 1000000LL; if (idleUs >= timeoutUs) { enterStandby(); } @@ -710,7 +742,7 @@ void WidgetManager::onUserActivity() { standbyActive_ = false; uint8_t returnId = standbyReturnScreenId_; if (returnId == SCREEN_ID_NONE) { - returnId = config_.startScreenId; + returnId = config_->startScreenId; } standbyWakeTarget_ = returnId; standbyWakePending_ = true; @@ -738,6 +770,52 @@ void WidgetManager::createAllWidgets(const ScreenConfig& screen, lv_obj_t* paren 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); + + // Position at top-left + lv_obj_set_pos(bgImg, 0, 0); + + // Apply scaling mode + switch (screen.bgImageMode) { + case BgImageMode::STRETCH: + lv_obj_set_size(bgImg, lv_pct(100), lv_pct(100)); + lv_image_set_inner_align(bgImg, LV_IMAGE_ALIGN_STRETCH); + 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; + } + + // 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]; @@ -884,48 +962,59 @@ void WidgetManager::applyCachedValuesToWidgets() { for (auto& widget : widgets_) { if (!widget) continue; + // Primary address uint16_t addr = widget->getKnxAddress(); - if (addr == 0) continue; - TextSource source = widget->getTextSource(); - if (source == TextSource::STATIC) continue; - 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; + 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); + } } - struct tm tmValue = {}; - if (getCachedKnxTime(addr, type, &tmValue)) { - widget->onKnxTime(tmValue, source); - } - continue; } - if (source == TextSource::KNX_DPT_SWITCH) { - bool state = false; - if (getCachedKnxSwitch(addr, &state)) { - widget->onKnxSwitch(state); - } - continue; - } - - if (source == TextSource::KNX_DPT_TEXT) { - char text[MAX_TEXT_LEN] = {}; - if (getCachedKnxText(addr, text, sizeof(text))) { - widget->onKnxText(text); - } - continue; - } - - if (isNumericTextSource(source)) { + // 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(addr, source, &value)) { - widget->onKnxValue(value); + 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); } } } @@ -972,10 +1061,25 @@ void WidgetManager::updateSystemTimeWidgets() { void WidgetManager::applyKnxValue(uint16_t groupAddr, float value, TextSource source) { for (auto& widget : widgets_) { - if (widget && widget->getKnxAddress() == groupAddr && + 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)) { @@ -985,12 +1089,24 @@ void WidgetManager::applyKnxValue(uint16_t groupAddr, float value, TextSource so void WidgetManager::applyKnxSwitch(uint16_t groupAddr, bool value) { for (auto& widget : widgets_) { - if (widget && widget->getKnxAddress() == groupAddr) { + 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(widget.get())->onSubButtonStatus(i, value); + } + } + } } - if (config_.knxNightModeAddress != 0 && groupAddr == config_.knxNightModeAddress) { + if (config_->knxNightModeAddress != 0 && groupAddr == config_->knxNightModeAddress) { nightMode_ = value; } } @@ -1006,9 +1122,9 @@ void WidgetManager::applyKnxText(uint16_t groupAddr, const char* 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 (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; @@ -1281,23 +1397,23 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const { return; } - cJSON_AddNumberToObject(root, "startScreen", config_.startScreenId); + 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_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_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]; + 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); @@ -1309,6 +1425,12 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const { 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(screen.bgImageMode)); + } + // Modal-specific properties if (screen.mode == ScreenMode::MODAL) { cJSON* modal = cJSON_AddObjectToObject(screenJson, "modal"); @@ -1373,9 +1495,78 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const { 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(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(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); @@ -1394,6 +1585,37 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const { } } + // 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(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(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); } @@ -1563,6 +1785,104 @@ bool WidgetManager::updateConfigFromJson(const char* json) { 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(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(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(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(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"); @@ -1604,6 +1924,77 @@ bool WidgetManager::updateConfigFromJson(const char* json) { } } + // 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(pos->valueint); + } + + cJSON* icon = cJSON_GetObjectItem(sbItem, "icon"); + if (cJSON_IsNumber(icon)) { + sb.iconCodepoint = static_cast(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(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++; } @@ -1646,6 +2037,22 @@ bool WidgetManager::updateConfigFromJson(const char* json) { 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->valueint); + } else { + screen.bgImageMode = BgImageMode::STRETCH; + } + // Parse modal-specific properties cJSON* modal = cJSON_GetObjectItem(screenJson, "modal"); if (cJSON_IsObject(modal)) { @@ -1753,8 +2160,8 @@ bool WidgetManager::updateConfigFromJson(const char* json) { newConfig->standbyScreenId = SCREEN_ID_NONE; } - config_ = *newConfig; + *config_ = *newConfig; cJSON_Delete(root); - ESP_LOGI(TAG, "Parsed %d screens from JSON", config_.screenCount); + ESP_LOGI(TAG, "Parsed %d screens from JSON", config_->screenCount); return true; } \ No newline at end of file diff --git a/main/WidgetManager.hpp b/main/WidgetManager.hpp index 4973b21..99625f6 100644 --- a/main/WidgetManager.hpp +++ b/main/WidgetManager.hpp @@ -59,9 +59,16 @@ public: void handleButtonAction(const WidgetConfig& cfg, lv_obj_t* target); void goBack(); + // Navigation (for RoomCard sub-buttons etc.) + void navigateToScreen(uint8_t screenId); + void navigateBack(); + + // KNX write (for RoomCard sub-buttons etc.) + void sendKnxSwitch(uint16_t groupAddr, bool value); + // Direct config access - GuiConfig& getConfig() { return config_; } - const GuiConfig& getConfig() const { return config_; } + GuiConfig& getConfig() { return *config_; } + const GuiConfig& getConfig() const { return *config_; } const ScreenConfig* currentScreen() const; private: @@ -158,7 +165,7 @@ private: static constexpr const char* CONFIG_FILE = "/sdcard/lvgl.json"; static constexpr int64_t NAV_DELAY_US = 10 * 1000; // 10ms delay (almost immediate) - GuiConfig config_; + GuiConfig* config_ = nullptr; // Allocated in PSRAM uint8_t activeScreenId_ = 0; uint8_t previousScreenId_ = 0xFF; uint8_t standbyReturnScreenId_ = 0xFF; diff --git a/main/main.cpp b/main/main.cpp index 7be4f9e..3c1aee6 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -52,7 +52,7 @@ public: 1280, // Vertical resolution ESP_LV_ADAPTER_ROTATE_90 // Rotation ); - disp_cfg.profile.buffer_height = 34; // Reduced to 10 (~25KB) to fit in Internal RAM + disp_cfg.profile.buffer_height = 40; // Reduced to 10 (~25KB) to fit in Internal RAM disp_cfg.profile.use_psram = true; lv_disp_t* lv_display = esp_lv_adapter_register_display(&disp_cfg); assert(lv_display != NULL); diff --git a/main/webserver/StaticFileHandlers.cpp b/main/webserver/StaticFileHandlers.cpp index e87ff67..ea21fff 100644 --- a/main/webserver/StaticFileHandlers.cpp +++ b/main/webserver/StaticFileHandlers.cpp @@ -31,5 +31,14 @@ esp_err_t WebServer::imagesHandler(httpd_req_t* req) { snprintf(filepath, sizeof(filepath), "/sdcard%.*s", (int)(sizeof(filepath) - 8), req->uri); - return sendFile(req, filepath); + // Try lowercase first + esp_err_t result = sendFile(req, filepath); + if (result != ESP_OK) { + // Try uppercase IMAGES folder as fallback + char altpath[CONFIG_HTTPD_MAX_URI_LEN + 8]; + snprintf(altpath, sizeof(altpath), "/sdcard/IMAGES%s", + req->uri + 7); // Skip "/images" + result = sendFile(req, altpath); + } + return result; } diff --git a/main/widgets/PowerNodeWidget.cpp b/main/widgets/PowerNodeWidget.cpp index 1da075c..0ab5212 100644 --- a/main/widgets/PowerNodeWidget.cpp +++ b/main/widgets/PowerNodeWidget.cpp @@ -8,6 +8,8 @@ PowerNodeWidget::PowerNodeWidget(const WidgetConfig& config) { labelText_[0] = '\0'; valueFormat_[0] = '\0'; + leftFormat_[0] = '\0'; + rightFormat_[0] = '\0'; } static bool set_label_text_if_changed(lv_obj_t* label, const char* text) { @@ -67,21 +69,36 @@ int PowerNodeWidget::encodeUtf8(uint32_t codepoint, char* buf) { void PowerNodeWidget::parseText() { labelText_[0] = '\0'; valueFormat_[0] = '\0'; + leftFormat_[0] = '\0'; + rightFormat_[0] = '\0'; + // Parse primary text (label\nformat) const char* text = config_.text; - if (!text || text[0] == '\0') return; + if (text && text[0] != '\0') { + const char* newline = strchr(text, '\n'); + if (newline) { + size_t labelLen = static_cast(newline - text); + if (labelLen >= MAX_TEXT_LEN) labelLen = MAX_TEXT_LEN - 1; + memcpy(labelText_, text, labelLen); + labelText_[labelLen] = '\0'; + strncpy(valueFormat_, newline + 1, MAX_TEXT_LEN - 1); + valueFormat_[MAX_TEXT_LEN - 1] = '\0'; + } else { + strncpy(labelText_, text, MAX_TEXT_LEN - 1); + labelText_[MAX_TEXT_LEN - 1] = '\0'; + } + } - const char* newline = strchr(text, '\n'); - if (newline) { - size_t labelLen = static_cast(newline - text); - if (labelLen >= MAX_TEXT_LEN) labelLen = MAX_TEXT_LEN - 1; - memcpy(labelText_, text, labelLen); - labelText_[labelLen] = '\0'; - strncpy(valueFormat_, newline + 1, MAX_TEXT_LEN - 1); - valueFormat_[MAX_TEXT_LEN - 1] = '\0'; - } else { - strncpy(labelText_, text, MAX_TEXT_LEN - 1); - labelText_[MAX_TEXT_LEN - 1] = '\0'; + // Copy left format from text2 + if (config_.text2[0] != '\0') { + strncpy(leftFormat_, config_.text2, MAX_FORMAT_LEN - 1); + leftFormat_[MAX_FORMAT_LEN - 1] = '\0'; + } + + // Copy right format from text3 + if (config_.text3[0] != '\0') { + strncpy(rightFormat_, config_.text3, MAX_FORMAT_LEN - 1); + rightFormat_[MAX_FORMAT_LEN - 1] = '\0'; } } @@ -104,6 +121,8 @@ lv_obj_t* PowerNodeWidget::create(lv_obj_t* parent) { lv_obj_set_flex_align(obj_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_all(obj_, 6, 0); lv_obj_set_style_pad_gap(obj_, 2, 0); + + // Top label (title) if (labelText_[0] != '\0') { labelLabel_ = lv_label_create(obj_); set_obj_name(labelLabel_, "PowerNode", config_.id, "label"); @@ -111,15 +130,58 @@ lv_obj_t* PowerNodeWidget::create(lv_obj_t* parent) { lv_obj_clear_flag(labelLabel_, LV_OBJ_FLAG_CLICKABLE); } - if (config_.iconCodepoint > 0 && Fonts::hasIconFont()) { - iconLabel_ = lv_label_create(obj_); - set_obj_name(iconLabel_, "PowerNode", config_.id, "icon"); - char iconText[5]; - encodeUtf8(config_.iconCodepoint, iconText); - lv_label_set_text(iconLabel_, iconText); - lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE); + // Check if we have left/right values (dual-value mode) + bool hasDualValues = (config_.knxAddress2 > 0 || config_.knxAddress3 > 0); + + if (hasDualValues) { + // Create middle row container for: left | icon | right + middleRow_ = lv_obj_create(obj_); + lv_obj_remove_style_all(middleRow_); + set_obj_name(middleRow_, "PowerNode", config_.id, "middleRow"); + lv_obj_set_size(middleRow_, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_clear_flag(middleRow_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(middleRow_, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(middleRow_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_gap(middleRow_, 8, 0); + + // Left value label (secondary) + if (config_.knxAddress2 > 0) { + leftLabel_ = lv_label_create(middleRow_); + set_obj_name(leftLabel_, "PowerNode", config_.id, "left"); + lv_label_set_text(leftLabel_, leftFormat_[0] != '\0' ? leftFormat_ : ""); + lv_obj_clear_flag(leftLabel_, LV_OBJ_FLAG_CLICKABLE); + } + + // Center icon + if (config_.iconCodepoint > 0 && Fonts::hasIconFont()) { + iconLabel_ = lv_label_create(middleRow_); + set_obj_name(iconLabel_, "PowerNode", config_.id, "icon"); + char iconText[5]; + encodeUtf8(config_.iconCodepoint, iconText); + lv_label_set_text(iconLabel_, iconText); + lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE); + } + + // Right value label (tertiary) + if (config_.knxAddress3 > 0) { + rightLabel_ = lv_label_create(middleRow_); + set_obj_name(rightLabel_, "PowerNode", config_.id, "right"); + lv_label_set_text(rightLabel_, rightFormat_[0] != '\0' ? rightFormat_ : ""); + lv_obj_clear_flag(rightLabel_, LV_OBJ_FLAG_CLICKABLE); + } + } else { + // Original layout: icon in column flow + if (config_.iconCodepoint > 0 && Fonts::hasIconFont()) { + iconLabel_ = lv_label_create(obj_); + set_obj_name(iconLabel_, "PowerNode", config_.id, "icon"); + char iconText[5]; + encodeUtf8(config_.iconCodepoint, iconText); + lv_label_set_text(iconLabel_, iconText); + lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE); + } } + // Bottom value label (primary) valueLabel_ = lv_label_create(obj_); set_obj_name(valueLabel_, "PowerNode", config_.id, "value"); if (valueFormat_[0] != '\0') { @@ -153,6 +215,7 @@ void PowerNodeWidget::applyStyle() { uint8_t valueSizeIdx = config_.fontSize; uint8_t labelSizeIdx = valueSizeIdx > 0 ? static_cast(valueSizeIdx - 1) : valueSizeIdx; + uint8_t sideValueSizeIdx = labelSizeIdx; // Left/right values use smaller font if (labelLabel_ != nullptr) { lv_obj_set_style_text_color(labelLabel_, lv_color_make( @@ -170,13 +233,26 @@ void PowerNodeWidget::applyStyle() { lv_obj_set_style_text_align(iconLabel_, LV_TEXT_ALIGN_CENTER, 0); } + if (leftLabel_ != nullptr) { + lv_obj_set_style_text_color(leftLabel_, lv_color_make( + config_.textColor.r, config_.textColor.g, config_.textColor.b), 0); + lv_obj_set_style_text_font(leftLabel_, Fonts::bySizeIndex(sideValueSizeIdx), 0); + lv_obj_set_style_text_align(leftLabel_, LV_TEXT_ALIGN_RIGHT, 0); + } + + if (rightLabel_ != nullptr) { + lv_obj_set_style_text_color(rightLabel_, lv_color_make( + config_.textColor.r, config_.textColor.g, config_.textColor.b), 0); + lv_obj_set_style_text_font(rightLabel_, Fonts::bySizeIndex(sideValueSizeIdx), 0); + lv_obj_set_style_text_align(rightLabel_, LV_TEXT_ALIGN_LEFT, 0); + } + if (valueLabel_ != nullptr) { lv_obj_set_style_text_color(valueLabel_, lv_color_make( config_.textColor.r, config_.textColor.g, config_.textColor.b), 0); lv_obj_set_style_text_font(valueLabel_, Fonts::bySizeIndex(valueSizeIdx), 0); lv_obj_set_style_text_align(valueLabel_, LV_TEXT_ALIGN_CENTER, 0); } - } void PowerNodeWidget::updateValueText(const char* text) { @@ -184,26 +260,86 @@ void PowerNodeWidget::updateValueText(const char* text) { set_label_text_if_changed(valueLabel_, text); } +void PowerNodeWidget::updateLeftText(const char* text) { + if (leftLabel_ == nullptr || text == nullptr) return; + set_label_text_if_changed(leftLabel_, text); +} + +void PowerNodeWidget::updateRightText(const char* text) { + if (rightLabel_ == nullptr || text == nullptr) return; + set_label_text_if_changed(rightLabel_, text); +} + +void PowerNodeWidget::updateIcon(uint32_t codepoint) { + if (iconLabel_ == nullptr || codepoint == 0) return; + char iconText[5]; + encodeUtf8(codepoint, iconText); + set_label_text_if_changed(iconLabel_, iconText); +} + +static bool isNumericSource(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; +} + +static void formatValue(char* buf, size_t bufSize, float value, const char* fmt, TextSource source) { + const char* useFmt = (fmt && fmt[0] != '\0') ? fmt : "%0.1f"; + if (source == TextSource::KNX_DPT_PERCENT || + source == TextSource::KNX_DPT_DECIMALFACTOR) { + int intVal = static_cast(value + 0.5f); + snprintf(buf, bufSize, useFmt, intVal); + } else { + snprintf(buf, bufSize, useFmt, value); + } +} + void PowerNodeWidget::onKnxValue(float value) { + // Cache and call base for condition evaluation + cachedPrimaryValue_ = value; + hasCachedPrimary_ = true; + if (valueLabel_ == nullptr) return; - if (config_.textSource != TextSource::KNX_DPT_TEMP && - config_.textSource != TextSource::KNX_DPT_PERCENT && - config_.textSource != TextSource::KNX_DPT_POWER && - config_.textSource != TextSource::KNX_DPT_ENERGY && - config_.textSource != TextSource::KNX_DPT_DECIMALFACTOR) return; + if (!isNumericSource(config_.textSource)) return; char buf[32]; - const char* fmt = valueFormat_[0] != '\0' ? valueFormat_ : "%0.1f"; - - if (config_.textSource == TextSource::KNX_DPT_PERCENT || - config_.textSource == TextSource::KNX_DPT_DECIMALFACTOR) { - int percent = static_cast(value + 0.5f); - snprintf(buf, sizeof(buf), fmt, percent); - } else { - snprintf(buf, sizeof(buf), fmt, value); - } - + formatValue(buf, sizeof(buf), value, valueFormat_, config_.textSource); updateValueText(buf); + + // Evaluate conditions after updating value + evaluateConditions(cachedPrimaryValue_, cachedSecondaryValue_, cachedTertiaryValue_); +} + +void PowerNodeWidget::onKnxValue2(float value) { + cachedSecondaryValue_ = value; + hasCachedSecondary_ = true; + + if (leftLabel_ == nullptr) return; + if (!isNumericSource(config_.textSource2)) return; + + char buf[32]; + formatValue(buf, sizeof(buf), value, leftFormat_, config_.textSource2); + updateLeftText(buf); + + // Evaluate conditions after updating value + evaluateConditions(cachedPrimaryValue_, cachedSecondaryValue_, cachedTertiaryValue_); +} + +void PowerNodeWidget::onKnxValue3(float value) { + cachedTertiaryValue_ = value; + hasCachedTertiary_ = true; + + if (rightLabel_ == nullptr) return; + if (!isNumericSource(config_.textSource3)) return; + + char buf[32]; + formatValue(buf, sizeof(buf), value, rightFormat_, config_.textSource3); + updateRightText(buf); + + // Evaluate conditions after updating value + evaluateConditions(cachedPrimaryValue_, cachedSecondaryValue_, cachedTertiaryValue_); } void PowerNodeWidget::onKnxSwitch(bool value) { @@ -217,3 +353,99 @@ void PowerNodeWidget::onKnxText(const char* text) { if (config_.textSource != TextSource::KNX_DPT_TEXT) return; updateValueText(text); } + +bool PowerNodeWidget::evaluateConditions(float primaryValue, float secondaryValue, float tertiaryValue) { + if (config_.conditionCount == 0) return false; + + const StyleCondition* bestMatch = nullptr; + uint8_t bestPriority = 255; + + for (uint8_t i = 0; i < config_.conditionCount && i < MAX_CONDITIONS; ++i) { + const StyleCondition& cond = config_.conditions[i]; + if (!cond.enabled) continue; + + // Get the value to check based on source + float checkValue = 0.0f; + bool hasValue = false; + switch (cond.source) { + case ConditionSource::PRIMARY: + checkValue = primaryValue; + hasValue = hasCachedPrimary_; + break; + case ConditionSource::SECONDARY: + checkValue = secondaryValue; + hasValue = hasCachedSecondary_; + break; + case ConditionSource::TERTIARY: + checkValue = tertiaryValue; + hasValue = hasCachedTertiary_; + break; + } + + if (!hasValue) continue; + + // Evaluate condition + bool matches = false; + switch (cond.op) { + case ConditionOp::LESS: + matches = checkValue < cond.threshold; + break; + case ConditionOp::LESS_EQUAL: + matches = checkValue <= cond.threshold; + break; + case ConditionOp::EQUAL: + matches = checkValue == cond.threshold; + break; + case ConditionOp::GREATER_EQUAL: + matches = checkValue >= cond.threshold; + break; + case ConditionOp::GREATER: + matches = checkValue > cond.threshold; + break; + case ConditionOp::NOT_EQUAL: + matches = checkValue != cond.threshold; + break; + } + + if (matches && cond.priority < bestPriority) { + bestMatch = &cond; + bestPriority = cond.priority; + } + } + + if (bestMatch) { + // Apply icon change + if (bestMatch->style.iconCodepoint != 0 && + bestMatch->style.iconCodepoint != currentConditionIcon_) { + updateIcon(bestMatch->style.iconCodepoint); + currentConditionIcon_ = bestMatch->style.iconCodepoint; + } + + // Apply text color to icon + if ((bestMatch->style.flags & ConditionStyle::FLAG_USE_TEXT_COLOR) && iconLabel_) { + lv_obj_set_style_text_color(iconLabel_, lv_color_make( + bestMatch->style.textColor.r, + bestMatch->style.textColor.g, + bestMatch->style.textColor.b), 0); + } + + // Apply border color change (bgColor affects border in PowerNode) + if (bestMatch->style.flags & ConditionStyle::FLAG_USE_BG_COLOR) { + lv_obj_set_style_border_color(obj_, lv_color_make( + bestMatch->style.bgColor.r, + bestMatch->style.bgColor.g, + bestMatch->style.bgColor.b), 0); + } + + // Handle hide flag + if (bestMatch->style.flags & ConditionStyle::FLAG_HIDE) { + lv_obj_add_flag(obj_, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_clear_flag(obj_, LV_OBJ_FLAG_HIDDEN); + } + + return true; + } + + return false; +} diff --git a/main/widgets/PowerNodeWidget.hpp b/main/widgets/PowerNodeWidget.hpp index 6cbcb48..eb20e38 100644 --- a/main/widgets/PowerNodeWidget.hpp +++ b/main/widgets/PowerNodeWidget.hpp @@ -8,18 +8,29 @@ public: lv_obj_t* create(lv_obj_t* parent) override; void applyStyle() override; void onKnxValue(float value) override; + void onKnxValue2(float value) override; + void onKnxValue3(float value) override; void onKnxSwitch(bool value) override; void onKnxText(const char* text) override; + bool evaluateConditions(float primaryValue, float secondaryValue, float tertiaryValue) override; private: - lv_obj_t* labelLabel_ = nullptr; - lv_obj_t* valueLabel_ = nullptr; - lv_obj_t* iconLabel_ = nullptr; + lv_obj_t* labelLabel_ = nullptr; // Top label (title) + lv_obj_t* middleRow_ = nullptr; // Horizontal container for left-icon-right + lv_obj_t* leftLabel_ = nullptr; // Left value (secondary) + lv_obj_t* iconLabel_ = nullptr; // Center icon + lv_obj_t* rightLabel_ = nullptr; // Right value (tertiary) + lv_obj_t* valueLabel_ = nullptr; // Bottom value (primary) char labelText_[MAX_TEXT_LEN] = {0}; - char valueFormat_[MAX_TEXT_LEN] = {0}; + char valueFormat_[MAX_TEXT_LEN] = {0}; // Format for primary/bottom value + char leftFormat_[MAX_FORMAT_LEN] = {0}; // Format for left value + char rightFormat_[MAX_FORMAT_LEN] = {0}; // Format for right value void parseText(); void updateValueText(const char* text); + void updateLeftText(const char* text); + void updateRightText(const char* text); + void updateIcon(uint32_t codepoint); static int encodeUtf8(uint32_t codepoint, char* buf); }; diff --git a/main/widgets/RoomCardWidget.cpp b/main/widgets/RoomCardWidget.cpp new file mode 100644 index 0000000..72ef22d --- /dev/null +++ b/main/widgets/RoomCardWidget.cpp @@ -0,0 +1,335 @@ +#include "RoomCardWidget.hpp" +#include "../Fonts.hpp" +#include "../WidgetManager.hpp" +#include +#include +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +RoomCardWidget::RoomCardWidget(const WidgetConfig& config) + : Widget(config) +{ + roomName_[0] = '\0'; + tempFormat_[0] = '\0'; + for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) { + subButtonStates_[i] = false; + } +} + +int RoomCardWidget::encodeUtf8(uint32_t codepoint, char* buf) { + if (codepoint < 0x80) { + buf[0] = static_cast(codepoint); + buf[1] = '\0'; + return 1; + } else if (codepoint < 0x800) { + buf[0] = static_cast(0xC0 | (codepoint >> 6)); + buf[1] = static_cast(0x80 | (codepoint & 0x3F)); + buf[2] = '\0'; + return 2; + } else if (codepoint < 0x10000) { + buf[0] = static_cast(0xE0 | (codepoint >> 12)); + buf[1] = static_cast(0x80 | ((codepoint >> 6) & 0x3F)); + buf[2] = static_cast(0x80 | (codepoint & 0x3F)); + buf[3] = '\0'; + return 3; + } else if (codepoint < 0x110000) { + buf[0] = static_cast(0xF0 | (codepoint >> 18)); + buf[1] = static_cast(0x80 | ((codepoint >> 12) & 0x3F)); + buf[2] = static_cast(0x80 | ((codepoint >> 6) & 0x3F)); + buf[3] = static_cast(0x80 | (codepoint & 0x3F)); + buf[4] = '\0'; + return 4; + } + buf[0] = '\0'; + return 0; +} + +void RoomCardWidget::parseText() { + roomName_[0] = '\0'; + tempFormat_[0] = '\0'; + + const char* text = config_.text; + if (text && text[0] != '\0') { + const char* newline = strchr(text, '\n'); + if (newline) { + size_t nameLen = static_cast(newline - text); + if (nameLen >= MAX_TEXT_LEN) nameLen = MAX_TEXT_LEN - 1; + memcpy(roomName_, text, nameLen); + roomName_[nameLen] = '\0'; + strncpy(tempFormat_, newline + 1, MAX_TEXT_LEN - 1); + tempFormat_[MAX_TEXT_LEN - 1] = '\0'; + } else { + strncpy(roomName_, text, MAX_TEXT_LEN - 1); + roomName_[MAX_TEXT_LEN - 1] = '\0'; + } + } +} + +void RoomCardWidget::calculateSubButtonPosition(SubButtonPosition pos, int16_t& x, int16_t& y) { + int16_t centerX = config_.width / 2; + int16_t centerY = config_.height / 2; + + // Sub-button size (configurable, default 40) + int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40; + + // Sub-buttons orbit radius in pixels (default 80) + int16_t orbitRadius = config_.subButtonDistance > 0 ? config_.subButtonDistance : 80; + + // Calculate angle: 0=TOP, going clockwise, 8 positions = 45 degrees each + float angle = static_cast(static_cast(pos)) * (M_PI / 4.0f) - (M_PI / 2.0f); + + // Calculate position (center of sub-button) + x = centerX + static_cast(orbitRadius * cosf(angle)) - subBtnSize / 2; + y = centerY + static_cast(orbitRadius * sinf(angle)) - subBtnSize / 2; +} + +lv_obj_t* RoomCardWidget::create(lv_obj_t* parent) { + parseText(); + + // Create main container + obj_ = lv_obj_create(parent); + lv_obj_remove_style_all(obj_); + lv_obj_set_pos(obj_, config_.x, config_.y); + lv_obj_set_size(obj_, config_.width, config_.height); + lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE); + + // Create central bubble first + createCentralBubble(); + + // Create sub-buttons + createSubButtons(); + + return obj_; +} + +void RoomCardWidget::createCentralBubble() { + // Calculate bubble size (80% of widget size, circular) + int16_t minSide = config_.width < config_.height ? config_.width : config_.height; + int16_t bubbleSize = (minSide * 80) / 100; + + // Create bubble container (centered) + bubble_ = lv_obj_create(obj_); + lv_obj_remove_style_all(bubble_); + lv_obj_set_size(bubble_, bubbleSize, bubbleSize); + lv_obj_center(bubble_); + lv_obj_clear_flag(bubble_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(bubble_, LV_OBJ_FLAG_CLICKABLE); + + // Circular shape + lv_obj_set_style_radius(bubble_, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_opa(bubble_, config_.bgOpacity, 0); + lv_obj_set_style_bg_color(bubble_, lv_color_hex(config_.bgColor.toLvColor()), 0); + + // Set up flex layout for bubble content + lv_obj_set_flex_flow(bubble_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(bubble_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(bubble_, 8, 0); + lv_obj_set_style_pad_gap(bubble_, 2, 0); + + // Room icon + if (config_.iconCodepoint > 0 && Fonts::hasIconFont()) { + roomIcon_ = lv_label_create(bubble_); + lv_obj_clear_flag(roomIcon_, LV_OBJ_FLAG_CLICKABLE); + char iconText[5]; + encodeUtf8(config_.iconCodepoint, iconText); + lv_label_set_text(roomIcon_, iconText); + } + + // Temperature label + tempLabel_ = lv_label_create(bubble_); + lv_obj_clear_flag(tempLabel_, LV_OBJ_FLAG_CLICKABLE); + lv_label_set_text(tempLabel_, tempFormat_[0] != '\0' ? "--" : ""); + + // Room name label + if (roomName_[0] != '\0') { + roomLabel_ = lv_label_create(bubble_); + lv_obj_clear_flag(roomLabel_, LV_OBJ_FLAG_CLICKABLE); + lv_label_set_text(roomLabel_, roomName_); + } + + // Click handler for navigation + lv_obj_add_event_cb(bubble_, bubbleClickCallback, LV_EVENT_CLICKED, this); +} + +void RoomCardWidget::createSubButtons() { + // Sub-button size (configurable, default 40) + int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40; + + for (uint8_t i = 0; i < config_.subButtonCount && i < MAX_SUBBUTTONS; ++i) { + const SubButtonConfig& cfg = config_.subButtons[i]; + if (!cfg.enabled) continue; + + // Create sub-button object + lv_obj_t* btn = lv_obj_create(obj_); + lv_obj_remove_style_all(btn); + lv_obj_set_size(btn, subBtnSize, subBtnSize); + lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(btn, LV_OBJ_FLAG_CLICKABLE); + + // Circular shape + lv_obj_set_style_radius(btn, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_opa(btn, 255, 0); + + // Position using circle geometry + int16_t x, y; + calculateSubButtonPosition(cfg.position, x, y); + lv_obj_set_pos(btn, x, y); + + // Create icon + if (cfg.iconCodepoint > 0 && Fonts::hasIconFont()) { + lv_obj_t* icon = lv_label_create(btn); + lv_obj_clear_flag(icon, LV_OBJ_FLAG_CLICKABLE); + char iconText[5]; + encodeUtf8(cfg.iconCodepoint, iconText); + lv_label_set_text(icon, iconText); + lv_obj_center(icon); + subButtonIcons_[i] = icon; + } + + // Store index in user_data for click handler + lv_obj_set_user_data(btn, reinterpret_cast(static_cast(i))); + lv_obj_add_event_cb(btn, subButtonClickCallback, LV_EVENT_CLICKED, this); + + subButtonObjs_[i] = btn; + subButtonStates_[i] = false; + + // Apply initial color (OFF state) + updateSubButtonColor(i); + } +} + +void RoomCardWidget::applyStyle() { + if (!obj_) return; + + // Apply shadow to bubble if enabled + if (bubble_ && config_.shadow.enabled) { + lv_obj_set_style_shadow_width(bubble_, config_.shadow.blur, 0); + lv_obj_set_style_shadow_ofs_x(bubble_, config_.shadow.offsetX, 0); + lv_obj_set_style_shadow_ofs_y(bubble_, config_.shadow.offsetY, 0); + lv_obj_set_style_shadow_color(bubble_, lv_color_hex(config_.shadow.color.toLvColor()), 0); + lv_obj_set_style_shadow_opa(bubble_, 255, 0); + } + + // Font sizes + const lv_font_t* iconFont = Fonts::iconFont(config_.iconSize); + const lv_font_t* textFont = Fonts::bySizeIndex(config_.fontSize); + const lv_font_t* labelFont = Fonts::bySizeIndex(config_.fontSize > 0 ? config_.fontSize - 1 : 0); + lv_color_t textColor = lv_color_hex(config_.textColor.toLvColor()); + + // Style room icon + if (roomIcon_ && iconFont) { + lv_obj_set_style_text_font(roomIcon_, iconFont, 0); + lv_obj_set_style_text_color(roomIcon_, textColor, 0); + } + + // Style temperature + if (tempLabel_ && textFont) { + lv_obj_set_style_text_font(tempLabel_, textFont, 0); + lv_obj_set_style_text_color(tempLabel_, textColor, 0); + } + + // Style room label + if (roomLabel_ && labelFont) { + lv_obj_set_style_text_font(roomLabel_, labelFont, 0); + lv_obj_set_style_text_color(roomLabel_, lv_color_hex(config_.textColor.toLvColor()), 0); + lv_obj_set_style_text_opa(roomLabel_, 180, 0); // Slightly dimmed + } + + // Style sub-buttons - adjust icon size based on button size + // subButtonSize: 30-40 -> font 1, 41-55 -> font 2, 56+ -> font 3 + uint8_t subBtnFontIdx = 1; // Default small + if (config_.subButtonSize > 55) { + subBtnFontIdx = 3; + } else if (config_.subButtonSize > 40) { + subBtnFontIdx = 2; + } + const lv_font_t* subBtnIconFont = Fonts::iconFont(subBtnFontIdx); + for (uint8_t i = 0; i < config_.subButtonCount && i < MAX_SUBBUTTONS; ++i) { + if (subButtonIcons_[i] && subBtnIconFont) { + lv_obj_set_style_text_font(subButtonIcons_[i], subBtnIconFont, 0); + lv_obj_set_style_text_color(subButtonIcons_[i], lv_color_white(), 0); + } + } +} + +void RoomCardWidget::updateSubButtonColor(uint8_t index) { + if (index >= MAX_SUBBUTTONS || !subButtonObjs_[index]) return; + + const SubButtonConfig& cfg = config_.subButtons[index]; + const Color& color = subButtonStates_[index] ? cfg.colorOn : cfg.colorOff; + + lv_obj_set_style_bg_color(subButtonObjs_[index], lv_color_hex(color.toLvColor()), 0); +} + +void RoomCardWidget::updateTemperature(float value) { + if (!tempLabel_ || tempFormat_[0] == '\0') return; + + char buf[32]; + snprintf(buf, sizeof(buf), tempFormat_, value); + lv_label_set_text(tempLabel_, buf); +} + +void RoomCardWidget::onKnxValue(float value) { + cachedPrimaryValue_ = value; + hasCachedPrimary_ = true; + updateTemperature(value); +} + +void RoomCardWidget::onKnxSwitch(bool value) { + // Not used directly - sub-button status is handled via onSubButtonStatus + (void)value; +} + +void RoomCardWidget::onSubButtonStatus(uint8_t index, bool value) { + if (index >= MAX_SUBBUTTONS) return; + + subButtonStates_[index] = value; + updateSubButtonColor(index); +} + +void RoomCardWidget::sendSubButtonToggle(uint8_t index) { + if (index >= config_.subButtonCount || index >= MAX_SUBBUTTONS) return; + + const SubButtonConfig& cfg = config_.subButtons[index]; + if (cfg.action == SubButtonAction::TOGGLE_KNX && cfg.knxAddrWrite > 0) { + bool newState = !subButtonStates_[index]; + // Send KNX toggle via WidgetManager + WidgetManager::instance().sendKnxSwitch(cfg.knxAddrWrite, newState); + // Optimistically update local state + subButtonStates_[index] = newState; + updateSubButtonColor(index); + } +} + +void RoomCardWidget::bubbleClickCallback(lv_event_t* e) { + RoomCardWidget* widget = static_cast(lv_event_get_user_data(e)); + if (!widget) return; + + // Handle navigation based on action + if (widget->config_.action == ButtonAction::JUMP) { + WidgetManager::instance().navigateToScreen(widget->config_.targetScreen); + } else if (widget->config_.action == ButtonAction::BACK) { + WidgetManager::instance().navigateBack(); + } +} + +void RoomCardWidget::subButtonClickCallback(lv_event_t* e) { + RoomCardWidget* widget = static_cast(lv_event_get_user_data(e)); + lv_obj_t* target = static_cast(lv_event_get_target(e)); + if (!widget || !target) return; + + uint8_t index = static_cast(reinterpret_cast(lv_obj_get_user_data(target))); + + if (index < widget->config_.subButtonCount && index < MAX_SUBBUTTONS) { + const SubButtonConfig& cfg = widget->config_.subButtons[index]; + + if (cfg.action == SubButtonAction::TOGGLE_KNX) { + widget->sendSubButtonToggle(index); + } else if (cfg.action == SubButtonAction::NAVIGATE) { + WidgetManager::instance().navigateToScreen(cfg.targetScreen); + } + } +} diff --git a/main/widgets/RoomCardWidget.hpp b/main/widgets/RoomCardWidget.hpp new file mode 100644 index 0000000..cb737fd --- /dev/null +++ b/main/widgets/RoomCardWidget.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include "Widget.hpp" + +class RoomCardWidget : public Widget { +public: + explicit RoomCardWidget(const WidgetConfig& config); + lv_obj_t* create(lv_obj_t* parent) override; + void applyStyle() override; + void onKnxValue(float value) override; // Temperature update + void onKnxSwitch(bool value) override; // Not used directly + + // Sub-button specific handling + void onSubButtonStatus(uint8_t index, bool value); + void sendSubButtonToggle(uint8_t index); + +private: + // Central bubble elements + lv_obj_t* bubble_ = nullptr; + lv_obj_t* roomIcon_ = nullptr; + lv_obj_t* roomLabel_ = nullptr; + lv_obj_t* tempLabel_ = nullptr; + + // Sub-button elements + lv_obj_t* subButtonObjs_[MAX_SUBBUTTONS] = {}; + lv_obj_t* subButtonIcons_[MAX_SUBBUTTONS] = {}; + bool subButtonStates_[MAX_SUBBUTTONS] = {}; + + // Cached config values + char roomName_[MAX_TEXT_LEN] = {0}; + char tempFormat_[MAX_TEXT_LEN] = {0}; + + // Layout helpers + void parseText(); + void createCentralBubble(); + void createSubButtons(); + void updateSubButtonColor(uint8_t index); + void updateTemperature(float value); + + // Geometry calculations + void calculateSubButtonPosition(SubButtonPosition pos, int16_t& x, int16_t& y); + + // Event handlers + static void bubbleClickCallback(lv_event_t* e); + static void subButtonClickCallback(lv_event_t* e); + + // UTF-8 encoding helper + static int encodeUtf8(uint32_t codepoint, char* buf); +}; diff --git a/main/widgets/Widget.cpp b/main/widgets/Widget.cpp index b5853c7..0d02131 100644 --- a/main/widgets/Widget.cpp +++ b/main/widgets/Widget.cpp @@ -26,8 +26,22 @@ void Widget::applyStyle() { applyCommonStyle(); } -void Widget::onKnxValue(float /*value*/) { - // Default: do nothing +void Widget::onKnxValue(float value) { + cachedPrimaryValue_ = value; + hasCachedPrimary_ = true; + evaluateConditions(cachedPrimaryValue_, cachedSecondaryValue_, cachedTertiaryValue_); +} + +void Widget::onKnxValue2(float value) { + cachedSecondaryValue_ = value; + hasCachedSecondary_ = true; + evaluateConditions(cachedPrimaryValue_, cachedSecondaryValue_, cachedTertiaryValue_); +} + +void Widget::onKnxValue3(float value) { + cachedTertiaryValue_ = value; + hasCachedTertiary_ = true; + evaluateConditions(cachedPrimaryValue_, cachedSecondaryValue_, cachedTertiaryValue_); } void Widget::onKnxSwitch(bool /*value*/) { @@ -46,6 +60,107 @@ void Widget::onHistoryUpdate() { // Default: do nothing } +bool Widget::evaluateConditions(float primaryValue, float secondaryValue, float tertiaryValue) { + if (config_.conditionCount == 0) return false; + + const StyleCondition* bestMatch = nullptr; + uint8_t bestPriority = 255; + + for (uint8_t i = 0; i < config_.conditionCount && i < MAX_CONDITIONS; ++i) { + const StyleCondition& cond = config_.conditions[i]; + if (!cond.enabled) continue; + + // Get the value to check based on source + float checkValue = 0.0f; + bool hasValue = false; + switch (cond.source) { + case ConditionSource::PRIMARY: + checkValue = primaryValue; + hasValue = hasCachedPrimary_; + break; + case ConditionSource::SECONDARY: + checkValue = secondaryValue; + hasValue = hasCachedSecondary_; + break; + case ConditionSource::TERTIARY: + checkValue = tertiaryValue; + hasValue = hasCachedTertiary_; + break; + } + + if (!hasValue) continue; + + // Evaluate condition + bool matches = false; + switch (cond.op) { + case ConditionOp::LESS: + matches = checkValue < cond.threshold; + break; + case ConditionOp::LESS_EQUAL: + matches = checkValue <= cond.threshold; + break; + case ConditionOp::EQUAL: + matches = checkValue == cond.threshold; + break; + case ConditionOp::GREATER_EQUAL: + matches = checkValue >= cond.threshold; + break; + case ConditionOp::GREATER: + matches = checkValue > cond.threshold; + break; + case ConditionOp::NOT_EQUAL: + matches = checkValue != cond.threshold; + break; + } + + if (matches && cond.priority < bestPriority) { + bestMatch = &cond; + bestPriority = cond.priority; + } + } + + if (bestMatch) { + applyConditionStyle(bestMatch->style); + return true; + } + + return false; +} + +void Widget::applyConditionStyle(const ConditionStyle& style) { + if (obj_ == nullptr) return; + + // Handle hide flag + if (style.flags & ConditionStyle::FLAG_HIDE) { + lv_obj_add_flag(obj_, LV_OBJ_FLAG_HIDDEN); + return; + } else { + lv_obj_clear_flag(obj_, LV_OBJ_FLAG_HIDDEN); + } + + // Apply text color if flag set + if (style.flags & ConditionStyle::FLAG_USE_TEXT_COLOR) { + lv_obj_set_style_text_color(obj_, lv_color_make( + style.textColor.r, style.textColor.g, style.textColor.b), 0); + } + + // Apply background color if flag set + if (style.flags & ConditionStyle::FLAG_USE_BG_COLOR) { + lv_obj_set_style_bg_color(obj_, lv_color_make( + style.bgColor.r, style.bgColor.g, style.bgColor.b), 0); + } + + // Apply background opacity if flag set + if (style.flags & ConditionStyle::FLAG_USE_BG_OPACITY) { + lv_obj_set_style_bg_opa(obj_, style.bgOpacity, 0); + } + + // Icon changes are handled by subclasses that have icons + if (style.iconCodepoint != 0) { + currentConditionIcon_ = style.iconCodepoint; + } +} + void Widget::applyCommonStyle() { if (obj_ == nullptr) return; diff --git a/main/widgets/Widget.hpp b/main/widgets/Widget.hpp index 8d8b706..abbb1ea 100644 --- a/main/widgets/Widget.hpp +++ b/main/widgets/Widget.hpp @@ -26,9 +26,13 @@ public: // KNX group address for read binding uint16_t getKnxAddress() const { return config_.knxAddress; } + uint16_t getKnxAddress2() const { return config_.knxAddress2; } + uint16_t getKnxAddress3() const { return config_.knxAddress3; } // TextSource for KNX callback filtering TextSource getTextSource() const { return config_.textSource; } + TextSource getTextSource2() const { return config_.textSource2; } + TextSource getTextSource3() const { return config_.textSource3; } // Widget type WidgetType getType() const { return config_.type; } @@ -44,17 +48,39 @@ public: // KNX callbacks - default implementations do nothing virtual void onKnxValue(float value); + virtual void onKnxValue2(float value); // For secondary address (left) + virtual void onKnxValue3(float value); // For tertiary address (right) virtual void onKnxSwitch(bool value); virtual void onKnxText(const char* text); virtual void onKnxTime(const struct tm& value, TextSource source); virtual void onHistoryUpdate(); + // Condition evaluation - returns true if style was changed + virtual bool evaluateConditions(float primaryValue, float secondaryValue, float tertiaryValue); + + // Get cached values for condition evaluation + float getCachedPrimaryValue() const { return cachedPrimaryValue_; } + float getCachedSecondaryValue() const { return cachedSecondaryValue_; } + float getCachedTertiaryValue() const { return cachedTertiaryValue_; } + protected: // Common style helper functions void applyCommonStyle(); void applyShadowStyle(); + void applyConditionStyle(const ConditionStyle& style); static const lv_font_t* getFontBySize(uint8_t sizeIndex); const WidgetConfig& config_; lv_obj_t* obj_ = nullptr; + + // Cached values for condition evaluation + float cachedPrimaryValue_ = 0.0f; + float cachedSecondaryValue_ = 0.0f; + float cachedTertiaryValue_ = 0.0f; + bool hasCachedPrimary_ = false; + bool hasCachedSecondary_ = false; + bool hasCachedTertiary_ = false; + + // Current applied condition (for detecting changes) + uint32_t currentConditionIcon_ = 0; }; diff --git a/main/widgets/WidgetFactory.cpp b/main/widgets/WidgetFactory.cpp index ccb4945..9ded5dd 100644 --- a/main/widgets/WidgetFactory.cpp +++ b/main/widgets/WidgetFactory.cpp @@ -10,6 +10,7 @@ #include "PowerLinkWidget.hpp" #include "ChartWidget.hpp" #include "ClockWidget.hpp" +#include "RoomCardWidget.hpp" std::unique_ptr WidgetFactory::create(const WidgetConfig& config) { if (!config.visible) return nullptr; @@ -37,6 +38,8 @@ std::unique_ptr WidgetFactory::create(const WidgetConfig& config) { return std::make_unique(config); case WidgetType::CLOCK: return std::make_unique(config); + case WidgetType::ROOMCARD: + return std::make_unique(config); default: return nullptr; } diff --git a/sdkconfig b/sdkconfig index 310db79..bfb155c 100644 --- a/sdkconfig +++ b/sdkconfig @@ -1485,7 +1485,7 @@ CONFIG_SPIRAM_USE_MALLOC=y CONFIG_SPIRAM_MEMTEST=y CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=16384 # CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP is not set -CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=100000 +CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=90000 CONFIG_SPIRAM_ALLOW_BSS_SEG_EXTERNAL_MEMORY=y # CONFIG_SPIRAM_ALLOW_NOINIT_SEG_EXTERNAL_MEMORY is not set # end of PSRAM config @@ -3169,7 +3169,10 @@ CONFIG_LV_USE_GRID=y # CONFIG_LV_FS_DEFAULT_DRIVER_LETTER=0 # CONFIG_LV_USE_FS_STDIO is not set -# CONFIG_LV_USE_FS_POSIX is not set +CONFIG_LV_USE_FS_POSIX=y +CONFIG_LV_FS_POSIX_LETTER=83 +CONFIG_LV_FS_POSIX_PATH="" +CONFIG_LV_FS_POSIX_CACHE_SIZE=0 # CONFIG_LV_USE_FS_WIN32 is not set # CONFIG_LV_USE_FS_FATFS is not set # CONFIG_LV_USE_FS_MEMFS is not set @@ -3181,7 +3184,7 @@ CONFIG_LV_FS_DEFAULT_DRIVER_LETTER=0 # CONFIG_LV_USE_LODEPNG is not set # CONFIG_LV_USE_LIBPNG is not set # CONFIG_LV_USE_BMP is not set -# CONFIG_LV_USE_TJPGD is not set +CONFIG_LV_USE_TJPGD=y # CONFIG_LV_USE_LIBJPEG_TURBO is not set # CONFIG_LV_USE_GIF is not set # CONFIG_LV_BIN_DECODER_RAM_LOAD is not set diff --git a/web-interface/src/App.vue b/web-interface/src/App.vue index 50d0b35..122096e 100644 --- a/web-interface/src/App.vue +++ b/web-interface/src/App.vue @@ -1,6 +1,6 @@ @@ -19,11 +20,13 @@ import SidebarRight from './components/SidebarRight.vue'; import CanvasArea from './components/CanvasArea.vue'; import SettingsModal from './components/SettingsModal.vue'; import ScreenSettingsModal from './components/ScreenSettingsModal.vue'; +import FileManager from './components/FileManager.vue'; import { useEditorStore } from './stores/editor'; const store = useEditorStore(); const showSettings = ref(false); const showScreenSettings = ref(false); +const showFiles = ref(false); onMounted(() => { store.loadConfig(); diff --git a/web-interface/src/components/CanvasArea.vue b/web-interface/src/components/CanvasArea.vue index 59ece4b..87eb246 100644 --- a/web-interface/src/components/CanvasArea.vue +++ b/web-interface/src/components/CanvasArea.vue @@ -90,11 +90,37 @@ const canvasH = computed(() => { return DISPLAY_H; }); -const canvasStyle = computed(() => ({ - width: `${canvasW.value * store.canvasScale}px`, - height: `${canvasH.value * store.canvasScale}px`, - backgroundColor: store.activeScreen?.bgColor || '#1A1A2E' -})); +const canvasStyle = computed(() => { + const style = { + width: `${canvasW.value * store.canvasScale}px`, + height: `${canvasH.value * store.canvasScale}px`, + backgroundColor: store.activeScreen?.bgColor || '#1A1A2E' + }; + + // Add background image if set + const bgImage = store.activeScreen?.bgImage; + if (bgImage) { + style.backgroundImage = `url(${bgImage})`; + style.backgroundPosition = 'center'; + + const mode = store.activeScreen?.bgImageMode || 1; + if (mode === 1) { + // Stretch + style.backgroundSize = '100% 100%'; + style.backgroundRepeat = 'no-repeat'; + } else if (mode === 2) { + // Center (original size) + style.backgroundSize = 'auto'; + style.backgroundRepeat = 'no-repeat'; + } else if (mode === 3) { + // Tile + style.backgroundSize = 'auto'; + style.backgroundRepeat = 'repeat'; + } + } + + return style; +}); const gridStyle = computed(() => ({ backgroundImage: 'linear-gradient(to right, rgba(255, 255, 255, 0.1) 1px, transparent 1px), linear-gradient(to bottom, rgba(255, 255, 255, 0.1) 1px, transparent 1px)', @@ -256,7 +282,7 @@ function resizeDrag(e) { let newW = Math.round(rawW); let newH = Math.round(rawH); - if (w.type === WIDGET_TYPES.LED || w.type === WIDGET_TYPES.POWERNODE) { + if (w.type === WIDGET_TYPES.LED || w.type === WIDGET_TYPES.POWERNODE || w.type === WIDGET_TYPES.ROOMCARD) { const maxSize = Math.min(maxW, maxH); const size = clamp(Math.max(newW, newH), minSize.w, maxSize); newW = size; diff --git a/web-interface/src/components/FileManager.vue b/web-interface/src/components/FileManager.vue new file mode 100644 index 0000000..f3ae236 --- /dev/null +++ b/web-interface/src/components/FileManager.vue @@ -0,0 +1,477 @@ + + + diff --git a/web-interface/src/components/ScreenSettingsModal.vue b/web-interface/src/components/ScreenSettingsModal.vue index f111747..95982c8 100644 --- a/web-interface/src/components/ScreenSettingsModal.vue +++ b/web-interface/src/components/ScreenSettingsModal.vue @@ -25,6 +25,42 @@ + +
+
Hintergrundbild
+
+
+ Kein Bild +
+
+ + +
+
+
+ + +
+
+
Modal
@@ -46,18 +82,38 @@
+ + +