Fixes
This commit is contained in:
parent
bc7a3ad0b4
commit
1e6f65807e
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.cache/clangd/index/RoomCardWidget.cpp.FA613078500AD19C.idx
Normal file
BIN
.cache/clangd/index/RoomCardWidget.cpp.FA613078500AD19C.idx
Normal file
Binary file not shown.
BIN
.cache/clangd/index/RoomCardWidget.hpp.DE0E3E44BD9BC30A.idx
Normal file
BIN
.cache/clangd/index/RoomCardWidget.hpp.DE0E3E44BD9BC30A.idx
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -12,6 +12,7 @@ idf_component_register(SRCS "HistoryStore.cpp" "KnxWorker.cpp" "Nvs.cpp" "main.c
|
|||||||
"widgets/PowerLinkWidget.cpp"
|
"widgets/PowerLinkWidget.cpp"
|
||||||
"widgets/ChartWidget.cpp"
|
"widgets/ChartWidget.cpp"
|
||||||
"widgets/ClockWidget.cpp"
|
"widgets/ClockWidget.cpp"
|
||||||
|
"widgets/RoomCardWidget.cpp"
|
||||||
"webserver/WebServer.cpp"
|
"webserver/WebServer.cpp"
|
||||||
"webserver/StaticFileHandlers.cpp"
|
"webserver/StaticFileHandlers.cpp"
|
||||||
"webserver/ConfigHandlers.cpp"
|
"webserver/ConfigHandlers.cpp"
|
||||||
|
|||||||
@ -66,6 +66,71 @@ void WidgetConfig::serialize(uint8_t* buf) const {
|
|||||||
buf[pos++] = chartSeriesColor[i].g;
|
buf[pos++] = chartSeriesColor[i].g;
|
||||||
buf[pos++] = chartSeriesColor[i].b;
|
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) {
|
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].g = buf[pos++];
|
||||||
chartSeriesColor[i].b = 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) {
|
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.iconPosition = 0;
|
||||||
cfg.iconSize = 1;
|
cfg.iconSize = 1;
|
||||||
cfg.iconGap = 8;
|
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;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -203,6 +375,19 @@ WidgetConfig WidgetConfig::createButton(uint8_t id, int16_t x, int16_t y,
|
|||||||
cfg.iconPosition = 0;
|
cfg.iconPosition = 0;
|
||||||
cfg.iconSize = 1;
|
cfg.iconSize = 1;
|
||||||
cfg.iconGap = 8;
|
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;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -211,6 +396,8 @@ void ScreenConfig::clear(uint8_t newId, const char* newName) {
|
|||||||
id = newId;
|
id = newId;
|
||||||
mode = ScreenMode::FULLSCREEN;
|
mode = ScreenMode::FULLSCREEN;
|
||||||
backgroundColor = {26, 26, 46}; // Dark blue background
|
backgroundColor = {26, 26, 46}; // Dark blue background
|
||||||
|
bgImagePath[0] = '\0'; // No background image
|
||||||
|
bgImageMode = BgImageMode::STRETCH;
|
||||||
widgetCount = 0;
|
widgetCount = 0;
|
||||||
memset(widgets, 0, sizeof(widgets));
|
memset(widgets, 0, sizeof(widgets));
|
||||||
memset(name, 0, sizeof(name));
|
memset(name, 0, sizeof(name));
|
||||||
|
|||||||
@ -9,7 +9,11 @@ static constexpr size_t MAX_WIDGETS = 64;
|
|||||||
static constexpr size_t MAX_SCREENS = 8;
|
static constexpr size_t MAX_SCREENS = 8;
|
||||||
static constexpr size_t MAX_TEXT_LEN = 32;
|
static constexpr size_t MAX_TEXT_LEN = 32;
|
||||||
static constexpr size_t MAX_SCREEN_NAME_LEN = 24;
|
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 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 {
|
enum class WidgetType : uint8_t {
|
||||||
LABEL = 0,
|
LABEL = 0,
|
||||||
@ -23,6 +27,7 @@ enum class WidgetType : uint8_t {
|
|||||||
POWERLINK = 8,
|
POWERLINK = 8,
|
||||||
CHART = 9,
|
CHART = 9,
|
||||||
CLOCK = 10,
|
CLOCK = 10,
|
||||||
|
ROOMCARD = 11,
|
||||||
};
|
};
|
||||||
|
|
||||||
enum class IconPosition : uint8_t {
|
enum class IconPosition : uint8_t {
|
||||||
@ -37,6 +42,13 @@ enum class ScreenMode : uint8_t {
|
|||||||
MODAL = 1,
|
MODAL = 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
enum class BgImageMode : uint8_t {
|
||||||
|
NONE = 0,
|
||||||
|
STRETCH = 1,
|
||||||
|
CENTER = 2,
|
||||||
|
TILE = 3,
|
||||||
|
};
|
||||||
|
|
||||||
enum class ButtonAction : uint8_t {
|
enum class ButtonAction : uint8_t {
|
||||||
KNX = 0,
|
KNX = 0,
|
||||||
JUMP = 1,
|
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
|
// Shadow configuration
|
||||||
struct ShadowConfig {
|
struct ShadowConfig {
|
||||||
int8_t offsetX;
|
int8_t offsetX;
|
||||||
@ -151,8 +238,29 @@ struct WidgetConfig {
|
|||||||
TextSource chartTextSource[CHART_MAX_SERIES];
|
TextSource chartTextSource[CHART_MAX_SERIES];
|
||||||
Color chartSeriesColor[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)
|
// 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 serialize(uint8_t* buf) const;
|
||||||
void deserialize(const uint8_t* buf);
|
void deserialize(const uint8_t* buf);
|
||||||
@ -175,6 +283,8 @@ struct ScreenConfig {
|
|||||||
char name[MAX_SCREEN_NAME_LEN];
|
char name[MAX_SCREEN_NAME_LEN];
|
||||||
ScreenMode mode;
|
ScreenMode mode;
|
||||||
Color backgroundColor;
|
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;
|
uint8_t widgetCount;
|
||||||
WidgetConfig widgets[MAX_WIDGETS];
|
WidgetConfig widgets[MAX_WIDGETS];
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
#include "WidgetManager.hpp"
|
#include "WidgetManager.hpp"
|
||||||
#include "widgets/WidgetFactory.hpp"
|
#include "widgets/WidgetFactory.hpp"
|
||||||
|
#include "widgets/RoomCardWidget.hpp"
|
||||||
#include "HistoryStore.hpp"
|
#include "HistoryStore.hpp"
|
||||||
#include "SdCard.hpp"
|
#include "SdCard.hpp"
|
||||||
#include "esp_lv_adapter.h"
|
#include "esp_lv_adapter.h"
|
||||||
@ -12,6 +13,7 @@
|
|||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <sys/stat.h>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
|
|
||||||
static const char* TAG = "WidgetMgr";
|
static const char* TAG = "WidgetMgr";
|
||||||
@ -148,6 +150,15 @@ WidgetManager& WidgetManager::instance() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
WidgetManager::WidgetManager() {
|
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
|
// widgets_ is default-initialized to nullptr
|
||||||
portMUX_INITIALIZE(&knxCacheMux_);
|
portMUX_INITIALIZE(&knxCacheMux_);
|
||||||
uiQueue_ = xQueueCreate(UI_EVENT_QUEUE_LEN, sizeof(UiEvent));
|
uiQueue_ = xQueueCreate(UI_EVENT_QUEUE_LEN, sizeof(UiEvent));
|
||||||
@ -155,14 +166,14 @@ WidgetManager::WidgetManager() {
|
|||||||
ESP_LOGE(TAG, "Failed to create UI event queue");
|
ESP_LOGE(TAG, "Failed to create UI event queue");
|
||||||
}
|
}
|
||||||
createDefaultConfig();
|
createDefaultConfig();
|
||||||
activeScreenId_ = config_.startScreenId;
|
activeScreenId_ = config_->startScreenId;
|
||||||
lastActivityUs_ = esp_timer_get_time();
|
lastActivityUs_ = esp_timer_get_time();
|
||||||
}
|
}
|
||||||
|
|
||||||
void WidgetManager::createDefaultConfig() {
|
void WidgetManager::createDefaultConfig() {
|
||||||
config_.clear();
|
config_->clear();
|
||||||
config_.screenCount = 1;
|
config_->screenCount = 1;
|
||||||
ScreenConfig& screen = config_.screens[0];
|
ScreenConfig& screen = config_->screens[0];
|
||||||
screen.clear(0, "Screen 1");
|
screen.clear(0, "Screen 1");
|
||||||
|
|
||||||
// Default: Temperature label
|
// Default: Temperature label
|
||||||
@ -178,26 +189,26 @@ void WidgetManager::createDefaultConfig() {
|
|||||||
|
|
||||||
ensureButtonLabels(screen);
|
ensureButtonLabels(screen);
|
||||||
|
|
||||||
config_.startScreenId = screen.id;
|
config_->startScreenId = screen.id;
|
||||||
config_.standbyEnabled = false;
|
config_->standbyEnabled = false;
|
||||||
config_.standbyScreenId = 0xFF;
|
config_->standbyScreenId = 0xFF;
|
||||||
config_.standbyMinutes = 0;
|
config_->standbyMinutes = 0;
|
||||||
activeScreenId_ = screen.id;
|
activeScreenId_ = screen.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
void WidgetManager::init() {
|
void WidgetManager::init() {
|
||||||
loadFromSdCard();
|
loadFromSdCard();
|
||||||
HistoryStore::instance().configureFromConfig(config_);
|
HistoryStore::instance().configureFromConfig(*config_);
|
||||||
HistoryStore::instance().loadFromSdCard();
|
HistoryStore::instance().loadFromSdCard();
|
||||||
if (config_.findScreen(config_.startScreenId)) {
|
if (config_->findScreen(config_->startScreenId)) {
|
||||||
activeScreenId_ = config_.startScreenId;
|
activeScreenId_ = config_->startScreenId;
|
||||||
} else if (config_.screenCount > 0) {
|
} else if (config_->screenCount > 0) {
|
||||||
activeScreenId_ = config_.screens[0].id;
|
activeScreenId_ = config_->screens[0].id;
|
||||||
} else {
|
} else {
|
||||||
activeScreenId_ = 0;
|
activeScreenId_ = 0;
|
||||||
}
|
}
|
||||||
lastActivityUs_ = esp_timer_get_time();
|
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() {
|
void WidgetManager::loadFromSdCard() {
|
||||||
@ -237,7 +248,7 @@ void WidgetManager::loadFromSdCard() {
|
|||||||
delete[] json;
|
delete[] json;
|
||||||
|
|
||||||
if (success) {
|
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 {
|
} else {
|
||||||
ESP_LOGE(TAG, "Failed to parse config file");
|
ESP_LOGE(TAG, "Failed to parse config file");
|
||||||
}
|
}
|
||||||
@ -269,19 +280,19 @@ void WidgetManager::saveToSdCard() {
|
|||||||
delete[] json;
|
delete[] json;
|
||||||
|
|
||||||
if (written > 0) {
|
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 {
|
} else {
|
||||||
ESP_LOGE(TAG, "Failed to write config file");
|
ESP_LOGE(TAG, "Failed to write config file");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void WidgetManager::applyConfig() {
|
void WidgetManager::applyConfig() {
|
||||||
HistoryStore::instance().configureFromConfig(config_);
|
HistoryStore::instance().configureFromConfig(*config_);
|
||||||
if (!config_.findScreen(activeScreenId_)) {
|
if (!config_->findScreen(activeScreenId_)) {
|
||||||
if (config_.findScreen(config_.startScreenId)) {
|
if (config_->findScreen(config_->startScreenId)) {
|
||||||
activeScreenId_ = config_.startScreenId;
|
activeScreenId_ = config_->startScreenId;
|
||||||
} else if (config_.screenCount > 0) {
|
} else if (config_->screenCount > 0) {
|
||||||
activeScreenId_ = config_.screens[0].id;
|
activeScreenId_ = config_->screens[0].id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
applyScreen(activeScreenId_);
|
applyScreen(activeScreenId_);
|
||||||
@ -306,16 +317,16 @@ void WidgetManager::resetToDefaults() {
|
|||||||
|
|
||||||
ScreenConfig* WidgetManager::activeScreen() {
|
ScreenConfig* WidgetManager::activeScreen() {
|
||||||
if (modalContainer_ && modalScreenId_ != SCREEN_ID_NONE) {
|
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 {
|
const ScreenConfig* WidgetManager::activeScreen() const {
|
||||||
if (modalContainer_ && modalScreenId_ != SCREEN_ID_NONE) {
|
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 {
|
const ScreenConfig* WidgetManager::currentScreen() const {
|
||||||
@ -334,7 +345,7 @@ void WidgetManager::applyScreen(uint8_t screenId) {
|
|||||||
void WidgetManager::applyScreenLocked(uint8_t screenId) {
|
void WidgetManager::applyScreenLocked(uint8_t screenId) {
|
||||||
ESP_LOGI(TAG, "applyScreen(%d) - start", screenId);
|
ESP_LOGI(TAG, "applyScreen(%d) - start", screenId);
|
||||||
|
|
||||||
ScreenConfig* screen = config_.findScreen(screenId);
|
ScreenConfig* screen = config_->findScreen(screenId);
|
||||||
if (!screen) {
|
if (!screen) {
|
||||||
ESP_LOGW(TAG, "Screen %d not found", screenId);
|
ESP_LOGW(TAG, "Screen %d not found", screenId);
|
||||||
return;
|
return;
|
||||||
@ -525,7 +536,7 @@ void WidgetManager::closeModalLocked() {
|
|||||||
void WidgetManager::showScreenLocked(uint8_t screenId) {
|
void WidgetManager::showScreenLocked(uint8_t screenId) {
|
||||||
ESP_LOGI(TAG, "showScreen(%d) called", screenId);
|
ESP_LOGI(TAG, "showScreen(%d) called", screenId);
|
||||||
|
|
||||||
ScreenConfig* screen = config_.findScreen(screenId);
|
ScreenConfig* screen = config_->findScreen(screenId);
|
||||||
if (!screen) {
|
if (!screen) {
|
||||||
ESP_LOGW(TAG, "Screen %d not found", screenId);
|
ESP_LOGW(TAG, "Screen %d not found", screenId);
|
||||||
return;
|
return;
|
||||||
@ -602,12 +613,12 @@ void WidgetManager::goBackLocked() {
|
|||||||
printf("WM: Modal closed. Restoring screen %d\n", activeScreenId_);
|
printf("WM: Modal closed. Restoring screen %d\n", activeScreenId_);
|
||||||
fflush(stdout);
|
fflush(stdout);
|
||||||
// Restore the active screen (which was in background)
|
// Restore the active screen (which was in background)
|
||||||
if (config_.findScreen(activeScreenId_)) {
|
if (config_->findScreen(activeScreenId_)) {
|
||||||
applyScreenLocked(activeScreenId_);
|
applyScreenLocked(activeScreenId_);
|
||||||
} else {
|
} else {
|
||||||
ESP_LOGE(TAG, "Active screen %d not found after closing modal!", activeScreenId_);
|
ESP_LOGE(TAG, "Active screen %d not found after closing modal!", activeScreenId_);
|
||||||
if (config_.findScreen(config_.startScreenId)) {
|
if (config_->findScreen(config_->startScreenId)) {
|
||||||
activeScreenId_ = config_.startScreenId;
|
activeScreenId_ = config_->startScreenId;
|
||||||
applyScreenLocked(activeScreenId_);
|
applyScreenLocked(activeScreenId_);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -617,7 +628,7 @@ void WidgetManager::goBackLocked() {
|
|||||||
if (previousScreenId_ != SCREEN_ID_NONE && previousScreenId_ != activeScreenId_) {
|
if (previousScreenId_ != SCREEN_ID_NONE && previousScreenId_ != activeScreenId_) {
|
||||||
printf("WM: Going back to screen %d\n", previousScreenId_);
|
printf("WM: Going back to screen %d\n", previousScreenId_);
|
||||||
fflush(stdout);
|
fflush(stdout);
|
||||||
if (config_.findScreen(previousScreenId_)) {
|
if (config_->findScreen(previousScreenId_)) {
|
||||||
activeScreenId_ = previousScreenId_;
|
activeScreenId_ = previousScreenId_;
|
||||||
previousScreenId_ = SCREEN_ID_NONE;
|
previousScreenId_ = SCREEN_ID_NONE;
|
||||||
applyScreenLocked(activeScreenId_);
|
applyScreenLocked(activeScreenId_);
|
||||||
@ -637,12 +648,33 @@ void WidgetManager::goBack() {
|
|||||||
esp_lv_adapter_unlock();
|
esp_lv_adapter_unlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
void WidgetManager::enterStandby() {
|
void WidgetManager::navigateToScreen(uint8_t screenId) {
|
||||||
if (!config_.standbyEnabled || config_.standbyMinutes == 0) return;
|
navAction_ = ButtonAction::JUMP;
|
||||||
if (standbyActive_) return;
|
navTargetScreen_ = screenId;
|
||||||
if (config_.standbyScreenId == SCREEN_ID_NONE) return;
|
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;
|
if (!standbyScreen) return;
|
||||||
|
|
||||||
standbyReturnScreenId_ = activeScreenId_;
|
standbyReturnScreenId_ = activeScreenId_;
|
||||||
@ -692,13 +724,13 @@ void WidgetManager::loop() {
|
|||||||
|
|
||||||
if (didUiNav) return;
|
if (didUiNav) return;
|
||||||
|
|
||||||
if (!config_.standbyEnabled || config_.standbyMinutes == 0) return;
|
if (!config_->standbyEnabled || config_->standbyMinutes == 0) return;
|
||||||
if (standbyActive_) 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 now = esp_timer_get_time();
|
||||||
int64_t idleUs = now - lastActivityUs_;
|
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) {
|
if (idleUs >= timeoutUs) {
|
||||||
enterStandby();
|
enterStandby();
|
||||||
}
|
}
|
||||||
@ -710,7 +742,7 @@ void WidgetManager::onUserActivity() {
|
|||||||
standbyActive_ = false;
|
standbyActive_ = false;
|
||||||
uint8_t returnId = standbyReturnScreenId_;
|
uint8_t returnId = standbyReturnScreenId_;
|
||||||
if (returnId == SCREEN_ID_NONE) {
|
if (returnId == SCREEN_ID_NONE) {
|
||||||
returnId = config_.startScreenId;
|
returnId = config_->startScreenId;
|
||||||
}
|
}
|
||||||
standbyWakeTarget_ = returnId;
|
standbyWakeTarget_ = returnId;
|
||||||
standbyWakePending_ = true;
|
standbyWakePending_ = true;
|
||||||
@ -738,6 +770,52 @@ void WidgetManager::createAllWidgets(const ScreenConfig& screen, lv_obj_t* paren
|
|||||||
screen.backgroundColor.b), 0);
|
screen.backgroundColor.b), 0);
|
||||||
lv_obj_set_style_bg_opa(parent, LV_OPA_COVER, 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)
|
// Pass 1: Create root widgets (parentId == -1)
|
||||||
for (uint8_t i = 0; i < screen.widgetCount; i++) {
|
for (uint8_t i = 0; i < screen.widgetCount; i++) {
|
||||||
const WidgetConfig& cfg = screen.widgets[i];
|
const WidgetConfig& cfg = screen.widgets[i];
|
||||||
@ -884,12 +962,11 @@ void WidgetManager::applyCachedValuesToWidgets() {
|
|||||||
for (auto& widget : widgets_) {
|
for (auto& widget : widgets_) {
|
||||||
if (!widget) continue;
|
if (!widget) continue;
|
||||||
|
|
||||||
|
// Primary address
|
||||||
uint16_t addr = widget->getKnxAddress();
|
uint16_t addr = widget->getKnxAddress();
|
||||||
if (addr == 0) continue;
|
|
||||||
|
|
||||||
TextSource source = widget->getTextSource();
|
TextSource source = widget->getTextSource();
|
||||||
if (source == TextSource::STATIC) continue;
|
|
||||||
|
|
||||||
|
if (addr != 0 && source != TextSource::STATIC) {
|
||||||
if (source == TextSource::KNX_DPT_TIME ||
|
if (source == TextSource::KNX_DPT_TIME ||
|
||||||
source == TextSource::KNX_DPT_DATE ||
|
source == TextSource::KNX_DPT_DATE ||
|
||||||
source == TextSource::KNX_DPT_DATETIME) {
|
source == TextSource::KNX_DPT_DATETIME) {
|
||||||
@ -903,32 +980,44 @@ void WidgetManager::applyCachedValuesToWidgets() {
|
|||||||
if (getCachedKnxTime(addr, type, &tmValue)) {
|
if (getCachedKnxTime(addr, type, &tmValue)) {
|
||||||
widget->onKnxTime(tmValue, source);
|
widget->onKnxTime(tmValue, source);
|
||||||
}
|
}
|
||||||
continue;
|
} else if (source == TextSource::KNX_DPT_SWITCH) {
|
||||||
}
|
|
||||||
|
|
||||||
if (source == TextSource::KNX_DPT_SWITCH) {
|
|
||||||
bool state = false;
|
bool state = false;
|
||||||
if (getCachedKnxSwitch(addr, &state)) {
|
if (getCachedKnxSwitch(addr, &state)) {
|
||||||
widget->onKnxSwitch(state);
|
widget->onKnxSwitch(state);
|
||||||
}
|
}
|
||||||
continue;
|
} else if (source == TextSource::KNX_DPT_TEXT) {
|
||||||
}
|
|
||||||
|
|
||||||
if (source == TextSource::KNX_DPT_TEXT) {
|
|
||||||
char text[MAX_TEXT_LEN] = {};
|
char text[MAX_TEXT_LEN] = {};
|
||||||
if (getCachedKnxText(addr, text, sizeof(text))) {
|
if (getCachedKnxText(addr, text, sizeof(text))) {
|
||||||
widget->onKnxText(text);
|
widget->onKnxText(text);
|
||||||
}
|
}
|
||||||
continue;
|
} else if (isNumericTextSource(source)) {
|
||||||
}
|
|
||||||
|
|
||||||
if (isNumericTextSource(source)) {
|
|
||||||
float value = 0.0f;
|
float value = 0.0f;
|
||||||
if (getCachedKnxValue(addr, source, &value)) {
|
if (getCachedKnxValue(addr, source, &value)) {
|
||||||
widget->onKnxValue(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() {
|
void WidgetManager::refreshChartWidgetsLocked() {
|
||||||
@ -972,10 +1061,25 @@ void WidgetManager::updateSystemTimeWidgets() {
|
|||||||
|
|
||||||
void WidgetManager::applyKnxValue(uint16_t groupAddr, float value, TextSource source) {
|
void WidgetManager::applyKnxValue(uint16_t groupAddr, float value, TextSource source) {
|
||||||
for (auto& widget : widgets_) {
|
for (auto& widget : widgets_) {
|
||||||
if (widget && widget->getKnxAddress() == groupAddr &&
|
if (!widget) continue;
|
||||||
|
|
||||||
|
// Primary address (bottom value)
|
||||||
|
if (widget->getKnxAddress() == groupAddr &&
|
||||||
widget->getTextSource() == source) {
|
widget->getTextSource() == source) {
|
||||||
widget->onKnxValue(value);
|
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)) {
|
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) {
|
void WidgetManager::applyKnxSwitch(uint16_t groupAddr, bool value) {
|
||||||
for (auto& widget : widgets_) {
|
for (auto& widget : widgets_) {
|
||||||
if (widget && widget->getKnxAddress() == groupAddr) {
|
if (!widget) continue;
|
||||||
|
|
||||||
|
if (widget->getKnxAddress() == groupAddr) {
|
||||||
widget->onKnxSwitch(value);
|
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;
|
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) {
|
void WidgetManager::applyKnxTime(uint16_t groupAddr, const struct tm& value, KnxTimeType type) {
|
||||||
// Simplified system time synchronization
|
// Simplified system time synchronization
|
||||||
bool isGlobalTime = false;
|
bool isGlobalTime = false;
|
||||||
if (type == KnxTimeType::TIME && config_.knxTimeAddress != 0 && groupAddr == config_.knxTimeAddress) 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::DATE && config_->knxDateAddress != 0 && groupAddr == config_->knxDateAddress) isGlobalTime = true;
|
||||||
if (type == KnxTimeType::DATETIME && config_.knxDateTimeAddress != 0 && groupAddr == config_.knxDateTimeAddress) isGlobalTime = true;
|
if (type == KnxTimeType::DATETIME && config_->knxDateTimeAddress != 0 && groupAddr == config_->knxDateTimeAddress) isGlobalTime = true;
|
||||||
|
|
||||||
if (isGlobalTime) {
|
if (isGlobalTime) {
|
||||||
time_t now;
|
time_t now;
|
||||||
@ -1281,23 +1397,23 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
cJSON_AddNumberToObject(root, "startScreen", config_.startScreenId);
|
cJSON_AddNumberToObject(root, "startScreen", config_->startScreenId);
|
||||||
|
|
||||||
cJSON* standby = cJSON_AddObjectToObject(root, "standby");
|
cJSON* standby = cJSON_AddObjectToObject(root, "standby");
|
||||||
cJSON_AddBoolToObject(standby, "enabled", config_.standbyEnabled);
|
cJSON_AddBoolToObject(standby, "enabled", config_->standbyEnabled);
|
||||||
cJSON_AddNumberToObject(standby, "screen", config_.standbyScreenId);
|
cJSON_AddNumberToObject(standby, "screen", config_->standbyScreenId);
|
||||||
cJSON_AddNumberToObject(standby, "minutes", config_.standbyMinutes);
|
cJSON_AddNumberToObject(standby, "minutes", config_->standbyMinutes);
|
||||||
|
|
||||||
cJSON* knx = cJSON_AddObjectToObject(root, "knx");
|
cJSON* knx = cJSON_AddObjectToObject(root, "knx");
|
||||||
cJSON_AddNumberToObject(knx, "time", config_.knxTimeAddress);
|
cJSON_AddNumberToObject(knx, "time", config_->knxTimeAddress);
|
||||||
cJSON_AddNumberToObject(knx, "date", config_.knxDateAddress);
|
cJSON_AddNumberToObject(knx, "date", config_->knxDateAddress);
|
||||||
cJSON_AddNumberToObject(knx, "dateTime", config_.knxDateTimeAddress);
|
cJSON_AddNumberToObject(knx, "dateTime", config_->knxDateTimeAddress);
|
||||||
cJSON_AddNumberToObject(knx, "night", config_.knxNightModeAddress);
|
cJSON_AddNumberToObject(knx, "night", config_->knxNightModeAddress);
|
||||||
|
|
||||||
cJSON* screens = cJSON_AddArrayToObject(root, "screens");
|
cJSON* screens = cJSON_AddArrayToObject(root, "screens");
|
||||||
|
|
||||||
for (uint8_t s = 0; s < config_.screenCount; s++) {
|
for (uint8_t s = 0; s < config_->screenCount; s++) {
|
||||||
const ScreenConfig& screen = config_.screens[s];
|
const ScreenConfig& screen = config_->screens[s];
|
||||||
cJSON* screenJson = cJSON_CreateObject();
|
cJSON* screenJson = cJSON_CreateObject();
|
||||||
|
|
||||||
cJSON_AddNumberToObject(screenJson, "id", screen.id);
|
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);
|
screen.backgroundColor.r, screen.backgroundColor.g, screen.backgroundColor.b);
|
||||||
cJSON_AddStringToObject(screenJson, "bgColor", bgColorStr);
|
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
|
// Modal-specific properties
|
||||||
if (screen.mode == ScreenMode::MODAL) {
|
if (screen.mode == ScreenMode::MODAL) {
|
||||||
cJSON* modal = cJSON_AddObjectToObject(screenJson, "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);
|
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) {
|
if (w.type == WidgetType::CHART) {
|
||||||
cJSON* chart = cJSON_AddObjectToObject(widget, "chart");
|
cJSON* chart = cJSON_AddObjectToObject(widget, "chart");
|
||||||
cJSON_AddNumberToObject(chart, "period", w.chartPeriod);
|
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);
|
cJSON_AddItemToArray(widgets, widget);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1563,6 +1785,104 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
|||||||
w.parentId = -1; // Default to root
|
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");
|
cJSON* chart = cJSON_GetObjectItem(widget, "chart");
|
||||||
if (cJSON_IsObject(chart)) {
|
if (cJSON_IsObject(chart)) {
|
||||||
cJSON* period = cJSON_GetObjectItem(chart, "period");
|
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++;
|
screen.widgetCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1646,6 +2037,22 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
|||||||
screen.backgroundColor = Color::fromHex(parseHexColor(bgColor->valuestring));
|
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
|
// Parse modal-specific properties
|
||||||
cJSON* modal = cJSON_GetObjectItem(screenJson, "modal");
|
cJSON* modal = cJSON_GetObjectItem(screenJson, "modal");
|
||||||
if (cJSON_IsObject(modal)) {
|
if (cJSON_IsObject(modal)) {
|
||||||
@ -1753,8 +2160,8 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
|||||||
newConfig->standbyScreenId = SCREEN_ID_NONE;
|
newConfig->standbyScreenId = SCREEN_ID_NONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
config_ = *newConfig;
|
*config_ = *newConfig;
|
||||||
cJSON_Delete(root);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
@ -59,9 +59,16 @@ public:
|
|||||||
void handleButtonAction(const WidgetConfig& cfg, lv_obj_t* target);
|
void handleButtonAction(const WidgetConfig& cfg, lv_obj_t* target);
|
||||||
void goBack();
|
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
|
// Direct config access
|
||||||
GuiConfig& getConfig() { return config_; }
|
GuiConfig& getConfig() { return *config_; }
|
||||||
const GuiConfig& getConfig() const { return config_; }
|
const GuiConfig& getConfig() const { return *config_; }
|
||||||
const ScreenConfig* currentScreen() const;
|
const ScreenConfig* currentScreen() const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@ -158,7 +165,7 @@ private:
|
|||||||
static constexpr const char* CONFIG_FILE = "/sdcard/lvgl.json";
|
static constexpr const char* CONFIG_FILE = "/sdcard/lvgl.json";
|
||||||
static constexpr int64_t NAV_DELAY_US = 10 * 1000; // 10ms delay (almost immediate)
|
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 activeScreenId_ = 0;
|
||||||
uint8_t previousScreenId_ = 0xFF;
|
uint8_t previousScreenId_ = 0xFF;
|
||||||
uint8_t standbyReturnScreenId_ = 0xFF;
|
uint8_t standbyReturnScreenId_ = 0xFF;
|
||||||
|
|||||||
@ -52,7 +52,7 @@ public:
|
|||||||
1280, // Vertical resolution
|
1280, // Vertical resolution
|
||||||
ESP_LV_ADAPTER_ROTATE_90 // Rotation
|
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;
|
disp_cfg.profile.use_psram = true;
|
||||||
lv_disp_t* lv_display = esp_lv_adapter_register_display(&disp_cfg);
|
lv_disp_t* lv_display = esp_lv_adapter_register_display(&disp_cfg);
|
||||||
assert(lv_display != NULL);
|
assert(lv_display != NULL);
|
||||||
|
|||||||
@ -31,5 +31,14 @@ esp_err_t WebServer::imagesHandler(httpd_req_t* req) {
|
|||||||
snprintf(filepath, sizeof(filepath), "/sdcard%.*s",
|
snprintf(filepath, sizeof(filepath), "/sdcard%.*s",
|
||||||
(int)(sizeof(filepath) - 8), req->uri);
|
(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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,8 @@ PowerNodeWidget::PowerNodeWidget(const WidgetConfig& config)
|
|||||||
{
|
{
|
||||||
labelText_[0] = '\0';
|
labelText_[0] = '\0';
|
||||||
valueFormat_[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) {
|
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() {
|
void PowerNodeWidget::parseText() {
|
||||||
labelText_[0] = '\0';
|
labelText_[0] = '\0';
|
||||||
valueFormat_[0] = '\0';
|
valueFormat_[0] = '\0';
|
||||||
|
leftFormat_[0] = '\0';
|
||||||
|
rightFormat_[0] = '\0';
|
||||||
|
|
||||||
|
// Parse primary text (label\nformat)
|
||||||
const char* text = config_.text;
|
const char* text = config_.text;
|
||||||
if (!text || text[0] == '\0') return;
|
if (text && text[0] != '\0') {
|
||||||
|
|
||||||
const char* newline = strchr(text, '\n');
|
const char* newline = strchr(text, '\n');
|
||||||
if (newline) {
|
if (newline) {
|
||||||
size_t labelLen = static_cast<size_t>(newline - text);
|
size_t labelLen = static_cast<size_t>(newline - text);
|
||||||
@ -83,6 +87,19 @@ void PowerNodeWidget::parseText() {
|
|||||||
strncpy(labelText_, text, MAX_TEXT_LEN - 1);
|
strncpy(labelText_, text, MAX_TEXT_LEN - 1);
|
||||||
labelText_[MAX_TEXT_LEN - 1] = '\0';
|
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) {
|
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_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_all(obj_, 6, 0);
|
||||||
lv_obj_set_style_pad_gap(obj_, 2, 0);
|
lv_obj_set_style_pad_gap(obj_, 2, 0);
|
||||||
|
|
||||||
|
// Top label (title)
|
||||||
if (labelText_[0] != '\0') {
|
if (labelText_[0] != '\0') {
|
||||||
labelLabel_ = lv_label_create(obj_);
|
labelLabel_ = lv_label_create(obj_);
|
||||||
set_obj_name(labelLabel_, "PowerNode", config_.id, "label");
|
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);
|
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()) {
|
if (config_.iconCodepoint > 0 && Fonts::hasIconFont()) {
|
||||||
iconLabel_ = lv_label_create(obj_);
|
iconLabel_ = lv_label_create(obj_);
|
||||||
set_obj_name(iconLabel_, "PowerNode", config_.id, "icon");
|
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_label_set_text(iconLabel_, iconText);
|
||||||
lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE);
|
lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom value label (primary)
|
||||||
valueLabel_ = lv_label_create(obj_);
|
valueLabel_ = lv_label_create(obj_);
|
||||||
set_obj_name(valueLabel_, "PowerNode", config_.id, "value");
|
set_obj_name(valueLabel_, "PowerNode", config_.id, "value");
|
||||||
if (valueFormat_[0] != '\0') {
|
if (valueFormat_[0] != '\0') {
|
||||||
@ -153,6 +215,7 @@ void PowerNodeWidget::applyStyle() {
|
|||||||
|
|
||||||
uint8_t valueSizeIdx = config_.fontSize;
|
uint8_t valueSizeIdx = config_.fontSize;
|
||||||
uint8_t labelSizeIdx = valueSizeIdx > 0 ? static_cast<uint8_t>(valueSizeIdx - 1) : valueSizeIdx;
|
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) {
|
if (labelLabel_ != nullptr) {
|
||||||
lv_obj_set_style_text_color(labelLabel_, lv_color_make(
|
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);
|
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) {
|
if (valueLabel_ != nullptr) {
|
||||||
lv_obj_set_style_text_color(valueLabel_, lv_color_make(
|
lv_obj_set_style_text_color(valueLabel_, lv_color_make(
|
||||||
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
|
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_font(valueLabel_, Fonts::bySizeIndex(valueSizeIdx), 0);
|
||||||
lv_obj_set_style_text_align(valueLabel_, LV_TEXT_ALIGN_CENTER, 0);
|
lv_obj_set_style_text_align(valueLabel_, LV_TEXT_ALIGN_CENTER, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void PowerNodeWidget::updateValueText(const char* text) {
|
void PowerNodeWidget::updateValueText(const char* text) {
|
||||||
@ -184,26 +260,86 @@ void PowerNodeWidget::updateValueText(const char* text) {
|
|||||||
set_label_text_if_changed(valueLabel_, 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) {
|
void PowerNodeWidget::onKnxValue(float value) {
|
||||||
|
// Cache and call base for condition evaluation
|
||||||
|
cachedPrimaryValue_ = value;
|
||||||
|
hasCachedPrimary_ = true;
|
||||||
|
|
||||||
if (valueLabel_ == nullptr) return;
|
if (valueLabel_ == nullptr) return;
|
||||||
if (config_.textSource != TextSource::KNX_DPT_TEMP &&
|
if (!isNumericSource(config_.textSource)) return;
|
||||||
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;
|
|
||||||
|
|
||||||
char buf[32];
|
char buf[32];
|
||||||
const char* fmt = valueFormat_[0] != '\0' ? valueFormat_ : "%0.1f";
|
formatValue(buf, sizeof(buf), value, valueFormat_, config_.textSource);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateValueText(buf);
|
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) {
|
void PowerNodeWidget::onKnxSwitch(bool value) {
|
||||||
@ -217,3 +353,99 @@ void PowerNodeWidget::onKnxText(const char* text) {
|
|||||||
if (config_.textSource != TextSource::KNX_DPT_TEXT) return;
|
if (config_.textSource != TextSource::KNX_DPT_TEXT) return;
|
||||||
updateValueText(text);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -8,18 +8,29 @@ public:
|
|||||||
lv_obj_t* create(lv_obj_t* parent) override;
|
lv_obj_t* create(lv_obj_t* parent) override;
|
||||||
void applyStyle() override;
|
void applyStyle() override;
|
||||||
void onKnxValue(float value) override;
|
void onKnxValue(float value) override;
|
||||||
|
void onKnxValue2(float value) override;
|
||||||
|
void onKnxValue3(float value) override;
|
||||||
void onKnxSwitch(bool value) override;
|
void onKnxSwitch(bool value) override;
|
||||||
void onKnxText(const char* text) override;
|
void onKnxText(const char* text) override;
|
||||||
|
bool evaluateConditions(float primaryValue, float secondaryValue, float tertiaryValue) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
lv_obj_t* labelLabel_ = nullptr;
|
lv_obj_t* labelLabel_ = nullptr; // Top label (title)
|
||||||
lv_obj_t* valueLabel_ = nullptr;
|
lv_obj_t* middleRow_ = nullptr; // Horizontal container for left-icon-right
|
||||||
lv_obj_t* iconLabel_ = nullptr;
|
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 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 parseText();
|
||||||
void updateValueText(const char* text);
|
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);
|
static int encodeUtf8(uint32_t codepoint, char* buf);
|
||||||
};
|
};
|
||||||
|
|||||||
335
main/widgets/RoomCardWidget.cpp
Normal file
335
main/widgets/RoomCardWidget.cpp
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
main/widgets/RoomCardWidget.hpp
Normal file
49
main/widgets/RoomCardWidget.hpp
Normal 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);
|
||||||
|
};
|
||||||
@ -26,8 +26,22 @@ void Widget::applyStyle() {
|
|||||||
applyCommonStyle();
|
applyCommonStyle();
|
||||||
}
|
}
|
||||||
|
|
||||||
void Widget::onKnxValue(float /*value*/) {
|
void Widget::onKnxValue(float value) {
|
||||||
// Default: do nothing
|
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*/) {
|
void Widget::onKnxSwitch(bool /*value*/) {
|
||||||
@ -46,6 +60,107 @@ void Widget::onHistoryUpdate() {
|
|||||||
// Default: do nothing
|
// 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() {
|
void Widget::applyCommonStyle() {
|
||||||
if (obj_ == nullptr) return;
|
if (obj_ == nullptr) return;
|
||||||
|
|
||||||
|
|||||||
@ -26,9 +26,13 @@ public:
|
|||||||
|
|
||||||
// KNX group address for read binding
|
// KNX group address for read binding
|
||||||
uint16_t getKnxAddress() const { return config_.knxAddress; }
|
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 for KNX callback filtering
|
||||||
TextSource getTextSource() const { return config_.textSource; }
|
TextSource getTextSource() const { return config_.textSource; }
|
||||||
|
TextSource getTextSource2() const { return config_.textSource2; }
|
||||||
|
TextSource getTextSource3() const { return config_.textSource3; }
|
||||||
|
|
||||||
// Widget type
|
// Widget type
|
||||||
WidgetType getType() const { return config_.type; }
|
WidgetType getType() const { return config_.type; }
|
||||||
@ -44,17 +48,39 @@ public:
|
|||||||
|
|
||||||
// KNX callbacks - default implementations do nothing
|
// KNX callbacks - default implementations do nothing
|
||||||
virtual void onKnxValue(float value);
|
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 onKnxSwitch(bool value);
|
||||||
virtual void onKnxText(const char* text);
|
virtual void onKnxText(const char* text);
|
||||||
virtual void onKnxTime(const struct tm& value, TextSource source);
|
virtual void onKnxTime(const struct tm& value, TextSource source);
|
||||||
virtual void onHistoryUpdate();
|
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:
|
protected:
|
||||||
// Common style helper functions
|
// Common style helper functions
|
||||||
void applyCommonStyle();
|
void applyCommonStyle();
|
||||||
void applyShadowStyle();
|
void applyShadowStyle();
|
||||||
|
void applyConditionStyle(const ConditionStyle& style);
|
||||||
static const lv_font_t* getFontBySize(uint8_t sizeIndex);
|
static const lv_font_t* getFontBySize(uint8_t sizeIndex);
|
||||||
|
|
||||||
const WidgetConfig& config_;
|
const WidgetConfig& config_;
|
||||||
lv_obj_t* obj_ = nullptr;
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
#include "PowerLinkWidget.hpp"
|
#include "PowerLinkWidget.hpp"
|
||||||
#include "ChartWidget.hpp"
|
#include "ChartWidget.hpp"
|
||||||
#include "ClockWidget.hpp"
|
#include "ClockWidget.hpp"
|
||||||
|
#include "RoomCardWidget.hpp"
|
||||||
|
|
||||||
std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
|
std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
|
||||||
if (!config.visible) return nullptr;
|
if (!config.visible) return nullptr;
|
||||||
@ -37,6 +38,8 @@ std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
|
|||||||
return std::make_unique<ChartWidget>(config);
|
return std::make_unique<ChartWidget>(config);
|
||||||
case WidgetType::CLOCK:
|
case WidgetType::CLOCK:
|
||||||
return std::make_unique<ClockWidget>(config);
|
return std::make_unique<ClockWidget>(config);
|
||||||
|
case WidgetType::ROOMCARD:
|
||||||
|
return std::make_unique<RoomCardWidget>(config);
|
||||||
default:
|
default:
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1485,7 +1485,7 @@ CONFIG_SPIRAM_USE_MALLOC=y
|
|||||||
CONFIG_SPIRAM_MEMTEST=y
|
CONFIG_SPIRAM_MEMTEST=y
|
||||||
CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=16384
|
CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=16384
|
||||||
# CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP is not set
|
# 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_BSS_SEG_EXTERNAL_MEMORY=y
|
||||||
# CONFIG_SPIRAM_ALLOW_NOINIT_SEG_EXTERNAL_MEMORY is not set
|
# CONFIG_SPIRAM_ALLOW_NOINIT_SEG_EXTERNAL_MEMORY is not set
|
||||||
# end of PSRAM config
|
# end of PSRAM config
|
||||||
@ -3169,7 +3169,10 @@ CONFIG_LV_USE_GRID=y
|
|||||||
#
|
#
|
||||||
CONFIG_LV_FS_DEFAULT_DRIVER_LETTER=0
|
CONFIG_LV_FS_DEFAULT_DRIVER_LETTER=0
|
||||||
# CONFIG_LV_USE_FS_STDIO is not set
|
# 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_WIN32 is not set
|
||||||
# CONFIG_LV_USE_FS_FATFS is not set
|
# CONFIG_LV_USE_FS_FATFS is not set
|
||||||
# CONFIG_LV_USE_FS_MEMFS 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_LODEPNG is not set
|
||||||
# CONFIG_LV_USE_LIBPNG is not set
|
# CONFIG_LV_USE_LIBPNG is not set
|
||||||
# CONFIG_LV_USE_BMP 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_LIBJPEG_TURBO is not set
|
||||||
# CONFIG_LV_USE_GIF is not set
|
# CONFIG_LV_USE_GIF is not set
|
||||||
# CONFIG_LV_BIN_DECODER_RAM_LOAD is not set
|
# CONFIG_LV_BIN_DECODER_RAM_LOAD is not set
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen flex flex-col">
|
<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]">
|
<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 />
|
<SidebarLeft />
|
||||||
<CanvasArea @open-screen-settings="showScreenSettings = true" />
|
<CanvasArea @open-screen-settings="showScreenSettings = true" />
|
||||||
@ -8,6 +8,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<SettingsModal v-if="showSettings" @close="showSettings = false" />
|
<SettingsModal v-if="showSettings" @close="showSettings = false" />
|
||||||
<ScreenSettingsModal v-if="showScreenSettings" @close="showScreenSettings = false" />
|
<ScreenSettingsModal v-if="showScreenSettings" @close="showScreenSettings = false" />
|
||||||
|
<FileManager v-if="showFiles" @close="showFiles = false" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -19,11 +20,13 @@ import SidebarRight from './components/SidebarRight.vue';
|
|||||||
import CanvasArea from './components/CanvasArea.vue';
|
import CanvasArea from './components/CanvasArea.vue';
|
||||||
import SettingsModal from './components/SettingsModal.vue';
|
import SettingsModal from './components/SettingsModal.vue';
|
||||||
import ScreenSettingsModal from './components/ScreenSettingsModal.vue';
|
import ScreenSettingsModal from './components/ScreenSettingsModal.vue';
|
||||||
|
import FileManager from './components/FileManager.vue';
|
||||||
import { useEditorStore } from './stores/editor';
|
import { useEditorStore } from './stores/editor';
|
||||||
|
|
||||||
const store = useEditorStore();
|
const store = useEditorStore();
|
||||||
const showSettings = ref(false);
|
const showSettings = ref(false);
|
||||||
const showScreenSettings = ref(false);
|
const showScreenSettings = ref(false);
|
||||||
|
const showFiles = ref(false);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.loadConfig();
|
store.loadConfig();
|
||||||
|
|||||||
@ -90,11 +90,37 @@ const canvasH = computed(() => {
|
|||||||
return DISPLAY_H;
|
return DISPLAY_H;
|
||||||
});
|
});
|
||||||
|
|
||||||
const canvasStyle = computed(() => ({
|
const canvasStyle = computed(() => {
|
||||||
|
const style = {
|
||||||
width: `${canvasW.value * store.canvasScale}px`,
|
width: `${canvasW.value * store.canvasScale}px`,
|
||||||
height: `${canvasH.value * store.canvasScale}px`,
|
height: `${canvasH.value * store.canvasScale}px`,
|
||||||
backgroundColor: store.activeScreen?.bgColor || '#1A1A2E'
|
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(() => ({
|
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)',
|
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 newW = Math.round(rawW);
|
||||||
let newH = Math.round(rawH);
|
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 maxSize = Math.min(maxW, maxH);
|
||||||
const size = clamp(Math.max(newW, newH), minSize.w, maxSize);
|
const size = clamp(Math.max(newW, newH), minSize.w, maxSize);
|
||||||
newW = size;
|
newW = size;
|
||||||
|
|||||||
477
web-interface/src/components/FileManager.vue
Normal file
477
web-interface/src/components/FileManager.vue
Normal 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">↑</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">📁</span>
|
||||||
|
<span v-else class="text-lg">📄</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">↓</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 →</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>
|
||||||
@ -25,6 +25,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="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="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>
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- File Picker Modal -->
|
||||||
|
<FileManager
|
||||||
|
v-if="showFilePicker"
|
||||||
|
:select-mode="true"
|
||||||
|
initial-path="/images"
|
||||||
|
@close="showFilePicker = false"
|
||||||
|
@select="handleImageSelect"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useEditorStore } from '../stores/editor';
|
import { useEditorStore } from '../stores/editor';
|
||||||
|
import FileManager from './FileManager.vue';
|
||||||
|
|
||||||
const emit = defineEmits(['close']);
|
const emit = defineEmits(['close']);
|
||||||
const store = useEditorStore();
|
const store = useEditorStore();
|
||||||
|
|
||||||
const screen = computed(() => store.activeScreen);
|
const screen = computed(() => store.activeScreen);
|
||||||
const canDelete = computed(() => store.config.screens.length > 1);
|
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() {
|
function handleDelete() {
|
||||||
if (!canDelete.value) return;
|
if (!canDelete.value) return;
|
||||||
|
|||||||
@ -42,6 +42,10 @@
|
|||||||
<span class="text-[13px] font-semibold">Chart</span>
|
<span class="text-[13px] font-semibold">Chart</span>
|
||||||
<span class="text-[11px] text-muted mt-0.5 block">Verlauf</span>
|
<span class="text-[11px] text-muted mt-0.5 block">Verlauf</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@ -65,7 +65,7 @@
|
|||||||
<h4 :class="headingClass">Icon</h4>
|
<h4 :class="headingClass">Icon</h4>
|
||||||
<div :class="rowClass">
|
<div :class="rowClass">
|
||||||
<label :class="labelClass">Icon</label>
|
<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-if="w.iconCodepoint" class="material-symbols-outlined text-[20px]">{{ String.fromCodePoint(w.iconCodepoint) }}</span>
|
||||||
<span v-else>Auswaehlen</span>
|
<span v-else>Auswaehlen</span>
|
||||||
</button>
|
</button>
|
||||||
@ -180,6 +180,9 @@
|
|||||||
<template v-if="key === 'powernode'">
|
<template v-if="key === 'powernode'">
|
||||||
<h4 :class="headingClass">Power Node</h4>
|
<h4 :class="headingClass">Power Node</h4>
|
||||||
<div :class="rowClass"><label :class="labelClass">Label</label><input :class="inputClass" type="text" v-model="powerNodeLabel"></div>
|
<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>
|
<div :class="rowClass"><label :class="labelClass">Quelle</label>
|
||||||
<select :class="inputClass" v-model.number="w.textSrc" @change="handleTextSrcChange">
|
<select :class="inputClass" v-model.number="w.textSrc" @change="handleTextSrcChange">
|
||||||
<optgroup v-for="group in groupedSources(sourceOptions.powernode)" :key="group.label" :label="group.label">
|
<optgroup v-for="group in groupedSources(sourceOptions.powernode)" :key="group.label" :label="group.label">
|
||||||
@ -201,6 +204,96 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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"><</option>
|
||||||
|
<option value="lte"><=</option>
|
||||||
|
<option value="eq">=</option>
|
||||||
|
<option value="gte">>=</option>
|
||||||
|
<option value="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>
|
||||||
|
|
||||||
<template v-if="key === 'chart'">
|
<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>
|
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="w.radius"></div>
|
||||||
</template>
|
</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 -->
|
<!-- Typography -->
|
||||||
<template v-if="key === 'label'">
|
<template v-if="key === 'label'">
|
||||||
<h4 :class="headingClass">Typo</h4>
|
<h4 :class="headingClass">Typo</h4>
|
||||||
@ -275,7 +504,7 @@
|
|||||||
<h4 :class="headingClass">Icon</h4>
|
<h4 :class="headingClass">Icon</h4>
|
||||||
<div :class="rowClass">
|
<div :class="rowClass">
|
||||||
<label :class="labelClass">Icon</label>
|
<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-if="w.iconCodepoint" class="material-symbols-outlined text-[20px]">{{ String.fromCodePoint(w.iconCodepoint) }}</span>
|
||||||
<span v-else>Kein Icon</span>
|
<span v-else>Kein Icon</span>
|
||||||
</button>
|
</button>
|
||||||
@ -308,7 +537,7 @@
|
|||||||
<h4 :class="headingClass">Icon</h4>
|
<h4 :class="headingClass">Icon</h4>
|
||||||
<div :class="rowClass">
|
<div :class="rowClass">
|
||||||
<label :class="labelClass">Icon</label>
|
<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-if="w.iconCodepoint" class="material-symbols-outlined text-[20px]">{{ String.fromCodePoint(w.iconCodepoint) }}</span>
|
||||||
<span v-else>Kein Icon</span>
|
<span v-else>Kein Icon</span>
|
||||||
</button>
|
</button>
|
||||||
@ -412,8 +641,8 @@
|
|||||||
<!-- Icon Picker Modal -->
|
<!-- Icon Picker Modal -->
|
||||||
<IconPicker
|
<IconPicker
|
||||||
v-if="showIconPicker"
|
v-if="showIconPicker"
|
||||||
v-model="w.iconCodepoint"
|
v-model="activeIconCodepoint"
|
||||||
@close="showIconPicker = false"
|
@close="handleIconPickerClose"
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
@ -422,7 +651,7 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useEditorStore } from '../stores/editor';
|
import { useEditorStore } from '../stores/editor';
|
||||||
import { typeKeyFor } from '../utils';
|
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';
|
import IconPicker from './IconPicker.vue';
|
||||||
|
|
||||||
const store = useEditorStore();
|
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 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 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 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 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 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]';
|
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) {
|
function groupedSources(options) {
|
||||||
const allowed = new Set(options || []);
|
const allowed = new Set(options || []);
|
||||||
return textSourceGroups
|
return textSourceGroups
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2.5 flex-wrap justify-end">
|
<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="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
|
<button
|
||||||
:class="[
|
:class="[
|
||||||
@ -44,7 +45,7 @@ import { ref, onMounted } from 'vue';
|
|||||||
import { useEditorStore } from '../stores/editor';
|
import { useEditorStore } from '../stores/editor';
|
||||||
|
|
||||||
const store = useEditorStore();
|
const store = useEditorStore();
|
||||||
const emit = defineEmits(['open-settings']);
|
const emit = defineEmits(['open-settings', 'open-files']);
|
||||||
const knxProgMode = ref(false);
|
const knxProgMode = ref(false);
|
||||||
const knxProgPending = ref(false);
|
const knxProgPending = ref(false);
|
||||||
const knxResetPending = ref(false);
|
const knxResetPending = ref(false);
|
||||||
|
|||||||
@ -138,6 +138,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</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 -->
|
<!-- Icon-only Widget -->
|
||||||
<template v-else-if="isIcon">
|
<template v-else-if="isIcon">
|
||||||
<span class="material-symbols-outlined flex items-center justify-center" :style="iconOnlyStyle">
|
<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 isPowerLink = computed(() => props.widget.type === WIDGET_TYPES.POWERLINK);
|
||||||
const isChart = computed(() => props.widget.type === WIDGET_TYPES.CHART);
|
const isChart = computed(() => props.widget.type === WIDGET_TYPES.CHART);
|
||||||
const isClock = computed(() => props.widget.type === WIDGET_TYPES.CLOCK);
|
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 tabPosition = computed(() => props.widget.iconPosition || 0); // 0=Top, 1=Bottom...
|
||||||
const tabHeight = computed(() => (props.widget.iconSize || 5) * 10);
|
const tabHeight = computed(() => (props.widget.iconSize || 5) * 10);
|
||||||
@ -260,6 +284,7 @@ const showDefaultText = computed(() => {
|
|||||||
if (isPowerFlow.value || isPowerNode.value) return false;
|
if (isPowerFlow.value || isPowerNode.value) return false;
|
||||||
if (isPowerLink.value) return false;
|
if (isPowerLink.value) return false;
|
||||||
if (isButtonContainer.value) return false;
|
if (isButtonContainer.value) return false;
|
||||||
|
if (isRoomCard.value) return false;
|
||||||
return true;
|
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 computedStyle = computed(() => {
|
||||||
const w = props.widget;
|
const w = props.widget;
|
||||||
const s = props.scale;
|
const s = props.scale;
|
||||||
@ -591,6 +722,9 @@ const computedStyle = computed(() => {
|
|||||||
style.height = '100%';
|
style.height = '100%';
|
||||||
style.left = '0';
|
style.left = '0';
|
||||||
style.top = '0';
|
style.top = '0';
|
||||||
|
} else if (isRoomCard.value) {
|
||||||
|
// RoomCard container - transparent, children handle rendering
|
||||||
|
style.overflow = 'visible';
|
||||||
}
|
}
|
||||||
|
|
||||||
return style;
|
return style;
|
||||||
|
|||||||
@ -13,7 +13,8 @@ export const WIDGET_TYPES = {
|
|||||||
POWERNODE: 7,
|
POWERNODE: 7,
|
||||||
POWERLINK: 8,
|
POWERLINK: 8,
|
||||||
CHART: 9,
|
CHART: 9,
|
||||||
CLOCK: 10
|
CLOCK: 10,
|
||||||
|
ROOMCARD: 11
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ICON_POSITIONS = {
|
export const ICON_POSITIONS = {
|
||||||
@ -46,7 +47,8 @@ export const TYPE_KEYS = {
|
|||||||
7: 'powernode',
|
7: 'powernode',
|
||||||
8: 'powerlink',
|
8: 'powerlink',
|
||||||
9: 'chart',
|
9: 'chart',
|
||||||
10: 'clock'
|
10: 'clock',
|
||||||
|
11: 'roomcard'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TYPE_LABELS = {
|
export const TYPE_LABELS = {
|
||||||
@ -60,7 +62,8 @@ export const TYPE_LABELS = {
|
|||||||
powernode: 'Power Node',
|
powernode: 'Power Node',
|
||||||
powerlink: 'Power Link',
|
powerlink: 'Power Link',
|
||||||
chart: 'Chart',
|
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],
|
powernode: [0, 1, 2, 3, 4, 5, 6, 7],
|
||||||
powerlink: [0, 1, 3, 5, 6, 7],
|
powerlink: [0, 1, 3, 5, 6, 7],
|
||||||
chart: [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 = [
|
export const chartPeriods = [
|
||||||
@ -115,6 +119,33 @@ export const chartPeriods = [
|
|||||||
{ value: 5, label: '1 Monat' }
|
{ 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 = {
|
export const ICON_DEFAULTS = {
|
||||||
iconCodepoint: 0,
|
iconCodepoint: 0,
|
||||||
iconPosition: 0,
|
iconPosition: 0,
|
||||||
@ -316,7 +347,17 @@ export const WIDGET_DEFAULTS = {
|
|||||||
iconCodepoint: 0,
|
iconCodepoint: 0,
|
||||||
iconPosition: 0,
|
iconPosition: 0,
|
||||||
iconSize: 1,
|
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: {
|
powerlink: {
|
||||||
w: 3,
|
w: 3,
|
||||||
@ -389,5 +430,30 @@ export const WIDGET_DEFAULTS = {
|
|||||||
iconPosition: 0,
|
iconPosition: 0,
|
||||||
iconSize: 1,
|
iconSize: 1,
|
||||||
iconGap: 0
|
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: []
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -47,6 +47,17 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
w.knxAddrWrite = addrByIndex.get(w.knxAddrWrite);
|
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)) {
|
if (w.chart && Array.isArray(w.chart.series)) {
|
||||||
w.chart.series.forEach((series) => {
|
w.chart.series.forEach((series) => {
|
||||||
if (typeof series.knxAddr === 'number' && series.knxAddr > 0) {
|
if (typeof series.knxAddr === 'number' && series.knxAddr > 0) {
|
||||||
@ -280,6 +291,8 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
name: `Screen ${id}`,
|
name: `Screen ${id}`,
|
||||||
mode: 0,
|
mode: 0,
|
||||||
bgColor: '#1A1A2E',
|
bgColor: '#1A1A2E',
|
||||||
|
bgImage: '',
|
||||||
|
bgImageMode: 1,
|
||||||
widgets: []
|
widgets: []
|
||||||
};
|
};
|
||||||
normalizeScreen(newScreen, null, nextWidgetId);
|
normalizeScreen(newScreen, null, nextWidgetId);
|
||||||
@ -321,6 +334,7 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
case 'powerlink': typeValue = WIDGET_TYPES.POWERLINK; break;
|
case 'powerlink': typeValue = WIDGET_TYPES.POWERLINK; break;
|
||||||
case 'chart': typeValue = WIDGET_TYPES.CHART; break;
|
case 'chart': typeValue = WIDGET_TYPES.CHART; break;
|
||||||
case 'clock': typeValue = WIDGET_TYPES.CLOCK; break;
|
case 'clock': typeValue = WIDGET_TYPES.CLOCK; break;
|
||||||
|
case 'roomcard': typeValue = WIDGET_TYPES.ROOMCARD; break;
|
||||||
default: typeValue = WIDGET_TYPES.LABEL;
|
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);
|
activeScreen.value.widgets.push(w);
|
||||||
selectedWidgetId.value = w.id;
|
selectedWidgetId.value = w.id;
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export function minSizeFor(widget) {
|
|||||||
if (key === 'powernode') return { w: 70, h: 70 };
|
if (key === 'powernode') return { w: 70, h: 70 };
|
||||||
if (key === 'powerlink') return { w: 1, h: 1 };
|
if (key === 'powerlink') return { w: 1, h: 1 };
|
||||||
if (key === 'chart') return { w: 160, h: 120 };
|
if (key === 'chart') return { w: 160, h: 120 };
|
||||||
|
if (key === 'roomcard') return { w: 120, h: 120 };
|
||||||
return { w: 40, h: 20 };
|
return { w: 40, h: 20 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,12 +35,34 @@ export function normalizeWidget(w, nextWidgetIdRef) {
|
|||||||
const defaults = WIDGET_DEFAULTS[key];
|
const defaults = WIDGET_DEFAULTS[key];
|
||||||
|
|
||||||
Object.keys(defaults).forEach((prop) => {
|
Object.keys(defaults).forEach((prop) => {
|
||||||
if (prop === 'shadow') return;
|
if (prop === 'shadow' || prop === 'conditions') return;
|
||||||
if (w[prop] === undefined || w[prop] === null) {
|
if (w[prop] === undefined || w[prop] === null) {
|
||||||
w[prop] = defaults[prop];
|
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) {
|
if (!w.shadow) {
|
||||||
w.shadow = { ...defaults.shadow };
|
w.shadow = { ...defaults.shadow };
|
||||||
} else {
|
} else {
|
||||||
@ -111,6 +134,8 @@ export function normalizeScreen(screen, nextScreenIdRef, nextWidgetIdRef) {
|
|||||||
if (!screen.name) screen.name = `Screen ${screen.id}`;
|
if (!screen.name) screen.name = `Screen ${screen.id}`;
|
||||||
if (screen.mode === undefined || screen.mode === null) screen.mode = 0;
|
if (screen.mode === undefined || screen.mode === null) screen.mode = 0;
|
||||||
if (!screen.bgColor) screen.bgColor = '#1A1A2E';
|
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 = [];
|
if (!Array.isArray(screen.widgets)) screen.widgets = [];
|
||||||
|
|
||||||
// Modal defaults
|
// Modal defaults
|
||||||
|
|||||||
@ -9,6 +9,10 @@ export default defineConfig({
|
|||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://192.168.178.81',
|
target: 'http://192.168.178.81',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/images': {
|
||||||
|
target: 'http://192.168.178.81',
|
||||||
|
changeOrigin: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user