This commit is contained in:
Thomas Peterson 2026-02-01 20:49:09 +01:00
parent bc7a3ad0b4
commit 1e6f65807e
39 changed files with 2850 additions and 167 deletions

View File

@ -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"

View File

@ -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<uint8_t>(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<uint8_t>(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<uint8_t>(cond.op);
buf[pos++] = static_cast<uint8_t>(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<uint8_t>(sb.position);
buf[pos++] = static_cast<uint8_t>(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<TextSource>(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<TextSource>(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<ConditionOp>(buf[pos++]);
cond.source = static_cast<ConditionSource>(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<SubButtonPosition>(buf[pos++]);
sb.action = static_cast<SubButtonAction>(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));

View File

@ -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];

View File

@ -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 <cstdio>
#include <cstdint>
#include <cstring>
#include <sys/stat.h>
#include <cstdlib>
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<GuiConfig*>(heap_caps_malloc(sizeof(GuiConfig), MALLOC_CAP_SPIRAM));
if (!config_) {
ESP_LOGE(TAG, "Failed to allocate GuiConfig in PSRAM, trying internal RAM");
config_ = new GuiConfig();
} else {
new (config_) GuiConfig(); // Placement new to call constructor
}
// widgets_ is default-initialized to nullptr
portMUX_INITIALIZE(&knxCacheMux_);
uiQueue_ = xQueueCreate(UI_EVENT_QUEUE_LEN, sizeof(UiEvent));
@ -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<int64_t>(config_.standbyMinutes) * 60 * 1000000LL;
int64_t timeoutUs = static_cast<int64_t>(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,12 +962,11 @@ 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 (addr != 0 && source != TextSource::STATIC) {
if (source == TextSource::KNX_DPT_TIME ||
source == TextSource::KNX_DPT_DATE ||
source == TextSource::KNX_DPT_DATETIME) {
@ -903,32 +980,44 @@ void WidgetManager::applyCachedValuesToWidgets() {
if (getCachedKnxTime(addr, type, &tmValue)) {
widget->onKnxTime(tmValue, source);
}
continue;
}
if (source == TextSource::KNX_DPT_SWITCH) {
} else if (source == TextSource::KNX_DPT_SWITCH) {
bool state = false;
if (getCachedKnxSwitch(addr, &state)) {
widget->onKnxSwitch(state);
}
continue;
}
if (source == TextSource::KNX_DPT_TEXT) {
} else if (source == TextSource::KNX_DPT_TEXT) {
char text[MAX_TEXT_LEN] = {};
if (getCachedKnxText(addr, text, sizeof(text))) {
widget->onKnxText(text);
}
continue;
}
if (isNumericTextSource(source)) {
} else if (isNumericTextSource(source)) {
float value = 0.0f;
if (getCachedKnxValue(addr, source, &value)) {
widget->onKnxValue(value);
}
}
}
// Secondary address (left value)
uint16_t addr2 = widget->getKnxAddress2();
TextSource source2 = widget->getTextSource2();
if (addr2 != 0 && source2 != TextSource::STATIC && isNumericTextSource(source2)) {
float value = 0.0f;
if (getCachedKnxValue(addr2, source2, &value)) {
widget->onKnxValue2(value);
}
}
// Tertiary address (right value)
uint16_t addr3 = widget->getKnxAddress3();
TextSource source3 = widget->getTextSource3();
if (addr3 != 0 && source3 != TextSource::STATIC && isNumericTextSource(source3)) {
float value = 0.0f;
if (getCachedKnxValue(addr3, source3, &value)) {
widget->onKnxValue3(value);
}
}
}
}
void WidgetManager::refreshChartWidgetsLocked() {
@ -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<RoomCardWidget*>(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<int>(screen.bgImageMode));
}
// Modal-specific properties
if (screen.mode == ScreenMode::MODAL) {
cJSON* modal = cJSON_AddObjectToObject(screenJson, "modal");
@ -1376,6 +1498,75 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
cJSON_AddNumberToObject(widget, "parentId", w.parentId);
// Secondary KNX address (left value)
if (w.knxAddress2 > 0) {
cJSON_AddNumberToObject(widget, "knxAddr2", w.knxAddress2);
cJSON_AddNumberToObject(widget, "textSrc2", static_cast<int>(w.textSource2));
cJSON_AddStringToObject(widget, "text2", w.text2);
}
// Tertiary KNX address (right value)
if (w.knxAddress3 > 0) {
cJSON_AddNumberToObject(widget, "knxAddr3", w.knxAddress3);
cJSON_AddNumberToObject(widget, "textSrc3", static_cast<int>(w.textSource3));
cJSON_AddStringToObject(widget, "text3", w.text3);
}
// Conditions
if (w.conditionCount > 0) {
cJSON* conditions = cJSON_AddArrayToObject(widget, "conditions");
for (uint8_t ci = 0; ci < w.conditionCount && ci < MAX_CONDITIONS; ++ci) {
const StyleCondition& cond = w.conditions[ci];
if (!cond.enabled) continue;
cJSON* condJson = cJSON_CreateObject();
// Source
const char* sourceStr = "primary";
if (cond.source == ConditionSource::SECONDARY) sourceStr = "secondary";
else if (cond.source == ConditionSource::TERTIARY) sourceStr = "tertiary";
cJSON_AddStringToObject(condJson, "source", sourceStr);
cJSON_AddNumberToObject(condJson, "threshold", cond.threshold);
// Operator
const char* opStr = "lt";
switch (cond.op) {
case ConditionOp::LESS: opStr = "lt"; break;
case ConditionOp::LESS_EQUAL: opStr = "lte"; break;
case ConditionOp::EQUAL: opStr = "eq"; break;
case ConditionOp::GREATER_EQUAL: opStr = "gte"; break;
case ConditionOp::GREATER: opStr = "gt"; break;
case ConditionOp::NOT_EQUAL: opStr = "neq"; break;
}
cJSON_AddStringToObject(condJson, "op", opStr);
cJSON_AddNumberToObject(condJson, "priority", cond.priority);
// Style
if (cond.style.iconCodepoint != 0) {
cJSON_AddNumberToObject(condJson, "icon", cond.style.iconCodepoint);
}
if (cond.style.flags & ConditionStyle::FLAG_USE_TEXT_COLOR) {
char colorStr[8];
snprintf(colorStr, sizeof(colorStr), "#%02X%02X%02X",
cond.style.textColor.r, cond.style.textColor.g, cond.style.textColor.b);
cJSON_AddStringToObject(condJson, "textColor", colorStr);
}
if (cond.style.flags & ConditionStyle::FLAG_USE_BG_COLOR) {
char colorStr[8];
snprintf(colorStr, sizeof(colorStr), "#%02X%02X%02X",
cond.style.bgColor.r, cond.style.bgColor.g, cond.style.bgColor.b);
cJSON_AddStringToObject(condJson, "bgColor", colorStr);
}
if (cond.style.flags & ConditionStyle::FLAG_HIDE) {
cJSON_AddBoolToObject(condJson, "hide", true);
}
cJSON_AddItemToArray(conditions, condJson);
}
}
if (w.type == WidgetType::CHART) {
cJSON* chart = cJSON_AddObjectToObject(widget, "chart");
cJSON_AddNumberToObject(chart, "period", w.chartPeriod);
@ -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<int>(sb.position));
cJSON_AddNumberToObject(sbJson, "icon", sb.iconCodepoint);
cJSON_AddNumberToObject(sbJson, "knxRead", sb.knxAddrRead);
cJSON_AddNumberToObject(sbJson, "knxWrite", sb.knxAddrWrite);
cJSON_AddNumberToObject(sbJson, "action", static_cast<int>(sb.action));
cJSON_AddNumberToObject(sbJson, "target", sb.targetScreen);
char colorOnStr[8], colorOffStr[8];
snprintf(colorOnStr, sizeof(colorOnStr), "#%02X%02X%02X",
sb.colorOn.r, sb.colorOn.g, sb.colorOn.b);
snprintf(colorOffStr, sizeof(colorOffStr), "#%02X%02X%02X",
sb.colorOff.r, sb.colorOff.g, sb.colorOff.b);
cJSON_AddStringToObject(sbJson, "colorOn", colorOnStr);
cJSON_AddStringToObject(sbJson, "colorOff", colorOffStr);
cJSON_AddItemToArray(subButtons, sbJson);
}
}
cJSON_AddItemToArray(widgets, widget);
}
@ -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<TextSource>(textSrc2->valueint);
cJSON* text2 = cJSON_GetObjectItem(widget, "text2");
if (cJSON_IsString(text2)) {
strncpy(w.text2, text2->valuestring, MAX_FORMAT_LEN - 1);
w.text2[MAX_FORMAT_LEN - 1] = '\0';
}
// Tertiary KNX address (right value)
cJSON* knxAddr3 = cJSON_GetObjectItem(widget, "knxAddr3");
if (cJSON_IsNumber(knxAddr3)) w.knxAddress3 = knxAddr3->valueint;
cJSON* textSrc3 = cJSON_GetObjectItem(widget, "textSrc3");
if (cJSON_IsNumber(textSrc3)) w.textSource3 = static_cast<TextSource>(textSrc3->valueint);
cJSON* text3 = cJSON_GetObjectItem(widget, "text3");
if (cJSON_IsString(text3)) {
strncpy(w.text3, text3->valuestring, MAX_FORMAT_LEN - 1);
w.text3[MAX_FORMAT_LEN - 1] = '\0';
}
// Conditions
cJSON* conditions = cJSON_GetObjectItem(widget, "conditions");
if (cJSON_IsArray(conditions)) {
uint8_t condIdx = 0;
cJSON* condItem = nullptr;
cJSON_ArrayForEach(condItem, conditions) {
if (condIdx >= MAX_CONDITIONS) break;
StyleCondition& cond = w.conditions[condIdx];
memset(&cond, 0, sizeof(cond));
cond.enabled = true;
// Source
cJSON* source = cJSON_GetObjectItem(condItem, "source");
if (cJSON_IsString(source)) {
if (strcmp(source->valuestring, "secondary") == 0) {
cond.source = ConditionSource::SECONDARY;
} else if (strcmp(source->valuestring, "tertiary") == 0) {
cond.source = ConditionSource::TERTIARY;
} else {
cond.source = ConditionSource::PRIMARY;
}
}
// Threshold
cJSON* threshold = cJSON_GetObjectItem(condItem, "threshold");
if (cJSON_IsNumber(threshold)) cond.threshold = static_cast<float>(threshold->valuedouble);
// Operator
cJSON* op = cJSON_GetObjectItem(condItem, "op");
if (cJSON_IsString(op)) {
if (strcmp(op->valuestring, "lt") == 0) cond.op = ConditionOp::LESS;
else if (strcmp(op->valuestring, "lte") == 0) cond.op = ConditionOp::LESS_EQUAL;
else if (strcmp(op->valuestring, "eq") == 0) cond.op = ConditionOp::EQUAL;
else if (strcmp(op->valuestring, "gte") == 0) cond.op = ConditionOp::GREATER_EQUAL;
else if (strcmp(op->valuestring, "gt") == 0) cond.op = ConditionOp::GREATER;
else if (strcmp(op->valuestring, "neq") == 0) cond.op = ConditionOp::NOT_EQUAL;
}
// Priority
cJSON* priority = cJSON_GetObjectItem(condItem, "priority");
if (cJSON_IsNumber(priority)) cond.priority = priority->valueint;
// Icon
cJSON* icon = cJSON_GetObjectItem(condItem, "icon");
if (cJSON_IsNumber(icon)) cond.style.iconCodepoint = static_cast<uint32_t>(icon->valuedouble);
// Text color
cJSON* textColor = cJSON_GetObjectItem(condItem, "textColor");
if (cJSON_IsString(textColor)) {
cond.style.textColor = Color::fromHex(parseHexColor(textColor->valuestring));
cond.style.flags |= ConditionStyle::FLAG_USE_TEXT_COLOR;
}
// Background color
cJSON* bgColor = cJSON_GetObjectItem(condItem, "bgColor");
if (cJSON_IsString(bgColor)) {
cond.style.bgColor = Color::fromHex(parseHexColor(bgColor->valuestring));
cond.style.flags |= ConditionStyle::FLAG_USE_BG_COLOR;
}
// Hide
cJSON* hide = cJSON_GetObjectItem(condItem, "hide");
if (cJSON_IsBool(hide) && cJSON_IsTrue(hide)) {
cond.style.flags |= ConditionStyle::FLAG_HIDE;
}
condIdx++;
}
w.conditionCount = condIdx;
}
cJSON* chart = cJSON_GetObjectItem(widget, "chart");
if (cJSON_IsObject(chart)) {
cJSON* period = cJSON_GetObjectItem(chart, "period");
@ -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<SubButtonPosition>(pos->valueint);
}
cJSON* icon = cJSON_GetObjectItem(sbItem, "icon");
if (cJSON_IsNumber(icon)) {
sb.iconCodepoint = static_cast<uint32_t>(icon->valuedouble);
}
cJSON* knxRead = cJSON_GetObjectItem(sbItem, "knxRead");
if (cJSON_IsNumber(knxRead)) {
sb.knxAddrRead = knxRead->valueint;
}
cJSON* knxWrite = cJSON_GetObjectItem(sbItem, "knxWrite");
if (cJSON_IsNumber(knxWrite)) {
sb.knxAddrWrite = knxWrite->valueint;
}
cJSON* action = cJSON_GetObjectItem(sbItem, "action");
if (cJSON_IsNumber(action)) {
sb.action = static_cast<SubButtonAction>(action->valueint);
}
cJSON* target = cJSON_GetObjectItem(sbItem, "target");
if (cJSON_IsNumber(target)) {
sb.targetScreen = target->valueint;
}
cJSON* colorOn = cJSON_GetObjectItem(sbItem, "colorOn");
if (cJSON_IsString(colorOn)) {
sb.colorOn = Color::fromHex(parseHexColor(colorOn->valuestring));
}
cJSON* colorOff = cJSON_GetObjectItem(sbItem, "colorOff");
if (cJSON_IsString(colorOff)) {
sb.colorOff = Color::fromHex(parseHexColor(colorOff->valuestring));
}
sbIdx++;
}
w.subButtonCount = sbIdx;
}
screen.widgetCount++;
}
@ -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>(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;
}

