This commit is contained in:
Thomas Peterson 2026-01-25 15:20:12 +01:00
parent c9196fcaf2
commit 1ea8bb7e12
37 changed files with 1541 additions and 110 deletions

View File

@ -4,6 +4,7 @@ idf_component_register(SRCS "KnxWorker.cpp" "Nvs.cpp" "main.cpp" "Display.cpp" "
"widgets/ButtonWidget.cpp" "widgets/ButtonWidget.cpp"
"widgets/LedWidget.cpp" "widgets/LedWidget.cpp"
"widgets/WidgetFactory.cpp" "widgets/WidgetFactory.cpp"
"widgets/IconWidget.cpp"
"webserver/WebServer.cpp" "webserver/WebServer.cpp"
"webserver/StaticFileHandlers.cpp" "webserver/StaticFileHandlers.cpp"
"webserver/ConfigHandlers.cpp" "webserver/ConfigHandlers.cpp"

View File

@ -13,11 +13,14 @@
namespace { namespace {
static const char* TAG = "Fonts"; static const char* TAG = "Fonts";
static constexpr const char* kFontPath = "/sdcard/fonts/Montserrat-Medium.ttf"; static constexpr const char* kFontPath = "/sdcard/fonts/Montserrat-Medium.ttf";
static constexpr const char* kIconFontPath = "/sdcard/fonts/MaterialSymbolsOutlined.ttf";
static constexpr uint16_t kFontSizes[] = {14, 18, 22, 28, 36, 48}; static constexpr uint16_t kFontSizes[] = {14, 18, 22, 28, 36, 48};
static constexpr size_t kFontCount = sizeof(kFontSizes) / sizeof(kFontSizes[0]); static constexpr size_t kFontCount = sizeof(kFontSizes) / sizeof(kFontSizes[0]);
static const lv_font_t* s_fonts[kFontCount] = {nullptr}; static const lv_font_t* s_fonts[kFontCount] = {nullptr};
static const lv_font_t* s_iconFonts[kFontCount] = {nullptr};
static bool s_initialized = false; static bool s_initialized = false;
static bool s_iconFontAvailable = false;
const lv_font_t* fallbackFont(uint8_t sizeIndex) { const lv_font_t* fallbackFont(uint8_t sizeIndex) {
switch (sizeIndex) { switch (sizeIndex) {
@ -42,9 +45,9 @@ const lv_font_t* fallbackFont(uint8_t sizeIndex) {
} }
#if CONFIG_ESP_LVGL_ADAPTER_ENABLE_FREETYPE #if CONFIG_ESP_LVGL_ADAPTER_ENABLE_FREETYPE
bool fontFileExists() { bool fontFileExists(const char* path) {
struct stat st; struct stat st;
return stat(kFontPath, &st) == 0; return stat(path, &st) == 0;
} }
#endif #endif
} // namespace } // namespace
@ -59,7 +62,7 @@ void Fonts::init() {
} }
s_initialized = true; s_initialized = true;
if (!fontFileExists()) { if (!fontFileExists(kFontPath)) {
ESP_LOGW(TAG, "Font file not found: %s", kFontPath); ESP_LOGW(TAG, "Font file not found: %s", kFontPath);
return; return;
} }
@ -104,7 +107,37 @@ void Fonts::init() {
} }
ESP_LOGI(TAG, "Font size %u loaded successfully", kFontSizes[i]); ESP_LOGI(TAG, "Font size %u loaded successfully", kFontSizes[i]);
} }
ESP_LOGI(TAG, "Font initialization complete"); ESP_LOGI(TAG, "Text font initialization complete");
// Load icon font if available
if (fontFileExists(kIconFontPath)) {
ESP_LOGI(TAG, "Icon font file exists: %s", kIconFontPath);
for (size_t i = 0; i < kFontCount; ++i) {
ESP_LOGI(TAG, "Loading icon font size %u...", kFontSizes[i]);
esp_lv_adapter_ft_font_config_t cfg = {};
cfg.name = kIconFontPath;
cfg.size = kFontSizes[i];
cfg.style = ESP_LV_ADAPTER_FT_FONT_STYLE_NORMAL;
cfg.mem = nullptr;
cfg.mem_size = 0;
esp_lv_adapter_ft_font_handle_t handle = nullptr;
esp_err_t ret = esp_lv_adapter_ft_font_init(&cfg, &handle);
if (ret != ESP_OK || handle == nullptr) {
ESP_LOGW(TAG, "Failed to load icon font size %u (err=%d)", cfg.size, ret);
continue;
}
s_iconFonts[i] = esp_lv_adapter_ft_font_get(handle);
if (s_iconFonts[i]) {
s_iconFontAvailable = true;
ESP_LOGI(TAG, "Icon font size %u loaded successfully", kFontSizes[i]);
}
}
ESP_LOGI(TAG, "Icon font initialization complete");
} else {
ESP_LOGW(TAG, "Icon font file not found: %s", kIconFontPath);
}
#else #else
ESP_LOGI(TAG, "FreeType disabled, using built-in fonts"); ESP_LOGI(TAG, "FreeType disabled, using built-in fonts");
#endif #endif
@ -118,3 +151,27 @@ const lv_font_t* Fonts::bySizeIndex(uint8_t sizeIndex) {
#endif #endif
return fallbackFont(sizeIndex); return fallbackFont(sizeIndex);
} }
const lv_font_t* Fonts::iconFont(uint8_t sizeIndex) {
#if CONFIG_ESP_LVGL_ADAPTER_ENABLE_FREETYPE
if (sizeIndex < kFontCount && s_iconFonts[sizeIndex]) {
return s_iconFonts[sizeIndex];
}
// Try to return any available icon font size as fallback
if (s_iconFontAvailable) {
for (size_t i = 0; i < kFontCount; ++i) {
if (s_iconFonts[i]) return s_iconFonts[i];
}
}
#endif
// No icon font available - return text font as last resort
return fallbackFont(sizeIndex);
}
bool Fonts::hasIconFont() {
#if CONFIG_ESP_LVGL_ADAPTER_ENABLE_FREETYPE
return s_iconFontAvailable;
#else
return false;
#endif
}

View File

@ -7,4 +7,8 @@ class Fonts {
public: public:
static void init(); static void init();
static const lv_font_t* bySizeIndex(uint8_t sizeIndex); static const lv_font_t* bySizeIndex(uint8_t sizeIndex);
// Icon font support
static const lv_font_t* iconFont(uint8_t sizeIndex);
static bool hasIconFont();
}; };

View File

@ -36,6 +36,16 @@ void WidgetConfig::serialize(uint8_t* buf) const {
buf[pos++] = knxAddressWrite & 0xFF; buf[pos++] = (knxAddressWrite >> 8) & 0xFF; buf[pos++] = knxAddressWrite & 0xFF; buf[pos++] = (knxAddressWrite >> 8) & 0xFF;
buf[pos++] = static_cast<uint8_t>(action); buf[pos++] = static_cast<uint8_t>(action);
buf[pos++] = targetScreen; buf[pos++] = targetScreen;
// Icon properties
buf[pos++] = iconCodepoint & 0xFF;
buf[pos++] = (iconCodepoint >> 8) & 0xFF;
buf[pos++] = (iconCodepoint >> 16) & 0xFF;
buf[pos++] = (iconCodepoint >> 24) & 0xFF;
buf[pos++] = iconPosition;
buf[pos++] = iconSize;
buf[pos++] = static_cast<uint8_t>(iconGap);
buf[pos++] = 0; // padding for alignment
} }
void WidgetConfig::deserialize(const uint8_t* buf) { void WidgetConfig::deserialize(const uint8_t* buf) {
@ -72,6 +82,14 @@ void WidgetConfig::deserialize(const uint8_t* buf) {
pos += 2; pos += 2;
action = static_cast<ButtonAction>(buf[pos++]); action = static_cast<ButtonAction>(buf[pos++]);
targetScreen = buf[pos++]; targetScreen = buf[pos++];
// Icon properties
iconCodepoint = buf[pos] | (buf[pos+1] << 8) | (buf[pos+2] << 16) | (buf[pos+3] << 24);
pos += 4;
iconPosition = buf[pos++];
iconSize = buf[pos++];
iconGap = static_cast<int8_t>(buf[pos++]);
pos++; // padding
} }
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) {
@ -91,6 +109,11 @@ WidgetConfig WidgetConfig::createLabel(uint8_t id, int16_t x, int16_t y, const c
cfg.bgOpacity = 0; cfg.bgOpacity = 0;
cfg.borderRadius = 0; cfg.borderRadius = 0;
cfg.shadow.enabled = false; cfg.shadow.enabled = false;
// Icon defaults
cfg.iconCodepoint = 0;
cfg.iconPosition = 0;
cfg.iconSize = 1;
cfg.iconGap = 8;
return cfg; return cfg;
} }
@ -129,6 +152,11 @@ WidgetConfig WidgetConfig::createButton(uint8_t id, int16_t x, int16_t y,
cfg.knxAddressWrite = knxAddrWrite; cfg.knxAddressWrite = knxAddrWrite;
cfg.action = ButtonAction::KNX; cfg.action = ButtonAction::KNX;
cfg.targetScreen = 0; cfg.targetScreen = 0;
// Icon defaults
cfg.iconCodepoint = 0;
cfg.iconPosition = 0;
cfg.iconSize = 1;
cfg.iconGap = 8;
return cfg; return cfg;
} }

View File

@ -14,7 +14,14 @@ enum class WidgetType : uint8_t {
LABEL = 0, LABEL = 0,
BUTTON = 1, BUTTON = 1,
LED = 2, LED = 2,
// Future: GAUGE, IMAGE, ARC, etc. ICON = 3,
};
enum class IconPosition : uint8_t {
LEFT = 0,
RIGHT = 1,
TOP = 2,
BOTTOM = 3,
}; };
enum class ScreenMode : uint8_t { enum class ScreenMode : uint8_t {
@ -94,8 +101,14 @@ struct WidgetConfig {
ButtonAction action; // Button action (KNX, Jump, Back) ButtonAction action; // Button action (KNX, Jump, Back)
uint8_t targetScreen; // Target screen ID for jump uint8_t targetScreen; // Target screen ID for jump
// Icon properties (for Label, Button, Icon widgets)
uint32_t iconCodepoint; // Unicode codepoint (0 = no icon)
uint8_t iconPosition; // IconPosition: 0=left, 1=right, 2=top, 3=bottom
uint8_t iconSize; // Font size index (0-5), same as fontSize
int8_t iconGap; // Gap between icon and text (px)
// Serialization size (fixed for NVS storage) // Serialization size (fixed for NVS storage)
static constexpr size_t SERIALIZED_SIZE = 68; static constexpr size_t SERIALIZED_SIZE = 76;
void serialize(uint8_t* buf) const; void serialize(uint8_t* buf) const;
void deserialize(const uint8_t* buf); void deserialize(const uint8_t* buf);

View File

@ -771,6 +771,12 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
cJSON_AddNumberToObject(widget, "action", static_cast<int>(w.action)); cJSON_AddNumberToObject(widget, "action", static_cast<int>(w.action));
cJSON_AddNumberToObject(widget, "targetScreen", w.targetScreen); cJSON_AddNumberToObject(widget, "targetScreen", w.targetScreen);
// Icon properties
cJSON_AddNumberToObject(widget, "iconCodepoint", w.iconCodepoint);
cJSON_AddNumberToObject(widget, "iconPosition", w.iconPosition);
cJSON_AddNumberToObject(widget, "iconSize", w.iconSize);
cJSON_AddNumberToObject(widget, "iconGap", w.iconGap);
cJSON_AddItemToArray(widgets, widget); cJSON_AddItemToArray(widgets, widget);
} }
@ -905,6 +911,19 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
cJSON* targetScreen = cJSON_GetObjectItem(widget, "targetScreen"); cJSON* targetScreen = cJSON_GetObjectItem(widget, "targetScreen");
if (cJSON_IsNumber(targetScreen)) w.targetScreen = targetScreen->valueint; if (cJSON_IsNumber(targetScreen)) w.targetScreen = targetScreen->valueint;
// Icon properties
cJSON* iconCodepoint = cJSON_GetObjectItem(widget, "iconCodepoint");
if (cJSON_IsNumber(iconCodepoint)) w.iconCodepoint = static_cast<uint32_t>(iconCodepoint->valuedouble);
cJSON* iconPosition = cJSON_GetObjectItem(widget, "iconPosition");
if (cJSON_IsNumber(iconPosition)) w.iconPosition = iconPosition->valueint;
cJSON* iconSize = cJSON_GetObjectItem(widget, "iconSize");
if (cJSON_IsNumber(iconSize)) w.iconSize = iconSize->valueint;
cJSON* iconGap = cJSON_GetObjectItem(widget, "iconGap");
if (cJSON_IsNumber(iconGap)) w.iconGap = iconGap->valueint;
screen.widgetCount++; screen.widgetCount++;
} }

