This commit is contained in:
Thomas Peterson 2026-01-28 22:41:05 +01:00
parent 75a7e18913
commit 8e90872c75
19 changed files with 688 additions and 60 deletions

View File

@ -41,6 +41,16 @@ GroupObjectTableObject& BauSystemBDevice::groupObjectTable()
return _groupObjTable; return _groupObjTable;
} }
AddressTableObject& BauSystemBDevice::addressTable()
{
return _addrTable;
}
AssociationTableObject& BauSystemBDevice::associationTable()
{
return _assocTable;
}
void BauSystemBDevice::loop() void BauSystemBDevice::loop()
{ {
_transLayer.loop(); _transLayer.loop();

View File

@ -23,6 +23,8 @@ class BauSystemBDevice : public BauSystemB
void loop() override; void loop() override;
bool configured() override; bool configured() override;
GroupObjectTableObject& groupObjectTable(); GroupObjectTableObject& groupObjectTable();
AddressTableObject& addressTable();
AssociationTableObject& associationTable();
protected: protected:
ApplicationLayer& applicationLayer() override; ApplicationLayer& applicationLayer() override;

View File

@ -47,5 +47,5 @@ void Gui::create()
void Gui::updateTemperature(float temp) void Gui::updateTemperature(float temp)
{ {
// Delegate to WidgetManager for KNX group address 1 // Delegate to WidgetManager for KNX group address 1
WidgetManager::instance().onKnxValue(1, temp); WidgetManager::instance().onKnxValue(1, temp, TextSource::KNX_DPT_TEMP);
} }

View File

@ -1,5 +1,5 @@
#include "KnxWorker.hpp" #include "KnxWorker.hpp"
#include "Gui.hpp" #include "WidgetManager.hpp"
#include "esp32_idf_platform.h" #include "esp32_idf_platform.h"
#include "knx_facade.h" #include "knx_facade.h"
#include "knx/bau07B0.h" #include "knx/bau07B0.h"
@ -8,7 +8,10 @@
#include "knx/dpt.h" #include "knx/dpt.h"
#include "esp_log.h" #include "esp_log.h"
#include <esp_timer.h> #include <esp_timer.h>
#include "esp_system.h"
#include "nvs.h"
#include <cstdio> #include <cstdio>
#include <cstring>
#define TAG "KNXWORKER" #define TAG "KNXWORKER"
#define MASK_VERSION 0x07B0 #define MASK_VERSION 0x07B0
@ -22,6 +25,65 @@ KnxFacade<Esp32IdfPlatform, Bau07B0> knx(knxBau);
KnxWorker::KnxWorker() {} 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<uint16_t>(tsap));
}
} // namespace
void KnxWorker::init() { void KnxWorker::init() {
ESP_LOGI(TAG, "INIT"); ESP_LOGI(TAG, "INIT");
@ -31,16 +93,83 @@ void KnxWorker::init() {
knxPlatform.setupUart(); knxPlatform.setupUart();
#if !UART_DEBUG_MODE #if !UART_DEBUG_MODE
knx.bau().deviceObject().hardwareType(kKnxHardwareType);
knx.bau().deviceObject().version(kKnxHardwareVersion);
knx.readMemory(); knx.readMemory();
// Register callback for GroupObject 1 (Temperature) uint32_t bauNumberOverride = 0;
GroupObject& go1 = knx.getGroupObject(1); if (loadKnxBauNumber(bauNumberOverride)) {
go1.dataPointType(DPT_Value_Temp); knx.bau().deviceObject().manufacturerId(0x00FA);
go1.callback([](GroupObject& go) { knx.bau().deviceObject().bauNumber(bauNumberOverride);
float temp = (float)go.value(DPT_Value_Temp); ESP_LOGI(TAG, "Applied KNX serial override: %04X%08lX", 0x00FA, (unsigned long)bauNumberOverride);
ESP_LOGI(TAG, "Temperature received: %.1f °C", temp); }
Gui::updateTemperature(temp);
// 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<bool>(switchValue));
}
KNXValue tempValue = 0.0f;
if (go.tryValue(tempValue, DPT_Value_Temp)) {
WidgetManager::instance().onKnxValue(groupAddr, static_cast<float>(tempValue),
TextSource::KNX_DPT_TEMP);
}
KNXValue percentValue = 0.0f;
if (go.tryValue(percentValue, DPT_Scaling)) {
WidgetManager::instance().onKnxValue(groupAddr, static_cast<float>(percentValue),
TextSource::KNX_DPT_PERCENT);
}
KNXValue factorValue = (uint8_t)0;
if (go.tryValue(factorValue, DPT_DecimalFactor)) {
WidgetManager::instance().onKnxValue(groupAddr, static_cast<float>(factorValue),
TextSource::KNX_DPT_DECIMALFACTOR);
}
KNXValue powerValue = 0.0f;
if (go.tryValue(powerValue, DPT_Value_Power)) {
WidgetManager::instance().onKnxValue(groupAddr, static_cast<float>(powerValue),
TextSource::KNX_DPT_POWER);
}
KNXValue energyValue = (int32_t)0;
if (go.tryValue(energyValue, DPT_ActiveEnergy_kWh)) {
WidgetManager::instance().onKnxValue(groupAddr, static_cast<float>(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<const char*>(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(); knx.start();
ESP_LOGI(TAG, "FINISH"); ESP_LOGI(TAG, "FINISH");
@ -60,6 +189,39 @@ void KnxWorker::toggleProgMode() {
#endif #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() { void KnxWorker::loop() {
#if UART_DEBUG_MODE #if UART_DEBUG_MODE
// Periodically send U_STATE_REQ to test TX direction // Periodically send U_STATE_REQ to test TX direction
@ -87,6 +249,19 @@ void KnxWorker::loop() {
} }
} }
#else #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(); knx.loop();
#endif #endif
} }
@ -119,8 +294,8 @@ bool KnxWorker::getGroupObjectInfo(size_t index, KnxGroupObjectInfo& info) {
info.readFlag = go.readEnable(); info.readFlag = go.readEnable();
info.writeFlag = go.writeEnable(); info.writeFlag = go.writeEnable();
// Get ASAP - this is the index we use for addressing // Resolve the primary group address via association/address tables
info.groupAddress = go.asap(); info.groupAddress = resolveGroupAddress(static_cast<uint16_t>(index));
return true; return true;
#else #else

View File

@ -6,7 +6,7 @@
// KNX Group Object Info für Web-API // KNX Group Object Info für Web-API
struct KnxGroupObjectInfo { struct KnxGroupObjectInfo {
uint16_t goIndex; // Group Object Index (1-based) 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 dptMain; // DPT Haupttyp
uint8_t dptSub; // DPT Subtyp uint8_t dptSub; // DPT Subtyp
bool commFlag; // Kommunikations-Flag bool commFlag; // Kommunikations-Flag
@ -19,6 +19,9 @@ public:
KnxWorker(); KnxWorker();
void init(); void init();
void toggleProgMode(); void toggleProgMode();
bool getProgMode();
void setProgMode(bool enabled);
void clearSettings();
void loop(); void loop();
// KNX Gruppenadressen auslesen // KNX Gruppenadressen auslesen
@ -27,4 +30,8 @@ public:
// Gruppenadresse als String formatieren (z.B. "1/2/3") // Gruppenadresse als String formatieren (z.B. "1/2/3")
static void formatGroupAddress(uint16_t addr, char* buf, size_t bufSize); static void formatGroupAddress(uint16_t addr, char* buf, size_t bufSize);
private:
volatile uint8_t knxResetState_ = 0;
volatile uint32_t knxResetAtMs_ = 0;
}; };

View File

@ -47,6 +47,9 @@ enum class TextSource : uint8_t {
KNX_DPT_SWITCH = 2, // KNX Switch (DPT 1.001) KNX_DPT_SWITCH = 2, // KNX Switch (DPT 1.001)
KNX_DPT_PERCENT = 3, // KNX Percent (DPT 5.001) KNX_DPT_PERCENT = 3, // KNX Percent (DPT 5.001)
KNX_DPT_TEXT = 4, // KNX Text (DPT 16.000) 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 { enum class TextAlign : uint8_t {
@ -94,7 +97,7 @@ struct WidgetConfig {
// Text properties // Text properties
TextSource textSource; TextSource textSource;
char text[MAX_TEXT_LEN]; // Static text or format string 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 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 uint8_t textAlign; // TextAlign: 0=left, 1=center, 2=right
bool isContainer; // For buttons: use as container (no internal label/icon) bool isContainer; // For buttons: use as container (no internal label/icon)
@ -110,7 +113,7 @@ struct WidgetConfig {
// Button specific // Button specific
bool isToggle; // For buttons: toggle mode 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) ButtonAction action; // Button action (KNX, Jump, Back)
uint8_t targetScreen; // Target screen ID for jump uint8_t targetScreen; // Target screen ID for jump

View File

@ -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 = {}; UiEvent event = {};
event.type = UiEventType::KNX_VALUE; event.type = UiEventType::KNX_VALUE;
event.groupAddr = groupAddr; event.groupAddr = groupAddr;
event.textSource = source;
event.value = value; event.value = value;
enqueueUiEvent(event); 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) { void WidgetManager::onKnxSwitch(uint16_t groupAddr, bool value) {
UiEvent event = {}; UiEvent event = {};
event.type = UiEventType::KNX_SWITCH; event.type = UiEventType::KNX_SWITCH;
@ -798,7 +803,7 @@ void WidgetManager::processUiQueue() {
xQueueReceive(uiQueue_, &event, 0) == pdTRUE) { xQueueReceive(uiQueue_, &event, 0) == pdTRUE) {
switch (event.type) { switch (event.type) {
case UiEventType::KNX_VALUE: case UiEventType::KNX_VALUE:
applyKnxValue(event.groupAddr, event.value); applyKnxValue(event.groupAddr, event.value, event.textSource);
break; break;
case UiEventType::KNX_SWITCH: case UiEventType::KNX_SWITCH:
applyKnxSwitch(event.groupAddr, event.state); applyKnxSwitch(event.groupAddr, event.state);
@ -813,9 +818,10 @@ void WidgetManager::processUiQueue() {
esp_lv_adapter_unlock(); 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_) { for (auto& widget : widgets_) {
if (widget && widget->getKnxAddress() == groupAddr) { if (widget && widget->getKnxAddress() == groupAddr &&
widget->getTextSource() == source) {
widget->onKnxValue(value); widget->onKnxValue(value);
} }
} }

View File

@ -38,6 +38,7 @@ public:
void onUserActivity(); void onUserActivity();
// Thread-safe KNX updates (queued to UI thread) // 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 onKnxValue(uint16_t groupAddr, float value);
void onKnxSwitch(uint16_t groupAddr, bool value); void onKnxSwitch(uint16_t groupAddr, bool value);
void onKnxText(uint16_t groupAddr, const char* text); void onKnxText(uint16_t groupAddr, const char* text);
@ -69,6 +70,7 @@ private:
struct UiEvent { struct UiEvent {
UiEventType type; UiEventType type;
uint16_t groupAddr; uint16_t groupAddr;
TextSource textSource;
float value; float value;
bool state; bool state;
char text[UI_EVENT_TEXT_LEN]; char text[UI_EVENT_TEXT_LEN];
@ -80,7 +82,7 @@ private:
void createAllWidgets(const ScreenConfig& screen, lv_obj_t* parent); void createAllWidgets(const ScreenConfig& screen, lv_obj_t* parent);
bool enqueueUiEvent(const UiEvent& event); bool enqueueUiEvent(const UiEvent& event);
void processUiQueue(); 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 applyKnxSwitch(uint16_t groupAddr, bool value);
void applyKnxText(uint16_t groupAddr, const char* text); void applyKnxText(uint16_t groupAddr, const char* text);

View File

@ -12,6 +12,9 @@ esp_err_t WebServer::getKnxAddressesHandler(httpd_req_t* req) {
for (size_t i = 1; i <= count; i++) { for (size_t i = 1; i <= count; i++) {
KnxGroupObjectInfo info; KnxGroupObjectInfo info;
if (knxWorker.getGroupObjectInfo(i, info)) { if (knxWorker.getGroupObjectInfo(i, info)) {
if (info.groupAddress == 0) {
continue;
}
char addrStr[16]; char addrStr[16];
KnxWorker::formatGroupAddress(info.groupAddress, addrStr, sizeof(addrStr)); KnxWorker::formatGroupAddress(info.groupAddress, addrStr, sizeof(addrStr));
@ -33,3 +36,83 @@ esp_err_t WebServer::getKnxAddressesHandler(httpd_req_t* req) {
cJSON_Delete(arr); cJSON_Delete(arr);
return ESP_OK; 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);
}

View File

@ -25,7 +25,7 @@ void WebServer::start() {
httpd_config_t config = HTTPD_DEFAULT_CONFIG(); httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.uri_match_fn = httpd_uri_match_wildcard; config.uri_match_fn = httpd_uri_match_wildcard;
config.stack_size = 8192; 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); 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_uri_t getKnxAddresses = { .uri = "/api/knx/addresses", .method = HTTP_GET, .handler = getKnxAddressesHandler, .user_ctx = nullptr };
httpd_register_uri_handler(server_, &getKnxAddresses); 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 // Status routes
httpd_uri_t postUsbMode = { .uri = "/api/usb-mode", .method = HTTP_POST, .handler = postUsbModeHandler, .user_ctx = nullptr }; httpd_uri_t postUsbMode = { .uri = "/api/usb-mode", .method = HTTP_POST, .handler = postUsbModeHandler, .user_ctx = nullptr };
httpd_register_uri_handler(server_, &postUsbMode); httpd_register_uri_handler(server_, &postUsbMode);

View File

@ -32,6 +32,9 @@ private:
// KNX handlers (KnxHandlers.cpp) // KNX handlers (KnxHandlers.cpp)
static esp_err_t getKnxAddressesHandler(httpd_req_t* req); 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) // Status handlers (StatusHandlers.cpp)
static esp_err_t postUsbModeHandler(httpd_req_t* req); static esp_err_t postUsbModeHandler(httpd_req_t* req);

View File

@ -187,10 +187,22 @@ void LabelWidget::applyStyle() {
void LabelWidget::onKnxValue(float value) { void LabelWidget::onKnxValue(float value) {
lv_obj_t* label = textLabel_ ? textLabel_ : obj_; lv_obj_t* label = textLabel_ ? textLabel_ : obj_;
if (label == nullptr) return; 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]; char buf[32];
if (config_.textSource == TextSource::KNX_DPT_PERCENT ||
config_.textSource == TextSource::KNX_DPT_DECIMALFACTOR) {
int intValue = static_cast<int>(value + 0.5f);
snprintf(buf, sizeof(buf), config_.text, intValue);
} else {
snprintf(buf, sizeof(buf), config_.text, value); snprintf(buf, sizeof(buf), config_.text, value);
}
lv_label_set_text(label, buf); lv_label_set_text(label, buf);
} }

View File

@ -247,7 +247,10 @@ void PowerLinkWidget::updateAnimation(float speed, bool reverse) {
void PowerLinkWidget::onKnxValue(float value) { void PowerLinkWidget::onKnxValue(float value) {
if (config_.textSource != TextSource::KNX_DPT_TEMP && 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 factor = parseFloatOr(config_.text, 1.0f);
float speed = std::fabs(value) * factor; float speed = std::fabs(value) * factor;

View File

@ -158,12 +158,16 @@ void PowerNodeWidget::updateValueText(const char* text) {
void PowerNodeWidget::onKnxValue(float value) { void PowerNodeWidget::onKnxValue(float value) {
if (valueLabel_ == nullptr) return; if (valueLabel_ == nullptr) return;
if (config_.textSource != TextSource::KNX_DPT_TEMP && 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]; char buf[32];
const char* fmt = valueFormat_[0] != '\0' ? valueFormat_ : "%0.1f"; 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<int>(value + 0.5f); int percent = static_cast<int>(value + 0.5f);
snprintf(buf, sizeof(buf), fmt, percent); snprintf(buf, sizeof(buf), fmt, percent);
} else { } else {

View File

@ -114,8 +114,16 @@
color: #ffd1d1; 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:hover { transform: translateY(-1px); }
.btn:active { transform: translateY(1px); } .btn:active { transform: translateY(1px); }
.btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
.icon-btn { .icon-btn {
width: 28px; width: 28px;
@ -599,6 +607,8 @@
</div> </div>
<div class="top-actions"> <div class="top-actions">
<button class="btn ghost" onclick="enableUsbMode()">USB-Modus</button> <button class="btn ghost" onclick="enableUsbMode()">USB-Modus</button>
<button class="btn ghost prog" id="knxProgBtn" onclick="toggleKnxProg()" aria-pressed="false" title="KNX Programmiermodus">KNX Prog AUS</button>
<button class="btn ghost danger" onclick="resetKnxSettings()">KNX Reset</button>
<button class="btn ghost danger" onclick="resetConfig()">Zuruecksetzen</button> <button class="btn ghost danger" onclick="resetConfig()">Zuruecksetzen</button>
<button class="btn primary" onclick="saveConfig()">Speichern & Anwenden</button> <button class="btn primary" onclick="saveConfig()">Speichern & Anwenden</button>
</div> </div>
@ -754,11 +764,24 @@
1: 'KNX Temperatur', 1: 'KNX Temperatur',
2: 'KNX Schalter', 2: 'KNX Schalter',
3: 'KNX Prozent', 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 = { const sourceOptions = {
label: [0, 1, 2, 3, 4], label: [0, 1, 2, 3, 4, 5, 6, 7],
button: [0], button: [0],
led: [0, 2] led: [0, 2]
}; };
@ -769,7 +792,10 @@
1: '%.1f °C', 1: '%.1f °C',
2: '%s', 2: '%s',
3: '%d %%', 3: '%d %%',
4: '%s' 4: '%s',
5: '%.1f W',
6: '%.0f kWh',
7: '%d'
}; };
const WIDGET_DEFAULTS = { const WIDGET_DEFAULTS = {
@ -837,6 +863,8 @@
let nextWidgetId = 0; let nextWidgetId = 0;
let nextScreenId = 0; let nextScreenId = 0;
let knxAddresses = []; let knxAddresses = [];
let knxProgMode = false;
let knxProgBusy = false;
let canvasScale = 0.6; let canvasScale = 0.6;
let showGrid = true; let showGrid = true;
let activeScreenId = 0; 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() { async function loadConfig() {
try { try {
await loadKnxAddresses(); await loadKnxAddresses();
@ -975,6 +1060,8 @@
}); });
}); });
mapLegacyKnxAddresses();
if (config.standby.screen >= 255) { if (config.standby.screen >= 255) {
config.standby.screen = -1; config.standby.screen = -1;
} }
@ -1203,6 +1290,19 @@
document.getElementById('treeCount').textContent = screen.widgets.length; 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 `<option value="${value}" ${selectedValue == value ? 'selected' : ''}>${label}</option>`;
}).join('');
return `<optgroup label="${group.label}">${options}</optgroup>`;
}).join('');
}
function renderProperties() { function renderProperties() {
const panel = document.getElementById('properties'); const panel = document.getElementById('properties');
const screen = getActiveScreen(); const screen = getActiveScreen();
@ -1215,17 +1315,14 @@
const key = typeKeyFor(w.type); const key = typeKeyFor(w.type);
const sourceList = sourceOptions[key] || [0]; const sourceList = sourceOptions[key] || [0];
const sourceOptionsHtml = sourceList.map((value) => { const sourceOptionsHtml = buildTextSourceOptions(sourceList, w.textSrc);
const label = textSources[value] || 'Unbekannt';
return `<option value="${value}" ${w.textSrc == value ? 'selected' : ''}>${label}</option>`;
}).join('');
const knxOptions = knxAddresses.map((a) => const knxOptions = knxAddresses.map((a) =>
`<option value="${a.index}" ${w.knxAddr == a.index ? 'selected' : ''}>GO${a.index} (${a.addrStr})</option>` `<option value="${a.addr}" ${w.knxAddr == a.addr ? 'selected' : ''}>GA ${a.addrStr} (GO${a.index})</option>`
).join(''); ).join('');
const knxWriteOptions = knxAddresses.filter(a => a.write).map((a) => const knxWriteOptions = knxAddresses.filter(a => a.write).map((a) =>
`<option value="${a.index}" ${w.knxAddrWrite == a.index ? 'selected' : ''}>GO${a.index} (${a.addrStr})</option>` `<option value="${a.addr}" ${w.knxAddrWrite == a.addr ? 'selected' : ''}>GA ${a.addrStr} (GO${a.index})</option>`
).join(''); ).join('');
const screenOptions = config.screens.map((screenItem) => 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() { async function enableUsbMode() {
if (!confirm('USB-Modus aktivieren?\n\nDie SD-Karte wird als USB-Laufwerk verfuegbar.\nZum Beenden: Geraet neu starten.')) return; if (!confirm('USB-Modus aktivieren?\n\nDie SD-Karte wird als USB-Laufwerk verfuegbar.\nZum Beenden: Geraet neu starten.')) return;
try { try {
@ -1782,6 +1928,7 @@
}); });
requestAnimationFrame(() => document.body.classList.add('loaded')); requestAnimationFrame(() => document.body.classList.add('loaded'));
loadKnxProgMode();
loadConfig(); loadConfig();
</script> </script>
</body> </body>

View File

@ -19,7 +19,9 @@
<h4 :class="headingClass">Inhalt</h4> <h4 :class="headingClass">Inhalt</h4>
<div :class="rowClass"><label :class="labelClass">Quelle</label> <div :class="rowClass"><label :class="labelClass">Quelle</label>
<select :class="inputClass" v-model.number="w.textSrc" @change="handleTextSrcChange"> <select :class="inputClass" v-model.number="w.textSrc" @change="handleTextSrcChange">
<option v-for="opt in sourceOptions.label" :key="opt" :value="opt">{{ textSources[opt] }}</option> <optgroup v-for="group in groupedSources(sourceOptions.label)" :key="group.label" :label="group.label">
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</optgroup>
</select> </select>
</div> </div>
<div v-if="w.textSrc === 0" :class="rowClass"> <div v-if="w.textSrc === 0" :class="rowClass">
@ -30,8 +32,8 @@
<div :class="rowClass"><label :class="labelClass">KNX Objekt</label> <div :class="rowClass"><label :class="labelClass">KNX Objekt</label>
<select :class="inputClass" v-model.number="w.knxAddr"> <select :class="inputClass" v-model.number="w.knxAddr">
<option :value="0">-- Waehlen --</option> <option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="addr.index" :value="addr.index"> <option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GO{{ addr.index }} ({{ addr.addrStr }}) GA {{ addr.addrStr }} (GO{{ addr.index }})
</option> </option>
</select> </select>
</div> </div>
@ -47,15 +49,17 @@
<h4 :class="headingClass">LED</h4> <h4 :class="headingClass">LED</h4>
<div :class="rowClass"><label :class="labelClass">Modus</label> <div :class="rowClass"><label :class="labelClass">Modus</label>
<select :class="inputClass" v-model.number="w.textSrc"> <select :class="inputClass" v-model.number="w.textSrc">
<option v-for="opt in sourceOptions.led" :key="opt" :value="opt">{{ textSources[opt] }}</option> <optgroup v-for="group in groupedSources(sourceOptions.led)" :key="group.label" :label="group.label">
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</optgroup>
</select> </select>
</div> </div>
<div v-if="w.textSrc === 2" :class="rowClass"> <div v-if="w.textSrc === 2" :class="rowClass">
<label :class="labelClass">KNX Objekt</label> <label :class="labelClass">KNX Objekt</label>
<select :class="inputClass" v-model.number="w.knxAddr"> <select :class="inputClass" v-model.number="w.knxAddr">
<option :value="0">-- Waehlen --</option> <option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="addr.index" :value="addr.index"> <option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GO{{ addr.index }} ({{ addr.addrStr }}) GA {{ addr.addrStr }} (GO{{ addr.index }})
</option> </option>
</select> </select>
</div> </div>
@ -79,15 +83,17 @@
</div> </div>
<div :class="rowClass"><label :class="labelClass">Modus</label> <div :class="rowClass"><label :class="labelClass">Modus</label>
<select :class="inputClass" v-model.number="w.textSrc"> <select :class="inputClass" v-model.number="w.textSrc">
<option v-for="opt in sourceOptions.icon" :key="opt" :value="opt">{{ textSources[opt] }}</option> <optgroup v-for="group in groupedSources(sourceOptions.icon)" :key="group.label" :label="group.label">
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</optgroup>
</select> </select>
</div> </div>
<div v-if="w.textSrc === 2" :class="rowClass"> <div v-if="w.textSrc === 2" :class="rowClass">
<label :class="labelClass">KNX Objekt</label> <label :class="labelClass">KNX Objekt</label>
<select :class="inputClass" v-model.number="w.knxAddr"> <select :class="inputClass" v-model.number="w.knxAddr">
<option :value="0">-- Waehlen --</option> <option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="addr.index" :value="addr.index"> <option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GO{{ addr.index }} ({{ addr.addrStr }}) GA {{ addr.addrStr }} (GO{{ addr.index }})
</option> </option>
</select> </select>
</div> </div>
@ -147,7 +153,9 @@
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted"> <div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[70px] text-[11px] text-muted">Speed</label> <label class="w-[70px] text-[11px] text-muted">Speed</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="link.widget.textSrc"> <select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="link.widget.textSrc">
<option v-for="opt in sourceOptions.powerlink" :key="opt" :value="opt">{{ textSources[opt] }}</option> <optgroup v-for="group in groupedSources(sourceOptions.powerlink)" :key="group.label" :label="group.label">
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</optgroup>
</select> </select>
</div> </div>
<div v-if="link.widget.textSrc === 0" class="flex items-center gap-2 mb-2 text-[11px] text-muted"> <div v-if="link.widget.textSrc === 0" class="flex items-center gap-2 mb-2 text-[11px] text-muted">
@ -163,8 +171,8 @@
<label class="w-[70px] text-[11px] text-muted">KNX</label> <label class="w-[70px] text-[11px] text-muted">KNX</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="link.widget.knxAddr"> <select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="link.widget.knxAddr">
<option :value="0">-- Waehlen --</option> <option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="addr.index" :value="addr.index"> <option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GO{{ addr.index }} ({{ addr.addrStr }}) GA {{ addr.addrStr }} (GO{{ addr.index }})
</option> </option>
</select> </select>
</div> </div>
@ -179,7 +187,9 @@
<div :class="rowClass"><label :class="labelClass">Label</label><input :class="inputClass" type="text" v-model="powerNodeLabel"></div> <div :class="rowClass"><label :class="labelClass">Label</label><input :class="inputClass" type="text" v-model="powerNodeLabel"></div>
<div :class="rowClass"><label :class="labelClass">Quelle</label> <div :class="rowClass"><label :class="labelClass">Quelle</label>
<select :class="inputClass" v-model.number="w.textSrc" @change="handleTextSrcChange"> <select :class="inputClass" v-model.number="w.textSrc" @change="handleTextSrcChange">
<option v-for="opt in sourceOptions.powernode" :key="opt" :value="opt">{{ textSources[opt] }}</option> <optgroup v-for="group in groupedSources(sourceOptions.powernode)" :key="group.label" :label="group.label">
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</optgroup>
</select> </select>
</div> </div>
<div v-if="w.textSrc === 0" :class="rowClass"> <div v-if="w.textSrc === 0" :class="rowClass">
@ -190,8 +200,8 @@
<div :class="rowClass"><label :class="labelClass">KNX Objekt</label> <div :class="rowClass"><label :class="labelClass">KNX Objekt</label>
<select :class="inputClass" v-model.number="w.knxAddr"> <select :class="inputClass" v-model.number="w.knxAddr">
<option :value="0">-- Waehlen --</option> <option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="addr.index" :value="addr.index"> <option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GO{{ addr.index }} ({{ addr.addrStr }}) GA {{ addr.addrStr }} (GO{{ addr.index }})
</option> </option>
</select> </select>
</div> </div>
@ -342,8 +352,8 @@
<div :class="rowClass"><label :class="labelClass">KNX Schreib</label> <div :class="rowClass"><label :class="labelClass">KNX Schreib</label>
<select :class="inputClass" v-model.number="w.knxAddrWrite"> <select :class="inputClass" v-model.number="w.knxAddrWrite">
<option :value="0">-- Keine --</option> <option :value="0">-- Keine --</option>
<option v-for="addr in writeableAddresses" :key="addr.index" :value="addr.index"> <option v-for="addr in writeableAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GO{{ addr.index }} ({{ addr.addrStr }}) GA {{ addr.addrStr }} (GO{{ addr.index }})
</option> </option>
</select> </select>
</div> </div>
@ -369,7 +379,7 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useEditorStore } from '../stores/editor'; import { useEditorStore } from '../stores/editor';
import { typeKeyFor } from '../utils'; import { typeKeyFor } from '../utils';
import { sourceOptions, textSources, 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'; import IconPicker from './IconPicker.vue';
const store = useEditorStore(); 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 iconSelectClass = 'flex-1 bg-panel-2 border border-border rounded-lg px-2.5 py-1.5 text-text text-[12px] flex items-center justify-center gap-1.5 cursor-pointer hover:bg-[#e4ebf2] hover:border-[#b8c4d2]';
const iconRemoveClass = 'w-7 h-7 rounded-md border border-red-200 bg-[#f7dede] text-[#b3261e] grid place-items-center text-[12px] cursor-pointer hover:bg-[#f2cfcf]'; const iconRemoveClass = 'w-7 h-7 rounded-md border border-red-200 bg-[#f7dede] text-[#b3261e] grid place-items-center text-[12px] cursor-pointer hover:bg-[#f2cfcf]';
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() { function addPowerNode() {
store.addWidget('powernode'); store.addWidget('powernode');
} }

View File

@ -10,6 +10,30 @@
<div class="flex items-center gap-2.5 flex-wrap justify-end"> <div class="flex items-center gap-2.5 flex-wrap justify-end">
<button class="border border-border bg-panel-2 text-text px-3.5 py-2 rounded-[10px] text-[13px] font-semibold transition hover:-translate-y-0.5 hover:bg-[#e4ebf2] active:translate-y-0.5" @click="emit('open-settings')">Einstellungen</button> <button class="border border-border bg-panel-2 text-text px-3.5 py-2 rounded-[10px] text-[13px] font-semibold transition hover:-translate-y-0.5 hover:bg-[#e4ebf2] active:translate-y-0.5" @click="emit('open-settings')">Einstellungen</button>
<button class="border border-border bg-panel-2 text-text px-3.5 py-2 rounded-[10px] text-[13px] font-semibold transition hover:-translate-y-0.5 hover:bg-[#e4ebf2] active:translate-y-0.5" @click="enableUsbMode">USB-Modus</button> <button class="border border-border bg-panel-2 text-text px-3.5 py-2 rounded-[10px] text-[13px] font-semibold transition hover:-translate-y-0.5 hover:bg-[#e4ebf2] active:translate-y-0.5" @click="enableUsbMode">USB-Modus</button>
<button
:class="[
'border px-3.5 py-2 rounded-[10px] text-[13px] font-semibold transition hover:-translate-y-0.5 active:translate-y-0.5',
knxProgMode ? 'border-red-200 bg-[#f7dede] text-[#b3261e] hover:bg-[#f2cfcf] shadow-[0_6px_14px_rgba(179,38,30,0.2)]' : 'border-border bg-panel-2 text-text hover:bg-[#e4ebf2]',
knxProgPending ? 'opacity-60 cursor-not-allowed' : ''
]"
:aria-pressed="knxProgMode"
:disabled="knxProgPending"
:title="knxProgMode ? 'KNX Programmiermodus aktiv' : 'KNX Programmiermodus aus'"
@click="toggleKnxProg"
>
{{ knxProgMode ? 'KNX Prog AN' : 'KNX Prog AUS' }}
</button>
<button
:class="[
'border border-red-200 bg-[#f7dede] text-[#b3261e] px-3.5 py-2 rounded-[10px] text-[13px] font-semibold transition hover:-translate-y-0.5 hover:bg-[#f2cfcf] active:translate-y-0.5',
knxResetPending ? 'opacity-60 cursor-not-allowed' : ''
]"
:disabled="knxResetPending"
title="KNX Einstellungen loeschen"
@click="resetKnxSettings"
>
KNX Reset
</button>
<button class="border border-red-200 bg-[#f7dede] text-[#b3261e] px-3.5 py-2 rounded-[10px] text-[13px] font-semibold transition hover:-translate-y-0.5 hover:bg-[#f2cfcf] active:translate-y-0.5" @click="handleReset">Zuruecksetzen</button> <button class="border border-red-200 bg-[#f7dede] text-[#b3261e] px-3.5 py-2 rounded-[10px] text-[13px] font-semibold transition hover:-translate-y-0.5 hover:bg-[#f2cfcf] active:translate-y-0.5" @click="handleReset">Zuruecksetzen</button>
<button class="border border-[#2b62a5] bg-[#2f6db8] text-white px-3.5 py-2 rounded-[10px] text-[13px] font-semibold transition hover:-translate-y-0.5 hover:bg-[#2b62a5] active:translate-y-0.5 shadow-[0_8px_18px_rgba(47,109,184,0.3)]" @click="handleSave">Speichern & Anwenden</button> <button class="border border-[#2b62a5] bg-[#2f6db8] text-white px-3.5 py-2 rounded-[10px] text-[13px] font-semibold transition hover:-translate-y-0.5 hover:bg-[#2b62a5] active:translate-y-0.5 shadow-[0_8px_18px_rgba(47,109,184,0.3)]" @click="handleSave">Speichern & Anwenden</button>
</div> </div>
@ -17,10 +41,18 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue';
import { useEditorStore } from '../stores/editor'; import { useEditorStore } from '../stores/editor';
const store = useEditorStore(); const store = useEditorStore();
const emit = defineEmits(['open-settings']); const emit = defineEmits(['open-settings']);
const knxProgMode = ref(false);
const knxProgPending = ref(false);
const knxResetPending = ref(false);
onMounted(() => {
loadKnxProgMode();
});
async function handleSave() { async function handleSave() {
try { try {
@ -55,4 +87,68 @@ async function enableUsbMode() {
alert('Fehler beim Aktivieren'); alert('Fehler beim Aktivieren');
} }
} }
async function loadKnxProgMode() {
try {
const resp = await fetch('/api/knx/prog');
if (!resp.ok) return;
const data = await resp.json();
if (typeof data.progMode === 'boolean') {
knxProgMode.value = data.progMode;
} else if (typeof data.enabled === 'boolean') {
knxProgMode.value = data.enabled;
}
} catch (e) {
// Ignore when API is not available.
}
}
async function toggleKnxProg() {
if (knxProgPending.value) return;
const next = !knxProgMode.value;
knxProgPending.value = true;
try {
const resp = await fetch('/api/knx/prog', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: next })
});
if (!resp.ok) {
alert('KNX Prog fehlgeschlagen');
return;
}
const data = await resp.json();
const updated =
typeof data.progMode === 'boolean'
? data.progMode
: (typeof data.enabled === 'boolean' ? data.enabled : null);
if (updated === null) {
alert('KNX Prog fehlgeschlagen');
return;
}
knxProgMode.value = updated;
} catch (e) {
alert('Fehler beim Umschalten');
} finally {
knxProgPending.value = false;
}
}
async function resetKnxSettings() {
if (knxResetPending.value) return;
if (!confirm('KNX Einstellungen wirklich loeschen?\n\nDas Geraet startet neu und muss in ETS neu programmiert werden.')) return;
knxResetPending.value = true;
try {
const resp = await fetch('/api/knx/reset', { method: 'POST' });
if (!resp.ok) {
alert('KNX Reset fehlgeschlagen');
return;
}
alert('KNX Einstellungen geloescht. Geraet startet neu.');
} catch (e) {
alert('KNX Reset fehlgeschlagen');
} finally {
knxResetPending.value = false;
}
}
</script> </script>

View File

@ -63,16 +63,29 @@ export const textSources = {
1: 'KNX Temperatur', 1: 'KNX Temperatur',
2: 'KNX Schalter', 2: 'KNX Schalter',
3: 'KNX Prozent', 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 = { export const sourceOptions = {
label: [0, 1, 2, 3, 4], label: [0, 1, 2, 3, 4, 5, 6, 7],
button: [0], button: [0],
led: [0, 2], led: [0, 2],
icon: [0, 2], icon: [0, 2],
powernode: [0, 1, 2, 3, 4], powernode: [0, 1, 2, 3, 4, 5, 6, 7],
powerlink: [0, 1, 3] powerlink: [0, 1, 3, 5, 6, 7]
}; };
export const ICON_DEFAULTS = { export const ICON_DEFAULTS = {
@ -88,7 +101,10 @@ export const defaultFormats = {
1: '%.1f °C', 1: '%.1f °C',
2: '%s', 2: '%s',
3: '%d %%', 3: '%d %%',
4: '%s' 4: '%s',
5: '%.1f W',
6: '%.0f kWh',
7: '%d'
}; };
export const WIDGET_DEFAULTS = { export const WIDGET_DEFAULTS = {

View File

@ -22,6 +22,34 @@ export const useEditorStore = defineStore('editor', () => {
const nextScreenId = ref(0); const nextScreenId = ref(0);
const nextWidgetId = 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(() => { const activeScreen = computed(() => {
return config.screens.find(s => s.id === activeScreenId.value) || config.screens[0]; 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'); const resp = await fetch('/api/knx/addresses');
if (resp.ok) { if (resp.ok) {
knxAddresses.value = await resp.json(); knxAddresses.value = await resp.json();
mapLegacyKnxAddresses();
} else { } else {
knxAddresses.value = []; knxAddresses.value = [];
} }
@ -152,6 +181,7 @@ export const useEditorStore = defineStore('editor', () => {
if (!config.standby) { if (!config.standby) {
config.standby = { enabled: false, screen: -1, minutes: 5 }; config.standby = { enabled: false, screen: -1, minutes: 5 };
} }
mapLegacyKnxAddresses();
// Recalculate IDs // Recalculate IDs
nextWidgetId.value = 0; nextWidgetId.value = 0;