diff --git a/.cache/clangd/index/ButtonWidget.cpp.6932614AE5FC71F9.idx b/.cache/clangd/index/ButtonWidget.cpp.6932614AE5FC71F9.idx index d2a7947..d22bc34 100644 Binary files a/.cache/clangd/index/ButtonWidget.cpp.6932614AE5FC71F9.idx and b/.cache/clangd/index/ButtonWidget.cpp.6932614AE5FC71F9.idx differ diff --git a/.cache/clangd/index/ButtonWidget.hpp.551D0D3595AEDB81.idx b/.cache/clangd/index/ButtonWidget.hpp.551D0D3595AEDB81.idx index a202e0f..29b5c1d 100644 Binary files a/.cache/clangd/index/ButtonWidget.hpp.551D0D3595AEDB81.idx and b/.cache/clangd/index/ButtonWidget.hpp.551D0D3595AEDB81.idx differ diff --git a/.cache/clangd/index/FileManagerHandlers.cpp.2F53BB33AABE7329.idx b/.cache/clangd/index/FileManagerHandlers.cpp.2F53BB33AABE7329.idx index e3426aa..e37f450 100644 Binary files a/.cache/clangd/index/FileManagerHandlers.cpp.2F53BB33AABE7329.idx and b/.cache/clangd/index/FileManagerHandlers.cpp.2F53BB33AABE7329.idx differ diff --git a/.cache/clangd/index/Fonts.cpp.63891DD6EC3699BC.idx b/.cache/clangd/index/Fonts.cpp.63891DD6EC3699BC.idx index dbd90e7..f58527a 100644 Binary files a/.cache/clangd/index/Fonts.cpp.63891DD6EC3699BC.idx and b/.cache/clangd/index/Fonts.cpp.63891DD6EC3699BC.idx differ diff --git a/.cache/clangd/index/Fonts.hpp.8E57D003C2C0CBDE.idx b/.cache/clangd/index/Fonts.hpp.8E57D003C2C0CBDE.idx index ca91a21..f7dfc6b 100644 Binary files a/.cache/clangd/index/Fonts.hpp.8E57D003C2C0CBDE.idx and b/.cache/clangd/index/Fonts.hpp.8E57D003C2C0CBDE.idx differ diff --git a/.cache/clangd/index/IconWidget.cpp.C804601C628F2FF0.idx b/.cache/clangd/index/IconWidget.cpp.C804601C628F2FF0.idx new file mode 100644 index 0000000..1d810cd Binary files /dev/null and b/.cache/clangd/index/IconWidget.cpp.C804601C628F2FF0.idx differ diff --git a/.cache/clangd/index/IconWidget.hpp.2B18DA23586DB313.idx b/.cache/clangd/index/IconWidget.hpp.2B18DA23586DB313.idx new file mode 100644 index 0000000..f6ad488 Binary files /dev/null and b/.cache/clangd/index/IconWidget.hpp.2B18DA23586DB313.idx differ diff --git a/.cache/clangd/index/LabelWidget.cpp.86E5A1BF3C34B7BC.idx b/.cache/clangd/index/LabelWidget.cpp.86E5A1BF3C34B7BC.idx index 566e6d8..18562a1 100644 Binary files a/.cache/clangd/index/LabelWidget.cpp.86E5A1BF3C34B7BC.idx and b/.cache/clangd/index/LabelWidget.cpp.86E5A1BF3C34B7BC.idx differ diff --git a/.cache/clangd/index/LabelWidget.hpp.C810267C2FCC36BA.idx b/.cache/clangd/index/LabelWidget.hpp.C810267C2FCC36BA.idx index 7584a44..a91f957 100644 Binary files a/.cache/clangd/index/LabelWidget.hpp.C810267C2FCC36BA.idx and b/.cache/clangd/index/LabelWidget.hpp.C810267C2FCC36BA.idx differ diff --git a/.cache/clangd/index/WidgetConfig.cpp.FD56F9F36C29A5DA.idx b/.cache/clangd/index/WidgetConfig.cpp.FD56F9F36C29A5DA.idx index 2637e0a..43d12a0 100644 Binary files a/.cache/clangd/index/WidgetConfig.cpp.FD56F9F36C29A5DA.idx and b/.cache/clangd/index/WidgetConfig.cpp.FD56F9F36C29A5DA.idx differ diff --git a/.cache/clangd/index/WidgetConfig.hpp.CAEFE2EEEB2A6996.idx b/.cache/clangd/index/WidgetConfig.hpp.CAEFE2EEEB2A6996.idx index c5d6d71..9262939 100644 Binary files a/.cache/clangd/index/WidgetConfig.hpp.CAEFE2EEEB2A6996.idx and b/.cache/clangd/index/WidgetConfig.hpp.CAEFE2EEEB2A6996.idx differ diff --git a/.cache/clangd/index/WidgetFactory.cpp.1026CAEFCA630F22.idx b/.cache/clangd/index/WidgetFactory.cpp.1026CAEFCA630F22.idx index cca6dac..f7534e0 100644 Binary files a/.cache/clangd/index/WidgetFactory.cpp.1026CAEFCA630F22.idx and b/.cache/clangd/index/WidgetFactory.cpp.1026CAEFCA630F22.idx differ diff --git a/.cache/clangd/index/main.cpp.7C677863E2582AB3.idx b/.cache/clangd/index/main.cpp.7C677863E2582AB3.idx index 929f868..7fb4f14 100644 Binary files a/.cache/clangd/index/main.cpp.7C677863E2582AB3.idx and b/.cache/clangd/index/main.cpp.7C677863E2582AB3.idx differ diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 8ee54ee..feb416c 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -4,6 +4,7 @@ idf_component_register(SRCS "KnxWorker.cpp" "Nvs.cpp" "main.cpp" "Display.cpp" " "widgets/ButtonWidget.cpp" "widgets/LedWidget.cpp" "widgets/WidgetFactory.cpp" + "widgets/IconWidget.cpp" "webserver/WebServer.cpp" "webserver/StaticFileHandlers.cpp" "webserver/ConfigHandlers.cpp" diff --git a/main/Fonts.cpp b/main/Fonts.cpp index 2fc9499..b1206bc 100644 --- a/main/Fonts.cpp +++ b/main/Fonts.cpp @@ -13,11 +13,14 @@ namespace { static const char* TAG = "Fonts"; 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 size_t kFontCount = sizeof(kFontSizes) / sizeof(kFontSizes[0]); 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_iconFontAvailable = false; const lv_font_t* fallbackFont(uint8_t sizeIndex) { switch (sizeIndex) { @@ -42,9 +45,9 @@ const lv_font_t* fallbackFont(uint8_t sizeIndex) { } #if CONFIG_ESP_LVGL_ADAPTER_ENABLE_FREETYPE -bool fontFileExists() { +bool fontFileExists(const char* path) { struct stat st; - return stat(kFontPath, &st) == 0; + return stat(path, &st) == 0; } #endif } // namespace @@ -59,7 +62,7 @@ void Fonts::init() { } s_initialized = true; - if (!fontFileExists()) { + if (!fontFileExists(kFontPath)) { ESP_LOGW(TAG, "Font file not found: %s", kFontPath); return; } @@ -104,7 +107,37 @@ void Fonts::init() { } 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 ESP_LOGI(TAG, "FreeType disabled, using built-in fonts"); #endif @@ -118,3 +151,27 @@ const lv_font_t* Fonts::bySizeIndex(uint8_t sizeIndex) { #endif 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 +} diff --git a/main/Fonts.hpp b/main/Fonts.hpp index fc2c632..35e1bc0 100644 --- a/main/Fonts.hpp +++ b/main/Fonts.hpp @@ -7,4 +7,8 @@ class Fonts { public: static void init(); static const lv_font_t* bySizeIndex(uint8_t sizeIndex); + + // Icon font support + static const lv_font_t* iconFont(uint8_t sizeIndex); + static bool hasIconFont(); }; diff --git a/main/WidgetConfig.cpp b/main/WidgetConfig.cpp index d26f8c9..3de9483 100644 --- a/main/WidgetConfig.cpp +++ b/main/WidgetConfig.cpp @@ -36,6 +36,16 @@ void WidgetConfig::serialize(uint8_t* buf) const { buf[pos++] = knxAddressWrite & 0xFF; buf[pos++] = (knxAddressWrite >> 8) & 0xFF; buf[pos++] = static_cast(action); 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(iconGap); + buf[pos++] = 0; // padding for alignment } void WidgetConfig::deserialize(const uint8_t* buf) { @@ -72,6 +82,14 @@ void WidgetConfig::deserialize(const uint8_t* buf) { pos += 2; action = static_cast(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(buf[pos++]); + pos++; // padding } 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.borderRadius = 0; cfg.shadow.enabled = false; + // Icon defaults + cfg.iconCodepoint = 0; + cfg.iconPosition = 0; + cfg.iconSize = 1; + cfg.iconGap = 8; return cfg; } @@ -129,6 +152,11 @@ WidgetConfig WidgetConfig::createButton(uint8_t id, int16_t x, int16_t y, cfg.knxAddressWrite = knxAddrWrite; cfg.action = ButtonAction::KNX; cfg.targetScreen = 0; + // Icon defaults + cfg.iconCodepoint = 0; + cfg.iconPosition = 0; + cfg.iconSize = 1; + cfg.iconGap = 8; return cfg; } diff --git a/main/WidgetConfig.hpp b/main/WidgetConfig.hpp index e8b67bd..9cc021e 100644 --- a/main/WidgetConfig.hpp +++ b/main/WidgetConfig.hpp @@ -14,7 +14,14 @@ enum class WidgetType : uint8_t { LABEL = 0, BUTTON = 1, 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 { @@ -94,8 +101,14 @@ struct WidgetConfig { ButtonAction action; // Button action (KNX, Jump, Back) 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) - static constexpr size_t SERIALIZED_SIZE = 68; + static constexpr size_t SERIALIZED_SIZE = 76; void serialize(uint8_t* buf) const; void deserialize(const uint8_t* buf); diff --git a/main/WidgetManager.cpp b/main/WidgetManager.cpp index bcaad38..40e7c86 100644 --- a/main/WidgetManager.cpp +++ b/main/WidgetManager.cpp @@ -771,6 +771,12 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const { cJSON_AddNumberToObject(widget, "action", static_cast(w.action)); 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); } @@ -905,6 +911,19 @@ bool WidgetManager::updateConfigFromJson(const char* json) { cJSON* targetScreen = cJSON_GetObjectItem(widget, "targetScreen"); if (cJSON_IsNumber(targetScreen)) w.targetScreen = targetScreen->valueint; + // Icon properties + cJSON* iconCodepoint = cJSON_GetObjectItem(widget, "iconCodepoint"); + if (cJSON_IsNumber(iconCodepoint)) w.iconCodepoint = static_cast(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++; } diff --git a/main/webserver/FileManagerHandlers.cpp b/main/webserver/FileManagerHandlers.cpp index 59f5e50..e8a683a 100644 --- a/main/webserver/FileManagerHandlers.cpp +++ b/main/webserver/FileManagerHandlers.cpp @@ -9,14 +9,20 @@ #include #include +// Helper for min +template +static inline T minVal(T a, T b) { return (a < b) ? a : b; } + static const char* TAG = "WebServer"; // Embedded file manager HTML (from Flash) extern const uint8_t filemanager_html_start[] asm("_binary_filemanager_html_start"); extern const uint8_t filemanager_html_end[] asm("_binary_filemanager_html_end"); -// Maximum upload size (2 MB) -static constexpr size_t MAX_UPLOAD_SIZE = 2 * 1024 * 1024; +// Maximum upload size (16 MB) - streaming, so RAM usage is low +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 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); } -// 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) { char path[128] = "/"; 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) { - return sendJsonError(req, "File too large (max 2MB)"); + return sendJsonError(req, "File too large (max 16MB)"); } char contentType[128] = ""; @@ -103,17 +109,12 @@ esp_err_t WebServer::filesUploadHandler(httpd_req_t* req) { } boundaryStart += 9; - // Skip leading quote if present - if (*boundaryStart == '"') { - boundaryStart++; - } + if (*boundaryStart == '"') boundaryStart++; - // Copy boundary and remove trailing quote if present char boundaryValue[72]; strncpy(boundaryValue, boundaryStart, sizeof(boundaryValue) - 1); boundaryValue[sizeof(boundaryValue) - 1] = '\0'; - // Remove trailing quote or any trailing whitespace/semicolon char* endPtr = boundaryValue; while (*endPtr && *endPtr != '"' && *endPtr != ';' && *endPtr != ' ' && *endPtr != '\r' && *endPtr != '\n') { endPtr++; @@ -121,34 +122,59 @@ esp_err_t WebServer::filesUploadHandler(httpd_req_t* req) { *endPtr = '\0'; 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; - char* fullData = new char[contentLen + 1]; - if (!fullData) { + // Allocate streaming buffer + char* buffer = new char[UPLOAD_BUFFER_SIZE]; + if (!buffer) { return sendJsonError(req, "Out of memory"); } + size_t contentLen = req->content_len; size_t totalReceived = 0; - while (totalReceived < contentLen) { - int ret = httpd_req_recv(req, fullData + totalReceived, contentLen - totalReceived); + size_t bufferFilled = 0; + + // 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) { - delete[] fullData; + delete[] buffer; return sendJsonError(req, "Receive failed"); } + headerLen += 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) { - delete[] fullData; + delete[] buffer; return sendJsonError(req, "No filename found"); } filenameStart += 10; char* filenameEnd = strchr(filenameStart, '"'); - if (!filenameEnd) { - delete[] fullData; + if (!filenameEnd || filenameEnd > dataStartInHeader) { + delete[] buffer; return sendJsonError(req, "Invalid filename"); } @@ -158,32 +184,7 @@ esp_err_t WebServer::filesUploadHandler(httpd_req_t* req) { memcpy(filename, filenameStart, filenameLen); filename[filenameLen] = '\0'; - char* dataStart = strstr(filenameEnd, "\r\n\r\n"); - 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; - + // Prepare target path char resolvedDir[384]; char targetDirPath[256]; 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]; 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; if (stat(actualDir, &dirStat) != 0) { if (mkdir(actualDir, 0755) != 0 && errno != EEXIST) { - delete[] fullData; + delete[] buffer; return sendJsonError(req, "Cannot create directory"); } } + // Open file for writing FILE* f = fopen(fullPath, "wb"); if (!f) { ESP_LOGE(TAG, "Cannot create file: %s (errno=%d)", fullPath, errno); - delete[] fullData; + delete[] buffer; return sendJsonError(req, "Cannot create file"); } - size_t written = fwrite(dataStart, 1, fileSize, f); - fclose(f); - delete[] fullData; + // Calculate how much file data is already in our header buffer + size_t headerDataLen = headerLen - (dataStartInHeader - header); + size_t fileSize = 0; - if (written != fileSize) { - return sendJsonError(req, "Write failed"); + // Copy initial file data from header buffer to streaming buffer + 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_AddBoolToObject(json, "success", true); diff --git a/main/widgets/ButtonWidget.cpp b/main/widgets/ButtonWidget.cpp index 727e294..98905c7 100644 --- a/main/widgets/ButtonWidget.cpp +++ b/main/widgets/ButtonWidget.cpp @@ -1,14 +1,16 @@ #include "ButtonWidget.hpp" #include "../WidgetManager.hpp" +#include "../Fonts.hpp" #include "esp_log.h" static const char* TAG = "ButtonWidget"; ButtonWidget::ButtonWidget(const WidgetConfig& config) : Widget(config) + , contentContainer_(nullptr) , label_(nullptr) + , iconLabel_(nullptr) { - } ButtonWidget::~ButtonWidget() { @@ -16,7 +18,37 @@ ButtonWidget::~ButtonWidget() { if (obj_) { lv_obj_remove_event_cb(obj_, clickCallback); } + contentContainer_ = nullptr; label_ = nullptr; + iconLabel_ = nullptr; +} + +int ButtonWidget::encodeUtf8(uint32_t codepoint, char* buf) { + if (codepoint < 0x80) { + buf[0] = static_cast(codepoint); + buf[1] = '\0'; + return 1; + } else if (codepoint < 0x800) { + buf[0] = static_cast(0xC0 | (codepoint >> 6)); + buf[1] = static_cast(0x80 | (codepoint & 0x3F)); + buf[2] = '\0'; + return 2; + } else if (codepoint < 0x10000) { + buf[0] = static_cast(0xE0 | (codepoint >> 12)); + buf[1] = static_cast(0x80 | ((codepoint >> 6) & 0x3F)); + buf[2] = static_cast(0x80 | (codepoint & 0x3F)); + buf[3] = '\0'; + return 3; + } else if (codepoint < 0x110000) { + buf[0] = static_cast(0xF0 | (codepoint >> 18)); + buf[1] = static_cast(0x80 | ((codepoint >> 12) & 0x3F)); + buf[2] = static_cast(0x80 | ((codepoint >> 6) & 0x3F)); + buf[3] = static_cast(0x80 | (codepoint & 0x3F)); + buf[4] = '\0'; + return 4; + } + buf[0] = '\0'; + return 0; } void ButtonWidget::clickCallback(lv_event_t* e) { @@ -27,20 +59,72 @@ void ButtonWidget::clickCallback(lv_event_t* e) { 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(IconPosition::TOP) || + config_.iconPosition == static_cast(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) { obj_ = lv_btn_create(parent); lv_obj_set_pos(obj_, config_.x, config_.y); lv_obj_set_size(obj_, config_.width > 0 ? config_.width : 100, config_.height > 0 ? config_.height : 50); - // Test: free function callback lv_obj_add_event_cb(obj_, clickCallback, LV_EVENT_CLICKED, this); - label_ = lv_label_create(obj_); - lv_label_set_text(label_, config_.text); - lv_obj_center(label_); + bool hasIcon = config_.iconCodepoint > 0 && Fonts::hasIconFont(); - 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(IconPosition::LEFT) || + config_.iconPosition == static_cast(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_; } @@ -56,6 +140,15 @@ void ButtonWidget::applyStyle() { config_.textColor.r, config_.textColor.g, config_.textColor.b), 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 { diff --git a/main/widgets/ButtonWidget.hpp b/main/widgets/ButtonWidget.hpp index 6f8b322..71b7e99 100644 --- a/main/widgets/ButtonWidget.hpp +++ b/main/widgets/ButtonWidget.hpp @@ -14,7 +14,11 @@ public: bool isChecked() const; private: + lv_obj_t* contentContainer_ = 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); }; diff --git a/main/widgets/IconWidget.cpp b/main/widgets/IconWidget.cpp new file mode 100644 index 0000000..18a14bd --- /dev/null +++ b/main/widgets/IconWidget.cpp @@ -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(codepoint); + buf[1] = '\0'; + return 1; + } else if (codepoint < 0x800) { + buf[0] = static_cast(0xC0 | (codepoint >> 6)); + buf[1] = static_cast(0x80 | (codepoint & 0x3F)); + buf[2] = '\0'; + return 2; + } else if (codepoint < 0x10000) { + buf[0] = static_cast(0xE0 | (codepoint >> 12)); + buf[1] = static_cast(0x80 | ((codepoint >> 6) & 0x3F)); + buf[2] = static_cast(0x80 | (codepoint & 0x3F)); + buf[3] = '\0'; + return 3; + } else if (codepoint < 0x110000) { + buf[0] = static_cast(0xF0 | (codepoint >> 18)); + buf[1] = static_cast(0x80 | ((codepoint >> 12) & 0x3F)); + buf[2] = static_cast(0x80 | ((codepoint >> 6) & 0x3F)); + buf[3] = static_cast(0x80 | (codepoint & 0x3F)); + buf[4] = '\0'; + return 4; + } + buf[0] = '\0'; + return 0; +} + +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); + } +} diff --git a/main/widgets/IconWidget.hpp b/main/widgets/IconWidget.hpp new file mode 100644 index 0000000..b786739 --- /dev/null +++ b/main/widgets/IconWidget.hpp @@ -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); +}; diff --git a/main/widgets/LabelWidget.cpp b/main/widgets/LabelWidget.cpp index f749e1d..5851ec5 100644 --- a/main/widgets/LabelWidget.cpp +++ b/main/widgets/LabelWidget.cpp @@ -1,42 +1,166 @@ #include "LabelWidget.hpp" +#include "../Fonts.hpp" #include LabelWidget::LabelWidget(const WidgetConfig& config) : Widget(config) + , container_(nullptr) + , textLabel_(nullptr) + , iconLabel_(nullptr) { } -lv_obj_t* LabelWidget::create(lv_obj_t* parent) { - obj_ = lv_label_create(parent); - lv_label_set_text(obj_, config_.text); +int LabelWidget::encodeUtf8(uint32_t codepoint, char* buf) { + if (codepoint < 0x80) { + buf[0] = static_cast(codepoint); + buf[1] = '\0'; + return 1; + } else if (codepoint < 0x800) { + buf[0] = static_cast(0xC0 | (codepoint >> 6)); + buf[1] = static_cast(0x80 | (codepoint & 0x3F)); + buf[2] = '\0'; + return 2; + } else if (codepoint < 0x10000) { + buf[0] = static_cast(0xE0 | (codepoint >> 12)); + buf[1] = static_cast(0x80 | ((codepoint >> 6) & 0x3F)); + buf[2] = static_cast(0x80 | (codepoint & 0x3F)); + buf[3] = '\0'; + return 3; + } else if (codepoint < 0x110000) { + buf[0] = static_cast(0xF0 | (codepoint >> 18)); + buf[1] = static_cast(0x80 | ((codepoint >> 12) & 0x3F)); + buf[2] = static_cast(0x80 | ((codepoint >> 6) & 0x3F)); + buf[3] = static_cast(0x80 | (codepoint & 0x3F)); + buf[4] = '\0'; + return 4; + } + buf[0] = '\0'; + return 0; +} - 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); +void LabelWidget::setupFlexLayout() { + if (container_ == nullptr) return; + + // Determine flex direction based on icon position + bool isVertical = (config_.iconPosition == static_cast(IconPosition::TOP) || + config_.iconPosition == static_cast(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(IconPosition::LEFT) || + config_.iconPosition == static_cast(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_; } -void LabelWidget::onKnxValue(float value) { +void LabelWidget::applyStyle() { 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; char buf[32]; snprintf(buf, sizeof(buf), config_.text, value); - lv_label_set_text(obj_, buf); + lv_label_set_text(label, buf); } 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; - lv_label_set_text(obj_, value ? "EIN" : "AUS"); + lv_label_set_text(label, value ? "EIN" : "AUS"); } 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; - lv_label_set_text(obj_, text); + lv_label_set_text(label, text); } diff --git a/main/widgets/LabelWidget.hpp b/main/widgets/LabelWidget.hpp index 134b750..12fe68d 100644 --- a/main/widgets/LabelWidget.hpp +++ b/main/widgets/LabelWidget.hpp @@ -7,9 +7,18 @@ public: explicit LabelWidget(const WidgetConfig& config); lv_obj_t* create(lv_obj_t* parent) override; + void applyStyle() override; // KNX updates void onKnxValue(float value) override; void onKnxSwitch(bool value) 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); }; diff --git a/main/widgets/WidgetFactory.cpp b/main/widgets/WidgetFactory.cpp index bef481a..f4a2430 100644 --- a/main/widgets/WidgetFactory.cpp +++ b/main/widgets/WidgetFactory.cpp @@ -2,6 +2,7 @@ #include "LabelWidget.hpp" #include "ButtonWidget.hpp" #include "LedWidget.hpp" +#include "IconWidget.hpp" std::unique_ptr WidgetFactory::create(const WidgetConfig& config) { if (!config.visible) return nullptr; @@ -13,6 +14,8 @@ std::unique_ptr WidgetFactory::create(const WidgetConfig& config) { return std::make_unique(config); case WidgetType::LED: return std::make_unique(config); + case WidgetType::ICON: + return std::make_unique(config); default: return nullptr; } diff --git a/sdcard_content/fonts/MaterialSymbolsOutlined.ttf b/sdcard_content/fonts/MaterialSymbolsOutlined.ttf new file mode 100644 index 0000000..9006c26 Binary files /dev/null and b/sdcard_content/fonts/MaterialSymbolsOutlined.ttf differ diff --git a/web-interface/src/components/IconPicker.vue b/web-interface/src/components/IconPicker.vue new file mode 100644 index 0000000..63b0d54 --- /dev/null +++ b/web-interface/src/components/IconPicker.vue @@ -0,0 +1,232 @@ + + + + + diff --git a/web-interface/src/components/SidebarLeft.vue b/web-interface/src/components/SidebarLeft.vue index f7f1851..6475ab0 100644 --- a/web-interface/src/components/SidebarLeft.vue +++ b/web-interface/src/components/SidebarLeft.vue @@ -18,6 +18,10 @@ LED Status + diff --git a/web-interface/src/components/SidebarRight.vue b/web-interface/src/components/SidebarRight.vue index 2fda0ba..5006740 100644 --- a/web-interface/src/components/SidebarRight.vue +++ b/web-interface/src/components/SidebarRight.vue @@ -61,6 +61,38 @@ + + + + + + + -

{{ key === 'led' ? 'Glow' : 'Schatten' }}

-
-
-
-
-
+ + + diff --git a/web-interface/src/components/WidgetElement.vue b/web-interface/src/components/WidgetElement.vue index 3f5ff0b..f5f1201 100644 --- a/web-interface/src/components/WidgetElement.vue +++ b/web-interface/src/components/WidgetElement.vue @@ -1,31 +1,59 @@ + + diff --git a/web-interface/src/constants.js b/web-interface/src/constants.js index bf43c00..74b28b0 100644 --- a/web-interface/src/constants.js +++ b/web-interface/src/constants.js @@ -5,7 +5,15 @@ export const MAX_SCREENS = 8; export const WIDGET_TYPES = { LABEL: 0, BUTTON: 1, - LED: 2 + LED: 2, + ICON: 3 +}; + +export const ICON_POSITIONS = { + LEFT: 0, + RIGHT: 1, + TOP: 2, + BOTTOM: 3 }; export const BUTTON_ACTIONS = { @@ -17,13 +25,15 @@ export const BUTTON_ACTIONS = { export const TYPE_KEYS = { 0: 'label', 1: 'button', - 2: 'led' + 2: 'led', + 3: 'icon' }; export const TYPE_LABELS = { label: 'Label', button: 'Button', - led: 'LED' + led: 'LED', + icon: 'Icon' }; export const textSources = { @@ -37,7 +47,15 @@ export const textSources = { export const sourceOptions = { label: [0, 1, 2, 3, 4], 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]; @@ -65,7 +83,11 @@ export const WIDGET_DEFAULTS = { knxAddrWrite: 0, knxAddr: 0, action: BUTTON_ACTIONS.KNX, - targetScreen: 0 + targetScreen: 0, + iconCodepoint: 0, + iconPosition: 0, + iconSize: 1, + iconGap: 8 }, button: { w: 130, @@ -82,7 +104,11 @@ export const WIDGET_DEFAULTS = { knxAddrWrite: 0, knxAddr: 0, action: BUTTON_ACTIONS.KNX, - targetScreen: 0 + targetScreen: 0, + iconCodepoint: 0, + iconPosition: 0, + iconSize: 1, + iconGap: 8 }, led: { w: 60, @@ -99,6 +125,31 @@ export const WIDGET_DEFAULTS = { knxAddrWrite: 0, knxAddr: 0, 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 } }; diff --git a/web-interface/src/icons.js b/web-interface/src/icons.js new file mode 100644 index 0000000..af7e0f1 --- /dev/null +++ b/web-interface/src/icons.js @@ -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); +} diff --git a/web-interface/src/stores/editor.js b/web-interface/src/stores/editor.js index 02e0959..e1f956f 100644 --- a/web-interface/src/stores/editor.js +++ b/web-interface/src/stores/editor.js @@ -157,8 +157,15 @@ export const useEditorStore = defineStore('editor', () => { function addWidget(typeStr) { 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 w = { @@ -181,7 +188,11 @@ export const useEditorStore = defineStore('editor', () => { isToggle: defaults.isToggle, knxAddrWrite: defaults.knxAddrWrite, 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); diff --git a/web-interface/src/style.css b/web-interface/src/style.css index d9ed306..dc724a3 100644 --- a/web-interface/src/style.css +++ b/web-interface/src/style.css @@ -1,4 +1,5 @@ @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 { --bg: #0f1419; @@ -580,3 +581,28 @@ body { 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; +} diff --git a/web-interface/src/utils.js b/web-interface/src/utils.js index 713ac9e..311c03c 100644 --- a/web-interface/src/utils.js +++ b/web-interface/src/utils.js @@ -13,6 +13,7 @@ export function minSizeFor(widget) { const key = typeKeyFor(widget.type); if (key === 'button') return { w: 60, h: 30 }; if (key === 'led') return { w: 20, h: 20 }; + if (key === 'icon') return { w: 24, h: 24 }; return { w: 40, h: 20 }; } @@ -53,6 +54,12 @@ export function normalizeWidget(w, nextWidgetIdRef) { // Action defaults if (w.action === undefined) w.action = 0; // BUTTON_ACTIONS.KNX 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) {