View File

@ -9,14 +9,20 @@
#include <unistd.h> #include <unistd.h>
#include <errno.h> #include <errno.h>
// Helper for min
template<typename T>
static inline T minVal(T a, T b) { return (a < b) ? a : b; }
static const char* TAG = "WebServer"; static const char* TAG = "WebServer";
// Embedded file manager HTML (from Flash) // Embedded file manager HTML (from Flash)
extern const uint8_t filemanager_html_start[] asm("_binary_filemanager_html_start"); extern const uint8_t filemanager_html_start[] asm("_binary_filemanager_html_start");
extern const uint8_t filemanager_html_end[] asm("_binary_filemanager_html_end"); extern const uint8_t filemanager_html_end[] asm("_binary_filemanager_html_end");
// Maximum upload size (2 MB) // Maximum upload size (16 MB) - streaming, so RAM usage is low
static constexpr size_t MAX_UPLOAD_SIZE = 2 * 1024 * 1024; static constexpr size_t MAX_UPLOAD_SIZE = 16 * 1024 * 1024;
// Buffer size for streaming uploads
static constexpr size_t UPLOAD_BUFFER_SIZE = 4096;
// GET /files - Serve embedded file manager HTML // GET /files - Serve embedded file manager HTML
esp_err_t WebServer::fileManagerHandler(httpd_req_t* req) { esp_err_t WebServer::fileManagerHandler(httpd_req_t* req) {
@ -79,7 +85,7 @@ esp_err_t WebServer::filesListHandler(httpd_req_t* req) {
return sendJsonObject(req, json); return sendJsonObject(req, json);
} }
// POST /api/files/upload?path=/ - Upload file (multipart/form-data) // POST /api/files/upload?path=/ - Upload file (multipart/form-data) with streaming
esp_err_t WebServer::filesUploadHandler(httpd_req_t* req) { esp_err_t WebServer::filesUploadHandler(httpd_req_t* req) {
char path[128] = "/"; char path[128] = "/";
getQueryParam(req, "path", path, sizeof(path)); getQueryParam(req, "path", path, sizeof(path));
@ -89,7 +95,7 @@ esp_err_t WebServer::filesUploadHandler(httpd_req_t* req) {
} }
if (req->content_len > MAX_UPLOAD_SIZE) { if (req->content_len > MAX_UPLOAD_SIZE) {
return sendJsonError(req, "File too large (max 2MB)"); return sendJsonError(req, "File too large (max 16MB)");
} }
char contentType[128] = ""; char contentType[128] = "";
@ -103,17 +109,12 @@ esp_err_t WebServer::filesUploadHandler(httpd_req_t* req) {
} }
boundaryStart += 9; boundaryStart += 9;
// Skip leading quote if present if (*boundaryStart == '"') boundaryStart++;
if (*boundaryStart == '"') {
boundaryStart++;
}
// Copy boundary and remove trailing quote if present
char boundaryValue[72]; char boundaryValue[72];
strncpy(boundaryValue, boundaryStart, sizeof(boundaryValue) - 1); strncpy(boundaryValue, boundaryStart, sizeof(boundaryValue) - 1);
boundaryValue[sizeof(boundaryValue) - 1] = '\0'; boundaryValue[sizeof(boundaryValue) - 1] = '\0';
// Remove trailing quote or any trailing whitespace/semicolon
char* endPtr = boundaryValue; char* endPtr = boundaryValue;
while (*endPtr && *endPtr != '"' && *endPtr != ';' && *endPtr != ' ' && *endPtr != '\r' && *endPtr != '\n') { while (*endPtr && *endPtr != '"' && *endPtr != ';' && *endPtr != ' ' && *endPtr != '\r' && *endPtr != '\n') {
endPtr++; endPtr++;
@ -121,34 +122,59 @@ esp_err_t WebServer::filesUploadHandler(httpd_req_t* req) {
*endPtr = '\0'; *endPtr = '\0';
char boundary[80]; char boundary[80];
snprintf(boundary, sizeof(boundary), "--%s", boundaryValue); snprintf(boundary, sizeof(boundary), "\r\n--%s", boundaryValue);
size_t boundaryLen = strlen(boundary);
size_t contentLen = req->content_len; // Allocate streaming buffer
char* fullData = new char[contentLen + 1]; char* buffer = new char[UPLOAD_BUFFER_SIZE];
if (!fullData) { if (!buffer) {
return sendJsonError(req, "Out of memory"); return sendJsonError(req, "Out of memory");
} }
size_t contentLen = req->content_len;
size_t totalReceived = 0; size_t totalReceived = 0;
while (totalReceived < contentLen) { size_t bufferFilled = 0;
int ret = httpd_req_recv(req, fullData + totalReceived, contentLen - totalReceived);
// Phase 1: Read header to find filename and data start
// Headers are typically < 512 bytes, read up to 2KB to be safe
static constexpr size_t HEADER_MAX = 2048;
char header[HEADER_MAX];
size_t headerLen = 0;
char* dataStartInHeader = nullptr;
while (headerLen < HEADER_MAX && totalReceived < contentLen) {
int ret = httpd_req_recv(req, header + headerLen,
minVal(HEADER_MAX - headerLen, contentLen - totalReceived));
if (ret <= 0) { if (ret <= 0) {
delete[] fullData; delete[] buffer;
return sendJsonError(req, "Receive failed"); return sendJsonError(req, "Receive failed");
} }
headerLen += ret;
totalReceived += ret; totalReceived += ret;
}
fullData[totalReceived] = '\0';
char* filenameStart = strstr(fullData, "filename=\""); // Look for end of headers (\r\n\r\n)
dataStartInHeader = (char*)memmem(header, headerLen, "\r\n\r\n", 4);
if (dataStartInHeader) {
dataStartInHeader += 4; // Skip past \r\n\r\n
break;
}
}
if (!dataStartInHeader) {
delete[] buffer;
return sendJsonError(req, "Invalid multipart header");
}
// Extract filename from header
char* filenameStart = strstr(header, "filename=\"");
if (!filenameStart) { if (!filenameStart) {
delete[] fullData; delete[] buffer;
return sendJsonError(req, "No filename found"); return sendJsonError(req, "No filename found");
} }
filenameStart += 10; filenameStart += 10;
char* filenameEnd = strchr(filenameStart, '"'); char* filenameEnd = strchr(filenameStart, '"');
if (!filenameEnd) { if (!filenameEnd || filenameEnd > dataStartInHeader) {
delete[] fullData; delete[] buffer;
return sendJsonError(req, "Invalid filename"); return sendJsonError(req, "Invalid filename");
} }
@ -158,32 +184,7 @@ esp_err_t WebServer::filesUploadHandler(httpd_req_t* req) {
memcpy(filename, filenameStart, filenameLen); memcpy(filename, filenameStart, filenameLen);
filename[filenameLen] = '\0'; filename[filenameLen] = '\0';
char* dataStart = strstr(filenameEnd, "\r\n\r\n"); // Prepare target path
if (!dataStart) {
delete[] fullData;
return sendJsonError(req, "Invalid multipart format");
}
dataStart += 4;
// Use memmem for binary-safe search (fonts/images may contain null bytes)
char endBoundary[84];
snprintf(endBoundary, sizeof(endBoundary), "\r\n%s", boundary);
size_t endBoundaryLen = strlen(endBoundary);
size_t searchLen = totalReceived - (dataStart - fullData);
char* dataEnd = (char*)memmem(dataStart, searchLen, endBoundary, endBoundaryLen);
if (!dataEnd) {
// Try without leading \r\n
size_t boundaryLen = strlen(boundary);
dataEnd = (char*)memmem(dataStart, searchLen, boundary, boundaryLen);
}
if (!dataEnd) {
delete[] fullData;
return sendJsonError(req, "End boundary not found");
}
size_t fileSize = dataEnd - dataStart;
char resolvedDir[384]; char resolvedDir[384];
char targetDirPath[256]; char targetDirPath[256];
snprintf(targetDirPath, sizeof(targetDirPath), "%.7s%.127s", SdCard::MOUNT_POINT, path); snprintf(targetDirPath, sizeof(targetDirPath), "%.7s%.127s", SdCard::MOUNT_POINT, path);
@ -196,32 +197,110 @@ esp_err_t WebServer::filesUploadHandler(httpd_req_t* req) {
char fullPath[384]; char fullPath[384];
snprintf(fullPath, sizeof(fullPath), "%.250s/%.120s", actualDir, filename); snprintf(fullPath, sizeof(fullPath), "%.250s/%.120s", actualDir, filename);
ESP_LOGI(TAG, "Uploading: %s (%zu bytes)", fullPath, fileSize); ESP_LOGI(TAG, "Streaming upload: %s (total content: %zu bytes)", fullPath, contentLen);
// Create directory if needed
struct stat dirStat; struct stat dirStat;
if (stat(actualDir, &dirStat) != 0) { if (stat(actualDir, &dirStat) != 0) {
if (mkdir(actualDir, 0755) != 0 && errno != EEXIST) { if (mkdir(actualDir, 0755) != 0 && errno != EEXIST) {
delete[] fullData; delete[] buffer;
return sendJsonError(req, "Cannot create directory"); return sendJsonError(req, "Cannot create directory");
} }
} }
// Open file for writing
FILE* f = fopen(fullPath, "wb"); FILE* f = fopen(fullPath, "wb");
if (!f) { if (!f) {
ESP_LOGE(TAG, "Cannot create file: %s (errno=%d)", fullPath, errno); ESP_LOGE(TAG, "Cannot create file: %s (errno=%d)", fullPath, errno);
delete[] fullData; delete[] buffer;
return sendJsonError(req, "Cannot create file"); return sendJsonError(req, "Cannot create file");
} }
size_t written = fwrite(dataStart, 1, fileSize, f); // Calculate how much file data is already in our header buffer
fclose(f); size_t headerDataLen = headerLen - (dataStartInHeader - header);
delete[] fullData; size_t fileSize = 0;
if (written != fileSize) { // Copy initial file data from header buffer to streaming buffer
return sendJsonError(req, "Write failed"); memcpy(buffer, dataStartInHeader, headerDataLen);
bufferFilled = headerDataLen;
// Phase 2: Stream remaining data, looking for end boundary
bool boundaryFound = false;
while (totalReceived < contentLen || bufferFilled > 0) {
// Try to receive more data if buffer has space
if (totalReceived < contentLen && bufferFilled < UPLOAD_BUFFER_SIZE) {
size_t toRead = minVal(UPLOAD_BUFFER_SIZE - bufferFilled, contentLen - totalReceived);
int ret = httpd_req_recv(req, buffer + bufferFilled, toRead);
if (ret < 0) {
fclose(f);
unlink(fullPath);
delete[] buffer;
return sendJsonError(req, "Receive failed");
}
if (ret > 0) {
bufferFilled += ret;
totalReceived += ret;
}
}
// Check for boundary in buffer
char* boundaryPos = nullptr;
if (bufferFilled >= boundaryLen) {
boundaryPos = (char*)memmem(buffer, bufferFilled, boundary, boundaryLen);
}
if (boundaryPos) {
// Found end boundary - write data before it
size_t dataToWrite = boundaryPos - buffer;
if (dataToWrite > 0) {
size_t written = fwrite(buffer, 1, dataToWrite, f);
if (written != dataToWrite) {
fclose(f);
unlink(fullPath);
delete[] buffer;
return sendJsonError(req, "Write failed");
}
fileSize += written;
}
boundaryFound = true;
break;
}
// No boundary found - write safe portion (keep boundaryLen-1 bytes for boundary detection)
if (totalReceived >= contentLen) {
// All data received, write everything (should not happen normally)
size_t written = fwrite(buffer, 1, bufferFilled, f);
fileSize += written;
bufferFilled = 0;
break;
}
size_t safeToWrite = (bufferFilled > boundaryLen) ? (bufferFilled - boundaryLen + 1) : 0;
if (safeToWrite > 0) {
size_t written = fwrite(buffer, 1, safeToWrite, f);
if (written != safeToWrite) {
fclose(f);
unlink(fullPath);
delete[] buffer;
return sendJsonError(req, "Write failed");
}
fileSize += written;
// Shift remaining data to front of buffer
memmove(buffer, buffer + safeToWrite, bufferFilled - safeToWrite);
bufferFilled -= safeToWrite;
}
} }
ESP_LOGI(TAG, "Upload complete: %s", fullPath); fclose(f);
delete[] buffer;
if (!boundaryFound) {
ESP_LOGW(TAG, "Upload finished without finding boundary (file may be truncated)");
}
ESP_LOGI(TAG, "Upload complete: %s (%zu bytes)", fullPath, fileSize);
cJSON* json = cJSON_CreateObject(); cJSON* json = cJSON_CreateObject();
cJSON_AddBoolToObject(json, "success", true); cJSON_AddBoolToObject(json, "success", true);

View File

@ -1,14 +1,16 @@
#include "ButtonWidget.hpp" #include "ButtonWidget.hpp"
#include "../WidgetManager.hpp" #include "../WidgetManager.hpp"
#include "../Fonts.hpp"
#include "esp_log.h" #include "esp_log.h"
static const char* TAG = "ButtonWidget"; static const char* TAG = "ButtonWidget";
ButtonWidget::ButtonWidget(const WidgetConfig& config) ButtonWidget::ButtonWidget(const WidgetConfig& config)
: Widget(config) : Widget(config)
, contentContainer_(nullptr)
, label_(nullptr) , label_(nullptr)
, iconLabel_(nullptr)
{ {
} }
ButtonWidget::~ButtonWidget() { ButtonWidget::~ButtonWidget() {
@ -16,7 +18,37 @@ ButtonWidget::~ButtonWidget() {
if (obj_) { if (obj_) {
lv_obj_remove_event_cb(obj_, clickCallback); lv_obj_remove_event_cb(obj_, clickCallback);
} }
contentContainer_ = nullptr;
label_ = nullptr; label_ = nullptr;
iconLabel_ = nullptr;
}
int ButtonWidget::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 ButtonWidget::clickCallback(lv_event_t* e) { void ButtonWidget::clickCallback(lv_event_t* e) {
@ -27,20 +59,72 @@ void ButtonWidget::clickCallback(lv_event_t* e) {
WidgetManager::instance().handleButtonAction(widget->getConfig(), target); WidgetManager::instance().handleButtonAction(widget->getConfig(), target);
} }
void ButtonWidget::setupFlexLayout() {
if (contentContainer_ == nullptr) return;
// Determine flex direction based on icon position
bool isVertical = (config_.iconPosition == static_cast<uint8_t>(IconPosition::TOP) ||
config_.iconPosition == static_cast<uint8_t>(IconPosition::BOTTOM));
lv_obj_set_flex_flow(contentContainer_,
isVertical ? LV_FLEX_FLOW_COLUMN : LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(contentContainer_,
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
// Set gap between icon and text
int gap = config_.iconGap > 0 ? config_.iconGap : 8;
lv_obj_set_style_pad_gap(contentContainer_, gap, 0);
}
lv_obj_t* ButtonWidget::create(lv_obj_t* parent) { lv_obj_t* ButtonWidget::create(lv_obj_t* parent) {
obj_ = lv_btn_create(parent); obj_ = lv_btn_create(parent);
lv_obj_set_pos(obj_, config_.x, config_.y); lv_obj_set_pos(obj_, config_.x, config_.y);
lv_obj_set_size(obj_, config_.width > 0 ? config_.width : 100, lv_obj_set_size(obj_, config_.width > 0 ? config_.width : 100,
config_.height > 0 ? config_.height : 50); config_.height > 0 ? config_.height : 50);
// Test: free function callback
lv_obj_add_event_cb(obj_, clickCallback, LV_EVENT_CLICKED, this); lv_obj_add_event_cb(obj_, clickCallback, LV_EVENT_CLICKED, this);
label_ = lv_label_create(obj_); bool hasIcon = config_.iconCodepoint > 0 && Fonts::hasIconFont();
lv_label_set_text(label_, config_.text);
lv_obj_center(label_);
ESP_LOGI(TAG, "Created button '%s' at %d,%d", config_.text, config_.x, config_.y); if (hasIcon) {
// Create container for flex layout
contentContainer_ = lv_obj_create(obj_);
lv_obj_remove_style_all(contentContainer_);
lv_obj_set_size(contentContainer_, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_center(contentContainer_);
// Create icon label
bool iconFirst = (config_.iconPosition == static_cast<uint8_t>(IconPosition::LEFT) ||
config_.iconPosition == static_cast<uint8_t>(IconPosition::TOP));
if (iconFirst) {
iconLabel_ = lv_label_create(contentContainer_);
char iconText[5];
encodeUtf8(config_.iconCodepoint, iconText);
lv_label_set_text(iconLabel_, iconText);
}
// Create text label
label_ = lv_label_create(contentContainer_);
lv_label_set_text(label_, config_.text);
if (!iconFirst) {
iconLabel_ = lv_label_create(contentContainer_);
char iconText[5];
encodeUtf8(config_.iconCodepoint, iconText);
lv_label_set_text(iconLabel_, iconText);
}
setupFlexLayout();
} else {
// Simple button without icon
label_ = lv_label_create(obj_);
lv_label_set_text(label_, config_.text);
lv_obj_center(label_);
}
ESP_LOGI(TAG, "Created button '%s' at %d,%d (icon: 0x%lx)",
config_.text, config_.x, config_.y, (unsigned long)config_.iconCodepoint);
return obj_; return obj_;
} }
@ -56,6 +140,15 @@ void ButtonWidget::applyStyle() {
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(label_, getFontBySize(config_.fontSize), 0); lv_obj_set_style_text_font(label_, getFontBySize(config_.fontSize), 0);
} }
// Apply icon style
if (iconLabel_ != nullptr) {
lv_obj_set_style_text_color(iconLabel_, lv_color_make(
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
uint8_t sizeIdx = config_.iconSize < 6 ? config_.iconSize : config_.fontSize;
lv_obj_set_style_text_font(iconLabel_, Fonts::iconFont(sizeIdx), 0);
}
} }
bool ButtonWidget::isChecked() const { bool ButtonWidget::isChecked() const {

View File

@ -14,7 +14,11 @@ public:
bool isChecked() const; bool isChecked() const;
private: private:
lv_obj_t* contentContainer_ = nullptr;
lv_obj_t* label_ = nullptr; lv_obj_t* label_ = nullptr;
lv_obj_t* iconLabel_ = nullptr;
void setupFlexLayout();
static int encodeUtf8(uint32_t codepoint, char* buf);
static void clickCallback(lv_event_t* e); static void clickCallback(lv_event_t* e);
}; };

112
main/widgets/IconWidget.cpp Normal file
View File

@ -0,0 +1,112 @@
#include "IconWidget.hpp"
#include "../Fonts.hpp"
#include "esp_log.h"
static const char* TAG = "IconWidget";
IconWidget::IconWidget(const WidgetConfig& config)
: Widget(config)
, iconLabel_(nullptr)
{
}
int IconWidget::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;
}
lv_obj_t* IconWidget::create(lv_obj_t* parent) {
// Create container for the icon
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 > 0 ? config_.width : 48,
config_.height > 0 ? config_.height : 48);
// Create icon label
iconLabel_ = lv_label_create(obj_);
if (config_.iconCodepoint > 0) {
char iconText[5];
encodeUtf8(config_.iconCodepoint, iconText);
lv_label_set_text(iconLabel_, iconText);
} else {
lv_label_set_text(iconLabel_, "?");
}
lv_obj_center(iconLabel_);
ESP_LOGI(TAG, "Created icon widget (codepoint: 0x%lx) at %d,%d",
(unsigned long)config_.iconCodepoint, config_.x, config_.y);
return obj_;
}
void IconWidget::applyStyle() {
if (obj_ == nullptr) return;
// Background
if (config_.bgOpacity > 0) {
lv_obj_set_style_bg_color(obj_, lv_color_make(
config_.bgColor.r, config_.bgColor.g, config_.bgColor.b), 0);
lv_obj_set_style_bg_opa(obj_, config_.bgOpacity, 0);
}
// Border radius
if (config_.borderRadius > 0) {
lv_obj_set_style_radius(obj_, config_.borderRadius, 0);
}
// Apply icon style
if (iconLabel_ != nullptr) {
lv_obj_set_style_text_color(iconLabel_, lv_color_make(
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
// Use icon font
uint8_t sizeIdx = config_.iconSize < 6 ? config_.iconSize : config_.fontSize;
if (Fonts::hasIconFont()) {
lv_obj_set_style_text_font(iconLabel_, Fonts::iconFont(sizeIdx), 0);
} else {
// Fallback to text font (won't show correct icon, but won't crash)
lv_obj_set_style_text_font(iconLabel_, Fonts::bySizeIndex(sizeIdx), 0);
}
}
}
void IconWidget::onKnxSwitch(bool value) {
if (iconLabel_ == nullptr) return;
if (config_.textSource != TextSource::KNX_DPT_SWITCH) return;
// Change icon color based on switch state
if (value) {
// Use text color for "on" state (default)
lv_obj_set_style_text_color(iconLabel_, lv_color_make(
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
} else {
// Dim color for "off" state (50% brightness)
lv_obj_set_style_text_color(iconLabel_, lv_color_make(
config_.textColor.r / 2, config_.textColor.g / 2, config_.textColor.b / 2), 0);
}
}

View File

@ -0,0 +1,20 @@
#pragma once
#include "Widget.hpp"
class IconWidget : public Widget {
public:
explicit IconWidget(const WidgetConfig& config);
lv_obj_t* create(lv_obj_t* parent) override;
void applyStyle() override;
// KNX updates (can change icon color based on state)
void onKnxSwitch(bool value) override;
private:
lv_obj_t* iconLabel_ = nullptr;
// Helper to encode unicode codepoint to UTF-8
static int encodeUtf8(uint32_t codepoint, char* buf);
};

View File

@ -1,42 +1,166 @@
#include "LabelWidget.hpp" #include "LabelWidget.hpp"
#include "../Fonts.hpp"
#include <cstdio> #include <cstdio>
LabelWidget::LabelWidget(const WidgetConfig& config) LabelWidget::LabelWidget(const WidgetConfig& config)
: Widget(config) : Widget(config)
, container_(nullptr)
, textLabel_(nullptr)
, iconLabel_(nullptr)
{ {
} }
lv_obj_t* LabelWidget::create(lv_obj_t* parent) { int LabelWidget::encodeUtf8(uint32_t codepoint, char* buf) {
obj_ = lv_label_create(parent); if (codepoint < 0x80) {
lv_label_set_text(obj_, config_.text); 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;
}
lv_obj_set_pos(obj_, config_.x, config_.y); void LabelWidget::setupFlexLayout() {
if (config_.width > 0 && config_.height > 0) { if (container_ == nullptr) return;
lv_obj_set_size(obj_, config_.width, config_.height);
// Determine flex direction based on icon position
bool isVertical = (config_.iconPosition == static_cast<uint8_t>(IconPosition::TOP) ||
config_.iconPosition == static_cast<uint8_t>(IconPosition::BOTTOM));
lv_obj_set_flex_flow(container_,
isVertical ? LV_FLEX_FLOW_COLUMN : LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(container_,
LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
// Set gap between icon and text
int gap = config_.iconGap > 0 ? config_.iconGap : 8;
lv_obj_set_style_pad_gap(container_, gap, 0);
}
lv_obj_t* LabelWidget::create(lv_obj_t* parent) {
bool hasIcon = config_.iconCodepoint > 0 && Fonts::hasIconFont();
if (hasIcon) {
// Create container object for flex layout
obj_ = lv_obj_create(parent);
lv_obj_remove_style_all(obj_);
lv_obj_set_pos(obj_, config_.x, config_.y);
if (config_.width > 0 && config_.height > 0) {
lv_obj_set_size(obj_, config_.width, config_.height);
} else {
lv_obj_set_size(obj_, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
}
container_ = obj_;
// Create elements in correct order based on icon position
bool iconFirst = (config_.iconPosition == static_cast<uint8_t>(IconPosition::LEFT) ||
config_.iconPosition == static_cast<uint8_t>(IconPosition::TOP));
if (iconFirst) {
iconLabel_ = lv_label_create(container_);
char iconText[5];
encodeUtf8(config_.iconCodepoint, iconText);
lv_label_set_text(iconLabel_, iconText);
}
textLabel_ = lv_label_create(container_);
lv_label_set_text(textLabel_, config_.text);
if (!iconFirst) {
iconLabel_ = lv_label_create(container_);
char iconText[5];
encodeUtf8(config_.iconCodepoint, iconText);
lv_label_set_text(iconLabel_, iconText);
}
setupFlexLayout();
} else {
// Simple label without icon
obj_ = lv_label_create(parent);
textLabel_ = obj_;
lv_label_set_text(obj_, config_.text);
lv_obj_set_pos(obj_, config_.x, config_.y);
if (config_.width > 0 && config_.height > 0) {
lv_obj_set_size(obj_, config_.width, config_.height);
}
} }
return obj_; return obj_;
} }
void LabelWidget::onKnxValue(float value) { void LabelWidget::applyStyle() {
if (obj_ == nullptr) return; if (obj_ == nullptr) return;
// Apply background if container
if (container_ != nullptr && config_.bgOpacity > 0) {
lv_obj_set_style_bg_color(obj_, lv_color_make(
config_.bgColor.r, config_.bgColor.g, config_.bgColor.b), 0);
lv_obj_set_style_bg_opa(obj_, config_.bgOpacity, 0);
}
// Apply text style
if (textLabel_ != nullptr) {
lv_obj_set_style_text_color(textLabel_, lv_color_make(
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
lv_obj_set_style_text_font(textLabel_, Fonts::bySizeIndex(config_.fontSize), 0);
}
// Apply icon style
if (iconLabel_ != nullptr) {
lv_obj_set_style_text_color(iconLabel_, lv_color_make(
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
uint8_t sizeIdx = config_.iconSize < 6 ? config_.iconSize : config_.fontSize;
lv_obj_set_style_text_font(iconLabel_, Fonts::iconFont(sizeIdx), 0);
}
// For simple label (no container), apply common style
if (container_ == nullptr) {
applyCommonStyle();
}
}
void LabelWidget::onKnxValue(float value) {
lv_obj_t* label = textLabel_ ? textLabel_ : obj_;
if (label == nullptr) return;
if (config_.textSource != TextSource::KNX_DPT_TEMP) return; if (config_.textSource != TextSource::KNX_DPT_TEMP) return;
char buf[32]; char buf[32];
snprintf(buf, sizeof(buf), config_.text, value); snprintf(buf, sizeof(buf), config_.text, value);
lv_label_set_text(obj_, buf); lv_label_set_text(label, buf);
} }
void LabelWidget::onKnxSwitch(bool value) { void LabelWidget::onKnxSwitch(bool value) {
if (obj_ == nullptr) return; lv_obj_t* label = textLabel_ ? textLabel_ : obj_;
if (label == nullptr) return;
if (config_.textSource != TextSource::KNX_DPT_SWITCH) return; if (config_.textSource != TextSource::KNX_DPT_SWITCH) return;
lv_label_set_text(obj_, value ? "EIN" : "AUS"); lv_label_set_text(label, value ? "EIN" : "AUS");
} }
void LabelWidget::onKnxText(const char* text) { void LabelWidget::onKnxText(const char* text) {
if (obj_ == nullptr) return; lv_obj_t* label = textLabel_ ? textLabel_ : obj_;
if (label == nullptr) return;
if (config_.textSource != TextSource::KNX_DPT_TEXT) return; if (config_.textSource != TextSource::KNX_DPT_TEXT) return;
lv_label_set_text(obj_, text); lv_label_set_text(label, text);
} }

View File

@ -7,9 +7,18 @@ public:
explicit LabelWidget(const WidgetConfig& config); explicit LabelWidget(const WidgetConfig& config);
lv_obj_t* create(lv_obj_t* parent) override; lv_obj_t* create(lv_obj_t* parent) override;
void applyStyle() override;
// KNX updates // KNX updates
void onKnxValue(float value) override; void onKnxValue(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;
private:
lv_obj_t* container_ = nullptr;
lv_obj_t* textLabel_ = nullptr;
lv_obj_t* iconLabel_ = nullptr;
void setupFlexLayout();
static int encodeUtf8(uint32_t codepoint, char* buf);
}; };

View File

@ -2,6 +2,7 @@
#include "LabelWidget.hpp" #include "LabelWidget.hpp"
#include "ButtonWidget.hpp" #include "ButtonWidget.hpp"
#include "LedWidget.hpp" #include "LedWidget.hpp"
#include "IconWidget.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;
@ -13,6 +14,8 @@ std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
return std::make_unique<ButtonWidget>(config); return std::make_unique<ButtonWidget>(config);
case WidgetType::LED: case WidgetType::LED:
return std::make_unique<LedWidget>(config); return std::make_unique<LedWidget>(config);
case WidgetType::ICON:
return std::make_unique<IconWidget>(config);
default: default:
return nullptr; return nullptr;
} }

Binary file not shown.

View File

@ -0,0 +1,232 @@
<template>
<div class="icon-picker-overlay" @click.self="$emit('close')">
<div class="icon-picker">
<div class="icon-picker-header">
<h3>Icon auswaehlen</h3>
<button class="close-btn" @click="$emit('close')">x</button>
</div>
<div class="icon-picker-search">
<input
type="text"
v-model="search"
placeholder="Icon suchen..."
ref="searchInput"
@keydown.esc="$emit('close')"
>
</div>
<div class="icon-picker-current" v-if="modelValue">
<span class="current-label">Aktuell:</span>
<span class="material-symbols-outlined current-icon">{{ String.fromCodePoint(modelValue) }}</span>
<span class="current-name">{{ currentIconName }}</span>
<button class="remove-btn" @click="selectIcon(0)">Entfernen</button>
</div>
<div class="icon-grid">
<button
v-for="icon in filteredIcons"
:key="icon.codepoint"
class="icon-btn"
:class="{ selected: icon.codepoint === modelValue }"
@click="selectIcon(icon.codepoint)"
:title="icon.name"
>
<span class="material-symbols-outlined">{{ String.fromCodePoint(icon.codepoint) }}</span>
<span class="icon-name">{{ icon.name }}</span>
</button>
</div>
<div v-if="filteredIcons.length === 0" class="no-results">
Keine Icons gefunden fuer "{{ search }}"
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { MATERIAL_ICONS, searchIcons, getIconByCodepoint } from '../icons';
const props = defineProps({
modelValue: { type: Number, default: 0 }
});
const emit = defineEmits(['update:modelValue', 'close']);
const search = ref('');
const searchInput = ref(null);
const filteredIcons = computed(() => searchIcons(search.value));
const currentIconName = computed(() => {
if (!props.modelValue) return '';
const icon = getIconByCodepoint(props.modelValue);
return icon ? icon.name : `0x${props.modelValue.toString(16)}`;
});
function selectIcon(codepoint) {
emit('update:modelValue', codepoint);
emit('close');
}
onMounted(() => {
if (searchInput.value) {
searchInput.value.focus();
}
});
</script>
<style scoped>
.icon-picker-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.icon-picker {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
width: 90%;
max-width: 600px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
}
.icon-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.icon-picker-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.close-btn {
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--panel-2);
color: var(--text);
cursor: pointer;
font-size: 14px;
}
.icon-picker-search {
padding: 12px 20px;
border-bottom: 1px solid var(--border);
}
.icon-picker-search input {
width: 100%;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
color: var(--text);
font-size: 14px;
}
.icon-picker-search input::placeholder {
color: var(--muted);
}
.icon-picker-current {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
background: var(--panel-2);
border-bottom: 1px solid var(--border);
}
.current-label {
font-size: 12px;
color: var(--muted);
}
.current-icon {
font-size: 24px;
color: var(--accent);
}
.current-name {
flex: 1;
font-size: 14px;
}
.remove-btn {
background: transparent;
border: 1px solid rgba(255, 107, 107, 0.4);
color: #ffd1d1;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
}
.icon-grid {
flex: 1;
overflow-y: auto;
padding: 16px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 8px;
}
.icon-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 8px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
transition: border-color 0.2s, transform 0.15s;
}
.icon-btn:hover {
border-color: var(--accent);
transform: translateY(-1px);
}
.icon-btn.selected {
border-color: var(--accent-2);
background: rgba(125, 211, 176, 0.1);
}
.icon-btn .material-symbols-outlined {
font-size: 24px;
color: var(--text);
}
.icon-btn .icon-name {
font-size: 10px;
color: var(--muted);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
max-width: 100%;
}
.no-results {
padding: 40px 20px;
text-align: center;
color: var(--muted);
}
</style>

View File

@ -18,6 +18,10 @@
<span class="element-title">LED</span> <span class="element-title">LED</span>
<span class="element-sub">Status</span> <span class="element-sub">Status</span>
</button> </button>
<button class="element-btn" @click="store.addWidget('icon')">
<span class="element-title">Icon</span>
<span class="element-sub">Symbol</span>
</button>
</div> </div>
</section> </section>

View File

@ -61,6 +61,38 @@
</div> </div>
</template> </template>
<!-- Icon Widget -->
<template v-if="key === 'icon'">
<h4>Icon</h4>
<div class="prop-row">
<label>Icon</label>
<button class="icon-select-btn" @click="showIconPicker = true">
<span v-if="w.iconCodepoint" class="material-symbols-outlined">{{ String.fromCodePoint(w.iconCodepoint) }}</span>
<span v-else>Auswaehlen</span>
</button>
</div>
<div class="prop-row">
<label>Groesse</label>
<select v-model.number="w.iconSize">
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
</select>
</div>
<div class="prop-row"><label>Modus</label>
<select v-model.number="w.textSrc">
<option v-for="opt in sourceOptions.icon" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</select>
</div>
<div v-if="w.textSrc === 2" class="prop-row">
<label>KNX Objekt</label>
<select v-model.number="w.knxAddr">
<option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="addr.index" :value="addr.index">
GO{{ addr.index }} ({{ addr.addrStr }})
</option>
</select>
</div>
</template>
<!-- Typography --> <!-- Typography -->
<template v-if="key === 'label' || key === 'button'"> <template v-if="key === 'label' || key === 'button'">
<h4>Typo</h4> <h4>Typo</h4>
@ -71,12 +103,53 @@
</div> </div>
</template> </template>
<!-- Icon for Label/Button -->
<template v-if="key === 'label' || key === 'button'">
<h4>Icon</h4>
<div class="prop-row">
<label>Icon</label>
<button class="icon-select-btn" @click="showIconPicker = true">
<span v-if="w.iconCodepoint" class="material-symbols-outlined">{{ String.fromCodePoint(w.iconCodepoint) }}</span>
<span v-else>Kein Icon</span>
</button>
<button v-if="w.iconCodepoint" class="icon-remove-btn" @click="w.iconCodepoint = 0">x</button>
</div>
<template v-if="w.iconCodepoint">
<div class="prop-row">
<label>Position</label>
<select v-model.number="w.iconPosition">
<option :value="0">Links</option>
<option :value="1">Rechts</option>
<option :value="2">Oben</option>
<option :value="3">Unten</option>
</select>
</div>
<div class="prop-row">
<label>Icon-Gr.</label>
<select v-model.number="w.iconSize">
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
</select>
</div>
<div class="prop-row">
<label>Abstand</label>
<input type="number" v-model.number="w.iconGap" min="0" max="50">
</div>
</template>
</template>
<!-- Style --> <!-- Style -->
<template v-if="key === 'led'"> <template v-if="key === 'led'">
<h4>Stil</h4> <h4>Stil</h4>
<div class="prop-row"><label>Farbe</label><input type="color" v-model="w.bgColor"></div> <div class="prop-row"><label>Farbe</label><input type="color" v-model="w.bgColor"></div>
<div class="prop-row"><label>Helligkeit</label><input type="number" min="0" max="255" v-model.number="w.bgOpacity"></div> <div class="prop-row"><label>Helligkeit</label><input type="number" min="0" max="255" v-model.number="w.bgOpacity"></div>
</template> </template>
<template v-else-if="key === 'icon'">
<h4>Stil</h4>
<div class="prop-row"><label>Farbe</label><input type="color" v-model="w.textColor"></div>
<div class="prop-row"><label>Hintergrund</label><input type="color" v-model="w.bgColor"></div>
<div class="prop-row"><label>Deckkraft</label><input type="number" min="0" max="255" v-model.number="w.bgOpacity"></div>
<div class="prop-row"><label>Radius</label><input type="number" v-model.number="w.radius"></div>
</template>
<template v-else> <template v-else>
<h4>Stil</h4> <h4>Stil</h4>
<div class="prop-row"><label>Textfarbe</label><input type="color" v-model="w.textColor"></div> <div class="prop-row"><label>Textfarbe</label><input type="color" v-model="w.textColor"></div>
@ -86,12 +159,14 @@
</template> </template>
<!-- Shadow/Glow --> <!-- Shadow/Glow -->
<h4>{{ key === 'led' ? 'Glow' : 'Schatten' }}</h4> <template v-if="key !== 'icon'">
<div class="prop-row"><label>Aktiv</label><input type="checkbox" v-model="w.shadow.enabled"></div> <h4>{{ key === 'led' ? 'Glow' : 'Schatten' }}</h4>
<div class="prop-row" v-if="key !== 'led'"><label>X</label><input type="number" v-model.number="w.shadow.x"></div> <div class="prop-row"><label>Aktiv</label><input type="checkbox" v-model="w.shadow.enabled"></div>
<div class="prop-row" v-if="key !== 'led'"><label>Y</label><input type="number" v-model.number="w.shadow.y"></div> <div class="prop-row" v-if="key !== 'led'"><label>X</label><input type="number" v-model.number="w.shadow.x"></div>
<div class="prop-row"><label>Blur</label><input type="number" v-model.number="w.shadow.blur"></div> <div class="prop-row" v-if="key !== 'led'"><label>Y</label><input type="number" v-model.number="w.shadow.y"></div>
<div class="prop-row"><label>Farbe</label><input type="color" v-model="w.shadow.color"></div> <div class="prop-row"><label>Blur</label><input type="number" v-model.number="w.shadow.blur"></div>
<div class="prop-row"><label>Farbe</label><input type="color" v-model="w.shadow.color"></div>
</template>
<!-- Button Actions --> <!-- Button Actions -->
<template v-if="key === 'button'"> <template v-if="key === 'button'">
@ -127,18 +202,27 @@
</div> </div>
</div> </div>
</section> </section>
<!-- Icon Picker Modal -->
<IconPicker
v-if="showIconPicker"
v-model="w.iconCodepoint"
@close="showIconPicker = false"
/>
</aside> </aside>
</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 { typeKeyFor } from '../utils'; import { typeKeyFor } from '../utils';
import { sourceOptions, textSources, fontSizes, BUTTON_ACTIONS, defaultFormats, WIDGET_TYPES } from '../constants'; import { sourceOptions, textSources, fontSizes, BUTTON_ACTIONS, defaultFormats, WIDGET_TYPES } from '../constants';
import IconPicker from './IconPicker.vue';
const store = useEditorStore(); const store = useEditorStore();
const w = computed(() => store.selectedWidget); const w = computed(() => store.selectedWidget);
const key = computed(() => w.value ? typeKeyFor(w.value.type) : 'label'); const key = computed(() => w.value ? typeKeyFor(w.value.type) : 'label');
const showIconPicker = ref(false);
const writeableAddresses = computed(() => store.knxAddresses.filter(a => a.write)); const writeableAddresses = computed(() => store.knxAddresses.filter(a => a.write));
@ -150,3 +234,41 @@ function handleTextSrcChange() {
} }
} }
</script> </script>
<style scoped>
.icon-select-btn {
flex: 1;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 6px 10px;
color: var(--text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 12px;
}
.icon-select-btn:hover {
border-color: var(--accent);
}
.icon-select-btn .material-symbols-outlined {
font-size: 20px;
}
.icon-remove-btn {
width: 28px;
height: 28px;
border-radius: 6px;
border: 1px solid rgba(255, 107, 107, 0.4);
background: transparent;
color: #ffd1d1;
cursor: pointer;
display: grid;
place-items: center;
font-size: 12px;
}
</style>

View File

@ -5,7 +5,8 @@
selected: selected, selected: selected,
'widget-label': isLabel, 'widget-label': isLabel,
'widget-button': isButton, 'widget-button': isButton,
'widget-led': isLed 'widget-led': isLed,
'widget-icon': isIcon
}" }"
:style="computedStyle" :style="computedStyle"
@mousedown="$emit('drag-start', $event)" @mousedown="$emit('drag-start', $event)"
@ -19,13 +20,40 @@
@touchstart.stop="$emit('resize-start', $event)" @touchstart.stop="$emit('resize-start', $event)"
></div> ></div>
{{ widget.text }} <!-- Icon-only Widget -->
<template v-if="isIcon">
<span class="material-symbols-outlined icon-display" :style="iconOnlyStyle">
{{ iconChar }}
</span>
</template>
<!-- Label/Button with Icon -->
<template v-else-if="hasIcon">
<div class="widget-content" :style="contentStyle">
<span
v-if="iconPosition === 0 || iconPosition === 2"
class="material-symbols-outlined widget-icon"
:style="iconStyle"
>{{ iconChar }}</span>
<span class="widget-text">{{ widget.text }}</span>
<span
v-if="iconPosition === 1 || iconPosition === 3"
class="material-symbols-outlined widget-icon"
:style="iconStyle"
>{{ iconChar }}</span>
</div>
</template>
<!-- Standard (no icon) -->
<template v-else>
{{ widget.text }}
</template>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { WIDGET_TYPES, fontSizes } from '../constants'; import { WIDGET_TYPES, fontSizes, ICON_POSITIONS } from '../constants';
import { clamp, hexToRgba } from '../utils'; import { clamp, hexToRgba } from '../utils';
const props = defineProps({ const props = defineProps({
@ -39,6 +67,56 @@ defineEmits(['select', 'drag-start', 'resize-start']);
const isLabel = computed(() => props.widget.type === WIDGET_TYPES.LABEL); const isLabel = computed(() => props.widget.type === WIDGET_TYPES.LABEL);
const isButton = computed(() => props.widget.type === WIDGET_TYPES.BUTTON); const isButton = computed(() => props.widget.type === WIDGET_TYPES.BUTTON);
const isLed = computed(() => props.widget.type === WIDGET_TYPES.LED); const isLed = computed(() => props.widget.type === WIDGET_TYPES.LED);
const isIcon = computed(() => props.widget.type === WIDGET_TYPES.ICON);
const hasIcon = computed(() => {
return (isLabel.value || isButton.value) && props.widget.iconCodepoint > 0;
});
const iconChar = computed(() => {
const cp = props.widget.iconCodepoint || 0xe88a;
return String.fromCodePoint(cp);
});
const iconPosition = computed(() => props.widget.iconPosition || 0);
const isVerticalLayout = computed(() => {
return iconPosition.value === ICON_POSITIONS.TOP || iconPosition.value === ICON_POSITIONS.BOTTOM;
});
const contentStyle = computed(() => {
const s = props.scale;
const gap = (props.widget.iconGap || 8) * s;
return {
display: 'flex',
flexDirection: isVerticalLayout.value ? 'column' : 'row',
alignItems: 'center',
justifyContent: 'center',
gap: `${gap}px`,
width: '100%',
height: '100%'
};
});
const iconStyle = computed(() => {
const s = props.scale;
const sizeIdx = props.widget.iconSize ?? props.widget.fontSize ?? 1;
const size = fontSizes[sizeIdx] || 18;
return {
fontSize: `${size * s}px`,
color: props.widget.textColor
};
});
const iconOnlyStyle = 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 computedStyle = computed(() => { const computedStyle = computed(() => {
const w = props.widget; const w = props.widget;
@ -58,11 +136,27 @@ const computedStyle = computed(() => {
touchAction: 'none' touchAction: 'none'
}; };
if (isLabel.value) { if (isIcon.value) {
// Icon widget
style.display = 'flex';
style.alignItems = 'center';
style.justifyContent = 'center';
if (w.bgOpacity > 0) { if (w.bgOpacity > 0) {
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2); const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
style.background = hexToRgba(w.bgColor, alpha); style.background = hexToRgba(w.bgColor, alpha);
} }
if (w.radius > 0) {
style.borderRadius = `${w.radius * s}px`;
}
} else if (isLabel.value) {
if (w.bgOpacity > 0) {
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
style.background = hexToRgba(w.bgColor, alpha);
}
if (hasIcon.value) {
style.display = 'flex';
style.alignItems = 'center';
}
} else if (isButton.value) { } else if (isButton.value) {
style.background = w.bgColor; style.background = w.bgColor;
style.borderRadius = `${w.radius * s}px`; style.borderRadius = `${w.radius * s}px`;
@ -104,3 +198,25 @@ const computedStyle = computed(() => {
return style; return style;
}); });
</script> </script>
<style scoped>
.widget-content {
display: flex;
align-items: center;
justify-content: center;
}
.widget-icon {
flex-shrink: 0;
}
.widget-text {
flex-shrink: 0;
}
.icon-display {
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@ -5,7 +5,15 @@ export const MAX_SCREENS = 8;
export const WIDGET_TYPES = { export const WIDGET_TYPES = {
LABEL: 0, LABEL: 0,
BUTTON: 1, BUTTON: 1,
LED: 2 LED: 2,
ICON: 3
};
export const ICON_POSITIONS = {
LEFT: 0,
RIGHT: 1,
TOP: 2,
BOTTOM: 3
}; };
export const BUTTON_ACTIONS = { export const BUTTON_ACTIONS = {
@ -17,13 +25,15 @@ export const BUTTON_ACTIONS = {
export const TYPE_KEYS = { export const TYPE_KEYS = {
0: 'label', 0: 'label',
1: 'button', 1: 'button',
2: 'led' 2: 'led',
3: 'icon'
}; };
export const TYPE_LABELS = { export const TYPE_LABELS = {
label: 'Label', label: 'Label',
button: 'Button', button: 'Button',
led: 'LED' led: 'LED',
icon: 'Icon'
}; };
export const textSources = { export const textSources = {
@ -37,7 +47,15 @@ export const textSources = {
export const sourceOptions = { export const sourceOptions = {
label: [0, 1, 2, 3, 4], label: [0, 1, 2, 3, 4],
button: [0], button: [0],
led: [0, 2] led: [0, 2],
icon: [0, 2]
};
export const ICON_DEFAULTS = {
iconCodepoint: 0,
iconPosition: 0,
iconSize: 1,
iconGap: 8
}; };
export const fontSizes = [14, 18, 22, 28, 36, 48]; export const fontSizes = [14, 18, 22, 28, 36, 48];
@ -65,7 +83,11 @@ export const WIDGET_DEFAULTS = {
knxAddrWrite: 0, knxAddrWrite: 0,
knxAddr: 0, knxAddr: 0,
action: BUTTON_ACTIONS.KNX, action: BUTTON_ACTIONS.KNX,
targetScreen: 0 targetScreen: 0,
iconCodepoint: 0,
iconPosition: 0,
iconSize: 1,
iconGap: 8
}, },
button: { button: {
w: 130, w: 130,
@ -82,7 +104,11 @@ export const WIDGET_DEFAULTS = {
knxAddrWrite: 0, knxAddrWrite: 0,
knxAddr: 0, knxAddr: 0,
action: BUTTON_ACTIONS.KNX, action: BUTTON_ACTIONS.KNX,
targetScreen: 0 targetScreen: 0,
iconCodepoint: 0,
iconPosition: 0,
iconSize: 1,
iconGap: 8
}, },
led: { led: {
w: 60, w: 60,
@ -99,6 +125,31 @@ export const WIDGET_DEFAULTS = {
knxAddrWrite: 0, knxAddrWrite: 0,
knxAddr: 0, knxAddr: 0,
action: BUTTON_ACTIONS.KNX, action: BUTTON_ACTIONS.KNX,
targetScreen: 0 targetScreen: 0,
iconCodepoint: 0,
iconPosition: 0,
iconSize: 1,
iconGap: 8
},
icon: {
w: 48,
h: 48,
text: '',
textSrc: 0,
fontSize: 3,
textColor: '#FFFFFF',
bgColor: '#0E1217',
bgOpacity: 0,
radius: 0,
shadow: { enabled: false, x: 0, y: 0, blur: 0, spread: 0, color: '#000000' },
isToggle: false,
knxAddrWrite: 0,
knxAddr: 0,
action: BUTTON_ACTIONS.KNX,
targetScreen: 0,
iconCodepoint: 0xe88a,
iconPosition: 0,
iconSize: 3,
iconGap: 8
} }
}; };

296
web-interface/src/icons.js Normal file
View File

@ -0,0 +1,296 @@
// Material Symbols Outlined icon list
// Codepoints from https://github.com/google/material-design-icons
// Format: { name, codepoint, tags (for search) }
export const MATERIAL_ICONS = [
// Home & Building
{ name: 'home', codepoint: 0xe88a, tags: ['haus', 'start', 'main'] },
{ name: 'cottage', codepoint: 0xe587, tags: ['haus', 'huette'] },
{ name: 'apartment', codepoint: 0xea40, tags: ['wohnung', 'gebaeude'] },
{ name: 'meeting_room', codepoint: 0xeb4f, tags: ['raum', 'zimmer', 'tuer'] },
{ name: 'door_front', codepoint: 0xeffc, tags: ['tuer', 'eingang'] },
{ name: 'door_back', codepoint: 0xeffb, tags: ['tuer', 'hinten'] },
{ name: 'door_sliding', codepoint: 0xeffd, tags: ['tuer', 'schiebe'] },
{ name: 'window', codepoint: 0xf11f, tags: ['fenster'] },
{ name: 'garage', codepoint: 0xf011, tags: ['garage', 'auto'] },
{ name: 'stairs', codepoint: 0xf1a9, tags: ['treppe', 'stufen'] },
{ name: 'roofing', codepoint: 0xf201, tags: ['dach'] },
{ name: 'fence', codepoint: 0xf1f6, tags: ['zaun', 'garten'] },
{ name: 'yard', codepoint: 0xf1f5, tags: ['garten', 'hof'] },
// Lighting
{ name: 'lightbulb', codepoint: 0xe0f0, tags: ['licht', 'lampe', 'bulb', 'gluehbirne'] },
{ name: 'light', codepoint: 0xf02a, tags: ['licht', 'lampe'] },
{ name: 'light_mode', codepoint: 0xe518, tags: ['licht', 'hell', 'tag', 'sonne'] },
{ name: 'dark_mode', codepoint: 0xe51c, tags: ['dunkel', 'nacht', 'mond'] },
{ name: 'wb_sunny', codepoint: 0xe430, tags: ['sonne', 'hell', 'tag'] },
{ name: 'nightlight', codepoint: 0xefda, tags: ['nachtlicht', 'mond'] },
{ name: 'fluorescent', codepoint: 0xf00d, tags: ['leuchtstoff', 'roehre'] },
{ name: 'highlight', codepoint: 0xe25f, tags: ['highlight', 'spot'] },
{ name: 'wb_incandescent', codepoint: 0xe42e, tags: ['gluehlampe'] },
{ name: 'tungsten', codepoint: 0xf07a, tags: ['wolfram', 'lampe'] },
{ name: 'ceiling', codepoint: 0xf1ff, tags: ['decke', 'deckenlampe'] },
{ name: 'floor_lamp', codepoint: 0xf009, tags: ['stehlampe', 'boden'] },
{ name: 'table_lamp', codepoint: 0xf00a, tags: ['tischlampe'] },
{ name: 'wall_lamp', codepoint: 0xf00b, tags: ['wandlampe'] },
{ name: 'outdoor_lamp', codepoint: 0xf1d4, tags: ['aussenlampe', 'garten'] },
// Climate & Temperature
{ name: 'thermostat', codepoint: 0xf076, tags: ['temperatur', 'heizung', 'klima'] },
{ name: 'thermostat_auto', codepoint: 0xf077, tags: ['temperatur', 'auto', 'heizung'] },
{ name: 'device_thermostat', codepoint: 0xe1ff, tags: ['temperatur', 'regler'] },
{ name: 'heat', codepoint: 0xf1b3, tags: ['heizung', 'waerme'] },
{ name: 'ac_unit', codepoint: 0xeb3b, tags: ['klimaanlage', 'kuehlung', 'ac'] },
{ name: 'air', codepoint: 0xefd8, tags: ['luft', 'wind', 'lueftung'] },
{ name: 'mode_fan', codepoint: 0xf00e, tags: ['ventilator', 'luefter', 'fan'] },
{ name: 'mode_fan_off', codepoint: 0xec17, tags: ['ventilator', 'aus'] },
{ name: 'hvac', codepoint: 0xf10e, tags: ['heizung', 'lueftung', 'klima'] },
{ name: 'water_drop', codepoint: 0xe798, tags: ['wasser', 'tropfen', 'feuchtigkeit'] },
{ name: 'humidity_percentage', codepoint: 0xf87e, tags: ['feuchtigkeit', 'prozent'] },
{ name: 'humidity_high', codepoint: 0xf163, tags: ['feuchtigkeit', 'hoch'] },
{ name: 'humidity_low', codepoint: 0xf164, tags: ['feuchtigkeit', 'niedrig'] },
{ name: 'humidity_mid', codepoint: 0xf165, tags: ['feuchtigkeit', 'mittel'] },
{ name: 'dew_point', codepoint: 0xf879, tags: ['taupunkt'] },
// Blinds & Covers
{ name: 'blinds', codepoint: 0xe286, tags: ['jalousie', 'rollo', 'beschattung'] },
{ name: 'blinds_closed', codepoint: 0xec1f, tags: ['jalousie', 'geschlossen'] },
{ name: 'vertical_shades', codepoint: 0xf1cb, tags: ['lamellen', 'vertikal'] },
{ name: 'vertical_shades_closed', codepoint: 0xf1ca, tags: ['lamellen', 'geschlossen'] },
{ name: 'roller_shades', codepoint: 0xec12, tags: ['rollo', 'screen'] },
{ name: 'roller_shades_closed', codepoint: 0xec11, tags: ['rollo', 'geschlossen'] },
{ name: 'curtains', codepoint: 0xec1e, tags: ['vorhang', 'gardine'] },
{ name: 'curtains_closed', codepoint: 0xec1d, tags: ['vorhang', 'geschlossen'] },
// Power & Energy
{ name: 'power_settings_new', codepoint: 0xe8ac, tags: ['power', 'strom', 'an', 'aus'] },
{ name: 'power', codepoint: 0xe63c, tags: ['strom', 'energie'] },
{ name: 'power_off', codepoint: 0xe646, tags: ['aus', 'strom'] },
{ name: 'bolt', codepoint: 0xea0b, tags: ['blitz', 'strom', 'energie'] },
{ name: 'electric_bolt', codepoint: 0xec1c, tags: ['blitz', 'strom'] },
{ name: 'electric_meter', codepoint: 0xec1b, tags: ['zaehler', 'strom'] },
{ name: 'outlet', codepoint: 0xe9ba, tags: ['steckdose', 'dose'] },
{ name: 'electrical_services', codepoint: 0xf102, tags: ['elektrik', 'strom'] },
{ name: 'battery_full', codepoint: 0xe1a4, tags: ['batterie', 'voll', 'akku'] },
{ name: 'battery_charging_full', codepoint: 0xe1a3, tags: ['batterie', 'laden'] },
{ name: 'battery_0_bar', codepoint: 0xebdc, tags: ['batterie', 'leer'] },
{ name: 'solar_power', codepoint: 0xec0f, tags: ['solar', 'pv', 'photovoltaik'] },
{ name: 'energy_savings_leaf', codepoint: 0xf1ae, tags: ['energie', 'sparen', 'eco'] },
{ name: 'ev_station', codepoint: 0xe56d, tags: ['elektro', 'auto', 'laden'] },
// Security & Safety
{ name: 'lock', codepoint: 0xe897, tags: ['schloss', 'sicherheit', 'gesperrt'] },
{ name: 'lock_open', codepoint: 0xe898, tags: ['schloss', 'offen', 'entsperrt'] },
{ name: 'key', codepoint: 0xe73c, tags: ['schluessel'] },
{ name: 'vpn_key', codepoint: 0xe62f, tags: ['schluessel', 'sicherheit'] },
{ name: 'security', codepoint: 0xe32a, tags: ['sicherheit', 'schutz'] },
{ name: 'shield', codepoint: 0xe9e0, tags: ['schild', 'schutz'] },
{ name: 'verified_user', codepoint: 0xe8e8, tags: ['sicher', 'verifiziert'] },
{ name: 'gpp_good', codepoint: 0xf1f8, tags: ['schutz', 'gut'] },
{ name: 'gpp_bad', codepoint: 0xf1f7, tags: ['schutz', 'schlecht', 'alarm'] },
{ name: 'crisis_alert', codepoint: 0xebe9, tags: ['alarm', 'warnung', 'notfall'] },
{ name: 'emergency', codepoint: 0xe1eb, tags: ['notfall', 'alarm'] },
{ name: 'warning', codepoint: 0xe002, tags: ['warnung', 'achtung'] },
{ name: 'error', codepoint: 0xe000, tags: ['fehler', 'error'] },
{ name: 'notifications', codepoint: 0xe7f4, tags: ['benachrichtigung', 'glocke'] },
{ name: 'notifications_active', codepoint: 0xe7f7, tags: ['benachrichtigung', 'aktiv'] },
{ name: 'notifications_off', codepoint: 0xe7f6, tags: ['benachrichtigung', 'aus'] },
// Sensors & Detection
{ name: 'sensors', codepoint: 0xe51e, tags: ['sensor', 'erkennung'] },
{ name: 'sensors_off', codepoint: 0xe51f, tags: ['sensor', 'aus'] },
{ name: 'motion_sensor_active', codepoint: 0xf090, tags: ['bewegung', 'aktiv'] },
{ name: 'motion_sensor_idle', codepoint: 0xf091, tags: ['bewegung', 'inaktiv'] },
{ name: 'motion_sensor_alert', codepoint: 0xf092, tags: ['bewegung', 'alarm'] },
{ name: 'nest_cam_outdoor', codepoint: 0xf8bd, tags: ['kamera', 'aussen'] },
{ name: 'videocam', codepoint: 0xe04b, tags: ['kamera', 'video'] },
{ name: 'videocam_off', codepoint: 0xe04c, tags: ['kamera', 'aus'] },
{ name: 'smoke_detector', codepoint: 0xf099, tags: ['rauch', 'rauchmelder'] },
{ name: 'detector_smoke', codepoint: 0xf239, tags: ['rauch', 'melder'] },
{ name: 'co2', codepoint: 0xe7b1, tags: ['co2', 'luft', 'qualitaet'] },
{ name: 'flood', codepoint: 0xebe6, tags: ['wasser', 'flut', 'leck'] },
// Media & Entertainment
{ name: 'tv', codepoint: 0xe333, tags: ['fernseher', 'television'] },
{ name: 'connected_tv', codepoint: 0xe998, tags: ['smart', 'tv', 'fernseher'] },
{ name: 'speaker', codepoint: 0xe32d, tags: ['lautsprecher', 'audio'] },
{ name: 'speaker_group', codepoint: 0xe32e, tags: ['lautsprecher', 'gruppe'] },
{ name: 'volume_up', codepoint: 0xe050, tags: ['laut', 'lauter', 'volume'] },
{ name: 'volume_down', codepoint: 0xe04d, tags: ['leise', 'leiser', 'volume'] },
{ name: 'volume_mute', codepoint: 0xe04e, tags: ['stumm', 'mute'] },
{ name: 'volume_off', codepoint: 0xe04f, tags: ['aus', 'stumm'] },
{ name: 'play_arrow', codepoint: 0xe037, tags: ['play', 'abspielen'] },
{ name: 'pause', codepoint: 0xe034, tags: ['pause', 'anhalten'] },
{ name: 'stop', codepoint: 0xe047, tags: ['stop', 'anhalten'] },
{ name: 'skip_next', codepoint: 0xe044, tags: ['naechstes', 'weiter'] },
{ name: 'skip_previous', codepoint: 0xe045, tags: ['vorheriges', 'zurueck'] },
{ name: 'music_note', codepoint: 0xe405, tags: ['musik', 'note'] },
{ name: 'radio', codepoint: 0xe03e, tags: ['radio'] },
// Appliances
{ name: 'kitchen', codepoint: 0xeb47, tags: ['kueche'] },
{ name: 'coffee_maker', codepoint: 0xefe1, tags: ['kaffee', 'maschine'] },
{ name: 'coffee', codepoint: 0xefef, tags: ['kaffee', 'tasse'] },
{ name: 'microwave', codepoint: 0xf204, tags: ['mikrowelle'] },
{ name: 'oven', codepoint: 0xf07e, tags: ['ofen', 'backofen', 'herd'] },
{ name: 'countertops', codepoint: 0xf1f4, tags: ['arbeitsplatte', 'kueche'] },
{ name: 'dishwasher', codepoint: 0xf02d, tags: ['spuelmaschine', 'geschirr'] },
{ name: 'local_laundry_service', codepoint: 0xe54a, tags: ['waschmaschine', 'waesche'] },
{ name: 'dry_cleaning', codepoint: 0xea58, tags: ['trockner', 'reinigung'] },
{ name: 'iron', codepoint: 0xe583, tags: ['buegeln', 'buegeleisen'] },
{ name: 'vacuum', codepoint: 0xf1d2, tags: ['staubsauger'] },
// Navigation & Arrows
{ name: 'arrow_back', codepoint: 0xe5c4, tags: ['zurueck', 'links', 'pfeil'] },
{ name: 'arrow_forward', codepoint: 0xe5c8, tags: ['weiter', 'rechts', 'pfeil'] },
{ name: 'arrow_upward', codepoint: 0xe5d8, tags: ['hoch', 'oben', 'pfeil'] },
{ name: 'arrow_downward', codepoint: 0xe5db, tags: ['runter', 'unten', 'pfeil'] },
{ name: 'expand_less', codepoint: 0xe5ce, tags: ['weniger', 'hoch', 'pfeil'] },
{ name: 'expand_more', codepoint: 0xe5cf, tags: ['mehr', 'runter', 'pfeil'] },
{ name: 'chevron_left', codepoint: 0xe5cb, tags: ['links', 'pfeil'] },
{ name: 'chevron_right', codepoint: 0xe5cc, tags: ['rechts', 'pfeil'] },
{ name: 'keyboard_arrow_up', codepoint: 0xe316, tags: ['hoch', 'pfeil'] },
{ name: 'keyboard_arrow_down', codepoint: 0xe313, tags: ['runter', 'pfeil'] },
{ name: 'keyboard_arrow_left', codepoint: 0xe314, tags: ['links', 'pfeil'] },
{ name: 'keyboard_arrow_right', codepoint: 0xe315, tags: ['rechts', 'pfeil'] },
{ name: 'menu', codepoint: 0xe5d2, tags: ['menu', 'hamburger'] },
{ name: 'close', codepoint: 0xe5cd, tags: ['schliessen', 'x'] },
{ name: 'refresh', codepoint: 0xe5d5, tags: ['aktualisieren', 'refresh'] },
{ name: 'sync', codepoint: 0xe627, tags: ['sync', 'synchronisieren'] },
// Status & Indicators
{ name: 'check', codepoint: 0xe5ca, tags: ['check', 'ok', 'fertig'] },
{ name: 'check_circle', codepoint: 0xe86c, tags: ['check', 'ok', 'kreis'] },
{ name: 'cancel', codepoint: 0xe5c9, tags: ['abbrechen', 'cancel'] },
{ name: 'block', codepoint: 0xe14b, tags: ['blockiert', 'verboten'] },
{ name: 'add', codepoint: 0xe145, tags: ['plus', 'hinzufuegen'] },
{ name: 'remove', codepoint: 0xe15b, tags: ['minus', 'entfernen'] },
{ name: 'add_circle', codepoint: 0xe147, tags: ['plus', 'kreis'] },
{ name: 'remove_circle', codepoint: 0xe15c, tags: ['minus', 'kreis'] },
{ name: 'help', codepoint: 0xe887, tags: ['hilfe', 'fragezeichen'] },
{ name: 'info', codepoint: 0xe88e, tags: ['info', 'information'] },
{ name: 'pending', codepoint: 0xef64, tags: ['warten', 'pending'] },
{ name: 'schedule', codepoint: 0xe8b5, tags: ['zeit', 'uhr', 'timer'] },
{ name: 'timer', codepoint: 0xe425, tags: ['timer', 'stoppuhr'] },
{ name: 'hourglass_empty', codepoint: 0xe88b, tags: ['sanduhr', 'warten'] },
// Rooms
{ name: 'bed', codepoint: 0xefeb, tags: ['bett', 'schlafzimmer'] },
{ name: 'king_bed', codepoint: 0xea45, tags: ['bett', 'gross'] },
{ name: 'single_bed', codepoint: 0xea48, tags: ['bett', 'einzelbett'] },
{ name: 'bedroom_parent', codepoint: 0xf885, tags: ['schlafzimmer', 'eltern'] },
{ name: 'bedroom_child', codepoint: 0xf884, tags: ['schlafzimmer', 'kind'] },
{ name: 'bedroom_baby', codepoint: 0xf883, tags: ['schlafzimmer', 'baby'] },
{ name: 'living', codepoint: 0xf07b, tags: ['wohnzimmer', 'sofa'] },
{ name: 'chair', codepoint: 0xefed, tags: ['stuhl', 'sitzen'] },
{ name: 'chair_alt', codepoint: 0xefee, tags: ['stuhl'] },
{ name: 'dining', codepoint: 0xeff4, tags: ['esszimmer', 'essen'] },
{ name: 'bathroom', codepoint: 0xefdd, tags: ['bad', 'badezimmer'] },
{ name: 'bathtub', codepoint: 0xea41, tags: ['badewanne', 'bad'] },
{ name: 'shower', codepoint: 0xf061, tags: ['dusche', 'bad'] },
{ name: 'hot_tub', codepoint: 0xeb46, tags: ['whirlpool', 'spa'] },
{ name: 'soap', codepoint: 0xf1b2, tags: ['seife', 'waschen'] },
// Weather
{ name: 'wb_cloudy', codepoint: 0xe42d, tags: ['wolke', 'bewoelkt'] },
{ name: 'cloud', codepoint: 0xe2bd, tags: ['wolke'] },
{ name: 'cloudy', codepoint: 0xf171, tags: ['bewoelkt'] },
{ name: 'partly_cloudy_day', codepoint: 0xf172, tags: ['teilweise', 'bewoelkt', 'tag'] },
{ name: 'partly_cloudy_night', codepoint: 0xf174, tags: ['teilweise', 'bewoelkt', 'nacht'] },
{ name: 'rainy', codepoint: 0xf176, tags: ['regen', 'regnerisch'] },
{ name: 'thunderstorm', codepoint: 0xebdb, tags: ['gewitter', 'blitz'] },
{ name: 'snowing', codepoint: 0xf17c, tags: ['schnee', 'winter'] },
{ name: 'foggy', codepoint: 0xf175, tags: ['nebel', 'neblig'] },
{ name: 'sunny', codepoint: 0xf177, tags: ['sonne', 'sonnig'] },
{ name: 'weather_mix', codepoint: 0xf879, tags: ['wetter', 'gemischt'] },
// Settings & Configuration
{ name: 'settings', codepoint: 0xe8b8, tags: ['einstellungen', 'zahnrad'] },
{ name: 'tune', codepoint: 0xe429, tags: ['anpassen', 'regler'] },
{ name: 'build', codepoint: 0xe869, tags: ['werkzeug', 'schraubenschluessel'] },
{ name: 'construction', codepoint: 0xea3c, tags: ['baustelle', 'werkzeug'] },
{ name: 'handyman', codepoint: 0xf10b, tags: ['handwerker', 'werkzeug'] },
{ name: 'miscellaneous_services', codepoint: 0xf10c, tags: ['dienste', 'verschiedenes'] },
{ name: 'edit', codepoint: 0xe3c9, tags: ['bearbeiten', 'stift'] },
{ name: 'delete', codepoint: 0xe872, tags: ['loeschen', 'papierkorb'] },
{ name: 'save', codepoint: 0xe161, tags: ['speichern'] },
{ name: 'content_copy', codepoint: 0xe14d, tags: ['kopieren'] },
{ name: 'content_paste', codepoint: 0xe14f, tags: ['einfuegen'] },
// People
{ name: 'person', codepoint: 0xe7fd, tags: ['person', 'benutzer'] },
{ name: 'people', codepoint: 0xe7fb, tags: ['personen', 'gruppe'] },
{ name: 'group', codepoint: 0xe7ef, tags: ['gruppe', 'team'] },
{ name: 'family_restroom', codepoint: 0xe59d, tags: ['familie'] },
{ name: 'child_care', codepoint: 0xeb41, tags: ['kind', 'baby'] },
{ name: 'elderly', codepoint: 0xf21a, tags: ['aelter', 'senior'] },
{ name: 'pets', codepoint: 0xe91d, tags: ['haustier', 'tier', 'pfote'] },
// Connectivity
{ name: 'wifi', codepoint: 0xe63e, tags: ['wlan', 'wireless', 'netzwerk'] },
{ name: 'wifi_off', codepoint: 0xe648, tags: ['wlan', 'aus'] },
{ name: 'signal_wifi_4_bar', codepoint: 0xe1d8, tags: ['wlan', 'signal', 'stark'] },
{ name: 'signal_wifi_0_bar', codepoint: 0xf067, tags: ['wlan', 'signal', 'schwach'] },
{ name: 'bluetooth', codepoint: 0xe1a7, tags: ['bluetooth'] },
{ name: 'bluetooth_connected', codepoint: 0xe1a8, tags: ['bluetooth', 'verbunden'] },
{ name: 'bluetooth_disabled', codepoint: 0xe1a9, tags: ['bluetooth', 'aus'] },
{ name: 'link', codepoint: 0xe157, tags: ['link', 'verbindung'] },
{ name: 'link_off', codepoint: 0xe16f, tags: ['link', 'getrennt'] },
{ name: 'sync_alt', codepoint: 0xea18, tags: ['sync', 'austausch'] },
{ name: 'cloud_sync', codepoint: 0xeb5a, tags: ['cloud', 'sync'] },
{ name: 'cloud_done', codepoint: 0xe2bf, tags: ['cloud', 'fertig'] },
{ name: 'cloud_off', codepoint: 0xe2c0, tags: ['cloud', 'offline'] },
// Misc useful
{ name: 'favorite', codepoint: 0xe87d, tags: ['favorit', 'herz', 'liebe'] },
{ name: 'favorite_border', codepoint: 0xe87e, tags: ['favorit', 'herz', 'rand'] },
{ name: 'star', codepoint: 0xe838, tags: ['stern', 'favorit'] },
{ name: 'star_border', codepoint: 0xe83a, tags: ['stern', 'rand'] },
{ name: 'thumb_up', codepoint: 0xe8dc, tags: ['daumen', 'hoch', 'gut'] },
{ name: 'thumb_down', codepoint: 0xe8db, tags: ['daumen', 'runter', 'schlecht'] },
{ name: 'eco', codepoint: 0xea35, tags: ['eco', 'blatt', 'natur'] },
{ name: 'recycling', codepoint: 0xe760, tags: ['recycling', 'umwelt'] },
{ name: 'local_fire_department', codepoint: 0xef55, tags: ['feuer', 'feuerwehr', 'flamme'] },
{ name: 'whatshot', codepoint: 0xe80e, tags: ['heiss', 'feuer', 'flamme'] },
{ name: 'terrain', codepoint: 0xe564, tags: ['berg', 'natur'] },
{ name: 'park', codepoint: 0xea63, tags: ['park', 'baum', 'natur'] },
{ name: 'grass', codepoint: 0xf205, tags: ['gras', 'rasen', 'garten'] },
{ name: 'spa', codepoint: 0xeb4c, tags: ['spa', 'wellness', 'blume'] },
{ name: 'local_florist', codepoint: 0xe546, tags: ['blume', 'pflanze'] },
{ name: 'pool', codepoint: 0xeb48, tags: ['pool', 'schwimmen', 'wasser'] },
{ name: 'water', codepoint: 0xf084, tags: ['wasser', 'wellen'] },
{ name: 'waves', codepoint: 0xe176, tags: ['wellen', 'wasser'] },
{ name: 'oil_barrel', codepoint: 0xec15, tags: ['oel', 'fass', 'heizoel'] },
{ name: 'gas_meter', codepoint: 0xec19, tags: ['gas', 'zaehler'] },
{ name: 'propane', codepoint: 0xec14, tags: ['propan', 'gas'] },
{ name: 'propane_tank', codepoint: 0xec13, tags: ['propan', 'tank'] },
{ name: 'heat_pump', codepoint: 0xec18, tags: ['waermepumpe', 'heizung'] },
{ name: 'mode_night', codepoint: 0xf036, tags: ['nacht', 'mond', 'dunkel'] },
{ name: 'bedtime', codepoint: 0xef44, tags: ['schlafenszeit', 'nacht'] },
{ name: 'alarm', codepoint: 0xe855, tags: ['wecker', 'alarm'] },
{ name: 'alarm_on', codepoint: 0xe856, tags: ['wecker', 'an'] },
{ name: 'alarm_off', codepoint: 0xe857, tags: ['wecker', 'aus'] },
{ name: 'snooze', codepoint: 0xe046, tags: ['schlummern', 'wecker'] },
];
// Helper to search icons
export function searchIcons(query) {
if (!query || query.length === 0) {
return MATERIAL_ICONS;
}
const q = query.toLowerCase();
return MATERIAL_ICONS.filter(icon =>
icon.name.toLowerCase().includes(q) ||
icon.tags.some(tag => tag.toLowerCase().includes(q))
);
}
// Get icon by codepoint
export function getIconByCodepoint(codepoint) {
return MATERIAL_ICONS.find(icon => icon.codepoint === codepoint);
}
// Convert codepoint to character
export function codepointToChar(codepoint) {
return String.fromCodePoint(codepoint);
}

View File

@ -158,7 +158,14 @@ export const useEditorStore = defineStore('editor', () => {
function addWidget(typeStr) { function addWidget(typeStr) {
if (!activeScreen.value) return; if (!activeScreen.value) return;
const typeValue = typeStr === 'label' ? WIDGET_TYPES.LABEL : (typeStr === 'button' ? WIDGET_TYPES.BUTTON : WIDGET_TYPES.LED); let typeValue;
switch (typeStr) {
case 'label': typeValue = WIDGET_TYPES.LABEL; break;
case 'button': typeValue = WIDGET_TYPES.BUTTON; break;
case 'led': typeValue = WIDGET_TYPES.LED; break;
case 'icon': typeValue = WIDGET_TYPES.ICON; break;
default: typeValue = WIDGET_TYPES.LABEL;
}
const defaults = WIDGET_DEFAULTS[typeStr]; const defaults = WIDGET_DEFAULTS[typeStr];
const w = { const w = {
@ -181,7 +188,11 @@ export const useEditorStore = defineStore('editor', () => {
isToggle: defaults.isToggle, isToggle: defaults.isToggle,
knxAddrWrite: defaults.knxAddrWrite, knxAddrWrite: defaults.knxAddrWrite,
action: defaults.action, action: defaults.action,
targetScreen: defaults.targetScreen targetScreen: defaults.targetScreen,
iconCodepoint: defaults.iconCodepoint || 0,
iconPosition: defaults.iconPosition || 0,
iconSize: defaults.iconSize || 1,
iconGap: defaults.iconGap || 8
}; };
activeScreen.value.widgets.push(w); activeScreen.value.widgets.push(w);

View File

@ -1,4 +1,5 @@
@import "tailwindcss"; @import "tailwindcss";
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200');
:root { :root {
--bg: #0f1419; --bg: #0f1419;
@ -580,3 +581,28 @@ body {
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
} }
/* Material Symbols Icon Font */
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-feature-settings: 'liga';
}
.widget-icon {
display: flex;
align-items: center;
justify-content: center;
}

View File

@ -13,6 +13,7 @@ export function minSizeFor(widget) {
const key = typeKeyFor(widget.type); const key = typeKeyFor(widget.type);
if (key === 'button') return { w: 60, h: 30 }; if (key === 'button') return { w: 60, h: 30 };
if (key === 'led') return { w: 20, h: 20 }; if (key === 'led') return { w: 20, h: 20 };
if (key === 'icon') return { w: 24, h: 24 };
return { w: 40, h: 20 }; return { w: 40, h: 20 };
} }
@ -53,6 +54,12 @@ export function normalizeWidget(w, nextWidgetIdRef) {
// Action defaults // Action defaults
if (w.action === undefined) w.action = 0; // BUTTON_ACTIONS.KNX if (w.action === undefined) w.action = 0; // BUTTON_ACTIONS.KNX
if (w.targetScreen === undefined) w.targetScreen = 0; if (w.targetScreen === undefined) w.targetScreen = 0;
// Icon defaults
if (w.iconCodepoint === undefined) w.iconCodepoint = defaults.iconCodepoint || 0;
if (w.iconPosition === undefined) w.iconPosition = defaults.iconPosition || 0;
if (w.iconSize === undefined) w.iconSize = defaults.iconSize || 1;
if (w.iconGap === undefined) w.iconGap = defaults.iconGap || 8;
} }
export function normalizeScreen(screen, nextScreenIdRef, nextWidgetIdRef) { export function normalizeScreen(screen, nextScreenIdRef, nextWidgetIdRef) {