Refactor GatewayModbus and GatewayNetwork components

- Updated GatewayModbusConfig to allow uart_port and pin values to be -1, indicating an unconfigured state.
- Enhanced GatewayNetworkService to support an additional setup AP button with configurable GPIO and active low settings.
- Refactored boot button configuration logic to reduce redundancy and improve clarity.
- Introduced a new method for handling GPIO input configuration.
- Improved boot button task loop to handle both boot and setup AP buttons more effectively.
- Added programming mode functionality to EtsDeviceRuntime, allowing toggling and querying of the programming state.
- Implemented memory checks to avoid unnecessary reads in EtsDeviceRuntime.
- Enhanced security storage to derive factory FDSK from the device's serial number and store it in NVS.
- Updated factory FDSK loading logic to ensure proper key generation and storage.

Signed-off-by: Tony <tonylu@tony-cloud.com>
This commit is contained in:
Tony
2026-05-13 12:36:16 +08:00
parent df1dd472cc
commit b74367e5a0
18 changed files with 2244 additions and 609 deletions
+275 -93
View File
@@ -15,7 +15,6 @@
#include "gateway_knx.hpp"
#include "gateway_modbus.hpp"
#include "gateway_provisioning.hpp"
#include "openknx_idf/ets_memory_loader.h"
#include "openknx_idf/security_storage.h"
#include "cJSON.h"
@@ -1296,12 +1295,20 @@ uart_stop_bits_t UartStopBits(int bits) {
} // namespace
struct GatewayBridgeService::ChannelRuntime {
explicit ChannelRuntime(DaliDomainService& domain, GatewayCache& cache, DaliChannelInfo channel,
explicit ChannelRuntime(GatewayBridgeService& service, DaliDomainService& domain,
GatewayCache& cache, DaliChannelInfo channel,
GatewayBridgeServiceConfig service_config)
: domain(domain), cache(cache), channel(std::move(channel)), service_config(service_config),
: service(service),
domain(domain),
cache(cache),
channel(std::move(channel)),
service_config(service_config),
lock(xSemaphoreCreateRecursiveMutex()) {}
~ChannelRuntime() {
if (knx_router != nullptr) {
knx_router->stop();
}
if (cloud != nullptr) {
cloud->stop();
}
@@ -1311,6 +1318,7 @@ struct GatewayBridgeService::ChannelRuntime {
}
}
GatewayBridgeService& service;
DaliDomainService& domain;
GatewayCache& cache;
DaliChannelInfo channel;
@@ -1427,18 +1435,17 @@ struct GatewayBridgeService::ChannelRuntime {
knx = std::make_unique<GatewayKnxBridge>(*engine);
knx_router = std::make_unique<GatewayKnxTpIpRouter>(
*knx, [this](const uint8_t* data, size_t len) {
LockGuard guard(lock);
if (knx == nullptr) {
DaliBridgeResult result;
result.error = "KNX bridge is not ready";
return result;
}
return knx->handleCemiFrame(data, len);
return service.routeKnxCemiFrame(data, len);
},
openKnxNamespace());
if (knx_config.has_value()) {
knx->setConfig(knx_config.value());
knx_router->setConfig(knx_config.value());
knx_router->setGroupWriteHandler(
[this](uint16_t group_address, const uint8_t* data, size_t len) {
return service.routeKnxGroupWrite(group_address, data, len);
});
if (const auto active_knx = activeKnxConfigLocked(); active_knx.has_value()) {
knx->setConfig(active_knx.value());
knx_router->setConfig(active_knx.value());
knx_router->setCommissioningOnly(!knx_config.has_value());
}
#if defined(CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED)
@@ -1459,33 +1466,8 @@ struct GatewayBridgeService::ChannelRuntime {
}
void refreshOpenKnxEtsAssociationsLocked() {
if (!service_config.knx_enabled) {
return;
}
const auto active_config = activeKnxConfigLocked();
if (!active_config.has_value()) {
return;
}
const auto snapshot = openknx::LoadEtsMemorySnapshot(openKnxNamespace());
const bool has_downloaded_address = snapshot.individual_address != 0 &&
snapshot.individual_address != 0xffff;
if (!snapshot.configured && !has_downloaded_address && snapshot.associations.empty()) {
return;
}
GatewayKnxConfig updated = active_config.value();
if (has_downloaded_address) {
updated.individual_address = snapshot.individual_address;
}
updated.ets_associations.clear();
updated.ets_associations.reserve(snapshot.associations.size());
for (const auto& association : snapshot.associations) {
updated.ets_associations.push_back(GatewayKnxEtsAssociation{
association.group_address, association.group_object_number});
}
knx_config = std::move(updated);
ESP_LOGI(kTag, "gateway=%u loaded OpenKNX ETS address=0x%04x associations=%u from NVS namespace %s",
channel.gateway_id, snapshot.individual_address,
static_cast<unsigned>(snapshot.associations.size()), openKnxNamespace().c_str());
// Bau07B0/OpenKNX memory restore is stack-heavy and owns TP-UART internals;
// the live KNX task restores and syncs ETS associations after startup.
}
std::optional<DaliDomainSnapshot> diagnosticSnapshotLocked(int short_address,
@@ -2056,25 +2038,41 @@ struct GatewayBridgeService::ChannelRuntime {
knx_last_error = validation_error;
return validation_err;
}
if (config->ip_router_enabled && used_ports != nullptr) {
if (used_ports->find(config->udp_port) != used_ports->end()) {
knx_last_error = "duplicate KNXnet/IP UDP port " + std::to_string(config->udp_port);
GatewayKnxConfig runtime_config = config.value();
if (runtime_config.ip_router_enabled && used_ports != nullptr) {
if (used_ports->find(runtime_config.udp_port) != used_ports->end()) {
knx_last_error = "KNXnet/IP UDP port " + std::to_string(runtime_config.udp_port) +
" is already owned by another runtime";
ESP_LOGW(kTag, "gateway=%u skips duplicate KNXnet/IP UDP port %u",
channel.gateway_id, static_cast<unsigned>(runtime_config.udp_port));
return ESP_ERR_INVALID_STATE;
}
used_ports->insert(config->udp_port);
used_ports->insert(runtime_config.udp_port);
}
if (config->ip_router_enabled && used_uarts != nullptr) {
const int uart_port = config->tp_uart.uart_port;
if (GatewayKnxConfigUsesTpUart(runtime_config) && used_uarts != nullptr) {
const int uart_port = runtime_config.tp_uart.uart_port;
if (used_uarts->find(uart_port) != used_uarts->end()) {
knx_last_error = "KNX TP-UART UART" + std::to_string(uart_port) +
" is already used by another runtime";
return ESP_ERR_INVALID_STATE;
ESP_LOGW(kTag,
"gateway=%u KNX TP-UART UART%d is already owned by another runtime; "
"starting this KNX/IP runtime without opening a second UART driver",
channel.gateway_id, uart_port);
runtime_config.tp_uart.uart_port = -1;
}
used_uarts->insert(uart_port);
}
knx->setConfig(config.value());
knx_router->setConfig(config.value());
if (!config->ip_router_enabled) {
const bool commissioning_only = !knx_config.has_value();
ESP_LOGI(kTag,
"gateway=%u KNX/IP start config namespace=%s storedConfig=%d udp=%u tunnel=%d "
"multicast=%d multicastGroup=%s mainGroup=%u tpUart=%d tx=%d rx=%d individual=0x%04x",
channel.gateway_id, openKnxNamespace().c_str(), !commissioning_only,
static_cast<unsigned>(runtime_config.udp_port), runtime_config.tunnel_enabled,
runtime_config.multicast_enabled, runtime_config.multicast_address.c_str(),
static_cast<unsigned>(runtime_config.main_group), runtime_config.tp_uart.uart_port,
runtime_config.tp_uart.tx_pin, runtime_config.tp_uart.rx_pin,
runtime_config.individual_address);
knx->setConfig(runtime_config);
knx_router->setConfig(runtime_config);
knx_router->setCommissioningOnly(commissioning_only);
if (!runtime_config.ip_router_enabled) {
knx_started = false;
return ESP_ERR_NOT_SUPPORTED;
}
@@ -2084,8 +2082,18 @@ struct GatewayBridgeService::ChannelRuntime {
knx_started = err == ESP_OK;
if (err != ESP_OK) {
knx_last_error = knx_router->lastError().empty()
? "failed to start KNX TP-UART router"
? "failed to start KNXnet/IP router"
: knx_router->lastError();
ESP_LOGW(kTag, "gateway=%u KNX/IP start failed err=%s(%d) detail=%s",
channel.gateway_id, esp_err_to_name(err), static_cast<int>(err),
knx_last_error.c_str());
} else {
if (knx_router->tpUartOnline() && used_uarts != nullptr) {
used_uarts->insert(runtime_config.tp_uart.uart_port);
}
ESP_LOGI(kTag, "gateway=%u KNX/IP started namespace=%s udp=%u detail=%s",
channel.gateway_id, openKnxNamespace().c_str(),
static_cast<unsigned>(runtime_config.udp_port), knx_router->lastError().c_str());
}
return err;
}
@@ -3014,12 +3022,15 @@ struct GatewayBridgeService::ChannelRuntime {
const std::optional<GatewayKnxConfig>& candidate_knx,
std::string* error_message = nullptr) const {
const int uart_port = config.serial.uart_port;
if (uart_port < 0 || uart_port > 2) {
if (uart_port < -1 || uart_port > 2) {
if (error_message != nullptr) {
*error_message = "Modbus serial UART port must be 0, 1, or 2";
*error_message = "Modbus serial UART port must be -1, 0, 1, or 2";
}
return ESP_ERR_INVALID_ARG;
}
if (uart_port < 0) {
return ESP_OK;
}
if (uart_port == 0 && !service_config.allow_modbus_uart0) {
if (error_message != nullptr) {
*error_message =
@@ -3035,7 +3046,8 @@ struct GatewayBridgeService::ChannelRuntime {
return ESP_ERR_INVALID_STATE;
}
if (service_config.knx_enabled && candidate_knx.has_value() &&
candidate_knx->ip_router_enabled && candidate_knx->tp_uart.uart_port == uart_port) {
GatewayKnxConfigUsesTpUart(candidate_knx.value()) &&
candidate_knx->tp_uart.uart_port == uart_port) {
if (error_message != nullptr) {
*error_message = "Modbus serial UART" + std::to_string(uart_port) +
" conflicts with KNX TP-UART; choose another free UART for RS485";
@@ -3048,16 +3060,16 @@ struct GatewayBridgeService::ChannelRuntime {
esp_err_t validateKnxConfigLocked(const GatewayKnxConfig& config,
const std::optional<GatewayModbusConfig>& candidate_modbus,
std::string* error_message = nullptr) const {
if (!config.ip_router_enabled) {
return ESP_OK;
}
const int uart_port = config.tp_uart.uart_port;
if (uart_port < 0 || uart_port > 2) {
if (config.tp_uart.uart_port < -1 || config.tp_uart.uart_port > 2) {
if (error_message != nullptr) {
*error_message = "KNX TP-UART port must be 0, 1, or 2";
*error_message = "KNX TP-UART port must be -1, 0, 1, or 2";
}
return ESP_ERR_INVALID_ARG;
}
if (!config.ip_router_enabled || !GatewayKnxConfigUsesTpUart(config)) {
return ESP_OK;
}
const int uart_port = config.tp_uart.uart_port;
if (uart_port == 0 && !service_config.allow_knx_uart0) {
if (error_message != nullptr) {
*error_message =
@@ -3144,7 +3156,18 @@ struct GatewayBridgeService::ChannelRuntime {
if (knx_config.has_value()) {
return knx_config;
}
return service_config.default_knx_config;
if (!service_config.default_knx_config.has_value()) {
return std::nullopt;
}
GatewayKnxConfig config = service_config.default_knx_config.value();
const uint8_t channel_index = channel.channel_index;
config.main_group = static_cast<uint8_t>(std::min<int>(31, config.main_group + channel_index));
const uint16_t device = config.individual_address & 0x00ff;
if (device > 0 && device + channel_index <= 0x00ff) {
config.individual_address = static_cast<uint16_t>((config.individual_address & 0xff00) |
(device + channel_index));
}
return config;
}
esp_err_t saveKnxConfig(const GatewayKnxConfig& config,
@@ -3165,12 +3188,7 @@ struct GatewayBridgeService::ChannelRuntime {
return validation_err;
}
const bool restart_router = knx_started || (knx_router != nullptr && knx_router->started());
if (restart_router && merged_config.ip_router_enabled && used_ports != nullptr &&
used_ports->find(merged_config.udp_port) != used_ports->end()) {
knx_last_error = "duplicate KNXnet/IP UDP port " + std::to_string(merged_config.udp_port);
return ESP_ERR_INVALID_STATE;
}
if (restart_router && merged_config.ip_router_enabled && used_uarts != nullptr &&
if (restart_router && GatewayKnxConfigUsesTpUart(merged_config) && used_uarts != nullptr &&
used_uarts->find(merged_config.tp_uart.uart_port) != used_uarts->end()) {
knx_last_error = "KNX TP-UART UART" + std::to_string(merged_config.tp_uart.uart_port) +
" is already used by another runtime";
@@ -3195,6 +3213,7 @@ struct GatewayBridgeService::ChannelRuntime {
}
if (knx_router != nullptr) {
knx_router->setConfig(merged_config);
knx_router->setCommissioningOnly(false);
}
if (restart_router) {
return startKnx(used_ports, used_uarts);
@@ -3350,6 +3369,10 @@ struct GatewayBridgeService::ChannelRuntime {
return ESP_ERR_NOT_FOUND;
}
if (GatewayModbusTransportIsSerial(config->transport)) {
if (config->serial.uart_port < 0) {
modbus_last_error = "Modbus serial UART disabled";
return ESP_ERR_NOT_SUPPORTED;
}
std::string validation_error;
const esp_err_t serial_err = validateSerialModbusConfigLocked(
config.value(), activeKnxConfigLocked(), &validation_error);
@@ -3532,8 +3555,10 @@ struct GatewayBridgeService::ChannelRuntime {
}
const int rts_pin = config.serial.rs485.enabled ? config.serial.rs485.de_pin
: UART_PIN_NO_CHANGE;
err = uart_set_pin(uart_port, config.serial.tx_pin, config.serial.rx_pin, rts_pin,
UART_PIN_NO_CHANGE);
err = uart_set_pin(uart_port,
config.serial.tx_pin < 0 ? UART_PIN_NO_CHANGE : config.serial.tx_pin,
config.serial.rx_pin < 0 ? UART_PIN_NO_CHANGE : config.serial.rx_pin,
rts_pin < 0 ? UART_PIN_NO_CHANGE : rts_pin, UART_PIN_NO_CHANGE);
if (err != ESP_OK) {
return err;
}
@@ -3794,7 +3819,7 @@ esp_err_t GatewayBridgeService::start() {
const auto channels = dali_domain_.channelInfo();
runtimes_.reserve(channels.size());
for (const auto& channel : channels) {
auto runtime = std::make_unique<ChannelRuntime>(dali_domain_, cache_, channel, config_);
auto runtime = std::make_unique<ChannelRuntime>(*this, dali_domain_, cache_, channel, config_);
const esp_err_t err = runtime->start();
if (err != ESP_OK) {
ESP_LOGE(kTag, "failed to start bridge runtime gateway=%u: %s", channel.gateway_id,
@@ -3820,13 +3845,18 @@ esp_err_t GatewayBridgeService::start() {
}
if (config_.knx_enabled && config_.knx_startup_enabled) {
std::set<uint16_t> used_knx_ports;
for (const auto& runtime : runtimes_) {
const esp_err_t err = runtime->startKnx(&used_knx_ports, &used_serial_uarts);
if (err != ESP_OK && err != ESP_ERR_NOT_FOUND && err != ESP_ERR_NOT_SUPPORTED) {
ESP_LOGW(kTag, "gateway=%u KNX/IP startup skipped: %s", runtime->channel.gateway_id,
esp_err_to_name(err));
}
ChannelRuntime* owner = selectKnxEndpointRuntime();
const esp_err_t err = startKnxEndpoint(owner, &used_serial_uarts);
if (err != ESP_OK && err != ESP_ERR_NOT_FOUND && err != ESP_ERR_NOT_SUPPORTED) {
const char* detail = owner == nullptr
? "no KNX endpoint owner"
: (!owner->knx_last_error.empty()
? owner->knx_last_error.c_str()
: (owner->knx_router == nullptr
? "router unavailable"
: owner->knx_router->lastError().c_str()));
ESP_LOGW(kTag, "KNX/IP startup skipped: %s(%d) detail=%s",
esp_err_to_name(err), static_cast<int>(err), detail);
}
}
@@ -3863,20 +3893,170 @@ const GatewayBridgeService::ChannelRuntime* GatewayBridgeService::findRuntime(
return nullptr;
}
GatewayBridgeService::ChannelRuntime* GatewayBridgeService::selectKnxEndpointRuntime() {
auto eligible = [](ChannelRuntime* runtime) {
if (runtime == nullptr) {
return false;
}
LockGuard guard(runtime->lock);
const auto config = runtime->activeKnxConfigLocked();
return config.has_value() && config->ip_router_enabled;
};
if (eligible(knx_endpoint_runtime_)) {
return knx_endpoint_runtime_;
}
ChannelRuntime* selected = nullptr;
for (const auto& runtime : runtimes_) {
if (!eligible(runtime.get())) {
continue;
}
if (selected == nullptr || runtime->channel.channel_index < selected->channel.channel_index) {
selected = runtime.get();
}
}
knx_endpoint_runtime_ = selected;
if (selected != nullptr) {
ESP_LOGI(kTag, "gateway=%u owns shared KNXnet/IP endpoint", selected->channel.gateway_id);
}
return selected;
}
bool GatewayBridgeService::isKnxEndpointRuntime(const ChannelRuntime* runtime) const {
return runtime != nullptr && runtime == knx_endpoint_runtime_;
}
esp_err_t GatewayBridgeService::startKnxEndpoint(ChannelRuntime* requested_runtime,
std::set<int>* used_uarts) {
if (!config_.knx_enabled) {
return ESP_ERR_NOT_SUPPORTED;
}
ChannelRuntime* owner = selectKnxEndpointRuntime();
if (owner == nullptr) {
return ESP_ERR_NOT_FOUND;
}
if (requested_runtime != nullptr && requested_runtime != owner) {
ESP_LOGI(kTag, "gateway=%u requested KNX start; shared endpoint remains owned by gateway=%u",
requested_runtime->channel.gateway_id, owner->channel.gateway_id);
}
if (used_uarts != nullptr) {
LockGuard guard(owner->lock);
const auto owner_config = owner->activeKnxConfigLocked();
if (owner_config.has_value() && GatewayKnxConfigUsesTpUart(owner_config.value())) {
used_uarts->erase(owner_config->tp_uart.uart_port);
}
}
std::set<uint16_t> used_knx_ports;
for (const auto& runtime : runtimes_) {
if (runtime.get() == owner) {
continue;
}
LockGuard guard(runtime->lock);
if (runtime->knx_started ||
(runtime->knx_router != nullptr && runtime->knx_router->started())) {
const auto config = runtime->activeKnxConfigLocked();
if (config.has_value()) {
used_knx_ports.insert(config->udp_port);
}
}
}
return owner->startKnx(&used_knx_ports, used_uarts);
}
esp_err_t GatewayBridgeService::stopKnxEndpoint(ChannelRuntime* requested_runtime) {
ChannelRuntime* owner = knx_endpoint_runtime_ != nullptr ? knx_endpoint_runtime_
: selectKnxEndpointRuntime();
if (owner == nullptr) {
return ESP_ERR_NOT_FOUND;
}
if (requested_runtime != nullptr && requested_runtime != owner) {
ESP_LOGI(kTag, "gateway=%u requested KNX stop; stopping shared endpoint owned by gateway=%u",
requested_runtime->channel.gateway_id, owner->channel.gateway_id);
}
return owner->stopKnx();
}
DaliBridgeResult GatewayBridgeService::routeKnxCemiFrame(const uint8_t* data, size_t len) {
std::vector<ChannelRuntime*> matches;
for (const auto& runtime : runtimes_) {
LockGuard guard(runtime->lock);
if (runtime->knx != nullptr && runtime->knx->matchesCemiFrame(data, len)) {
matches.push_back(runtime.get());
}
}
if (matches.empty()) {
DaliBridgeResult result;
result.error = "No DALI bridge mapping matched KNX cEMI group write";
return result;
}
if (matches.size() > 1) {
DaliBridgeResult result;
result.error = "KNX cEMI group write matched multiple DALI bridge channels";
ESP_LOGW(kTag, "%s", result.error.c_str());
return result;
}
ChannelRuntime* runtime = matches.front();
LockGuard guard(runtime->lock);
if (runtime->knx == nullptr || !runtime->knx->matchesCemiFrame(data, len)) {
DaliBridgeResult result;
result.error = "DALI bridge mapping changed before KNX cEMI dispatch";
return result;
}
return runtime->knx->handleCemiFrame(data, len);
}
DaliBridgeResult GatewayBridgeService::routeKnxGroupWrite(uint16_t group_address,
const uint8_t* data, size_t len) {
std::vector<ChannelRuntime*> matches;
for (const auto& runtime : runtimes_) {
LockGuard guard(runtime->lock);
if (runtime->knx != nullptr && runtime->knx->matchesGroupAddress(group_address)) {
matches.push_back(runtime.get());
}
}
if (matches.empty()) {
DaliBridgeResult result;
result.error = "No DALI bridge mapping matched KNX group " +
GatewayKnxGroupAddressString(group_address);
return result;
}
if (matches.size() > 1) {
DaliBridgeResult result;
result.error = "KNX group " + GatewayKnxGroupAddressString(group_address) +
" matched multiple DALI bridge channels";
ESP_LOGW(kTag, "%s", result.error.c_str());
return result;
}
ChannelRuntime* runtime = matches.front();
LockGuard guard(runtime->lock);
if (runtime->knx == nullptr || !runtime->knx->matchesGroupAddress(group_address)) {
DaliBridgeResult result;
result.error = "DALI bridge mapping changed before KNX group dispatch";
return result;
}
return runtime->knx->handleGroupWrite(group_address, data, len);
}
void GatewayBridgeService::handleDaliRawFrame(const DaliRawFrame& frame) {
const auto update = DecodeDaliKnxStatusUpdate(frame);
if (!update.has_value()) {
return;
}
auto* runtime = findRuntime(frame.gateway_id);
if (runtime == nullptr) {
auto* owner = knx_endpoint_runtime_ != nullptr ? knx_endpoint_runtime_
: selectKnxEndpointRuntime();
if (owner == nullptr || owner->channel.gateway_id != frame.gateway_id) {
return;
}
LockGuard guard(runtime->lock);
if (!runtime->knx_started || runtime->knx_router == nullptr) {
LockGuard guard(owner->lock);
if (!owner->knx_started || owner->knx_router == nullptr) {
return;
}
runtime->knx_router->publishDaliStatus(update->target, update->actual_level);
owner->knx_router->publishDaliStatus(update->target, update->actual_level);
}
void GatewayBridgeService::collectUsedRuntimeResources(
@@ -3909,7 +4089,8 @@ void GatewayBridgeService::collectUsedRuntimeResources(
if (knx_udp_ports != nullptr) {
knx_udp_ports->insert(knx_config->udp_port);
}
if (serial_uarts != nullptr) {
if (serial_uarts != nullptr && runtime->knx_router != nullptr &&
runtime->knx_router->tpUartOnline()) {
serial_uarts->insert(knx_config->tp_uart.uart_port);
}
}
@@ -4214,20 +4395,21 @@ GatewayBridgeHttpResponse GatewayBridgeService::handlePost(
return handleGet("knx", gateway_id.value());
}
if (action == "knx_start") {
std::set<uint16_t> used_knx_ports;
ChannelRuntime* owner = selectKnxEndpointRuntime();
std::set<int> used_serial_uarts;
collectUsedRuntimeResources(gateway_id.value(), nullptr, &used_knx_ports,
&used_serial_uarts);
const esp_err_t err = runtime->startKnx(&used_knx_ports, &used_serial_uarts);
collectUsedRuntimeResources(owner == nullptr ? gateway_id.value() : owner->channel.gateway_id,
nullptr, nullptr, &used_serial_uarts);
const esp_err_t err = startKnxEndpoint(runtime, &used_serial_uarts);
if (err != ESP_OK) {
return ErrorResponse(err, runtime->knx_last_error.empty()
auto* owner = knx_endpoint_runtime_ != nullptr ? knx_endpoint_runtime_ : runtime;
return ErrorResponse(err, owner->knx_last_error.empty()
? "failed to start KNX/IP bridge"
: runtime->knx_last_error.c_str());
: owner->knx_last_error.c_str());
}
return handleGet("knx", gateway_id.value());
}
if (action == "knx_stop") {
const esp_err_t err = runtime->stopKnx();
const esp_err_t err = stopKnxEndpoint(runtime);
if (err != ESP_OK) {
return ErrorResponse(err, "failed to stop KNX/IP bridge");
}