diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 947d15b..e0d75c2 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -7,6 +7,9 @@ idf_component_register(SRCS "KnxWorker.cpp" "Nvs.cpp" "main.cpp" "Display.cpp" " "widgets/IconWidget.cpp" "widgets/TabViewWidget.cpp" "widgets/TabPageWidget.cpp" + "widgets/PowerFlowWidget.cpp" + "widgets/PowerNodeWidget.cpp" + "widgets/PowerLinkWidget.cpp" "webserver/WebServer.cpp" "webserver/StaticFileHandlers.cpp" "webserver/ConfigHandlers.cpp" diff --git a/main/WidgetConfig.hpp b/main/WidgetConfig.hpp index efc53c1..d0a3f4b 100644 --- a/main/WidgetConfig.hpp +++ b/main/WidgetConfig.hpp @@ -17,6 +17,9 @@ enum class WidgetType : uint8_t { ICON = 3, TABVIEW = 4, TABPAGE = 5, + POWERFLOW = 6, + POWERNODE = 7, + POWERLINK = 8, }; enum class IconPosition : uint8_t { diff --git a/main/WidgetManager.cpp b/main/WidgetManager.cpp index a363438..13b1229 100644 --- a/main/WidgetManager.cpp +++ b/main/WidgetManager.cpp @@ -297,6 +297,10 @@ const ScreenConfig* WidgetManager::activeScreen() const { return config_.findScreen(activeScreenId_); } +const ScreenConfig* WidgetManager::currentScreen() const { + return activeScreen(); +} + void WidgetManager::applyScreen(uint8_t screenId) { ESP_LOGI(TAG, "applyScreen(%d) - start", screenId); diff --git a/main/WidgetManager.hpp b/main/WidgetManager.hpp index c30f8e7..3458973 100644 --- a/main/WidgetManager.hpp +++ b/main/WidgetManager.hpp @@ -49,6 +49,7 @@ public: // Direct config access GuiConfig& getConfig() { return config_; } const GuiConfig& getConfig() const { return config_; } + const ScreenConfig* currentScreen() const; private: WidgetManager(); diff --git a/main/widgets/PowerFlowWidget.cpp b/main/widgets/PowerFlowWidget.cpp new file mode 100644 index 0000000..36533ae --- /dev/null +++ b/main/widgets/PowerFlowWidget.cpp @@ -0,0 +1,60 @@ +#include "PowerFlowWidget.hpp" +#include "../Fonts.hpp" + +PowerFlowWidget::PowerFlowWidget(const WidgetConfig& config) + : Widget(config) + , titleLabel_(nullptr) +{ +} + +lv_obj_t* PowerFlowWidget::create(lv_obj_t* parent) { + obj_ = lv_obj_create(parent); + if (obj_ == nullptr) { + return nullptr; + } + + lv_obj_remove_style_all(obj_); + lv_obj_set_pos(obj_, config_.x, config_.y); + lv_obj_set_size(obj_, + config_.width > 0 ? config_.width : 240, + config_.height > 0 ? config_.height : 180); + lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE); + + if (config_.text[0] != '\0') { + titleLabel_ = lv_label_create(obj_); + lv_label_set_text(titleLabel_, config_.text); + lv_obj_set_pos(titleLabel_, 10, 8); + lv_obj_clear_flag(titleLabel_, LV_OBJ_FLAG_CLICKABLE); + } + + return obj_; +} + +void PowerFlowWidget::applyStyle() { + if (obj_ == nullptr) return; + + if (config_.bgOpacity > 0) { + lv_obj_set_style_bg_color(obj_, lv_color_make( + config_.bgColor.r, config_.bgColor.g, config_.bgColor.b), 0); + lv_obj_set_style_bg_opa(obj_, config_.bgOpacity, 0); + } else { + lv_obj_set_style_bg_opa(obj_, LV_OPA_TRANSP, 0); + } + + lv_obj_set_style_border_width(obj_, 1, 0); + lv_obj_set_style_border_color(obj_, lv_color_make( + config_.textColor.r, config_.textColor.g, config_.textColor.b), 0); + lv_obj_set_style_border_opa(obj_, LV_OPA_20, 0); + + if (config_.borderRadius > 0) { + lv_obj_set_style_radius(obj_, config_.borderRadius, 0); + } + + applyShadowStyle(); + + if (titleLabel_ != nullptr) { + lv_obj_set_style_text_color(titleLabel_, lv_color_make( + config_.textColor.r, config_.textColor.g, config_.textColor.b), 0); + lv_obj_set_style_text_font(titleLabel_, Fonts::bySizeIndex(config_.fontSize), 0); + } +} diff --git a/main/widgets/PowerFlowWidget.hpp b/main/widgets/PowerFlowWidget.hpp new file mode 100644 index 0000000..9d4e644 --- /dev/null +++ b/main/widgets/PowerFlowWidget.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include "Widget.hpp" + +class PowerFlowWidget : public Widget { +public: + explicit PowerFlowWidget(const WidgetConfig& config); + lv_obj_t* create(lv_obj_t* parent) override; + void applyStyle() override; + +private: + lv_obj_t* titleLabel_ = nullptr; +}; diff --git a/main/widgets/PowerLinkWidget.cpp b/main/widgets/PowerLinkWidget.cpp new file mode 100644 index 0000000..95cb978 --- /dev/null +++ b/main/widgets/PowerLinkWidget.cpp @@ -0,0 +1,67 @@ +#include "PowerLinkWidget.hpp" +#include "../WidgetManager.hpp" + +PowerLinkWidget::PowerLinkWidget(const WidgetConfig& config) + : Widget(config) +{ + points_[0].x = 0; + points_[0].y = 0; + points_[1].x = 1; + points_[1].y = 1; +} + +void PowerLinkWidget::buildLinePoints() { + if (obj_ == nullptr) return; + if (config_.x < 0 || config_.y < 0) return; + + const ScreenConfig* screen = WidgetManager::instance().currentScreen(); + if (screen == nullptr) return; + + const WidgetConfig* fromNode = screen->findWidget(static_cast(config_.x)); + const WidgetConfig* toNode = screen->findWidget(static_cast(config_.y)); + if (fromNode == nullptr || toNode == nullptr) return; + if (fromNode->parentId != config_.parentId || toNode->parentId != config_.parentId) return; + + int16_t x1 = fromNode->x + fromNode->width / 2; + int16_t y1 = fromNode->y + fromNode->height / 2; + int16_t x2 = toNode->x + toNode->width / 2; + int16_t y2 = toNode->y + toNode->height / 2; + + int16_t minX = x1 < x2 ? x1 : x2; + int16_t minY = y1 < y2 ? y1 : y2; + int16_t maxX = x1 > x2 ? x1 : x2; + int16_t maxY = y1 > y2 ? y1 : y2; + + points_[0].x = x1 - minX; + points_[0].y = y1 - minY; + points_[1].x = x2 - minX; + points_[1].y = y2 - minY; + + lv_obj_set_pos(obj_, minX, minY); + lv_obj_set_size(obj_, (maxX - minX) + 1, (maxY - minY) + 1); + lv_line_set_points(obj_, points_, 2); +} + +lv_obj_t* PowerLinkWidget::create(lv_obj_t* parent) { + obj_ = lv_line_create(parent); + if (obj_ == nullptr) { + return nullptr; + } + + lv_obj_clear_flag(obj_, LV_OBJ_FLAG_CLICKABLE); + buildLinePoints(); + return obj_; +} + +void PowerLinkWidget::applyStyle() { + if (obj_ == nullptr) return; + + lv_obj_set_style_line_color(obj_, lv_color_make( + config_.bgColor.r, config_.bgColor.g, config_.bgColor.b), 0); + lv_obj_set_style_line_width(obj_, config_.width > 0 ? config_.width : 2, 0); + lv_obj_set_style_line_opa(obj_, config_.bgOpacity, 0); + + int32_t dash = config_.iconGap > 0 ? config_.iconGap : 6; + lv_obj_set_style_line_dash_width(obj_, dash, 0); + lv_obj_set_style_line_dash_gap(obj_, dash + 4, 0); +} diff --git a/main/widgets/PowerLinkWidget.hpp b/main/widgets/PowerLinkWidget.hpp new file mode 100644 index 0000000..167fca0 --- /dev/null +++ b/main/widgets/PowerLinkWidget.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include "Widget.hpp" + +class PowerLinkWidget : public Widget { +public: + explicit PowerLinkWidget(const WidgetConfig& config); + lv_obj_t* create(lv_obj_t* parent) override; + void applyStyle() override; + +private: + lv_point_precise_t points_[2] = {}; + void buildLinePoints(); +}; diff --git a/main/widgets/PowerNodeWidget.cpp b/main/widgets/PowerNodeWidget.cpp new file mode 100644 index 0000000..ba483b4 --- /dev/null +++ b/main/widgets/PowerNodeWidget.cpp @@ -0,0 +1,186 @@ +#include "PowerNodeWidget.hpp" +#include "../Fonts.hpp" +#include +#include + +PowerNodeWidget::PowerNodeWidget(const WidgetConfig& config) + : Widget(config) +{ + labelText_[0] = '\0'; + valueFormat_[0] = '\0'; +} + +int PowerNodeWidget::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 PowerNodeWidget::parseText() { + labelText_[0] = '\0'; + valueFormat_[0] = '\0'; + + const char* text = config_.text; + if (!text || text[0] == '\0') return; + + 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'; + } +} + +lv_obj_t* PowerNodeWidget::create(lv_obj_t* parent) { + parseText(); + + obj_ = lv_obj_create(parent); + if (obj_ == nullptr) { + return nullptr; + } + + lv_obj_remove_style_all(obj_); + lv_obj_set_pos(obj_, config_.x, config_.y); + lv_obj_set_size(obj_, + config_.width > 0 ? config_.width : 120, + config_.height > 0 ? config_.height : 120); + lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_set_flex_flow(obj_, LV_FLEX_FLOW_COLUMN); + 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); + + if (labelText_[0] != '\0') { + labelLabel_ = lv_label_create(obj_); + lv_label_set_text(labelLabel_, labelText_); + lv_obj_clear_flag(labelLabel_, LV_OBJ_FLAG_CLICKABLE); + } + + if (config_.iconCodepoint > 0 && Fonts::hasIconFont()) { + iconLabel_ = lv_label_create(obj_); + char iconText[5]; + encodeUtf8(config_.iconCodepoint, iconText); + lv_label_set_text(iconLabel_, iconText); + lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE); + } + + valueLabel_ = lv_label_create(obj_); + if (valueFormat_[0] != '\0') { + lv_label_set_text(valueLabel_, valueFormat_); + } else { + lv_label_set_text(valueLabel_, ""); + } + lv_obj_clear_flag(valueLabel_, LV_OBJ_FLAG_CLICKABLE); + + return obj_; +} + +void PowerNodeWidget::applyStyle() { + if (obj_ == nullptr) return; + + lv_obj_set_style_bg_color(obj_, lv_color_white(), 0); + lv_obj_set_style_bg_opa(obj_, LV_OPA_COVER, 0); + lv_obj_set_style_radius(obj_, LV_RADIUS_CIRCLE, 0); + + int16_t minSide = config_.width < config_.height ? config_.width : config_.height; + int16_t ring = minSide / 12; + if (ring < 2) ring = 2; + if (ring > 12) ring = 12; + + lv_obj_set_style_border_width(obj_, ring, 0); + lv_obj_set_style_border_color(obj_, lv_color_make( + config_.bgColor.r, config_.bgColor.g, config_.bgColor.b), 0); + lv_obj_set_style_border_opa(obj_, config_.bgOpacity, 0); + + applyShadowStyle(); + + uint8_t valueSizeIdx = config_.fontSize; + uint8_t labelSizeIdx = valueSizeIdx > 0 ? static_cast(valueSizeIdx - 1) : valueSizeIdx; + + if (labelLabel_ != nullptr) { + lv_obj_set_style_text_color(labelLabel_, lv_color_make( + config_.textColor.r, config_.textColor.g, config_.textColor.b), 0); + lv_obj_set_style_text_font(labelLabel_, Fonts::bySizeIndex(labelSizeIdx), 0); + lv_obj_set_style_text_align(labelLabel_, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_opa(labelLabel_, LV_OPA_70, 0); + } + + if (iconLabel_ != nullptr) { + lv_obj_set_style_text_color(iconLabel_, lv_color_make( + config_.textColor.r, config_.textColor.g, config_.textColor.b), 0); + uint8_t sizeIdx = config_.iconSize < 6 ? config_.iconSize : valueSizeIdx; + lv_obj_set_style_text_font(iconLabel_, Fonts::iconFont(sizeIdx), 0); + lv_obj_set_style_text_align(iconLabel_, LV_TEXT_ALIGN_CENTER, 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) { + if (valueLabel_ == nullptr || text == nullptr) return; + lv_label_set_text(valueLabel_, text); +} + +void PowerNodeWidget::onKnxValue(float value) { + if (valueLabel_ == nullptr) return; + if (config_.textSource != TextSource::KNX_DPT_TEMP && + config_.textSource != TextSource::KNX_DPT_PERCENT) return; + + char buf[32]; + const char* fmt = valueFormat_[0] != '\0' ? valueFormat_ : "%0.1f"; + + if (config_.textSource == TextSource::KNX_DPT_PERCENT) { + int percent = static_cast(value + 0.5f); + snprintf(buf, sizeof(buf), fmt, percent); + } else { + snprintf(buf, sizeof(buf), fmt, value); + } + + updateValueText(buf); +} + +void PowerNodeWidget::onKnxSwitch(bool value) { + if (valueLabel_ == nullptr) return; + if (config_.textSource != TextSource::KNX_DPT_SWITCH) return; + updateValueText(value ? "EIN" : "AUS"); +} + +void PowerNodeWidget::onKnxText(const char* text) { + if (valueLabel_ == nullptr) return; + if (config_.textSource != TextSource::KNX_DPT_TEXT) return; + updateValueText(text); +} diff --git a/main/widgets/PowerNodeWidget.hpp b/main/widgets/PowerNodeWidget.hpp new file mode 100644 index 0000000..6cbcb48 --- /dev/null +++ b/main/widgets/PowerNodeWidget.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include "Widget.hpp" + +class PowerNodeWidget : public Widget { +public: + explicit PowerNodeWidget(const WidgetConfig& config); + lv_obj_t* create(lv_obj_t* parent) override; + void applyStyle() override; + void onKnxValue(float value) override; + void onKnxSwitch(bool value) override; + void onKnxText(const char* text) override; + +private: + lv_obj_t* labelLabel_ = nullptr; + lv_obj_t* valueLabel_ = nullptr; + lv_obj_t* iconLabel_ = nullptr; + + char labelText_[MAX_TEXT_LEN] = {0}; + char valueFormat_[MAX_TEXT_LEN] = {0}; + + void parseText(); + void updateValueText(const char* text); + static int encodeUtf8(uint32_t codepoint, char* buf); +}; diff --git a/main/widgets/WidgetFactory.cpp b/main/widgets/WidgetFactory.cpp index c000594..8c08a89 100644 --- a/main/widgets/WidgetFactory.cpp +++ b/main/widgets/WidgetFactory.cpp @@ -5,6 +5,9 @@ #include "IconWidget.hpp" #include "TabViewWidget.hpp" #include "TabPageWidget.hpp" +#include "PowerFlowWidget.hpp" +#include "PowerNodeWidget.hpp" +#include "PowerLinkWidget.hpp" std::unique_ptr WidgetFactory::create(const WidgetConfig& config) { if (!config.visible) return nullptr; @@ -22,6 +25,12 @@ std::unique_ptr WidgetFactory::create(const WidgetConfig& config) { return std::make_unique(config); case WidgetType::TABPAGE: return std::make_unique(config); + case WidgetType::POWERFLOW: + return std::make_unique(config); + case WidgetType::POWERNODE: + return std::make_unique(config); + case WidgetType::POWERLINK: + return std::make_unique(config); default: return nullptr; } diff --git a/web-interface/src/components/CanvasArea.vue b/web-interface/src/components/CanvasArea.vue index f09339d..59ece4b 100644 --- a/web-interface/src/components/CanvasArea.vue +++ b/web-interface/src/components/CanvasArea.vue @@ -108,6 +108,7 @@ function deselect() { function selectScreen(id) { store.activeScreenId = id; store.selectedWidgetId = null; + store.setPowerLinkMode(false); } function snap(val) { @@ -255,7 +256,7 @@ function resizeDrag(e) { let newW = Math.round(rawW); let newH = Math.round(rawH); - if (w.type === WIDGET_TYPES.LED) { + if (w.type === WIDGET_TYPES.LED || w.type === WIDGET_TYPES.POWERNODE) { 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/SidebarLeft.vue b/web-interface/src/components/SidebarLeft.vue index 20bcdd3..c133b53 100644 --- a/web-interface/src/components/SidebarLeft.vue +++ b/web-interface/src/components/SidebarLeft.vue @@ -26,6 +26,14 @@ Tabs Container + + diff --git a/web-interface/src/components/SidebarRight.vue b/web-interface/src/components/SidebarRight.vue index c5d2233..f1d8ae7 100644 --- a/web-interface/src/components/SidebarRight.vue +++ b/web-interface/src/components/SidebarRight.vue @@ -114,6 +114,58 @@
+ + + + + + + + + +