View File

@ -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;

View File

@ -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);

View File

@ -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;
}

View File

@ -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,10 +69,12 @@ 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<size_t>(newline - text);
@ -83,6 +87,19 @@ void PowerNodeWidget::parseText() {
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';
}
}
lv_obj_t* PowerNodeWidget::create(lv_obj_t* parent) {
@ -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,6 +130,47 @@ lv_obj_t* PowerNodeWidget::create(lv_obj_t* parent) {
lv_obj_clear_flag(labelLabel_, 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");
@ -119,7 +179,9 @@ lv_obj_t* PowerNodeWidget::create(lv_obj_t* parent) {
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<uint8_t>(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<int>(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<int>(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;
}

View File

@ -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);
};

View File

@ -0,0 +1,335 @@
#include "RoomCardWidget.hpp"
#include "../Fonts.hpp"
#include "../WidgetManager.hpp"
#include <cstring>
#include <cstdio>
#include <cmath>
#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<char>(codepoint);
buf[1] = '\0';
return 1;
} else if (codepoint < 0x800) {
buf[0] = static_cast<char>(0xC0 | (codepoint >> 6));
buf[1] = static_cast<char>(0x80 | (codepoint & 0x3F));
buf[2] = '\0';
return 2;
} else if (codepoint < 0x10000) {
buf[0] = static_cast<char>(0xE0 | (codepoint >> 12));
buf[1] = static_cast<char>(0x80 | ((codepoint >> 6) & 0x3F));
buf[2] = static_cast<char>(0x80 | (codepoint & 0x3F));
buf[3] = '\0';
return 3;
} else if (codepoint < 0x110000) {
buf[0] = static_cast<char>(0xF0 | (codepoint >> 18));
buf[1] = static_cast<char>(0x80 | ((codepoint >> 12) & 0x3F));
buf[2] = static_cast<char>(0x80 | ((codepoint >> 6) & 0x3F));
buf[3] = static_cast<char>(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<size_t>(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<float>(static_cast<uint8_t>(pos)) * (M_PI / 4.0f) - (M_PI / 2.0f);
// Calculate position (center of sub-button)
x = centerX + static_cast<int16_t>(orbitRadius * cosf(angle)) - subBtnSize / 2;
y = centerY + static_cast<int16_t>(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<void*>(static_cast<uintptr_t>(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<RoomCardWidget*>(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<RoomCardWidget*>(lv_event_get_user_data(e));
lv_obj_t* target = static_cast<lv_obj_t*>(lv_event_get_target(e));
if (!widget || !target) return;
uint8_t index = static_cast<uint8_t>(reinterpret_cast<uintptr_t>(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);
}
}
}

View File

@ -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);
};

View File

@ -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;

View File

@ -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;
};

View File

@ -10,6 +10,7 @@
#include "PowerLinkWidget.hpp"
#include "ChartWidget.hpp"
#include "ClockWidget.hpp"
#include "RoomCardWidget.hpp"
std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
if (!config.visible) return nullptr;
@ -37,6 +38,8 @@ std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
return std::make_unique<ChartWidget>(config);
case WidgetType::CLOCK:
return std::make_unique<ClockWidget>(config);
case WidgetType::ROOMCARD:
return std::make_unique<RoomCardWidget>(config);
default:
return nullptr;
}

View File

@ -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

View File

@ -1,6 +1,6 @@
<template>
<div class="min-h-screen flex flex-col">
<TopBar @open-settings="showSettings = true" />
<TopBar @open-settings="showSettings = true" @open-files="showFiles = true" />
<div class="flex-1 min-h-0 grid grid-cols-[300px_1fr_320px] max-[1100px]:grid-cols-1 max-[1100px]:grid-rows-[auto_auto_auto]">
<SidebarLeft />
<CanvasArea @open-screen-settings="showScreenSettings = true" />
@ -8,6 +8,7 @@
</div>
<SettingsModal v-if="showSettings" @close="showSettings = false" />
<ScreenSettingsModal v-if="showScreenSettings" @close="showScreenSettings = false" />
<FileManager v-if="showFiles" @close="showFiles = false" />
</div>
</template>
@ -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();

View File

@ -90,11 +90,37 @@ const canvasH = computed(() => {
return DISPLAY_H;
});
const canvasStyle = computed(() => ({
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;

View File

@ -0,0 +1,477 @@
<template>
<div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
@click.self="$emit('close')"
>
<div class="border border-border rounded-2xl w-[90%] max-w-3xl max-h-[85vh] flex flex-col shadow-2xl bg-gradient-to-b from-white to-[#f6f9fc]">
<!-- Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 class="text-base font-semibold text-[#3a5f88]">
{{ selectMode ? 'Bild auswaehlen' : 'Datei-Manager' }}
</h3>
<button
class="w-7 h-7 rounded-lg border border-border bg-panel-2 text-text cursor-pointer hover:bg-[#e4ebf2] hover:border-[#b8c4d2]"
@click="$emit('close')"
>
x
</button>
</div>
<!-- Path Navigation -->
<div class="flex items-center gap-2 px-5 py-3 border-b border-border bg-panel-2">
<span class="text-xs text-muted">Pfad:</span>
<div class="flex-1 flex items-center gap-1 overflow-x-auto">
<button
v-for="(segment, idx) in pathSegments"
:key="idx"
class="text-sm text-[#2f6db8] hover:underline cursor-pointer"
@click="navigateToSegment(idx)"
>
{{ segment || '/' }}
</button>
<span v-if="pathSegments.length > 1" class="text-muted">/</span>
</div>
<button
v-if="currentPath !== '/'"
class="w-7 h-7 rounded-lg border border-border bg-white text-text cursor-pointer hover:bg-[#e4ebf2]"
@click="navigateUp"
title="Eine Ebene hoch"
>
<span class="text-sm">&#8593;</span>
</button>
<button
v-if="!selectMode"
class="w-7 h-7 rounded-lg border border-border bg-white text-text cursor-pointer hover:bg-[#e4ebf2]"
@click="showNewFolderInput = true"
title="Neuer Ordner"
>
+
</button>
</div>
<!-- New Folder Input -->
<div v-if="showNewFolderInput" class="flex items-center gap-2 px-5 py-2 border-b border-border bg-yellow-50">
<input
v-model="newFolderName"
type="text"
placeholder="Ordnername..."
class="flex-1 bg-white border border-border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-[#2f6db8]"
@keydown.enter="createFolder"
@keydown.esc="showNewFolderInput = false"
ref="newFolderInput"
>
<button
class="px-3 py-1.5 rounded-lg border border-[#2b62a5] bg-[#2f6db8] text-white text-sm cursor-pointer hover:bg-[#2b62a5]"
@click="createFolder"
>
Erstellen
</button>
<button
class="px-3 py-1.5 rounded-lg border border-border bg-white text-text text-sm cursor-pointer hover:bg-[#e4ebf2]"
@click="showNewFolderInput = false"
>
Abbrechen
</button>
</div>
<!-- Loading -->
<div v-if="loading" class="flex-1 flex items-center justify-center py-10">
<span class="text-muted">Lade...</span>
</div>
<!-- Error -->
<div v-else-if="error" class="flex-1 flex items-center justify-center py-10 text-red-500">
{{ error }}
</div>
<!-- File List -->
<div v-else class="flex-1 overflow-y-auto">
<div v-if="filteredFiles.length === 0" class="py-10 text-center text-muted">
{{ selectMode ? 'Keine Bilder in diesem Ordner' : 'Ordner ist leer' }}
</div>
<div v-else class="divide-y divide-border">
<div
v-for="file in filteredFiles"
:key="file.name"
class="flex items-center gap-3 px-5 py-3 hover:bg-panel-2 cursor-pointer transition-colors"
:class="{ 'bg-[#2f6db8]/5': selectedFile === file.name }"
@click="handleFileClick(file)"
>
<!-- Icon -->
<div class="w-10 h-10 rounded-lg bg-panel-2 border border-border flex items-center justify-center flex-shrink-0 overflow-hidden">
<img
v-if="isImageFile(file.name) && !file.isDir"
:src="getImageUrl(file.name)"
class="w-full h-full object-cover"
@error="$event.target.style.display='none'"
>
<span v-else-if="file.isDir" class="text-lg">&#128193;</span>
<span v-else class="text-lg">&#128196;</span>
</div>
<!-- Name & Size -->
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">{{ file.name }}</div>
<div v-if="!file.isDir" class="text-xs text-muted">{{ formatSize(file.size) }}</div>
</div>
<!-- Actions -->
<div v-if="!selectMode" class="flex items-center gap-1">
<button
v-if="!file.isDir"
class="w-7 h-7 rounded-lg border border-border bg-white text-text cursor-pointer hover:bg-[#e4ebf2]"
@click.stop="downloadFile(file.name)"
title="Herunterladen"
>
<span class="text-xs">&#8595;</span>
</button>
<button
class="w-7 h-7 rounded-lg border border-red-200 bg-[#f7dede] text-[#b3261e] cursor-pointer hover:bg-[#f2cfcf]"
@click.stop="deleteFile(file)"
title="Loeschen"
>
x
</button>
</div>
<!-- Select indicator for images -->
<div v-if="selectMode && isImageFile(file.name) && !file.isDir" class="text-[#2f6db8]">
<span class="text-sm">Auswaehlen &#8594;</span>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="px-5 py-3 border-t border-border flex items-center gap-2">
<div v-if="!selectMode" class="flex-1">
<label
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[#2b62a5] bg-[#2f6db8] text-white text-sm cursor-pointer hover:bg-[#2b62a5]"
>
<span>Datei hochladen</span>
<input
type="file"
class="hidden"
:accept="selectMode ? 'image/*' : '*'"
multiple
@change="handleFileUpload"
>
</label>
</div>
<div v-if="selectMode" class="flex-1">
<label
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[#2b62a5] bg-[#2f6db8] text-white text-sm cursor-pointer hover:bg-[#2b62a5]"
>
<span>Bild hochladen</span>
<input
type="file"
class="hidden"
accept="image/*"
@change="handleFileUpload"
>
</label>
</div>
<span v-if="uploadProgress" class="text-sm text-muted">{{ uploadProgress }}</span>
<button
class="px-4 py-2 rounded-lg border border-border bg-panel-2 text-text text-sm cursor-pointer hover:bg-[#e4ebf2]"
@click="$emit('close')"
>
{{ selectMode ? 'Abbrechen' : 'Schliessen' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue';
const props = defineProps({
selectMode: { type: Boolean, default: false },
initialPath: { type: String, default: '/images' }
});
const emit = defineEmits(['close', 'select']);
const currentPath = ref(props.initialPath);
const files = ref([]);
const loading = ref(false);
const error = ref(null);
const selectedFile = ref(null);
const showNewFolderInput = ref(false);
const newFolderName = ref('');
const newFolderInput = ref(null);
const uploadProgress = ref('');
const pathSegments = computed(() => {
if (currentPath.value === '/') return ['/'];
return ['/', ...currentPath.value.split('/').filter(Boolean)];
});
const filteredFiles = computed(() => {
if (!props.selectMode) return files.value;
// In select mode, show directories and image files only
return files.value.filter(f => f.isDir || isImageFile(f.name));
});
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg'];
// Image optimization settings for ESP32
const MAX_IMAGE_WIDTH = 1280; // Display width
const MAX_IMAGE_HEIGHT = 800; // Display height
const JPEG_QUALITY = 0.75; // 75% quality
function isImageFile(name) {
const ext = name.split('.').pop().toLowerCase();
return imageExtensions.includes(ext);
}
function getImageUrl(name) {
const fullPath = currentPath.value === '/' ? `/${name}` : `${currentPath.value}/${name}`;
return fullPath;
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
async function loadDirectory() {
loading.value = true;
error.value = null;
files.value = [];
try {
const response = await fetch(`/api/files/list?path=${encodeURIComponent(currentPath.value)}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
// Sort: directories first, then by name
files.value = (data.files || []).sort((a, b) => {
if (a.isDir && !b.isDir) return -1;
if (!a.isDir && b.isDir) return 1;
return a.name.localeCompare(b.name);
});
} catch (e) {
error.value = `Fehler beim Laden: ${e.message}`;
} finally {
loading.value = false;
}
}
function navigateToSegment(idx) {
if (idx === 0) {
currentPath.value = '/';
} else {
const segments = currentPath.value.split('/').filter(Boolean);
currentPath.value = '/' + segments.slice(0, idx).join('/');
}
}
function navigateUp() {
const segments = currentPath.value.split('/').filter(Boolean);
if (segments.length > 0) {
segments.pop();
currentPath.value = segments.length > 0 ? '/' + segments.join('/') : '/';
}
}
function handleFileClick(file) {
if (file.isDir) {
currentPath.value = currentPath.value === '/'
? `/${file.name}`
: `${currentPath.value}/${file.name}`;
} else if (props.selectMode && isImageFile(file.name)) {
const fullPath = currentPath.value === '/'
? `/${file.name}`
: `${currentPath.value}/${file.name}`;
emit('select', fullPath);
emit('close');
} else {
selectedFile.value = selectedFile.value === file.name ? null : file.name;
}
}
async function createFolder() {
if (!newFolderName.value.trim()) return;
const path = currentPath.value === '/'
? `/${newFolderName.value}`
: `${currentPath.value}/${newFolderName.value}`;
try {
const response = await fetch(`/api/files/mkdir?path=${encodeURIComponent(path)}`, {
method: 'POST'
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
showNewFolderInput.value = false;
newFolderName.value = '';
await loadDirectory();
} catch (e) {
alert(`Fehler beim Erstellen: ${e.message}`);
}
}
async function deleteFile(file) {
const typeName = file.isDir ? 'Ordner' : 'Datei';
if (!confirm(`${typeName} "${file.name}" wirklich loeschen?`)) return;
const path = currentPath.value === '/'
? `/${file.name}`
: `${currentPath.value}/${file.name}`;
try {
const response = await fetch(`/api/files/delete?file=${encodeURIComponent(path)}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
await loadDirectory();
} catch (e) {
alert(`Fehler beim Loeschen: ${e.message}`);
}
}
function downloadFile(name) {
const path = currentPath.value === '/'
? `/${name}`
: `${currentPath.value}/${name}`;
window.open(`/api/files/download?file=${encodeURIComponent(path)}`, '_blank');
}
// Optimize image for ESP32: resize and compress
async function optimizeImage(file) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let width = img.width;
let height = img.height;
// Check if resize is needed
if (width <= MAX_IMAGE_WIDTH && height <= MAX_IMAGE_HEIGHT) {
// Image is small enough, but still convert to JPEG for consistency
// Unless it's an SVG (keep as-is)
if (file.name.toLowerCase().endsWith('.svg')) {
resolve({ file, optimized: false });
return;
}
}
// Calculate new dimensions while maintaining aspect ratio
if (width > MAX_IMAGE_WIDTH) {
height = Math.round(height * (MAX_IMAGE_WIDTH / width));
width = MAX_IMAGE_WIDTH;
}
if (height > MAX_IMAGE_HEIGHT) {
width = Math.round(width * (MAX_IMAGE_HEIGHT / height));
height = MAX_IMAGE_HEIGHT;
}
// Create canvas and draw scaled image
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
// Convert to JPEG blob
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('Canvas toBlob failed'));
return;
}
// Create new filename with .jpg extension
const baseName = file.name.replace(/\.[^/.]+$/, '');
const newFile = new File([blob], `${baseName}.jpg`, { type: 'image/jpeg' });
resolve({
file: newFile,
optimized: true,
originalSize: file.size,
newSize: blob.size,
originalDimensions: { w: img.width, h: img.height },
newDimensions: { w: width, h: height }
});
}, 'image/jpeg', JPEG_QUALITY);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load image'));
};
img.src = url;
});
}
async function handleFileUpload(event) {
const fileList = event.target.files;
if (!fileList || fileList.length === 0) return;
for (const file of fileList) {
let fileToUpload = file;
let uploadName = file.name;
// Check if it's an image that should be optimized
if (isImageFile(file.name) && !file.name.toLowerCase().endsWith('.svg')) {
uploadProgress.value = `Optimiere ${file.name}...`;
try {
const result = await optimizeImage(file);
fileToUpload = result.file;
uploadName = result.file.name;
if (result.optimized) {
const savedKB = Math.round((result.originalSize - result.newSize) / 1024);
const savedPercent = Math.round((1 - result.newSize / result.originalSize) * 100);
console.log(`Optimized ${file.name}: ${result.originalDimensions.w}x${result.originalDimensions.h} -> ${result.newDimensions.w}x${result.newDimensions.h}, saved ${savedKB}KB (${savedPercent}%)`);
}
} catch (e) {
console.warn(`Could not optimize ${file.name}, uploading original:`, e);
fileToUpload = file;
uploadName = file.name;
}
}
uploadProgress.value = `Lade ${uploadName} hoch...`;
const formData = new FormData();
formData.append('file', fileToUpload, uploadName);
try {
const response = await fetch(`/api/files/upload?path=${encodeURIComponent(currentPath.value)}`, {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
} catch (e) {
alert(`Fehler beim Hochladen von ${uploadName}: ${e.message}`);
}
}
uploadProgress.value = '';
event.target.value = '';
await loadDirectory();
}
watch(currentPath, () => {
loadDirectory();
});
watch(showNewFolderInput, async (show) => {
if (show) {
await nextTick();
newFolderInput.value?.focus();
}
});
onMounted(() => {
loadDirectory();
});
</script>

View File

@ -25,6 +25,42 @@
</div>
</div>
<!-- Background Image -->
<div class="flex flex-col gap-2.5">
<div class="text-[12px] uppercase tracking-[0.08em] text-[#3a5f88]">Hintergrundbild</div>
<div class="flex items-center gap-2.5">
<div
class="w-16 h-16 rounded-lg border border-border bg-panel-2 flex items-center justify-center overflow-hidden flex-shrink-0"
:style="screen.bgImage ? { backgroundImage: `url(${screen.bgImage})`, backgroundSize: 'cover', backgroundPosition: 'center' } : {}"
>
<span v-if="!screen.bgImage" class="text-muted text-xs">Kein Bild</span>
</div>
<div class="flex flex-col gap-1.5 flex-1">
<button
class="bg-panel-2 border border-border rounded-lg px-3 py-1.5 text-text text-[12px] cursor-pointer hover:bg-[#e4ebf2] hover:border-[#b8c4d2] text-left truncate"
@click="showFilePicker = true"
>
{{ screen.bgImage || 'Bild auswaehlen...' }}
</button>
<button
v-if="screen.bgImage"
class="bg-[#f7dede] border border-red-200 text-[#b3261e] rounded-lg px-3 py-1.5 text-[12px] cursor-pointer hover:bg-[#f2cfcf]"
@click="screen.bgImage = ''"
>
Bild entfernen
</button>
</div>
</div>
<div v-if="screen.bgImage" class="flex items-center justify-between gap-2.5">
<label class="text-[12px] text-muted">Skalierung</label>
<select class="flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent" v-model.number="screen.bgImageMode">
<option :value="1">Strecken</option>
<option :value="2">Zentrieren</option>
<option :value="3">Kacheln</option>
</select>
</div>
</div>
<div class="flex flex-col gap-2.5" v-if="screen.mode === 1">
<div class="text-[12px] uppercase tracking-[0.08em] text-[#3a5f88]">Modal</div>
<div class="flex items-center justify-between gap-2.5"><label class="text-[12px] text-muted">X</label><input class="flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent" type="number" v-model.number="screen.modal.x"></div>
@ -46,18 +82,38 @@
<button class="border border-red-200 bg-[#f7dede] text-[#b3261e] px-3.5 py-2 rounded-[10px] text-[13px] font-semibold transition hover:-translate-y-0.5 hover:bg-[#f2cfcf] active:translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed" :disabled="!canDelete" @click="handleDelete">Screen loeschen</button>
</div>
</div>
<!-- File Picker Modal -->
<FileManager
v-if="showFilePicker"
:select-mode="true"
initial-path="/images"
@close="showFilePicker = false"
@select="handleImageSelect"
/>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useEditorStore } from '../stores/editor';
import FileManager from './FileManager.vue';
const emit = defineEmits(['close']);
const store = useEditorStore();
const screen = computed(() => store.activeScreen);
const canDelete = computed(() => store.config.screens.length > 1);
const showFilePicker = ref(false);
function handleImageSelect(path) {
if (screen.value) {
screen.value.bgImage = path;
if (!screen.value.bgImageMode) {
screen.value.bgImageMode = 1; // Default: Stretch
}
}
}
function handleDelete() {
if (!canDelete.value) return;

View File

@ -42,6 +42,10 @@
<span class="text-[13px] font-semibold">Chart</span>
<span class="text-[11px] text-muted mt-0.5 block">Verlauf</span>
</button>
<button class="bg-panel-2 border border-border rounded-xl p-2.5 text-left transition hover:-translate-y-0.5 hover:border-accent" @click="store.addWidget('roomcard')">
<span class="text-[13px] font-semibold">Room Card</span>
<span class="text-[11px] text-muted mt-0.5 block">Raum</span>
</button>
</div>
</section>

View File

@ -65,7 +65,7 @@
<h4 :class="headingClass">Icon</h4>
<div :class="rowClass">
<label :class="labelClass">Icon</label>
<button :class="iconSelectClass" @click="showIconPicker = true">
<button :class="iconSelectClass" @click="openWidgetIconPicker">
<span v-if="w.iconCodepoint" class="material-symbols-outlined text-[20px]">{{ String.fromCodePoint(w.iconCodepoint) }}</span>
<span v-else>Auswaehlen</span>
</button>
@ -180,6 +180,9 @@
<template v-if="key === 'powernode'">
<h4 :class="headingClass">Power Node</h4>
<div :class="rowClass"><label :class="labelClass">Label</label><input :class="inputClass" type="text" v-model="powerNodeLabel"></div>
<!-- Primary Value (Bottom) -->
<h4 :class="subHeadingClass">Unten (Primaer)</h4>
<div :class="rowClass"><label :class="labelClass">Quelle</label>
<select :class="inputClass" v-model.number="w.textSrc" @change="handleTextSrcChange">
<optgroup v-for="group in groupedSources(sourceOptions.powernode)" :key="group.label" :label="group.label">
@ -201,6 +204,96 @@
</select>
</div>
</template>
<!-- Secondary Value (Left) -->
<h4 :class="subHeadingClass">Links (Sekundaer)</h4>
<div :class="rowClass"><label :class="labelClass">Quelle</label>
<select :class="inputClass" v-model.number="w.textSrc2">
<option :value="0">-- Keine --</option>
<optgroup v-for="group in groupedSources(sourceOptions.powernode)" :key="group.label" :label="group.label">
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</optgroup>
</select>
</div>
<template v-if="w.textSrc2 > 0">
<div :class="rowClass"><label :class="labelClass">Format</label><input :class="inputClass" type="text" v-model="w.text2" maxlength="15"></div>
<div :class="rowClass"><label :class="labelClass">KNX Objekt</label>
<select :class="inputClass" v-model.number="w.knxAddr2">
<option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }} (GO{{ addr.index }})
</option>
</select>
</div>
</template>
<!-- Tertiary Value (Right) -->
<h4 :class="subHeadingClass">Rechts (Tertiaer)</h4>
<div :class="rowClass"><label :class="labelClass">Quelle</label>
<select :class="inputClass" v-model.number="w.textSrc3">
<option :value="0">-- Keine --</option>
<optgroup v-for="group in groupedSources(sourceOptions.powernode)" :key="group.label" :label="group.label">
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</optgroup>
</select>
</div>
<template v-if="w.textSrc3 > 0">
<div :class="rowClass"><label :class="labelClass">Format</label><input :class="inputClass" type="text" v-model="w.text3" maxlength="15"></div>
<div :class="rowClass"><label :class="labelClass">KNX Objekt</label>
<select :class="inputClass" v-model.number="w.knxAddr3">
<option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }} (GO{{ addr.index }})
</option>
</select>
</div>
</template>
<!-- Conditions -->
<h4 :class="headingClass">Bedingungen</h4>
<div :class="rowClass">
<label :class="labelClass">Anzahl</label>
<select :class="inputClass" v-model.number="conditionCount">
<option :value="0">Keine</option>
<option :value="1">1</option>
<option :value="2">2</option>
<option :value="3">3</option>
</select>
</div>
<div v-for="(cond, idx) in conditions" :key="idx" class="border border-border rounded-lg px-2.5 py-2 mb-2 bg-panel-2">
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">Bed. {{ idx + 1 }}</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model="cond.source">
<option value="primary">Unten</option>
<option value="secondary">Links</option>
<option value="tertiary">Rechts</option>
</select>
</div>
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">Wenn</label>
<select class="w-[60px] bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model="cond.op">
<option value="lt">&lt;</option>
<option value="lte">&lt;=</option>
<option value="eq">=</option>
<option value="gte">&gt;=</option>
<option value="gt">&gt;</option>
<option value="neq">!=</option>
</select>
<input class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" type="number" step="0.1" v-model.number="cond.threshold" placeholder="Schwelle">
</div>
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">Icon</label>
<button class="flex-1 bg-white border border-border rounded-md px-2.5 py-1 text-[11px] flex items-center justify-center gap-1 cursor-pointer hover:bg-[#e4ebf2]" @click="openConditionIconPicker(idx)">
<span v-if="cond.icon" class="material-symbols-outlined text-[16px]">{{ String.fromCodePoint(cond.icon) }}</span>
<span v-else>Kein Icon</span>
</button>
<button v-if="cond.icon" class="w-6 h-6 rounded-md border border-red-200 bg-[#f7dede] text-[#b3261e] grid place-items-center text-[11px] cursor-pointer hover:bg-[#f2cfcf]" @click="cond.icon = 0">x</button>
</div>
<div class="flex items-center gap-2 text-[11px] text-muted">
<label class="w-[50px]">Farbe</label>
<input class="h-[22px] w-[32px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="cond.textColor">
</div>
</div>
</template>
<template v-if="key === 'chart'">
@ -251,6 +344,142 @@
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="w.radius"></div>
</template>
<template v-if="key === 'roomcard'">
<h4 :class="headingClass">Room Card</h4>
<div :class="rowClass"><label :class="labelClass">Raumname</label><input :class="inputClass" type="text" v-model="roomCardName"></div>
<div :class="rowClass"><label :class="labelClass">Temp. Format</label><input :class="inputClass" type="text" v-model="roomCardFormat"></div>
<div :class="rowClass"><label :class="labelClass">Temp. Quelle</label>
<select :class="inputClass" v-model.number="w.textSrc">
<optgroup v-for="group in groupedSources(sourceOptions.roomcard)" :key="group.label" :label="group.label">
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</optgroup>
</select>
</div>
<div v-if="w.textSrc > 0" :class="rowClass"><label :class="labelClass">KNX Objekt</label>
<select :class="inputClass" v-model.number="w.knxAddr">
<option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }} (GO{{ addr.index }})
</option>
</select>
</div>
<div :class="rowClass"><label :class="labelClass">Klick-Aktion</label>
<select :class="inputClass" v-model.number="w.action">
<option :value="BUTTON_ACTIONS.JUMP">Sprung</option>
<option :value="BUTTON_ACTIONS.BACK">Zurueck</option>
</select>
</div>
<div v-if="w.action === BUTTON_ACTIONS.JUMP" :class="rowClass">
<label :class="labelClass">Ziel Screen</label>
<select :class="inputClass" v-model.number="w.targetScreen">
<option v-for="s in store.config.screens" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<!-- Icon -->
<h4 :class="headingClass">Icon</h4>
<div :class="rowClass">
<label :class="labelClass">Icon</label>
<button :class="iconSelectClass" @click="openWidgetIconPicker">
<span v-if="w.iconCodepoint" class="material-symbols-outlined text-[20px]">{{ String.fromCodePoint(w.iconCodepoint) }}</span>
<span v-else>Auswaehlen</span>
</button>
<button v-if="w.iconCodepoint" :class="iconRemoveClass" @click="w.iconCodepoint = 0">x</button>
</div>
<template v-if="w.iconCodepoint">
<div :class="rowClass">
<label :class="labelClass">Icon-Gr.</label>
<select :class="inputClass" v-model.number="w.iconSize">
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
</select>
</div>
</template>
<!-- Sub-Buttons -->
<h4 :class="headingClass">Sub-Buttons</h4>
<div :class="rowClass">
<label :class="labelClass">Anzahl</label>
<select :class="inputClass" v-model.number="subButtonCount">
<option :value="0">Keine</option>
<option :value="1">1</option>
<option :value="2">2</option>
<option :value="3">3</option>
<option :value="4">4</option>
<option :value="5">5</option>
<option :value="6">6</option>
</select>
</div>
<div v-if="subButtonCount > 0" :class="rowClass">
<label :class="labelClass">Button-Gr.</label>
<input :class="inputClass" type="number" min="30" max="80" v-model.number="w.subButtonSize">
<span class="text-[10px] text-muted">px</span>
</div>
<div v-if="subButtonCount > 0" :class="rowClass">
<label :class="labelClass">Abstand</label>
<input :class="inputClass" type="number" min="40" max="200" v-model.number="w.subButtonDistance">
<span class="text-[10px] text-muted">px</span>
</div>
<div v-for="(sb, idx) in subButtons" :key="idx" class="border border-border rounded-lg px-2.5 py-2 mb-2 bg-panel-2">
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">Btn {{ idx + 1 }}</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="sb.pos">
<option v-for="(label, pos) in SUBBUTTON_POSITION_LABELS" :key="pos" :value="Number(pos)">{{ label }}</option>
</select>
</div>
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">Icon</label>
<button class="flex-1 bg-white border border-border rounded-md px-2.5 py-1 text-[11px] flex items-center justify-center gap-1 cursor-pointer hover:bg-[#e4ebf2]" @click="openSubButtonIconPicker(idx)">
<span v-if="sb.icon" class="material-symbols-outlined text-[16px]">{{ String.fromCodePoint(sb.icon) }}</span>
<span v-else>Kein Icon</span>
</button>
</div>
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">Aktion</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="sb.action">
<option :value="0">KNX Toggle</option>
<option :value="1">Navigation</option>
</select>
</div>
<div v-if="sb.action === 0" class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">KNX R</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="sb.knxRead">
<option :value="0">-- Keine --</option>
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }}
</option>
</select>
</div>
<div v-if="sb.action === 0" class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">KNX W</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="sb.knxWrite">
<option :value="0">-- Keine --</option>
<option v-for="addr in writeableAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }}
</option>
</select>
</div>
<div v-if="sb.action === 1" class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">Ziel</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="sb.target">
<option v-for="s in store.config.screens" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<div class="flex items-center gap-2 text-[11px] text-muted">
<label class="w-[50px]">Farben</label>
<span class="text-[10px]">An:</span>
<input class="h-[22px] w-[28px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="sb.colorOn">
<span class="text-[10px]">Aus:</span>
<input class="h-[22px] w-[28px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="sb.colorOff">
</div>
</div>
<!-- Style -->
<h4 :class="headingClass">Stil</h4>
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input class="h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="w.textColor"></div>
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input class="h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="w.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="w.bgOpacity"></div>
</template>
<!-- Typography -->
<template v-if="key === 'label'">
<h4 :class="headingClass">Typo</h4>
@ -275,7 +504,7 @@
<h4 :class="headingClass">Icon</h4>
<div :class="rowClass">
<label :class="labelClass">Icon</label>
<button :class="iconSelectClass" @click="showIconPicker = true">
<button :class="iconSelectClass" @click="openWidgetIconPicker">
<span v-if="w.iconCodepoint" class="material-symbols-outlined text-[20px]">{{ String.fromCodePoint(w.iconCodepoint) }}</span>
<span v-else>Kein Icon</span>
</button>
@ -308,7 +537,7 @@
<h4 :class="headingClass">Icon</h4>
<div :class="rowClass">
<label :class="labelClass">Icon</label>
<button :class="iconSelectClass" @click="showIconPicker = true">
<button :class="iconSelectClass" @click="openWidgetIconPicker">
<span v-if="w.iconCodepoint" class="material-symbols-outlined text-[20px]">{{ String.fromCodePoint(w.iconCodepoint) }}</span>
<span v-else>Kein Icon</span>
</button>
@ -412,8 +641,8 @@
<!-- Icon Picker Modal -->
<IconPicker
v-if="showIconPicker"
v-model="w.iconCodepoint"
@close="showIconPicker = false"
v-model="activeIconCodepoint"
@close="handleIconPickerClose"
/>
</aside>
</template>
@ -422,7 +651,7 @@
import { computed, ref } from 'vue';
import { useEditorStore } from '../stores/editor';
import { typeKeyFor } from '../utils';
import { sourceOptions, textSources, textSourceGroups, fontSizes, BUTTON_ACTIONS, defaultFormats, WIDGET_TYPES, chartPeriods } from '../constants';
import { sourceOptions, textSources, textSourceGroups, fontSizes, BUTTON_ACTIONS, defaultFormats, WIDGET_TYPES, chartPeriods, SUBBUTTON_POSITION_LABELS } from '../constants';
import IconPicker from './IconPicker.vue';
const store = useEditorStore();
@ -520,10 +749,143 @@ const rowClass = 'flex items-center gap-2.5 mb-2';
const labelClass = 'w-[90px] text-[12px] text-muted';
const inputClass = 'flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent';
const headingClass = 'mt-4 mb-2.5 text-[12px] uppercase tracking-[0.08em] text-[#3a5f88]';
const subHeadingClass = 'mt-3 mb-2 text-[11px] uppercase tracking-[0.06em] text-[#5a7f9a]';
const noteClass = 'text-[11px] text-muted leading-tight';
const iconSelectClass = 'flex-1 bg-panel-2 border border-border rounded-lg px-2.5 py-1.5 text-text text-[12px] flex items-center justify-center gap-1.5 cursor-pointer hover:bg-[#e4ebf2] hover:border-[#b8c4d2]';
const iconRemoveClass = 'w-7 h-7 rounded-md border border-red-200 bg-[#f7dede] text-[#b3261e] grid place-items-center text-[12px] cursor-pointer hover:bg-[#f2cfcf]';
// Conditions for PowerNode
const conditionIconPickerIdx = ref(-1);
const conditions = computed(() => w.value?.conditions ?? []);
const conditionCount = computed({
get() {
return conditions.value.length || 0;
},
set(value) {
if (!w.value) return;
const target = Math.max(0, Math.min(value, 3));
if (!Array.isArray(w.value.conditions)) {
w.value.conditions = [];
}
while (w.value.conditions.length < target) {
w.value.conditions.push({
source: 'secondary',
threshold: 0,
op: 'lt',
priority: w.value.conditions.length,
icon: 0,
textColor: '#FF0000'
});
}
if (w.value.conditions.length > target) {
w.value.conditions = w.value.conditions.slice(0, target);
}
}
});
// RoomCard computed properties
const roomCardName = computed({
get() {
if (!w.value?.text) return '';
const parts = w.value.text.split('\n');
return parts[0] || '';
},
set(value) {
if (!w.value) return;
const parts = w.value.text ? w.value.text.split('\n') : ['', ''];
parts[0] = value;
w.value.text = parts.join('\n');
}
});
const roomCardFormat = computed({
get() {
if (!w.value?.text) return '';
const parts = w.value.text.split('\n');
return parts[1] || '';
},
set(value) {
if (!w.value) return;
const parts = w.value.text ? w.value.text.split('\n') : ['', ''];
parts[1] = value;
w.value.text = parts.join('\n');
}
});
const subButtons = computed(() => w.value?.subButtons ?? []);
const subButtonCount = computed({
get() {
return subButtons.value.length || 0;
},
set(value) {
if (!w.value) return;
const target = Math.max(0, Math.min(value, 6));
if (!Array.isArray(w.value.subButtons)) {
w.value.subButtons = [];
}
while (w.value.subButtons.length < target) {
w.value.subButtons.push({
pos: w.value.subButtons.length, // Auto-assign position
icon: 0,
knxRead: 0,
knxWrite: 0,
action: 0,
target: 0,
colorOn: '#FFCC00',
colorOff: '#666666'
});
}
if (w.value.subButtons.length > target) {
w.value.subButtons = w.value.subButtons.slice(0, target);
}
}
});
const subButtonIconPickerIdx = ref(-1);
function openSubButtonIconPicker(idx) {
subButtonIconPickerIdx.value = idx;
showIconPicker.value = true;
}
function openWidgetIconPicker() {
conditionIconPickerIdx.value = -1;
showIconPicker.value = true;
}
function openConditionIconPicker(idx) {
conditionIconPickerIdx.value = idx;
showIconPicker.value = true;
}
// Dynamic icon binding for IconPicker (widget icon or condition icon)
const activeIconCodepoint = computed({
get() {
if (conditionIconPickerIdx.value >= 0 && w.value?.conditions?.[conditionIconPickerIdx.value]) {
return w.value.conditions[conditionIconPickerIdx.value].icon || 0;
}
if (subButtonIconPickerIdx.value >= 0 && w.value?.subButtons?.[subButtonIconPickerIdx.value]) {
return w.value.subButtons[subButtonIconPickerIdx.value].icon || 0;
}
return w.value?.iconCodepoint || 0;
},
set(value) {
if (conditionIconPickerIdx.value >= 0 && w.value?.conditions?.[conditionIconPickerIdx.value]) {
w.value.conditions[conditionIconPickerIdx.value].icon = value;
} else if (subButtonIconPickerIdx.value >= 0 && w.value?.subButtons?.[subButtonIconPickerIdx.value]) {
w.value.subButtons[subButtonIconPickerIdx.value].icon = value;
} else if (w.value) {
w.value.iconCodepoint = value;
}
}
});
function handleIconPickerClose() {
showIconPicker.value = false;
conditionIconPickerIdx.value = -1;
subButtonIconPickerIdx.value = -1;
}
function groupedSources(options) {
const allowed = new Set(options || []);
return textSourceGroups

View File

@ -8,6 +8,7 @@
</div>
</div>
<div class="flex items-center gap-2.5 flex-wrap justify-end">
<button class="border border-border bg-panel-2 text-text px-3.5 py-2 rounded-[10px] text-[13px] font-semibold transition hover:-translate-y-0.5 hover:bg-[#e4ebf2] active:translate-y-0.5" @click="emit('open-files')">Dateien</button>
<button class="border border-border bg-panel-2 text-text px-3.5 py-2 rounded-[10px] text-[13px] font-semibold transition hover:-translate-y-0.5 hover:bg-[#e4ebf2] active:translate-y-0.5" @click="emit('open-settings')">Einstellungen</button>
<button
:class="[
@ -44,7 +45,7 @@ import { ref, onMounted } from 'vue';
import { useEditorStore } from '../stores/editor';
const store = useEditorStore();
const emit = defineEmits(['open-settings']);
const emit = defineEmits(['open-settings', 'open-files']);
const knxProgMode = ref(false);
const knxProgPending = ref(false);
const knxResetPending = ref(false);

View File

@ -138,6 +138,29 @@
</div>
</template>
<!-- RoomCard Widget -->
<template v-else-if="isRoomCard">
<!-- Central Bubble -->
<div class="absolute rounded-full flex flex-col items-center justify-center" :style="roomCardBubbleStyle">
<span v-if="widget.iconCodepoint" class="material-symbols-outlined" :style="roomCardIconStyle">
{{ iconChar }}
</span>
<span v-if="roomCardParts.name" class="leading-tight font-semibold" :style="roomCardNameStyle">{{ roomCardParts.name }}</span>
<span v-if="roomCardParts.format" class="leading-tight opacity-70" :style="roomCardTempStyle">{{ roomCardParts.format }}</span>
</div>
<!-- Sub-Buttons -->
<div
v-for="(sb, idx) in roomCardSubButtons"
:key="idx"
class="absolute rounded-full flex items-center justify-center shadow-md"
:style="getSubButtonStyle(sb, idx)"
>
<span v-if="sb.icon" class="material-symbols-outlined" :style="getSubButtonIconStyle(sb)">
{{ String.fromCodePoint(sb.icon) }}
</span>
</div>
</template>
<!-- Icon-only Widget -->
<template v-else-if="isIcon">
<span class="material-symbols-outlined flex items-center justify-center" :style="iconOnlyStyle">
@ -213,6 +236,7 @@ const isPowerNode = computed(() => props.widget.type === WIDGET_TYPES.POWERNODE)
const isPowerLink = computed(() => props.widget.type === WIDGET_TYPES.POWERLINK);
const isChart = computed(() => props.widget.type === WIDGET_TYPES.CHART);
const isClock = computed(() => props.widget.type === WIDGET_TYPES.CLOCK);
const isRoomCard = computed(() => props.widget.type === WIDGET_TYPES.ROOMCARD);
const tabPosition = computed(() => props.widget.iconPosition || 0); // 0=Top, 1=Bottom...
const tabHeight = computed(() => (props.widget.iconSize || 5) * 10);
@ -260,6 +284,7 @@ const showDefaultText = computed(() => {
if (isPowerFlow.value || isPowerNode.value) return false;
if (isPowerLink.value) return false;
if (isButtonContainer.value) return false;
if (isRoomCard.value) return false;
return true;
});
@ -454,6 +479,112 @@ const powerFlowBgStyle = computed(() => {
};
});
// RoomCard computed properties
const roomCardParts = computed(() => {
if (!props.widget.text) return { name: '', format: '' };
const parts = props.widget.text.split('\n');
return {
name: parts[0] || '',
format: parts[1] || ''
};
});
const roomCardSubButtons = computed(() => props.widget.subButtons || []);
const roomCardBubbleStyle = computed(() => {
const s = props.scale;
const w = props.widget.w;
const h = props.widget.h;
const subBtnSize = (props.widget.subButtonSize || 40) * s;
const padding = subBtnSize * 0.6;
const bubbleSize = Math.min(w, h) * s - padding * 2;
const left = (w * s - bubbleSize) / 2;
const top = (h * s - bubbleSize) / 2;
const alpha = clamp((props.widget.bgOpacity ?? 255) / 255, 0, 1);
return {
width: `${bubbleSize}px`,
height: `${bubbleSize}px`,
left: `${left}px`,
top: `${top}px`,
backgroundColor: hexToRgba(props.widget.bgColor, alpha),
color: props.widget.textColor,
boxShadow: props.widget.shadow?.enabled
? `${(props.widget.shadow.x || 0) * s}px ${(props.widget.shadow.y || 0) * s}px ${(props.widget.shadow.blur || 0) * s}px ${hexToRgba(props.widget.shadow.color || '#000000', 0.3)}`
: '0 4px 12px rgba(0,0,0,0.15)'
};
});
const roomCardIconStyle = computed(() => {
const s = props.scale;
const sizeIdx = props.widget.iconSize ?? 3;
const size = fontSizes[sizeIdx] || 28;
return {
fontSize: `${size * s}px`,
color: props.widget.textColor
};
});
const roomCardNameStyle = computed(() => {
const s = props.scale;
const sizeIdx = props.widget.fontSize ?? 2;
const size = fontSizes[sizeIdx] || 22;
return {
fontSize: `${size * s * 0.7}px`,
color: props.widget.textColor
};
});
const roomCardTempStyle = computed(() => {
const s = props.scale;
const sizeIdx = props.widget.fontSize ?? 2;
const size = fontSizes[sizeIdx] || 22;
return {
fontSize: `${size * s * 0.55}px`,
color: props.widget.textColor
};
});
function getSubButtonStyle(sb, idx) {
const s = props.scale;
const w = props.widget.w;
const h = props.widget.h;
const subBtnSize = (props.widget.subButtonSize || 40) * s;
const centerX = w * s / 2;
const centerY = h * s / 2;
// Distance from center in pixels (default 80)
const orbitRadius = (props.widget.subButtonDistance || 80) * s;
// Position based on sb.pos (0=Top, 1=TopRight, 2=Right, etc.)
const pos = sb.pos ?? idx;
const angle = (pos * (Math.PI / 4)) - (Math.PI / 2); // Start from top, go clockwise
const x = centerX + orbitRadius * Math.cos(angle) - subBtnSize / 2;
const y = centerY + orbitRadius * Math.sin(angle) - subBtnSize / 2;
// Use colorOff for preview (no KNX state in editor)
const bgColor = sb.colorOff || '#666666';
return {
width: `${subBtnSize}px`,
height: `${subBtnSize}px`,
left: `${x}px`,
top: `${y}px`,
backgroundColor: bgColor,
border: `2px solid ${hexToRgba('#ffffff', 0.3)}`
};
}
function getSubButtonIconStyle(sb) {
const s = props.scale;
const subBtnSize = (props.widget.subButtonSize || 40) * s;
const iconSize = subBtnSize * 0.5;
return {
fontSize: `${iconSize}px`,
color: '#ffffff'
};
}
const computedStyle = computed(() => {
const w = props.widget;
const s = props.scale;
@ -591,6 +722,9 @@ const computedStyle = computed(() => {
style.height = '100%';
style.left = '0';
style.top = '0';
} else if (isRoomCard.value) {
// RoomCard container - transparent, children handle rendering
style.overflow = 'visible';
}
return style;

View File

@ -13,7 +13,8 @@ export const WIDGET_TYPES = {
POWERNODE: 7,
POWERLINK: 8,
CHART: 9,
CLOCK: 10
CLOCK: 10,
ROOMCARD: 11
};
export const ICON_POSITIONS = {
@ -46,7 +47,8 @@ export const TYPE_KEYS = {
7: 'powernode',
8: 'powerlink',
9: 'chart',
10: 'clock'
10: 'clock',
11: 'roomcard'
};
export const TYPE_LABELS = {
@ -60,7 +62,8 @@ export const TYPE_LABELS = {
powernode: 'Power Node',
powerlink: 'Power Link',
chart: 'Chart',
clock: 'Uhr (Analog)'
clock: 'Uhr (Analog)',
roomcard: 'Room Card'
};
@ -103,7 +106,8 @@ export const sourceOptions = {
powernode: [0, 1, 2, 3, 4, 5, 6, 7],
powerlink: [0, 1, 3, 5, 6, 7],
chart: [1, 3, 5, 6, 7],
clock: [11]
clock: [11],
roomcard: [0, 1, 3, 5, 6, 7] // Temperature sources
};
export const chartPeriods = [
@ -115,6 +119,33 @@ export const chartPeriods = [
{ value: 5, label: '1 Monat' }
];
export const SUBBUTTON_POSITIONS = {
TOP: 0,
TOP_RIGHT: 1,
RIGHT: 2,
BOTTOM_RIGHT: 3,
BOTTOM: 4,
BOTTOM_LEFT: 5,
LEFT: 6,
TOP_LEFT: 7
};
export const SUBBUTTON_POSITION_LABELS = {
0: 'Oben',
1: 'Oben rechts',
2: 'Rechts',
3: 'Unten rechts',
4: 'Unten',
5: 'Unten links',
6: 'Links',
7: 'Oben links'
};
export const SUBBUTTON_ACTIONS = {
TOGGLE_KNX: 0,
NAVIGATE: 1
};
export const ICON_DEFAULTS = {
iconCodepoint: 0,
iconPosition: 0,
@ -316,7 +347,17 @@ export const WIDGET_DEFAULTS = {
iconCodepoint: 0,
iconPosition: 0,
iconSize: 1,
iconGap: 8
iconGap: 8,
// Secondary value (left)
textSrc2: 0,
text2: '',
knxAddr2: 0,
// Tertiary value (right)
textSrc3: 0,
text3: '',
knxAddr3: 0,
// Conditions
conditions: []
},
powerlink: {
w: 3,
@ -389,5 +430,30 @@ export const WIDGET_DEFAULTS = {
iconPosition: 0,
iconSize: 1,
iconGap: 0
},
roomcard: {
w: 200,
h: 200,
text: 'Wohnzimmer\n%.1f °C',
textSrc: 1, // Temperature
fontSize: 2,
textAlign: TEXT_ALIGNS.CENTER,
textColor: '#223447',
bgColor: '#FFFFFF',
bgOpacity: 255,
radius: 100,
shadow: { enabled: true, x: 0, y: 4, blur: 12, spread: 0, color: '#00000022' },
isToggle: false,
knxAddrWrite: 0,
knxAddr: 0,
action: BUTTON_ACTIONS.JUMP,
targetScreen: 0,
iconCodepoint: 0xe88a, // Home icon
iconPosition: 0,
iconSize: 3,
iconGap: 8,
subButtonSize: 40, // Sub-button size in pixels
subButtonDistance: 80, // Distance from center in pixels
subButtons: []
}
};

View File

@ -47,6 +47,17 @@ export const useEditorStore = defineStore('editor', () => {
w.knxAddrWrite = addrByIndex.get(w.knxAddrWrite);
}
}
// PowerNode secondary/tertiary addresses
if (typeof w.knxAddr2 === 'number' && w.knxAddr2 > 0) {
if (!gaSet.has(w.knxAddr2) && addrByIndex.has(w.knxAddr2)) {
w.knxAddr2 = addrByIndex.get(w.knxAddr2);
}
}
if (typeof w.knxAddr3 === 'number' && w.knxAddr3 > 0) {
if (!gaSet.has(w.knxAddr3) && addrByIndex.has(w.knxAddr3)) {
w.knxAddr3 = addrByIndex.get(w.knxAddr3);
}
}
if (w.chart && Array.isArray(w.chart.series)) {
w.chart.series.forEach((series) => {
if (typeof series.knxAddr === 'number' && series.knxAddr > 0) {
@ -280,6 +291,8 @@ export const useEditorStore = defineStore('editor', () => {
name: `Screen ${id}`,
mode: 0,
bgColor: '#1A1A2E',
bgImage: '',
bgImageMode: 1,
widgets: []
};
normalizeScreen(newScreen, null, nextWidgetId);
@ -321,6 +334,7 @@ export const useEditorStore = defineStore('editor', () => {
case 'powerlink': typeValue = WIDGET_TYPES.POWERLINK; break;
case 'chart': typeValue = WIDGET_TYPES.CHART; break;
case 'clock': typeValue = WIDGET_TYPES.CLOCK; break;
case 'roomcard': typeValue = WIDGET_TYPES.ROOMCARD; break;
default: typeValue = WIDGET_TYPES.LABEL;
}
@ -404,6 +418,22 @@ export const useEditorStore = defineStore('editor', () => {
};
}
// PowerNode: secondary/tertiary values and conditions
if (typeStr === 'powernode') {
w.textSrc2 = defaults.textSrc2 ?? 0;
w.text2 = defaults.text2 ?? '';
w.knxAddr2 = defaults.knxAddr2 ?? 0;
w.textSrc3 = defaults.textSrc3 ?? 0;
w.text3 = defaults.text3 ?? '';
w.knxAddr3 = defaults.knxAddr3 ?? 0;
w.conditions = [];
}
// RoomCard: initialize sub-buttons array
if (typeStr === 'roomcard') {
w.subButtons = [];
}
activeScreen.value.widgets.push(w);
selectedWidgetId.value = w.id;

View File

@ -19,6 +19,7 @@ export function minSizeFor(widget) {
if (key === 'powernode') return { w: 70, h: 70 };
if (key === 'powerlink') return { w: 1, h: 1 };
if (key === 'chart') return { w: 160, h: 120 };
if (key === 'roomcard') return { w: 120, h: 120 };
return { w: 40, h: 20 };
}
@ -34,12 +35,34 @@ export function normalizeWidget(w, nextWidgetIdRef) {
const defaults = WIDGET_DEFAULTS[key];
Object.keys(defaults).forEach((prop) => {
if (prop === 'shadow') return;
if (prop === 'shadow' || prop === 'conditions') return;
if (w[prop] === undefined || w[prop] === null) {
w[prop] = defaults[prop];
}
});
// Conditions: ensure each widget has its own array
if (defaults.conditions !== undefined) {
if (!Array.isArray(w.conditions)) {
w.conditions = [];
}
}
// SubButtons: ensure each widget has its own array
if (defaults.subButtons !== undefined) {
if (!Array.isArray(w.subButtons)) {
w.subButtons = [];
}
// Ensure subButtonSize has a default
if (w.subButtonSize === undefined || w.subButtonSize === null) {
w.subButtonSize = defaults.subButtonSize || 40;
}
// Ensure subButtonDistance has a default
if (w.subButtonDistance === undefined || w.subButtonDistance === null) {
w.subButtonDistance = defaults.subButtonDistance || 80;
}
}
if (!w.shadow) {
w.shadow = { ...defaults.shadow };
} else {
@ -111,6 +134,8 @@ export function normalizeScreen(screen, nextScreenIdRef, nextWidgetIdRef) {
if (!screen.name) screen.name = `Screen ${screen.id}`;
if (screen.mode === undefined || screen.mode === null) screen.mode = 0;
if (!screen.bgColor) screen.bgColor = '#1A1A2E';
if (screen.bgImage === undefined) screen.bgImage = '';
if (screen.bgImageMode === undefined) screen.bgImageMode = 1;
if (!Array.isArray(screen.widgets)) screen.widgets = [];
// Modal defaults

View File

@ -9,6 +9,10 @@ export default defineConfig({
'/api': {
target: 'http://192.168.178.81',
changeOrigin: true,
},
'/images': {
target: 'http://192.168.178.81',
changeOrigin: true,
}
}
}