diff --git a/components/knx/src/knx/bau_systemB_device.cpp b/components/knx/src/knx/bau_systemB_device.cpp index a852d4f..24fb236 100644 --- a/components/knx/src/knx/bau_systemB_device.cpp +++ b/components/knx/src/knx/bau_systemB_device.cpp @@ -41,6 +41,16 @@ GroupObjectTableObject& BauSystemBDevice::groupObjectTable() return _groupObjTable; } +AddressTableObject& BauSystemBDevice::addressTable() +{ + return _addrTable; +} + +AssociationTableObject& BauSystemBDevice::associationTable() +{ + return _assocTable; +} + void BauSystemBDevice::loop() { _transLayer.loop(); diff --git a/components/knx/src/knx/bau_systemB_device.h b/components/knx/src/knx/bau_systemB_device.h index 4e5e283..8c0f040 100644 --- a/components/knx/src/knx/bau_systemB_device.h +++ b/components/knx/src/knx/bau_systemB_device.h @@ -23,6 +23,8 @@ class BauSystemBDevice : public BauSystemB void loop() override; bool configured() override; GroupObjectTableObject& groupObjectTable(); + AddressTableObject& addressTable(); + AssociationTableObject& associationTable(); protected: ApplicationLayer& applicationLayer() override; diff --git a/main/Gui.cpp b/main/Gui.cpp index c9c86ab..50a3ef2 100644 --- a/main/Gui.cpp +++ b/main/Gui.cpp @@ -47,5 +47,5 @@ void Gui::create() void Gui::updateTemperature(float temp) { // Delegate to WidgetManager for KNX group address 1 - WidgetManager::instance().onKnxValue(1, temp); + WidgetManager::instance().onKnxValue(1, temp, TextSource::KNX_DPT_TEMP); } diff --git a/main/KnxWorker.cpp b/main/KnxWorker.cpp index 549a2b0..b33d80f 100644 --- a/main/KnxWorker.cpp +++ b/main/KnxWorker.cpp @@ -1,5 +1,5 @@ #include "KnxWorker.hpp" -#include "Gui.hpp" +#include "WidgetManager.hpp" #include "esp32_idf_platform.h" #include "knx_facade.h" #include "knx/bau07B0.h" @@ -8,7 +8,10 @@ #include "knx/dpt.h" #include "esp_log.h" #include +#include "esp_system.h" +#include "nvs.h" #include +#include #define TAG "KNXWORKER" #define MASK_VERSION 0x07B0 @@ -22,6 +25,65 @@ KnxFacade knx(knxBau); KnxWorker::KnxWorker() {} +namespace { +constexpr char kKnxNvsNamespace[] = "knx"; +constexpr char kKnxSerialKey[] = "serial_bau"; +constexpr uint8_t kKnxHardwareType[6] = {0x00, 0x00, 0xAB, 0xCE, 0x03, 0x00}; +constexpr uint16_t kKnxHardwareVersion = 1; + +bool loadKnxBauNumber(uint32_t& outValue) { + nvs_handle_t handle; + esp_err_t err = nvs_open(kKnxNvsNamespace, NVS_READONLY, &handle); + if (err != ESP_OK) { + return false; + } + + uint32_t value = 0; + err = nvs_get_u32(handle, kKnxSerialKey, &value); + nvs_close(handle); + if (err != ESP_OK) { + return false; + } + + outValue = value; + return true; +} + +bool saveKnxBauNumber(uint32_t value) { + nvs_handle_t handle; + esp_err_t err = nvs_open(kKnxNvsNamespace, NVS_READWRITE, &handle); + if (err != ESP_OK) { + return false; + } + + err = nvs_set_u32(handle, kKnxSerialKey, value); + if (err == ESP_OK) { + err = nvs_commit(handle); + } + nvs_close(handle); + return err == ESP_OK; +} + +uint32_t generateRandomBauNumber() { + uint32_t value = esp_random(); + if (value == 0) { + value = 1; + } + return value; +} + +uint16_t resolveGroupAddress(uint16_t asap) { + if (!knxBau.configured()) { + return 0; + } + int32_t tsap = knxBau.associationTable().translateAsap(asap); + if (tsap < 0) { + return 0; + } + return knxBau.addressTable().getGroupAddress(static_cast(tsap)); +} +} // namespace + void KnxWorker::init() { ESP_LOGI(TAG, "INIT"); @@ -31,16 +93,83 @@ void KnxWorker::init() { knxPlatform.setupUart(); #if !UART_DEBUG_MODE + knx.bau().deviceObject().hardwareType(kKnxHardwareType); + knx.bau().deviceObject().version(kKnxHardwareVersion); + knx.readMemory(); - // Register callback for GroupObject 1 (Temperature) - GroupObject& go1 = knx.getGroupObject(1); - go1.dataPointType(DPT_Value_Temp); - go1.callback([](GroupObject& go) { - float temp = (float)go.value(DPT_Value_Temp); - ESP_LOGI(TAG, "Temperature received: %.1f °C", temp); - Gui::updateTemperature(temp); - }); + uint32_t bauNumberOverride = 0; + if (loadKnxBauNumber(bauNumberOverride)) { + knx.bau().deviceObject().manufacturerId(0x00FA); + knx.bau().deviceObject().bauNumber(bauNumberOverride); + ESP_LOGI(TAG, "Applied KNX serial override: %04X%08lX", 0x00FA, (unsigned long)bauNumberOverride); + } + + // Register callbacks for all group objects to forward updates to the GUI + size_t goCount = getGroupObjectCount(); + if (goCount == 0) { + ESP_LOGW(TAG, "No KNX group objects configured; skipping callbacks"); + } else { + for (size_t i = 1; i <= goCount; i++) { + GroupObject& go = knx.getGroupObject(i); + go.callback([](GroupObject& go) { + uint16_t groupAddr = resolveGroupAddress(go.asap()); + if (groupAddr == 0) { + return; + } + + KNXValue switchValue = false; + if (go.tryValue(switchValue, DPT_Switch)) { + WidgetManager::instance().onKnxSwitch(groupAddr, static_cast(switchValue)); + } + + KNXValue tempValue = 0.0f; + if (go.tryValue(tempValue, DPT_Value_Temp)) { + WidgetManager::instance().onKnxValue(groupAddr, static_cast(tempValue), + TextSource::KNX_DPT_TEMP); + } + + KNXValue percentValue = 0.0f; + if (go.tryValue(percentValue, DPT_Scaling)) { + WidgetManager::instance().onKnxValue(groupAddr, static_cast(percentValue), + TextSource::KNX_DPT_PERCENT); + } + + KNXValue factorValue = (uint8_t)0; + if (go.tryValue(factorValue, DPT_DecimalFactor)) { + WidgetManager::instance().onKnxValue(groupAddr, static_cast(factorValue), + TextSource::KNX_DPT_DECIMALFACTOR); + } + + KNXValue powerValue = 0.0f; + if (go.tryValue(powerValue, DPT_Value_Power)) { + WidgetManager::instance().onKnxValue(groupAddr, static_cast(powerValue), + TextSource::KNX_DPT_POWER); + } + + KNXValue energyValue = (int32_t)0; + if (go.tryValue(energyValue, DPT_ActiveEnergy_kWh)) { + WidgetManager::instance().onKnxValue(groupAddr, static_cast(energyValue), + TextSource::KNX_DPT_ENERGY); + } + + KNXValue textValue = ""; + if (go.tryValue(textValue, DPT_String_8859_1) || + go.tryValue(textValue, DPT_String_ASCII)) { + const char* raw = static_cast(textValue); + size_t maxLen = go.valueSize(); + if (maxLen > 14) { + maxLen = 14; + } + size_t len = strnlen(raw, maxLen); + char buf[15]; + memcpy(buf, raw, len); + buf[len] = '\0'; + WidgetManager::instance().onKnxText(groupAddr, buf); + } + }); + } + } knx.start(); ESP_LOGI(TAG, "FINISH"); @@ -60,6 +189,39 @@ void KnxWorker::toggleProgMode() { #endif } +bool KnxWorker::getProgMode() { +#if !UART_DEBUG_MODE + return knx.progMode(); +#else + return false; +#endif +} + +void KnxWorker::setProgMode(bool enabled) { +#if !UART_DEBUG_MODE + knx.progMode(enabled); +#else + (void)enabled; +#endif +} + +void KnxWorker::clearSettings() { +#if !UART_DEBUG_MODE + if (knxResetState_ == 0) { + uint32_t bauNumber = generateRandomBauNumber(); + bool stored = saveKnxBauNumber(bauNumber); + knx.bau().deviceObject().manufacturerId(0x00FA); + knx.bau().deviceObject().bauNumber(bauNumber); + if (stored) { + ESP_LOGI(TAG, "KNX serial randomized to %04X%08lX", 0x00FA, (unsigned long)bauNumber); + } else { + ESP_LOGW(TAG, "Failed to persist randomized KNX serial"); + } + knxResetState_ = 1; + } +#endif +} + void KnxWorker::loop() { #if UART_DEBUG_MODE // Periodically send U_STATE_REQ to test TX direction @@ -87,6 +249,19 @@ void KnxWorker::loop() { } } #else + if (knxResetState_ != 0) { + uint32_t nowMs = (uint32_t)(esp_timer_get_time() / 1000); + if (knxResetState_ == 1) { + knx.bau().memory().clearMemory(); + knxResetAtMs_ = nowMs + 300; + knxResetState_ = 2; + } else if (knxResetState_ == 2) { + if ((int32_t)(nowMs - knxResetAtMs_) >= 0) { + knxResetState_ = 0; + knx.bau().platform().restart(); + } + } + } knx.loop(); #endif } @@ -119,8 +294,8 @@ bool KnxWorker::getGroupObjectInfo(size_t index, KnxGroupObjectInfo& info) { info.readFlag = go.readEnable(); info.writeFlag = go.writeEnable(); - // Get ASAP - this is the index we use for addressing - info.groupAddress = go.asap(); + // Resolve the primary group address via association/address tables + info.groupAddress = resolveGroupAddress(static_cast(index)); return true; #else @@ -136,4 +311,4 @@ void KnxWorker::formatGroupAddress(uint16_t addr, char* buf, size_t bufSize) { uint8_t middle = (addr >> 8) & 0x07; uint8_t sub = addr & 0xFF; snprintf(buf, bufSize, "%d/%d/%d", main, middle, sub); -} \ No newline at end of file +} diff --git a/main/KnxWorker.hpp b/main/KnxWorker.hpp index 848b212..cc62419 100644 --- a/main/KnxWorker.hpp +++ b/main/KnxWorker.hpp @@ -6,7 +6,7 @@ // KNX Group Object Info für Web-API struct KnxGroupObjectInfo { uint16_t goIndex; // Group Object Index (1-based) - uint16_t groupAddress; // Gruppenadresse (z.B. 1/2/3 = 0x0A03) + uint16_t groupAddress; // Gruppenadresse (z.B. 1/2/3 = 0x0A03), 0 wenn nicht zugeordnet uint8_t dptMain; // DPT Haupttyp uint8_t dptSub; // DPT Subtyp bool commFlag; // Kommunikations-Flag @@ -19,6 +19,9 @@ public: KnxWorker(); void init(); void toggleProgMode(); + bool getProgMode(); + void setProgMode(bool enabled); + void clearSettings(); void loop(); // KNX Gruppenadressen auslesen @@ -27,4 +30,8 @@ public: // Gruppenadresse als String formatieren (z.B. "1/2/3") static void formatGroupAddress(uint16_t addr, char* buf, size_t bufSize); + +private: + volatile uint8_t knxResetState_ = 0; + volatile uint32_t knxResetAtMs_ = 0; }; diff --git a/main/WidgetConfig.hpp b/main/WidgetConfig.hpp index d0a3f4b..9debde6 100644 --- a/main/WidgetConfig.hpp +++ b/main/WidgetConfig.hpp @@ -47,6 +47,9 @@ enum class TextSource : uint8_t { KNX_DPT_SWITCH = 2, // KNX Switch (DPT 1.001) KNX_DPT_PERCENT = 3, // KNX Percent (DPT 5.001) KNX_DPT_TEXT = 4, // KNX Text (DPT 16.000) + KNX_DPT_POWER = 5, // KNX Power (DPT 14.056) + KNX_DPT_ENERGY = 6, // KNX Energy (DPT 13.013) + KNX_DPT_DECIMALFACTOR = 7, // KNX Decimal Factor (DPT 5.005) }; enum class TextAlign : uint8_t { @@ -94,7 +97,7 @@ struct WidgetConfig { // Text properties TextSource textSource; char text[MAX_TEXT_LEN]; // Static text or format string - uint16_t knxAddress; // KNX group address (if textSource != STATIC) + uint16_t knxAddress; // KNX group address (GA) for read binding uint8_t fontSize; // Font size index (0=14, 1=18, 2=22, 3=28, 4=36, 5=48) uint8_t textAlign; // TextAlign: 0=left, 1=center, 2=right bool isContainer; // For buttons: use as container (no internal label/icon) @@ -110,7 +113,7 @@ struct WidgetConfig { // Button specific bool isToggle; // For buttons: toggle mode - uint16_t knxAddressWrite; // KNX address to write on click + uint16_t knxAddressWrite; // KNX group address (GA) to write on click ButtonAction action; // Button action (KNX, Jump, Back) uint8_t targetScreen; // Target screen ID for jump diff --git a/main/WidgetManager.cpp b/main/WidgetManager.cpp index 13b1229..32795ac 100644 --- a/main/WidgetManager.cpp +++ b/main/WidgetManager.cpp @@ -745,14 +745,19 @@ void WidgetManager::createAllWidgets(const ScreenConfig& screen, lv_obj_t* paren } } -void WidgetManager::onKnxValue(uint16_t groupAddr, float value) { +void WidgetManager::onKnxValue(uint16_t groupAddr, float value, TextSource source) { UiEvent event = {}; event.type = UiEventType::KNX_VALUE; event.groupAddr = groupAddr; + event.textSource = source; event.value = value; enqueueUiEvent(event); } +void WidgetManager::onKnxValue(uint16_t groupAddr, float value) { + onKnxValue(groupAddr, value, TextSource::KNX_DPT_TEMP); +} + void WidgetManager::onKnxSwitch(uint16_t groupAddr, bool value) { UiEvent event = {}; event.type = UiEventType::KNX_SWITCH; @@ -798,7 +803,7 @@ void WidgetManager::processUiQueue() { xQueueReceive(uiQueue_, &event, 0) == pdTRUE) { switch (event.type) { case UiEventType::KNX_VALUE: - applyKnxValue(event.groupAddr, event.value); + applyKnxValue(event.groupAddr, event.value, event.textSource); break; case UiEventType::KNX_SWITCH: applyKnxSwitch(event.groupAddr, event.state); @@ -813,9 +818,10 @@ void WidgetManager::processUiQueue() { esp_lv_adapter_unlock(); } -void WidgetManager::applyKnxValue(uint16_t groupAddr, float value) { +void WidgetManager::applyKnxValue(uint16_t groupAddr, float value, TextSource source) { for (auto& widget : widgets_) { - if (widget && widget->getKnxAddress() == groupAddr) { + if (widget && widget->getKnxAddress() == groupAddr && + widget->getTextSource() == source) { widget->onKnxValue(value); } } diff --git a/main/WidgetManager.hpp b/main/WidgetManager.hpp index 3458973..2f15bc6 100644 --- a/main/WidgetManager.hpp +++ b/main/WidgetManager.hpp @@ -38,6 +38,7 @@ public: void onUserActivity(); // Thread-safe KNX updates (queued to UI thread) + void onKnxValue(uint16_t groupAddr, float value, TextSource source); void onKnxValue(uint16_t groupAddr, float value); void onKnxSwitch(uint16_t groupAddr, bool value); void onKnxText(uint16_t groupAddr, const char* text); @@ -69,6 +70,7 @@ private: struct UiEvent { UiEventType type; uint16_t groupAddr; + TextSource textSource; float value; bool state; char text[UI_EVENT_TEXT_LEN]; @@ -80,7 +82,7 @@ private: void createAllWidgets(const ScreenConfig& screen, lv_obj_t* parent); bool enqueueUiEvent(const UiEvent& event); void processUiQueue(); - void applyKnxValue(uint16_t groupAddr, float value); + void applyKnxValue(uint16_t groupAddr, float value, TextSource source); void applyKnxSwitch(uint16_t groupAddr, bool value); void applyKnxText(uint16_t groupAddr, const char* text); diff --git a/main/webserver/KnxHandlers.cpp b/main/webserver/KnxHandlers.cpp index 116743f..7a661d3 100644 --- a/main/webserver/KnxHandlers.cpp +++ b/main/webserver/KnxHandlers.cpp @@ -12,6 +12,9 @@ esp_err_t WebServer::getKnxAddressesHandler(httpd_req_t* req) { for (size_t i = 1; i <= count; i++) { KnxGroupObjectInfo info; if (knxWorker.getGroupObjectInfo(i, info)) { + if (info.groupAddress == 0) { + continue; + } char addrStr[16]; KnxWorker::formatGroupAddress(info.groupAddress, addrStr, sizeof(addrStr)); @@ -33,3 +36,83 @@ esp_err_t WebServer::getKnxAddressesHandler(httpd_req_t* req) { cJSON_Delete(arr); return ESP_OK; } + +esp_err_t WebServer::getKnxProgHandler(httpd_req_t* req) { + KnxWorker& knxWorker = Gui::knxWorker; + cJSON* json = cJSON_CreateObject(); + cJSON_AddBoolToObject(json, "progMode", knxWorker.getProgMode()); + return sendJsonObject(req, json); +} + +esp_err_t WebServer::postKnxProgHandler(httpd_req_t* req) { + KnxWorker& knxWorker = Gui::knxWorker; + bool current = knxWorker.getProgMode(); + bool target = current; + bool hasTarget = false; + + int total_len = req->content_len; + if (total_len > 0) { + if (total_len > 256) { + return sendJsonError(req, "Content too large"); + } + + char* buf = new char[total_len + 1]; + if (!buf) { + return sendJsonError(req, "Out of memory"); + } + + int received = 0; + while (received < total_len) { + int ret = httpd_req_recv(req, buf + received, total_len - received); + if (ret <= 0) { + delete[] buf; + return sendJsonError(req, "Receive failed"); + } + received += ret; + } + buf[received] = '\0'; + + cJSON* json = cJSON_Parse(buf); + delete[] buf; + + if (!json) { + return sendJsonError(req, "Invalid JSON"); + } + + cJSON* enabled = cJSON_GetObjectItemCaseSensitive(json, "enabled"); + if (!enabled) { + enabled = cJSON_GetObjectItemCaseSensitive(json, "progMode"); + } + + if (cJSON_IsBool(enabled)) { + target = cJSON_IsTrue(enabled); + hasTarget = true; + } + + cJSON_Delete(json); + + if (!hasTarget) { + return sendJsonError(req, "Missing 'enabled'"); + } + } + + if (!hasTarget) { + target = !current; + } + knxWorker.setProgMode(target); + + cJSON* json = cJSON_CreateObject(); + cJSON_AddStringToObject(json, "status", "ok"); + cJSON_AddBoolToObject(json, "progMode", target); + return sendJsonObject(req, json); +} + +esp_err_t WebServer::postKnxResetHandler(httpd_req_t* req) { + KnxWorker& knxWorker = Gui::knxWorker; + knxWorker.clearSettings(); + + cJSON* json = cJSON_CreateObject(); + cJSON_AddStringToObject(json, "status", "ok"); + cJSON_AddStringToObject(json, "message", "KNX settings cleared, rebooting"); + return sendJsonObject(req, json); +} diff --git a/main/webserver/WebServer.cpp b/main/webserver/WebServer.cpp index 80c1f9f..757c683 100644 --- a/main/webserver/WebServer.cpp +++ b/main/webserver/WebServer.cpp @@ -25,7 +25,7 @@ void WebServer::start() { httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.uri_match_fn = httpd_uri_match_wildcard; config.stack_size = 8192; - config.max_uri_handlers = 20; + config.max_uri_handlers = 24; ESP_LOGI(TAG, "Starting HTTP server on port %d", config.server_port); @@ -61,6 +61,15 @@ void WebServer::start() { httpd_uri_t getKnxAddresses = { .uri = "/api/knx/addresses", .method = HTTP_GET, .handler = getKnxAddressesHandler, .user_ctx = nullptr }; httpd_register_uri_handler(server_, &getKnxAddresses); + httpd_uri_t getKnxProg = { .uri = "/api/knx/prog", .method = HTTP_GET, .handler = getKnxProgHandler, .user_ctx = nullptr }; + httpd_register_uri_handler(server_, &getKnxProg); + + httpd_uri_t postKnxProg = { .uri = "/api/knx/prog", .method = HTTP_POST, .handler = postKnxProgHandler, .user_ctx = nullptr }; + httpd_register_uri_handler(server_, &postKnxProg); + + httpd_uri_t postKnxReset = { .uri = "/api/knx/reset", .method = HTTP_POST, .handler = postKnxResetHandler, .user_ctx = nullptr }; + httpd_register_uri_handler(server_, &postKnxReset); + // Status routes httpd_uri_t postUsbMode = { .uri = "/api/usb-mode", .method = HTTP_POST, .handler = postUsbModeHandler, .user_ctx = nullptr }; httpd_register_uri_handler(server_, &postUsbMode); diff --git a/main/webserver/WebServer.hpp b/main/webserver/WebServer.hpp index 0cc6a1c..c555f78 100644 --- a/main/webserver/WebServer.hpp +++ b/main/webserver/WebServer.hpp @@ -32,6 +32,9 @@ private: // KNX handlers (KnxHandlers.cpp) static esp_err_t getKnxAddressesHandler(httpd_req_t* req); + static esp_err_t getKnxProgHandler(httpd_req_t* req); + static esp_err_t postKnxProgHandler(httpd_req_t* req); + static esp_err_t postKnxResetHandler(httpd_req_t* req); // Status handlers (StatusHandlers.cpp) static esp_err_t postUsbModeHandler(httpd_req_t* req); diff --git a/main/widgets/LabelWidget.cpp b/main/widgets/LabelWidget.cpp index 7c29890..2dbb866 100644 --- a/main/widgets/LabelWidget.cpp +++ b/main/widgets/LabelWidget.cpp @@ -187,10 +187,22 @@ void LabelWidget::applyStyle() { 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 && + config_.textSource != TextSource::KNX_DPT_PERCENT && + config_.textSource != TextSource::KNX_DPT_POWER && + config_.textSource != TextSource::KNX_DPT_ENERGY && + config_.textSource != TextSource::KNX_DPT_DECIMALFACTOR) { + return; + } char buf[32]; - snprintf(buf, sizeof(buf), config_.text, value); + if (config_.textSource == TextSource::KNX_DPT_PERCENT || + config_.textSource == TextSource::KNX_DPT_DECIMALFACTOR) { + int intValue = static_cast(value + 0.5f); + snprintf(buf, sizeof(buf), config_.text, intValue); + } else { + snprintf(buf, sizeof(buf), config_.text, value); + } lv_label_set_text(label, buf); } diff --git a/main/widgets/PowerLinkWidget.cpp b/main/widgets/PowerLinkWidget.cpp index 6beb15a..7240861 100644 --- a/main/widgets/PowerLinkWidget.cpp +++ b/main/widgets/PowerLinkWidget.cpp @@ -247,7 +247,10 @@ void PowerLinkWidget::updateAnimation(float speed, bool reverse) { void PowerLinkWidget::onKnxValue(float value) { if (config_.textSource != TextSource::KNX_DPT_TEMP && - config_.textSource != TextSource::KNX_DPT_PERCENT) return; + config_.textSource != TextSource::KNX_DPT_PERCENT && + config_.textSource != TextSource::KNX_DPT_POWER && + config_.textSource != TextSource::KNX_DPT_ENERGY && + config_.textSource != TextSource::KNX_DPT_DECIMALFACTOR) return; float factor = parseFloatOr(config_.text, 1.0f); float speed = std::fabs(value) * factor; diff --git a/main/widgets/PowerNodeWidget.cpp b/main/widgets/PowerNodeWidget.cpp index ba483b4..42db462 100644 --- a/main/widgets/PowerNodeWidget.cpp +++ b/main/widgets/PowerNodeWidget.cpp @@ -158,12 +158,16 @@ void PowerNodeWidget::updateValueText(const char* text) { void PowerNodeWidget::onKnxValue(float value) { if (valueLabel_ == nullptr) return; if (config_.textSource != TextSource::KNX_DPT_TEMP && - config_.textSource != TextSource::KNX_DPT_PERCENT) return; + config_.textSource != TextSource::KNX_DPT_PERCENT && + config_.textSource != TextSource::KNX_DPT_POWER && + config_.textSource != TextSource::KNX_DPT_ENERGY && + config_.textSource != TextSource::KNX_DPT_DECIMALFACTOR) return; char buf[32]; const char* fmt = valueFormat_[0] != '\0' ? valueFormat_ : "%0.1f"; - if (config_.textSource == TextSource::KNX_DPT_PERCENT) { + if (config_.textSource == TextSource::KNX_DPT_PERCENT || + config_.textSource == TextSource::KNX_DPT_DECIMALFACTOR) { int percent = static_cast(value + 0.5f); snprintf(buf, sizeof(buf), fmt, percent); } else { diff --git a/sdcard_content/webseite/index.html b/sdcard_content/webseite/index.html index cd00114..2bccd7b 100644 --- a/sdcard_content/webseite/index.html +++ b/sdcard_content/webseite/index.html @@ -114,8 +114,16 @@ color: #ffd1d1; } + .btn.prog.active { + background: rgba(255, 107, 107, 0.18); + border-color: rgba(255, 107, 107, 0.6); + color: #ffd1d1; + box-shadow: 0 8px 18px rgba(255, 107, 107, 0.2); + } + .btn:hover { transform: translateY(-1px); } .btn:active { transform: translateY(1px); } + .btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; } .icon-btn { width: 28px; @@ -599,6 +607,8 @@
+ +
@@ -754,11 +764,24 @@ 1: 'KNX Temperatur', 2: 'KNX Schalter', 3: 'KNX Prozent', - 4: 'KNX Text' + 4: 'KNX Text', + 5: 'KNX Leistung (DPT 14.056)', + 6: 'KNX Energie (DPT 13.013)', + 7: 'KNX Dezimalfaktor (DPT 5.005)' }; + const textSourceGroups = [ + { label: 'Statisch', values: [0] }, + { label: 'DPT 1.x', values: [2] }, + { label: 'DPT 5.x', values: [3, 7] }, + { label: 'DPT 9.x', values: [1] }, + { label: 'DPT 13.x', values: [6] }, + { label: 'DPT 14.x', values: [5] }, + { label: 'DPT 16.x', values: [4] } + ]; + const sourceOptions = { - label: [0, 1, 2, 3, 4], + label: [0, 1, 2, 3, 4, 5, 6, 7], button: [0], led: [0, 2] }; @@ -769,7 +792,10 @@ 1: '%.1f °C', 2: '%s', 3: '%d %%', - 4: '%s' + 4: '%s', + 5: '%.1f W', + 6: '%.0f kWh', + 7: '%d' }; const WIDGET_DEFAULTS = { @@ -837,6 +863,8 @@ let nextWidgetId = 0; let nextScreenId = 0; let knxAddresses = []; + let knxProgMode = false; + let knxProgBusy = false; let canvasScale = 0.6; let showGrid = true; let activeScreenId = 0; @@ -932,6 +960,63 @@ } } + function mapLegacyKnxAddresses() { + if (!knxAddresses.length || !config || !Array.isArray(config.screens)) return; + const addrByIndex = new Map(); + const gaSet = new Set(); + knxAddresses.forEach((addr) => { + if (typeof addr.index === 'number' && typeof addr.addr === 'number') { + addrByIndex.set(addr.index, addr.addr); + gaSet.add(addr.addr); + } + }); + + config.screens.forEach((screen) => { + if (!Array.isArray(screen.widgets)) return; + screen.widgets.forEach((w) => { + if (typeof w.knxAddr === 'number' && w.knxAddr > 0) { + if (!gaSet.has(w.knxAddr) && addrByIndex.has(w.knxAddr)) { + w.knxAddr = addrByIndex.get(w.knxAddr); + } + } + if (typeof w.knxAddrWrite === 'number' && w.knxAddrWrite > 0) { + if (!gaSet.has(w.knxAddrWrite) && addrByIndex.has(w.knxAddrWrite)) { + w.knxAddrWrite = addrByIndex.get(w.knxAddrWrite); + } + } + }); + }); + } + + function updateKnxProgButton() { + const btn = document.getElementById('knxProgBtn'); + if (!btn) return; + btn.classList.toggle('active', knxProgMode); + btn.setAttribute('aria-pressed', knxProgMode ? 'true' : 'false'); + btn.textContent = knxProgMode ? 'KNX Prog AN' : 'KNX Prog AUS'; + btn.title = knxProgMode ? 'KNX Programmiermodus aktiv' : 'KNX Programmiermodus aus'; + btn.disabled = knxProgBusy; + } + + async function loadKnxProgMode() { + try { + const resp = await fetch('/api/knx/prog'); + if (!resp.ok) { + updateKnxProgButton(); + return; + } + const data = await resp.json(); + if (typeof data.progMode === 'boolean') { + knxProgMode = data.progMode; + } else if (typeof data.enabled === 'boolean') { + knxProgMode = data.enabled; + } + } catch (e) { + // Ignore when API is not available. + } + updateKnxProgButton(); + } + async function loadConfig() { try { await loadKnxAddresses(); @@ -975,6 +1060,8 @@ }); }); + mapLegacyKnxAddresses(); + if (config.standby.screen >= 255) { config.standby.screen = -1; } @@ -1203,6 +1290,19 @@ document.getElementById('treeCount').textContent = screen.widgets.length; } + function buildTextSourceOptions(sourceList, selectedValue) { + const allowed = new Set(sourceList || []); + return textSourceGroups.map((group) => { + const values = group.values.filter((value) => allowed.has(value)); + if (!values.length) return ''; + const options = values.map((value) => { + const label = textSources[value] || 'Unbekannt'; + return ``; + }).join(''); + return `${options}`; + }).join(''); + } + function renderProperties() { const panel = document.getElementById('properties'); const screen = getActiveScreen(); @@ -1215,17 +1315,14 @@ const key = typeKeyFor(w.type); const sourceList = sourceOptions[key] || [0]; - const sourceOptionsHtml = sourceList.map((value) => { - const label = textSources[value] || 'Unbekannt'; - return ``; - }).join(''); + const sourceOptionsHtml = buildTextSourceOptions(sourceList, w.textSrc); const knxOptions = knxAddresses.map((a) => - `` + `` ).join(''); const knxWriteOptions = knxAddresses.filter(a => a.write).map((a) => - `` + `` ).join(''); const screenOptions = config.screens.map((screenItem) => @@ -1705,6 +1802,55 @@ } } + async function resetKnxSettings() { + if (!confirm('KNX Einstellungen wirklich loeschen?\n\nDas Geraet startet neu und muss in ETS neu programmiert werden.')) return; + try { + const resp = await fetch('/api/knx/reset', { method: 'POST' }); + if (!resp.ok) { + showStatus('KNX Reset fehlgeschlagen', true); + return; + } + showStatus('KNX Reset...'); + alert('KNX Einstellungen geloescht. Geraet startet neu.'); + } catch (e) { + showStatus('KNX Reset fehlgeschlagen', true); + } + } + + async function toggleKnxProg() { + if (knxProgBusy) return; + knxProgBusy = true; + const next = !knxProgMode; + updateKnxProgButton(); + try { + const resp = await fetch('/api/knx/prog', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: next }) + }); + if (!resp.ok) { + showStatus('KNX Prog fehlgeschlagen', true); + return; + } + const data = await resp.json(); + const updated = + (typeof data.progMode === 'boolean') + ? data.progMode + : (typeof data.enabled === 'boolean' ? data.enabled : null); + if (updated === null) { + showStatus('KNX Prog fehlgeschlagen', true); + return; + } + knxProgMode = updated; + showStatus(knxProgMode ? 'KNX Prog AN' : 'KNX Prog AUS'); + } catch (e) { + showStatus('KNX Prog fehlgeschlagen', true); + } finally { + knxProgBusy = false; + updateKnxProgButton(); + } + } + async function enableUsbMode() { if (!confirm('USB-Modus aktivieren?\n\nDie SD-Karte wird als USB-Laufwerk verfuegbar.\nZum Beenden: Geraet neu starten.')) return; try { @@ -1782,6 +1928,7 @@ }); requestAnimationFrame(() => document.body.classList.add('loaded')); + loadKnxProgMode(); loadConfig(); diff --git a/web-interface/src/components/SidebarRight.vue b/web-interface/src/components/SidebarRight.vue index f865059..2c0a0e9 100644 --- a/web-interface/src/components/SidebarRight.vue +++ b/web-interface/src/components/SidebarRight.vue @@ -19,7 +19,9 @@

Inhalt

@@ -30,8 +32,8 @@
@@ -47,15 +49,17 @@

LED

@@ -79,15 +83,17 @@
@@ -147,7 +153,9 @@
@@ -163,8 +171,8 @@
@@ -179,7 +187,9 @@
@@ -190,8 +200,8 @@
@@ -342,8 +352,8 @@
@@ -369,7 +379,7 @@ import { computed, ref } from 'vue'; import { useEditorStore } from '../stores/editor'; import { typeKeyFor } from '../utils'; -import { sourceOptions, textSources, fontSizes, BUTTON_ACTIONS, defaultFormats, WIDGET_TYPES } from '../constants'; +import { sourceOptions, textSources, textSourceGroups, fontSizes, BUTTON_ACTIONS, defaultFormats, WIDGET_TYPES } from '../constants'; import IconPicker from './IconPicker.vue'; const store = useEditorStore(); @@ -444,6 +454,16 @@ const noteClass = 'text-[11px] text-muted leading-tight'; const iconSelectClass = 'flex-1 bg-panel-2 border border-border rounded-lg px-2.5 py-1.5 text-text text-[12px] flex items-center justify-center gap-1.5 cursor-pointer hover:bg-[#e4ebf2] hover:border-[#b8c4d2]'; const iconRemoveClass = 'w-7 h-7 rounded-md border border-red-200 bg-[#f7dede] text-[#b3261e] grid place-items-center text-[12px] cursor-pointer hover:bg-[#f2cfcf]'; +function groupedSources(options) { + const allowed = new Set(options || []); + return textSourceGroups + .map((group) => ({ + label: group.label, + values: group.values.filter((value) => allowed.has(value)) + })) + .filter((group) => group.values.length > 0); +} + function addPowerNode() { store.addWidget('powernode'); } diff --git a/web-interface/src/components/TopBar.vue b/web-interface/src/components/TopBar.vue index eebd646..ee5b365 100644 --- a/web-interface/src/components/TopBar.vue +++ b/web-interface/src/components/TopBar.vue @@ -10,6 +10,30 @@
+ +
@@ -17,10 +41,18 @@ diff --git a/web-interface/src/constants.js b/web-interface/src/constants.js index 848de3c..8308fe0 100644 --- a/web-interface/src/constants.js +++ b/web-interface/src/constants.js @@ -63,16 +63,29 @@ export const textSources = { 1: 'KNX Temperatur', 2: 'KNX Schalter', 3: 'KNX Prozent', - 4: 'KNX Text' + 4: 'KNX Text', + 5: 'KNX Leistung (DPT 14.056)', + 6: 'KNX Energie (DPT 13.013)', + 7: 'KNX Dezimalfaktor (DPT 5.005)' }; +export const textSourceGroups = [ + { label: 'Statisch', values: [0] }, + { label: 'DPT 1.x', values: [2] }, + { label: 'DPT 5.x', values: [3, 7] }, + { label: 'DPT 9.x', values: [1] }, + { label: 'DPT 13.x', values: [6] }, + { label: 'DPT 14.x', values: [5] }, + { label: 'DPT 16.x', values: [4] } +]; + export const sourceOptions = { - label: [0, 1, 2, 3, 4], + label: [0, 1, 2, 3, 4, 5, 6, 7], button: [0], led: [0, 2], icon: [0, 2], - powernode: [0, 1, 2, 3, 4], - powerlink: [0, 1, 3] + powernode: [0, 1, 2, 3, 4, 5, 6, 7], + powerlink: [0, 1, 3, 5, 6, 7] }; export const ICON_DEFAULTS = { @@ -88,7 +101,10 @@ export const defaultFormats = { 1: '%.1f °C', 2: '%s', 3: '%d %%', - 4: '%s' + 4: '%s', + 5: '%.1f W', + 6: '%.0f kWh', + 7: '%d' }; export const WIDGET_DEFAULTS = { diff --git a/web-interface/src/stores/editor.js b/web-interface/src/stores/editor.js index 30a4f7f..895f45b 100644 --- a/web-interface/src/stores/editor.js +++ b/web-interface/src/stores/editor.js @@ -22,6 +22,34 @@ export const useEditorStore = defineStore('editor', () => { const nextScreenId = ref(0); const nextWidgetId = ref(0); + function mapLegacyKnxAddresses() { + if (!knxAddresses.value.length || !Array.isArray(config.screens)) return; + const addrByIndex = new Map(); + const gaSet = new Set(); + knxAddresses.value.forEach((addr) => { + if (typeof addr.index === 'number' && typeof addr.addr === 'number') { + addrByIndex.set(addr.index, addr.addr); + gaSet.add(addr.addr); + } + }); + + config.screens.forEach((screen) => { + if (!Array.isArray(screen.widgets)) return; + screen.widgets.forEach((w) => { + if (typeof w.knxAddr === 'number' && w.knxAddr > 0) { + if (!gaSet.has(w.knxAddr) && addrByIndex.has(w.knxAddr)) { + w.knxAddr = addrByIndex.get(w.knxAddr); + } + } + if (typeof w.knxAddrWrite === 'number' && w.knxAddrWrite > 0) { + if (!gaSet.has(w.knxAddrWrite) && addrByIndex.has(w.knxAddrWrite)) { + w.knxAddrWrite = addrByIndex.get(w.knxAddrWrite); + } + } + }); + }); + } + const activeScreen = computed(() => { return config.screens.find(s => s.id === activeScreenId.value) || config.screens[0]; }); @@ -110,6 +138,7 @@ export const useEditorStore = defineStore('editor', () => { const resp = await fetch('/api/knx/addresses'); if (resp.ok) { knxAddresses.value = await resp.json(); + mapLegacyKnxAddresses(); } else { knxAddresses.value = []; } @@ -152,6 +181,7 @@ export const useEditorStore = defineStore('editor', () => { if (!config.standby) { config.standby = { enabled: false, screen: -1, minutes: 5 }; } + mapLegacyKnxAddresses(); // Recalculate IDs nextWidgetId.value = 0;