feat: Enhance OAM router functionality and improve KNX device handling

- Added support for OAM router configuration in app_main, allowing the IP interface individual address to be set based on the OAM router's individual address.
- Updated GatewayBridgeService to validate IP interface addresses only when OAM router is disabled, ensuring proper address management.
- Introduced KnxResponseDeduplicator to prevent duplicate responses in KNX communication.
- Enhanced ETS device runtime to handle bus frames and set up frame receivers for OAM router.
- Improved GatewayKnxTpIpRouter to manage OAM router interactions, including handling tunnel frames and bus frames.
- Updated CMakeLists to include new knx_device_broker source file.
- Refined logging messages to provide clearer context regarding the IP interface being used.
- Added methods to retrieve IP interface names and friendly names based on the OAM router configuration.

Signed-off-by: Tony <tonylu@tony-cloud.com>
This commit is contained in:
Tony
2026-05-28 15:44:17 +08:00
parent 078c37a20f
commit 8211514fe3
17 changed files with 432 additions and 75 deletions
+3 -3
View File
@@ -60,17 +60,17 @@ The native rewrite now wires a shared `gateway_core` bootstrap component, a mult
KNX Data Secure and KNXnet/IP Secure support are controlled by `GATEWAY_KNX_DATA_SECURE_SUPPORTED` and `GATEWAY_KNX_IP_SECURE_SUPPORTED`. KNXnet/IP Secure now recognizes the secure service family, performs secure session setup/authentication with provisioned tunnel user keys, wraps secure tunnel responses, handles secure group-sync frames, and wraps/unpacks secure multicast routing frames when an OAM backbone key is active. The gateway derives its KNX serial identity from the ESP base MAC, and the development factory setup key is deterministically derived from that KNX serial so the same board keeps the same FDSK across NVS erases.
The shared KNXnet/IP endpoint can also be provisioned with an OAM-compatible IP-Router persona by enabling `GATEWAY_KNX_OAM_ROUTER_SUPPORTED` and the nested `knx.oamRouter` config. This second logical application is part of the same KNX/IP router endpoint: it does not open a second UDP/TCP listener and it does not own a second TP-UART driver. The gateway hosts a BAU091A/OAM router runtime beside the REG1-Dali BAU07B0 runtime, with separate individual/tunnel addresses, separate programming button/LED GPIOs, separate ETS/security storage, and a KNX serial derived from the ESP base MAC plus one. Secure tunnels are assigned to the OAM persona and OAM-addressed management frames are dispatched to the BAU091A runtime while DALI group/function-property traffic stays on the REG1-Dali application. The default OAM identity follows the OAM-IP-Router release database (`0x00FA` manufacturer, `0xA11F` application number, version `0x07`) unless overridden in Kconfig.
The shared KNXnet/IP endpoint can also be provisioned with an OAM-compatible IP-Router persona by enabling `GATEWAY_KNX_OAM_ROUTER_SUPPORTED` and the nested `knx.oamRouter` config. This second logical application is part of the same KNX/IP router endpoint: it does not open a second UDP/TCP listener and it does not own a second TP-UART driver. When OAM is enabled, KNXnet/IP search, description, tunnel, and multicast identity are advertised as the OAM IP router and follow the OAM individual address instead of a DALI-channel namespace. The gateway hosts a BAU091A/OAM router runtime beside the REG1-Dali BAU07B0 runtime, with separate individual/tunnel addresses, separate programming button/LED GPIOs, separate ETS/security storage, and a KNX serial derived from the ESP base MAC plus one. Secure tunnels are assigned to the OAM persona, and non-secure IP management/tunnel connections opened while OAM programming mode is active are also assigned to the OAM persona so ETS can modify and verify the OAM individual address. OAM-addressed management frames are dispatched to the BAU091A runtime while DALI group/function-property traffic stays on the REG1-Dali application. Physical TP ingress is brokered at the shared TP-UART owner: OAM-addressed TP telegrams and broadcast management telegrams are delivered to the matching local logical runtime, and OAM responses are transmitted through the same TP-UART and mirrored to KNXnet/IP once. Normal REG1-Dali TP group/application traffic remains on the existing REG1 data-link path so ETS group-object dispatch and DALI routing do not duplicate. The default OAM identity follows the OAM-IP-Router release database (`0x00FA` manufacturer, `0xA11F` application number, version `0x07`) unless overridden in Kconfig.
OAM IP Secure keyring preparation uses the `knx_oam_sec` NVS namespace. Development HTTP actions can read/generate/reset/export the OAM factory setup key and store already-extracted IP Secure keyring material (`backboneKeyHex`, tunnel user keys, and an optional device-authentication key). Stored OAM credentials are reported in `knx.security.oamRouter.ipSecureCredentials`; the tunnel user keys authenticate secure sessions and the backbone key protects secure routing/group-sync traffic. The routing sequence counter is persisted back to NVS after secure routing sends or authenticated sync updates.
Cloud KNX remote-access preparation is part of the `knx.oamRouter.cloudRemote` config. The status JSON reports the selected mode (`mqtt`, relay, or UDP punch-through-oriented deployments), whether secure tunnels are required, and whether relay endpoint, MQTT topic prefix, and token-reference fields are configured. The firmware does not start an external relay client yet; this config is the stable handoff surface for a future UDP relay/MQTT tunnel transport that will reuse the secure OAM tunnel path.
The KNXnet/IP tunnel can start from the built-in default configuration before any ETS download. KNX TP-UART is enabled only when `GATEWAY_KNX_TP_UART_PORT` is `0`, `1`, or `2`; set that UART port to `-1` for IP-only operation. UART TX/RX GPIO values of `-1` mean use the ESP-IDF target default pins for that UART, not disabled. `GATEWAY_KNX_TP_UART_9BIT_MODE` enables the NCN5120/OpenKNX-style 9-bit host frame on the wire, represented on ESP-IDF as 8 data bits plus even parity. Enable `GATEWAY_KNX_TP_FULL_IP_FORWARD` when the gateway must mirror all physical TP telegrams back out to KNXnet/IP tunnelling and multicast so ETS can monitor or download other TP devices through the gateway's IP endpoint. Non-UART GPIO options use `-1` as disabled, including the KNX programming button, KNX programming LED, setup AP button, Wi-Fi reset button, and status LED.
The KNXnet/IP tunnel can start from the built-in default configuration before any ETS download. KNX TP-UART is enabled only when `GATEWAY_KNX_TP_UART_PORT` is `0`, `1`, or `2`; set that UART port to `-1` for IP-only operation. UART TX/RX GPIO values of `-1` mean use the ESP-IDF target default pins for that UART, not disabled. `GATEWAY_KNX_TP_UART_9BIT_MODE` enables the NCN5120/OpenKNX-style 9-bit host frame on the wire, represented on ESP-IDF as 8 data bits plus even parity. Enable `GATEWAY_KNX_TP_FULL_IP_FORWARD` when the gateway must mirror all physical TP telegrams back out to KNXnet/IP tunnelling and multicast so ETS can monitor or download other TP devices through the gateway's IP endpoint. Local logical-device responses are de-duplicated by exact cEMI payload at the tunnel egress, so the same response is not replayed through tunnel, routing, and TP echo paths while distinct source addresses remain visible as separate devices. Non-UART GPIO options use `-1` as disabled, including the KNX programming button, KNX programming LED, setup AP button, Wi-Fi reset button, and status LED.
When no KNX bridge config or ETS application data has been downloaded, the KNXnet/IP router starts in commissioning mode: OpenKNX receives tunnel programming traffic from ETS, while DALI group routing and REG1-Dali function-property actions stay inactive until ETS reports a configured application.
The bridge service exposes one shared KNXnet/IP endpoint per physical gateway on the configured UDP port. Per-channel DALI/KNX bridge runtimes keep their own group-address mappings behind that endpoint, and incoming group writes are dispatched to the matching channel instead of starting one UDP socket per DALI channel.
The bridge service exposes one chip-level KNXnet/IP endpoint per physical gateway module on the configured UDP port. Per-channel DALI/KNX bridge runtimes keep their own group-address mappings behind that endpoint, and incoming group writes are dispatched to matching channel bridges instead of starting one UDP socket or multicast responder per DALI channel. If a non-owner channel router was already running, starting the shared endpoint stops it so KNXnet/IP search and description requests receive one response from the chip-level interface.
KNX programming mode can be controlled locally with `GATEWAY_KNX_PROGRAMMING_BUTTON_GPIO`, and `GATEWAY_KNX_PROGRAMMING_LED_GPIO` mirrors the current programming-mode state. The setup AP entry button is configured separately with `GATEWAY_SETUP_AP_BUTTON_GPIO`; Wi-Fi credential reset remains a separate long-press function on `GATEWAY_BOOT_BUTTON_GPIO` when enabled.
+3
View File
@@ -1090,6 +1090,9 @@ extern "C" void app_main(void) {
static_cast<uint16_t>(CONFIG_GATEWAY_KNX_OAM_ROUTER_INDIVIDUAL_ADDRESS);
default_knx.oam_router.tunnel_address_base =
static_cast<uint16_t>(CONFIG_GATEWAY_KNX_OAM_ROUTER_TUNNEL_ADDRESS_BASE);
if (default_knx.oam_router.enabled) {
default_knx.ip_interface_individual_address = default_knx.oam_router.individual_address;
}
default_knx.oam_router.programming_button_gpio =
CONFIG_GATEWAY_KNX_OAM_PROGRAMMING_BUTTON_GPIO;
default_knx.oam_router.programming_led_gpio =
@@ -3427,8 +3427,9 @@ struct GatewayBridgeService::ChannelRuntime {
}
return ESP_ERR_INVALID_ARG;
}
if (config.ip_interface_individual_address == 0 ||
config.ip_interface_individual_address == 0xffff) {
if (!config.oam_router.enabled &&
(config.ip_interface_individual_address == 0 ||
config.ip_interface_individual_address == 0xffff)) {
if (error_message != nullptr) {
*error_message = "KNX IP interface individual address must be a configured address";
}
@@ -3440,7 +3441,8 @@ struct GatewayBridgeService::ChannelRuntime {
}
return ESP_ERR_INVALID_ARG;
}
if (config.ip_interface_individual_address == config.individual_address) {
if (!config.oam_router.enabled &&
config.ip_interface_individual_address == config.individual_address) {
if (error_message != nullptr) {
*error_message = "KNX IP interface and KNX-DALI gateway addresses must differ";
}
@@ -3463,11 +3465,10 @@ struct GatewayBridgeService::ChannelRuntime {
return ESP_ERR_INVALID_ARG;
}
if (config.oam_router.individual_address == config.individual_address ||
config.oam_router.individual_address == config.ip_interface_individual_address ||
config.oam_router.tunnel_address_base == config.individual_address ||
config.oam_router.tunnel_address_base == config.ip_interface_individual_address) {
config.oam_router.tunnel_address_base == config.oam_router.individual_address) {
if (error_message != nullptr) {
*error_message = "OAM KNX/IP router addresses must differ from the shared IP interface and KNX-DALI gateway addresses";
*error_message = "OAM KNX/IP router addresses must differ from KNX-DALI gateway and OAM tunnel addresses";
}
return ESP_ERR_INVALID_ARG;
}
@@ -4364,6 +4365,19 @@ esp_err_t GatewayBridgeService::startKnxEndpoint(ChannelRuntime* requested_runti
requested_runtime->channel.gateway_id, owner->channel.gateway_id);
}
for (const auto& runtime : runtimes_) {
if (runtime.get() == owner) {
continue;
}
if (runtime->knx_started ||
(runtime->knx_router != nullptr && runtime->knx_router->started())) {
ESP_LOGI(kTag,
"gateway=%u stopping non-owner KNXnet/IP router; shared endpoint owner is gateway=%u",
runtime->channel.gateway_id, owner->channel.gateway_id);
runtime->stopKnx();
}
}
if (used_uarts != nullptr) {
LockGuard guard(owner->lock);
const auto owner_config = owner->activeKnxConfigLocked();
@@ -4404,23 +4418,33 @@ esp_err_t GatewayBridgeService::stopKnxEndpoint(ChannelRuntime* requested_runtim
DaliBridgeResult GatewayBridgeService::routeKnxGroupWrite(uint16_t group_address,
const uint8_t* data, size_t len) {
ChannelRuntime* runtime = knx_endpoint_runtime_ != nullptr ? knx_endpoint_runtime_
: selectKnxEndpointRuntime();
if (runtime == nullptr) {
DaliBridgeResult result;
result.error = "No DALI channel is selected for KNX group " +
GatewayKnxGroupAddressString(group_address);
return result;
DaliBridgeResult aggregate;
aggregate.error = "No DALI channel maps KNX group " +
GatewayKnxGroupAddressString(group_address);
size_t matched_count = 0;
for (const auto& candidate : runtimes_) {
LockGuard guard(candidate->lock);
const auto config = candidate->activeKnxConfigLocked();
if (candidate->knx == nullptr || !config.has_value() || !config->dali_router_enabled ||
!candidate->channel.native_bus_id.has_value() ||
candidate->channel.native_bus_id.value() != config->dali_bus_id ||
!candidate->knx->matchesGroupAddress(group_address)) {
continue;
}
++matched_count;
DaliBridgeResult child = candidate->knx->handleGroupWrite(group_address, data, len);
child.metadata["gatewayId"] = static_cast<int>(candidate->channel.gateway_id);
child.metadata["channelIndex"] = static_cast<int>(candidate->channel.channel_index);
aggregate.results.emplace_back(child.toJson());
if (child.ok) {
aggregate.ok = true;
aggregate.error.clear();
} else if (!child.error.empty() && aggregate.error.empty()) {
aggregate.error = child.error;
}
}
LockGuard guard(runtime->lock);
if (runtime->knx == nullptr || !runtime->knx->matchesGroupAddress(group_address)) {
DaliBridgeResult result;
result.error = "Selected DALI bus does not map KNX group " +
GatewayKnxGroupAddressString(group_address);
return result;
}
return runtime->knx->handleGroupWrite(group_address, data, len);
aggregate.metadata["matchedChannels"] = static_cast<int>(matched_count);
return aggregate;
}
DaliBridgeResult GatewayBridgeService::routeKnxGroupObjectWrite(uint16_t group_object_number,
+1
View File
@@ -7,6 +7,7 @@ idf_component_register(
"src/gateway_knx_router_packets.cpp"
"src/gateway_knx_router_services.cpp"
"src/gateway_knx_secure_transport.cpp"
"src/knx_device_broker.cpp"
"src/oam_router_runtime.cpp"
"src/ets_device_runtime.cpp"
"src/ets_memory_loader.cpp"
@@ -22,6 +22,7 @@ class TpuartUartInterface;
class EtsDeviceRuntime {
public:
using CemiFrameSender = std::function<void(const uint8_t* data, size_t len)>;
using CemiFrameReceiver = std::function<bool(const uint8_t* data, size_t len)>;
using GroupWriteHandler = std::function<void(uint16_t group_address, const uint8_t* data,
size_t len)>;
using GroupObjectWriteHandler = std::function<void(uint16_t group_object_number,
@@ -62,6 +63,7 @@ class EtsDeviceRuntime {
void setGroupWriteHandler(GroupWriteHandler handler);
void setGroupObjectWriteHandler(GroupObjectWriteHandler handler);
void setBusFrameSender(CemiFrameSender sender);
void setTpFrameReceiver(CemiFrameReceiver receiver);
void setNetworkInterface(esp_netif_t* netif);
bool hasTpUart() const;
bool enableTpUart(bool enabled = true);
@@ -121,6 +123,7 @@ class EtsDeviceRuntime {
Bau07B0 device_;
CemiFrameSender sender_;
CemiFrameSender bus_frame_sender_;
CemiFrameReceiver tp_frame_receiver_;
GroupWriteHandler group_write_handler_;
GroupObjectWriteHandler group_object_write_handler_;
FunctionPropertyHandler command_handler_;
@@ -340,8 +340,10 @@ class GatewayKnxTpIpRouter {
::sockaddr_in data_remote{};
std::vector<uint8_t> last_received_cemi;
std::vector<uint8_t> last_tunnel_confirmation_packet;
std::vector<uint8_t> last_sent_cemi;
uint16_t secure_session_id{0};
bool oam_router_persona{false};
TickType_t last_sent_cemi_tick{0};
};
struct SecureSession {
@@ -468,12 +470,16 @@ class GatewayKnxTpIpRouter {
size_t suppress_routing_echo_len = 0);
bool handleOpenKnxBusFrame(const uint8_t* data, size_t len);
bool transmitOpenKnxTpFrame(const uint8_t* data, size_t len);
bool transmitOpenKnxTpFrameLocked(const uint8_t* data, size_t len);
bool handleOpenKnxTpIngressFrame(const uint8_t* data, size_t len);
void publishCloudCemiFrame(const uint8_t* data, size_t len);
void selectOpenKnxNetworkInterface(const ::sockaddr_in& remote);
bool routeOpenKnxGroupWrite(const uint8_t* data, size_t len, const char* context);
bool emitOpenKnxGroupValue(uint16_t group_object_number, const uint8_t* data, size_t len);
bool shouldRouteDaliApplicationFrames() const;
uint8_t advertisedMedium() const;
const char* ipInterfaceName() const;
const char* ipInterfaceFriendlyName() const;
void syncOpenKnxConfigFromDevice();
uint16_t effectiveIpInterfaceIndividualAddress() const;
uint16_t effectiveKnxDeviceIndividualAddress() const;
@@ -0,0 +1,38 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <vector>
namespace gateway {
enum class KnxPortKind : uint8_t {
kIpTunnel,
kIpRouting,
kTpUart,
kCloud,
kRfReserved,
};
struct KnxIngressContext {
KnxPortKind port{KnxPortKind::kIpTunnel};
bool oam_persona_hint{false};
bool broadcast_management{false};
};
class KnxResponseDeduplicator {
public:
KnxResponseDeduplicator() = default;
KnxResponseDeduplicator(const uint8_t* original, size_t len);
bool remember(const uint8_t* data, size_t len);
bool remember(const std::vector<uint8_t>& data);
size_t suppressedCount() const;
private:
std::vector<uint8_t> original_;
std::vector<std::vector<uint8_t>> sent_;
size_t suppressed_count_{0};
};
} // namespace gateway
@@ -43,9 +43,11 @@ class OamRouterRuntime {
DeviceObject* deviceObject();
Platform* platform();
void setNetworkInterface(esp_netif_t* netif);
void setBusFrameSender(CemiFrameSender sender);
bool handleTunnelFrame(const uint8_t* data, size_t len, CemiFrameSender sender);
bool handleLocalBroadcastManagementFrame(const uint8_t* data, size_t len,
CemiFrameSender sender);
bool handleBusFrame(const uint8_t* data, size_t len, CemiFrameSender sender);
void loop();
private:
@@ -53,9 +55,11 @@ class OamRouterRuntime {
static void EmitTunnelFrame(CemiFrame& frame, void* context);
static uint16_t DefaultTunnelClientAddress(uint16_t individual_address);
bool shouldConsumeTunnelFrame(CemiFrame& frame) const;
bool shouldConsumeBusFrame(CemiFrame& frame) const;
std::string nvs_namespace_;
CemiFrameSender sender_;
CemiFrameSender bus_frame_sender_;
#if defined(ENABLE_BAU091A_PERSONA)
EspIdfPlatform platform_;
Bau091A device_;
@@ -163,6 +163,9 @@ EtsDeviceRuntime::EtsDeviceRuntime(std::string nvs_namespace,
}
EtsDeviceRuntime::~EtsDeviceRuntime() {
if (auto* data_link_layer = device_.getDataLinkLayer()) {
data_link_layer->receiveFrameHandler({});
}
if (tp_uart_interface_ != nullptr) {
device_.enabled(false);
}
@@ -289,6 +292,15 @@ void EtsDeviceRuntime::setBusFrameSender(CemiFrameSender sender) {
bus_frame_sender_ = std::move(sender);
}
void EtsDeviceRuntime::setTpFrameReceiver(CemiFrameReceiver receiver) {
tp_frame_receiver_ = std::move(receiver);
if (auto* data_link_layer = device_.getDataLinkLayer()) {
data_link_layer->receiveFrameHandler([this](CemiFrame& frame) {
return tp_frame_receiver_ ? tp_frame_receiver_(frame.data(), frame.dataLength()) : false;
});
}
}
void EtsDeviceRuntime::setNetworkInterface(esp_netif_t* netif) {
platform_.networkInterface(netif);
}
@@ -647,12 +659,27 @@ bool EtsDeviceRuntime::shouldConsumeTunnelFrame(CemiFrame& frame) const {
}
bool EtsDeviceRuntime::shouldConsumeBusFrame(CemiFrame& frame) const {
if (frame.messageCode() != L_data_ind && frame.messageCode() != L_data_req) {
return false;
}
#ifdef USE_DATASECURE
return frame.messageCode() == L_data_ind && frame.addressType() == GroupAddress &&
frame.apdu().type() == SecureService;
#else
return false;
if (frame.addressType() == GroupAddress && frame.apdu().type() == SecureService) {
return true;
}
#endif
if (IsBroadcastManagementRequest(frame)) {
return true;
}
if (frame.addressType() != IndividualAddress) {
return false;
}
const uint16_t dest = frame.destinationAddress();
const bool commissioning = !const_cast<Bau07B0&>(device_).configured() || programmingMode();
return dest == individualAddress() || dest == tunnelClientAddress() ||
(commissioning && dest == kKnxUnconfiguredBroadcastAddress);
}
} // namespace gateway::openknx
@@ -824,7 +824,8 @@ std::optional<MessageCode> CemiMessageCode(const uint8_t* data, size_t len) {
}
bool IsKnxBroadcastManagementRequest(CemiFrame& frame) {
if (frame.messageCode() != L_data_req || frame.addressType() != GroupAddress ||
if ((frame.messageCode() != L_data_req && frame.messageCode() != L_data_ind) ||
frame.addressType() != GroupAddress ||
frame.destinationAddress() != 0x0000) {
return false;
}
@@ -154,9 +154,9 @@ esp_err_t GatewayKnxTpIpRouter::start(uint32_t task_stack_size, UBaseType_t task
&log_tp_uart_rx_pin);
}
ESP_LOGI(kTag,
"starting KNXnet/IP router namespace=%s udp=%u tunnel=%d multicast=%d group=%s "
"starting KNXnet/IP router ipInterface=%s runtimeNamespace=%s udp=%u tunnel=%d multicast=%d group=%s "
"tpUart=%d tx=%s rx=%s nineBit=%d commissioningOnly=%d",
openknx_namespace_.c_str(), static_cast<unsigned>(config_.udp_port),
ipInterfaceName(), openknx_namespace_.c_str(), static_cast<unsigned>(config_.udp_port),
config_.tunnel_enabled, config_.multicast_enabled,
config_.multicast_address.c_str(), config_.tp_uart.uart_port,
UartPinDescription(config_.tp_uart.tx_pin, log_tp_uart_tx_pin).c_str(),
@@ -351,6 +351,16 @@ esp_err_t GatewayKnxTpIpRouter::initializeRuntime() {
sendTunnelIndication(data, len);
sendRoutingIndication(data, len);
});
ets_device_->setTpFrameReceiver([this](const uint8_t* data, size_t len) {
return handleOpenKnxTpIngressFrame(data, len);
});
if (oam_router_ != nullptr) {
oam_router_->setBusFrameSender([this](const uint8_t* data, size_t len) {
publishCloudCemiFrame(data, len);
sendTunnelIndication(data, len);
sendRoutingIndication(data, len);
});
}
syncOpenKnxConfigFromDevice();
}
if (!configureTpUart()) {
@@ -1,5 +1,7 @@
#include "gateway_knx_private.hpp"
#include "knx_device_broker.h"
namespace gateway {
void GatewayKnxTpIpRouter::selectOpenKnxNetworkInterface(const sockaddr_in& remote) {
@@ -18,7 +20,13 @@ bool GatewayKnxTpIpRouter::handleOpenKnxTunnelFrame(const uint8_t* data, size_t
uint16_t response_service,
const uint8_t* suppress_routing_echo,
size_t suppress_routing_echo_len) {
bool route_to_oam = response_client != nullptr && response_client->oam_router_persona;
const KnxIngressContext ingress_context{
response_service == kServiceRoutingIndication ? KnxPortKind::kIpRouting
: KnxPortKind::kIpTunnel,
response_client != nullptr && response_client->oam_router_persona,
false,
};
bool route_to_oam = ingress_context.oam_persona_hint;
bool route_to_all_internal_instances = false;
if (data != nullptr && len >= 2) {
std::vector<uint8_t> frame_data(data, data + len);
@@ -36,25 +44,35 @@ bool GatewayKnxTpIpRouter::handleOpenKnxTunnelFrame(const uint8_t* data, size_t
if (ets_device_ == nullptr && oam_router_ == nullptr) {
return false;
}
if (response_client != nullptr && oam_router_ != nullptr &&
(oam_router_->programmingMode() || !oam_router_->configured())) {
response_client->oam_router_persona = true;
}
std::vector<uint8_t> tunnel_confirmation;
const bool needs_tunnel_confirmation =
response_client != nullptr && response_client->connected &&
response_service == kServiceTunnellingRequest &&
BuildTunnelConfirmationFrame(data, len, &tunnel_confirmation);
bool sent_tunnel_confirmation = false;
KnxResponseDeduplicator response_broker;
const auto send_response =
[this, response_client, response_service, needs_tunnel_confirmation,
&tunnel_confirmation, &sent_tunnel_confirmation, suppress_routing_echo,
suppress_routing_echo_len](const uint8_t* response, size_t response_len) {
&tunnel_confirmation, &sent_tunnel_confirmation, &response_broker,
suppress_routing_echo, suppress_routing_echo_len](const uint8_t* response,
size_t response_len) {
if (response == nullptr || response_len == 0) {
return;
}
publishCloudCemiFrame(response, response_len);
std::vector<uint8_t> response_data(response, response + response_len);
if (!response_broker.remember(response_data)) {
return;
}
publishCloudCemiFrame(response_data.data(), response_data.size());
const bool routing_context =
response_client == nullptr && response_service == kServiceRoutingIndication;
const auto message_code = CemiMessageCode(response, response_len);
const auto message_code = CemiMessageCode(response_data.data(), response_data.size());
if (routing_context && suppress_routing_echo != nullptr &&
IsLocalRoutingEchoIndication(response, response_len, suppress_routing_echo,
IsLocalRoutingEchoIndication(response_data.data(), response_data.size(), suppress_routing_echo,
suppress_routing_echo_len)) {
return;
}
@@ -65,12 +83,14 @@ bool GatewayKnxTpIpRouter::handleOpenKnxTunnelFrame(const uint8_t* data, size_t
tunnel_confirmation.data(), tunnel_confirmation.size());
}
const uint16_t service = KnxIpServiceForCemi(response, response_len, response_service);
const uint16_t service = KnxIpServiceForCemi(response_data.data(), response_data.size(),
response_service);
if (service == kServiceDeviceConfigurationRequest) {
if (response_client != nullptr && response_client->connected) {
sendCemiFrameToClient(*response_client, service, response, response_len);
sendCemiFrameToClient(*response_client, service, response_data.data(),
response_data.size());
} else if (routing_context) {
sendRoutingIndication(response, response_len);
sendRoutingIndication(response_data.data(), response_data.size());
}
return;
}
@@ -80,20 +100,22 @@ bool GatewayKnxTpIpRouter::handleOpenKnxTunnelFrame(const uint8_t* data, size_t
}
if (response_client != nullptr && response_client->connected) {
sent_tunnel_confirmation =
sendCemiFrameToClient(*response_client, service, response, response_len) ||
sendCemiFrameToClient(*response_client, service, response_data.data(),
response_data.size()) ||
sent_tunnel_confirmation;
}
return;
}
if (routing_context) {
sendRoutingIndication(response, response_len);
sendRoutingIndication(response_data.data(), response_data.size());
return;
}
if (response_client != nullptr && response_client->connected) {
sendCemiFrameToClient(*response_client, service, response, response_len);
sendCemiFrameToClient(*response_client, service, response_data.data(),
response_data.size());
return;
}
sendTunnelIndication(response, response_len);
sendTunnelIndication(response_data.data(), response_data.size());
};
bool consumed = false;
if (ets_device_ != nullptr) {
@@ -112,6 +134,9 @@ bool GatewayKnxTpIpRouter::handleOpenKnxTunnelFrame(const uint8_t* data, size_t
return consumed;
}
if (route_to_oam) {
if (response_client != nullptr) {
response_client->oam_router_persona = true;
}
return handleOamRouterTunnelFrame(data, len, response_client, response_service,
suppress_routing_echo, suppress_routing_echo_len);
}
@@ -204,21 +229,26 @@ bool GatewayKnxTpIpRouter::handleOamRouterTunnelFrame(const uint8_t* data, size_
response_service == kServiceTunnellingRequest &&
BuildTunnelConfirmationFrame(data, len, &tunnel_confirmation);
bool sent_tunnel_confirmation = false;
KnxResponseDeduplicator response_broker;
const bool consumed = oam_router_->handleTunnelFrame(
data, len,
[this, response_client, response_service, needs_tunnel_confirmation,
&tunnel_confirmation, &sent_tunnel_confirmation,
&tunnel_confirmation, &sent_tunnel_confirmation, &response_broker,
suppress_routing_echo, suppress_routing_echo_len](const uint8_t* response,
size_t response_len) {
if (response == nullptr || response_len == 0) {
return;
}
publishCloudCemiFrame(response, response_len);
std::vector<uint8_t> response_data(response, response + response_len);
if (!response_broker.remember(response_data)) {
return;
}
publishCloudCemiFrame(response_data.data(), response_data.size());
const bool routing_context =
response_client == nullptr && response_service == kServiceRoutingIndication;
const auto message_code = CemiMessageCode(response, response_len);
const auto message_code = CemiMessageCode(response_data.data(), response_data.size());
if (routing_context && suppress_routing_echo != nullptr &&
IsLocalRoutingEchoIndication(response, response_len, suppress_routing_echo,
IsLocalRoutingEchoIndication(response_data.data(), response_data.size(), suppress_routing_echo,
suppress_routing_echo_len)) {
return;
}
@@ -229,12 +259,14 @@ bool GatewayKnxTpIpRouter::handleOamRouterTunnelFrame(const uint8_t* data, size_
tunnel_confirmation.data(), tunnel_confirmation.size());
}
const uint16_t service = KnxIpServiceForCemi(response, response_len, response_service);
const uint16_t service = KnxIpServiceForCemi(response_data.data(), response_data.size(),
response_service);
if (service == kServiceDeviceConfigurationRequest) {
if (response_client != nullptr && response_client->connected) {
sendCemiFrameToClient(*response_client, service, response, response_len);
sendCemiFrameToClient(*response_client, service, response_data.data(),
response_data.size());
} else if (routing_context) {
sendRoutingIndication(response, response_len);
sendRoutingIndication(response_data.data(), response_data.size());
}
return;
}
@@ -244,20 +276,22 @@ bool GatewayKnxTpIpRouter::handleOamRouterTunnelFrame(const uint8_t* data, size_
}
if (response_client != nullptr && response_client->connected) {
sent_tunnel_confirmation =
sendCemiFrameToClient(*response_client, service, response, response_len) ||
sendCemiFrameToClient(*response_client, service, response_data.data(),
response_data.size()) ||
sent_tunnel_confirmation;
}
return;
}
if (routing_context) {
sendRoutingIndication(response, response_len);
sendRoutingIndication(response_data.data(), response_data.size());
return;
}
if (response_client != nullptr && response_client->connected) {
sendCemiFrameToClient(*response_client, service, response, response_len);
sendCemiFrameToClient(*response_client, service, response_data.data(),
response_data.size());
return;
}
sendTunnelIndication(response, response_len);
sendTunnelIndication(response_data.data(), response_data.size());
});
if (needs_tunnel_confirmation && consumed && !sent_tunnel_confirmation) {
sendCemiFrameToClient(*response_client, kServiceTunnellingRequest,
@@ -268,6 +302,10 @@ bool GatewayKnxTpIpRouter::handleOamRouterTunnelFrame(const uint8_t* data, size_
bool GatewayKnxTpIpRouter::transmitOpenKnxTpFrame(const uint8_t* data, size_t len) {
SemaphoreGuard guard(openknx_lock_);
return transmitOpenKnxTpFrameLocked(data, len);
}
bool GatewayKnxTpIpRouter::transmitOpenKnxTpFrameLocked(const uint8_t* data, size_t len) {
if (ets_device_ == nullptr) {
return false;
}
@@ -276,6 +314,56 @@ bool GatewayKnxTpIpRouter::transmitOpenKnxTpFrame(const uint8_t* data, size_t le
return sent;
}
bool GatewayKnxTpIpRouter::handleOpenKnxTpIngressFrame(const uint8_t* data, size_t len) {
if (data == nullptr || len < 2 || (ets_device_ == nullptr && oam_router_ == nullptr)) {
return false;
}
std::vector<uint8_t> frame_data(data, data + len);
CemiFrame frame(frame_data.data(), static_cast<uint16_t>(frame_data.size()));
if (!frame.valid()) {
return false;
}
const bool broadcast_management = IsKnxBroadcastManagementRequest(frame);
const bool addressed_to_oam =
oam_router_ != nullptr && MatchesOamRouterLocalIndividualAddress(frame, *oam_router_);
if (!broadcast_management && !addressed_to_oam) {
return false;
}
const KnxIngressContext ingress_context{KnxPortKind::kTpUart, addressed_to_oam,
broadcast_management};
KnxResponseDeduplicator response_broker(frame_data.data(), frame_data.size());
const auto send_response = [this, &response_broker](const uint8_t* response,
size_t response_len) {
if (response == nullptr || response_len == 0) {
return;
}
std::vector<uint8_t> response_data(response, response + response_len);
if (!response_broker.remember(response_data)) {
return;
}
publishCloudCemiFrame(response_data.data(), response_data.size());
transmitOpenKnxTpFrameLocked(response_data.data(), response_data.size());
sendTunnelIndication(response_data.data(), response_data.size());
sendRoutingIndication(response_data.data(), response_data.size());
};
bool consumed = false;
if (ingress_context.broadcast_management && ets_device_ != nullptr) {
consumed = ets_device_->handleBusFrame(data, len) || consumed;
}
if ((ingress_context.broadcast_management || ingress_context.oam_persona_hint) &&
oam_router_ != nullptr) {
consumed = oam_router_->handleBusFrame(data, len, send_response) || consumed;
}
if (consumed) {
publishCloudCemiFrame(data, len);
syncOpenKnxConfigFromDevice();
}
return consumed;
}
bool GatewayKnxTpIpRouter::handleOpenKnxBusFrame(const uint8_t* data, size_t len) {
bool consumed = false;
{
@@ -441,6 +529,14 @@ uint8_t GatewayKnxTpIpRouter::advertisedMedium() const {
return (config_.tunnel_enabled || tp_uart_online_) ? kKnxMediumTp1 : kKnxMediumIp;
}
const char* GatewayKnxTpIpRouter::ipInterfaceName() const {
return config_.oam_router.enabled ? "oam_ip_router" : "knx_ip_interface";
}
const char* GatewayKnxTpIpRouter::ipInterfaceFriendlyName() const {
return config_.oam_router.enabled ? "OAM IP Router" : "DaliMaster KNX IP";
}
void GatewayKnxTpIpRouter::syncOpenKnxConfigFromDevice() {
if (ets_device_ == nullptr) {
return;
@@ -454,6 +550,15 @@ void GatewayKnxTpIpRouter::syncOpenKnxConfigFromDevice() {
updated.individual_address = snapshot.individual_address;
changed = true;
}
if (oam_router_ != nullptr) {
const auto oam_snapshot = oam_router_->snapshot();
if (oam_snapshot.individual_address != 0 && oam_snapshot.individual_address != 0xffff &&
oam_snapshot.individual_address != updated.oam_router.individual_address) {
updated.oam_router.individual_address = oam_snapshot.individual_address;
updated.ip_interface_individual_address = oam_snapshot.individual_address;
changed = true;
}
}
if (snapshot.configured || !snapshot.associations.empty()) {
std::vector<GatewayKnxEtsAssociation> associations;
associations.reserve(snapshot.associations.size());
@@ -480,6 +585,18 @@ void GatewayKnxTpIpRouter::syncOpenKnxConfigFromDevice() {
}
uint16_t GatewayKnxTpIpRouter::effectiveIpInterfaceIndividualAddress() const {
if (config_.oam_router.enabled) {
if (oam_router_ != nullptr) {
const uint16_t address = oam_router_->individualAddress();
if (address != 0 && address != 0xffff) {
return address;
}
}
if (config_.oam_router.individual_address != 0 &&
config_.oam_router.individual_address != 0xffff) {
return config_.oam_router.individual_address;
}
}
if (config_.ip_interface_individual_address != 0 &&
config_.ip_interface_individual_address != 0xffff) {
return config_.ip_interface_individual_address;
@@ -498,6 +615,10 @@ uint16_t GatewayKnxTpIpRouter::effectiveKnxDeviceIndividualAddress() const {
}
uint16_t GatewayKnxTpIpRouter::effectiveTunnelAddress() const {
if (config_.oam_router.enabled && config_.oam_router.tunnel_address_base != 0 &&
config_.oam_router.tunnel_address_base != 0xffff) {
return config_.oam_router.tunnel_address_base;
}
const uint16_t interface_address = effectiveIpInterfaceIndividualAddress();
uint16_t device = static_cast<uint16_t>((interface_address & 0x00ff) + 1);
if (device == 0 || device > 0xff) {
@@ -141,16 +141,28 @@ std::vector<uint8_t> GatewayKnxTpIpRouter::buildDeviceInfoDib(
uint8_t mac[6]{};
if (ReadBaseMac(mac)) {
dib[8] = static_cast<uint8_t>((knx_internal::kReg1DaliManufacturerId >> 8) & 0xff);
dib[9] = static_cast<uint8_t>(knx_internal::kReg1DaliManufacturerId & 0xff);
std::memcpy(dib.data() + 10, mac + 2, 4);
const bool oam_ip_owner = config_.oam_router.enabled;
const uint16_t manufacturer = oam_ip_owner ? knx_internal::kOamRouterManufacturerId
: knx_internal::kReg1DaliManufacturerId;
uint32_t serial_suffix = (static_cast<uint32_t>(mac[2]) << 24) |
(static_cast<uint32_t>(mac[3]) << 16) |
(static_cast<uint32_t>(mac[4]) << 8) |
static_cast<uint32_t>(mac[5]);
if (oam_ip_owner) {
serial_suffix += knx_internal::kOamRouterSerialMacIncrement;
}
dib[8] = static_cast<uint8_t>((manufacturer >> 8) & 0xff);
dib[9] = static_cast<uint8_t>(manufacturer & 0xff);
dib[10] = static_cast<uint8_t>((serial_suffix >> 24) & 0xff);
dib[11] = static_cast<uint8_t>((serial_suffix >> 16) & 0xff);
dib[12] = static_cast<uint8_t>((serial_suffix >> 8) & 0xff);
dib[13] = static_cast<uint8_t>(serial_suffix & 0xff);
std::memcpy(dib.data() + 18, mac, 6);
}
WriteIp(dib.data() + 14, inet_addr(config_.multicast_address.c_str()));
char friendly[31]{};
std::snprintf(friendly, sizeof(friendly), "DALI GW MG%u %s",
static_cast<unsigned>(config_.main_group), openknx_namespace_.c_str());
std::snprintf(friendly, sizeof(friendly), "%s", ipInterfaceFriendlyName());
std::memcpy(dib.data() + 24, friendly, std::min<size_t>(30, std::strlen(friendly)));
(void)remote;
return dib;
@@ -336,6 +348,14 @@ bool GatewayKnxTpIpRouter::sendCemiFrameToClient(TunnelClient& client, uint16_t
request.connectionHeader().length(LEN_CH);
request.connectionHeader().channelId(client.channel_id);
const auto message_code = CemiMessageCode(data, len);
if (service == kServiceTunnellingRequest && message_code.has_value() &&
message_code.value() != L_data_con && client.last_sent_cemi == frame_data &&
xTaskGetTickCount() - client.last_sent_cemi_tick < pdMS_TO_TICKS(500)) {
ESP_LOGD(kTag, "suppress duplicate KNXnet/IP cEMI channel=%u cemi=0x%02x len=%u to %s",
static_cast<unsigned>(client.channel_id), static_cast<unsigned>(data[0]),
static_cast<unsigned>(len), EndpointString(client.data_remote).c_str());
return true;
}
const uint8_t send_sequence = client.send_sequence++;
request.connectionHeader().sequenceCounter(send_sequence);
request.connectionHeader().status(kKnxNoError);
@@ -357,6 +377,11 @@ bool GatewayKnxTpIpRouter::sendCemiFrameToClient(TunnelClient& client, uint16_t
client.last_tunnel_confirmation_packet = packet;
}
}
if (service == kServiceTunnellingRequest && message_code.has_value() &&
message_code.value() != L_data_con) {
client.last_sent_cemi = std::move(frame_data);
client.last_sent_cemi_tick = xTaskGetTickCount();
}
ESP_LOGI(kTag, "sent KNXnet/IP cEMI service=0x%04x channel=%u seq=%u cemi=0x%02x len=%u to %s",
static_cast<unsigned>(service), static_cast<unsigned>(client.channel_id),
static_cast<unsigned>(request.connectionHeader().sequenceCounter()), static_cast<unsigned>(data[0]),
@@ -119,9 +119,9 @@ void GatewayKnxTpIpRouter::handleSearchRequest(uint16_t service, const uint8_t*
service == kServiceSearchRequestExt ? kServiceSearchResponseExt : kServiceSearchResponse,
body_resp);
sendPacket(response_packet, response_remote);
ESP_LOGI(kTag, "sent KNXnet/IP search response service=0x%04x namespace=%s mainGroup=%u to %s:%u endpoint=%u.%u.%u.%u:%u",
static_cast<unsigned>(service), openknx_namespace_.c_str(),
static_cast<unsigned>(config_.main_group),
ESP_LOGI(kTag, "sent KNXnet/IP search response service=0x%04x ipInterface=%s ia=0x%04x to %s:%u endpoint=%u.%u.%u.%u:%u",
static_cast<unsigned>(service), ipInterfaceName(),
static_cast<unsigned>(effectiveIpInterfaceIndividualAddress()),
Ipv4String(response_remote.sin_addr.s_addr).c_str(), static_cast<unsigned>(ntohs(response_remote.sin_port)),
static_cast<unsigned>((*hpai)[2]), static_cast<unsigned>((*hpai)[3]),
static_cast<unsigned>((*hpai)[4]), static_cast<unsigned>((*hpai)[5]),
@@ -164,8 +164,9 @@ void GatewayKnxTpIpRouter::handleDescriptionRequest(const uint8_t* packet_data,
body_resp.insert(body_resp.end(), tunneling.begin(), tunneling.end());
const auto response_packet = OpenKnxIpPacket(kServiceDescriptionResponse, body_resp);
sendPacket(response_packet, response_remote);
ESP_LOGI(kTag, "sent KNXnet/IP description response namespace=%s medium=0x%02x tpOnline=%d to %s:%u",
openknx_namespace_.c_str(), static_cast<unsigned>(advertisedMedium()),
ESP_LOGI(kTag, "sent KNXnet/IP description response ipInterface=%s ia=0x%04x medium=0x%02x tpOnline=%d to %s:%u",
ipInterfaceName(), static_cast<unsigned>(effectiveIpInterfaceIndividualAddress()),
static_cast<unsigned>(advertisedMedium()),
tp_uart_online_, Ipv4String(response_remote.sin_addr.s_addr).c_str(),
static_cast<unsigned>(ntohs(response_remote.sin_port)));
}
@@ -314,9 +315,13 @@ GatewayKnxTpIpRouter::TunnelClient* GatewayKnxTpIpRouter::allocateTunnelClient(
free_client->connection_type = connection_type;
free_client->received_sequence = 255;
free_client->send_sequence = 0;
const bool oam_router_persona =
config_.oam_router.enabled && config_.oam_router.secure_tunnel_enabled &&
active_secure_session_id_ != 0;
const bool oam_runtime_available = config_.oam_router.enabled && oam_router_ != nullptr;
const bool oam_secure_persona = oam_runtime_available &&
config_.oam_router.secure_tunnel_enabled && active_secure_session_id_ != 0;
const bool oam_programming_persona = oam_runtime_available && oam_programming_mode_ &&
(connection_type == kKnxConnectionTypeDeviceManagement ||
connection_type == kKnxConnectionTypeTunnel);
const bool oam_router_persona = oam_secure_persona || oam_programming_persona;
if (oam_router_persona) {
const uint16_t first = config_.oam_router.tunnel_address_base;
const uint16_t line = first & 0xff00;
@@ -582,9 +587,11 @@ void GatewayKnxTpIpRouter::handleConnectRequest(const uint8_t* packet_data, size
return;
}
ESP_LOGI(kTag,
"accepted KNXnet/IP connect namespace=%s channel=%u type=0x%02x tunnelPa=0x%04x ctrl=%s data=%s remote=%s active=%u/%u",
openknx_namespace_.c_str(), static_cast<unsigned>(client->channel_id),
static_cast<unsigned>(connection_type), static_cast<unsigned>(client->individual_address),
"accepted KNXnet/IP connect ipInterface=%s channel=%u type=0x%02x persona=%s tunnelPa=0x%04x ctrl=%s data=%s remote=%s active=%u/%u",
ipInterfaceName(), static_cast<unsigned>(client->channel_id),
static_cast<unsigned>(connection_type),
client->oam_router_persona ? "oam" : "reg1",
static_cast<unsigned>(client->individual_address),
EndpointString(control_remote).c_str(), EndpointString(data_remote).c_str(),
EndpointString(remote).c_str(),
static_cast<unsigned>(std::count_if(tunnel_clients_.begin(), tunnel_clients_.end(),
@@ -0,0 +1,33 @@
#include "knx_device_broker.h"
#include <algorithm>
namespace gateway {
KnxResponseDeduplicator::KnxResponseDeduplicator(const uint8_t* original, size_t len) {
if (original != nullptr && len > 0) {
original_.assign(original, original + len);
}
}
bool KnxResponseDeduplicator::remember(const uint8_t* data, size_t len) {
if (data == nullptr || len == 0) {
++suppressed_count_;
return false;
}
return remember(std::vector<uint8_t>(data, data + len));
}
bool KnxResponseDeduplicator::remember(const std::vector<uint8_t>& data) {
if (data.empty() || (!original_.empty() && data == original_) ||
std::find(sent_.begin(), sent_.end(), data) != sent_.end()) {
++suppressed_count_;
return false;
}
sent_.push_back(data);
return true;
}
size_t KnxResponseDeduplicator::suppressedCount() const { return suppressed_count_; }
} // namespace gateway
@@ -208,6 +208,10 @@ void OamRouterRuntime::setNetworkInterface(esp_netif_t* netif) {
#endif
}
void OamRouterRuntime::setBusFrameSender(CemiFrameSender sender) {
bus_frame_sender_ = std::move(sender);
}
bool OamRouterRuntime::handleTunnelFrame(const uint8_t* data, size_t len,
CemiFrameSender sender) {
#if defined(ENABLE_BAU091A_PERSONA) && defined(USE_CEMI_SERVER)
@@ -260,6 +264,34 @@ bool OamRouterRuntime::handleLocalBroadcastManagementFrame(const uint8_t* data,
#endif
}
bool OamRouterRuntime::handleBusFrame(const uint8_t* data, size_t len,
CemiFrameSender sender) {
#if defined(ENABLE_BAU091A_PERSONA)
auto* data_link_layer = device_.getSecondaryDataLinkLayer();
if (data_link_layer == nullptr || data == nullptr || len < 2) {
return false;
}
std::vector<uint8_t> frame_data(data, data + len);
CemiFrame frame(frame_data.data(), static_cast<uint16_t>(frame_data.size()));
if (!shouldConsumeBusFrame(frame)) {
return false;
}
if (frame.messageCode() == L_data_req) {
frame.messageCode(L_data_ind);
}
sender_ = std::move(sender);
data_link_layer->externalFrameReceived(frame);
loop();
sender_ = nullptr;
return true;
#else
(void)data;
(void)len;
(void)sender;
return false;
#endif
}
void OamRouterRuntime::loop() {
#if defined(ENABLE_BAU091A_PERSONA)
device_.loop();
@@ -277,10 +309,16 @@ bool OamRouterRuntime::HandleOutboundCemiFrame(CemiFrame& frame, void* context)
void OamRouterRuntime::EmitTunnelFrame(CemiFrame& frame, void* context) {
auto* self = static_cast<OamRouterRuntime*>(context);
if (self == nullptr || !self->sender_) {
if (self == nullptr) {
return;
}
self->sender_(frame.data(), frame.dataLength());
if (self->sender_) {
self->sender_(frame.data(), frame.dataLength());
return;
}
if (self->bus_frame_sender_) {
self->bus_frame_sender_(frame.data(), frame.dataLength());
}
}
uint16_t OamRouterRuntime::DefaultTunnelClientAddress(uint16_t individual_address) {
@@ -320,4 +358,20 @@ bool OamRouterRuntime::shouldConsumeTunnelFrame(CemiFrame& frame) const {
}
}
bool OamRouterRuntime::shouldConsumeBusFrame(CemiFrame& frame) const {
if (frame.messageCode() != L_data_ind && frame.messageCode() != L_data_req) {
return false;
}
if (IsBroadcastManagementRequest(frame)) {
return true;
}
if (frame.addressType() != IndividualAddress) {
return false;
}
const uint16_t dest = frame.destinationAddress();
const bool commissioning = !configured() || programmingMode();
return dest == individualAddress() || dest == tunnelClientAddress() ||
(commissioning && dest == kKnxUnconfiguredBroadcastAddress);
}
} // namespace gateway::openknx
+1 -1
Submodule knx updated: d19859af47...29fa7318f9