Add station-only WiFi UDP bridge with eight authenticated peers

This commit is contained in:
mikecarper committed 2026-10-22 12:20:43 -07:00
1 parent 7bc3aeb234
commit 5e25faa3d0
45 files changed
+2699 -35

No files matched your search

+4
View File
@@ -412,6 +412,10 @@ jobs:
python3 -B test/test_rs232_bridge_diagnostics.py -v
python3 -B test/test_rs232_diagnostic_cli.py -v
python3 -B test/test_bridge_debug_formatter.py -v
python3 -B test/test_udp_bridge_config.py -v
python3 -B test/test_udp_packet_origin.py -v
python3 -B test/test_udp_bridge.py -v
python3 -B test/test_udp_bridge_integration.py -v
- name: Verify saved repeat-off trace exception and relay guards
run: |
+12
View File
@@ -2584,6 +2584,14 @@ apply_esp32_full_shared_bridge_profile() {
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DWITH_ESPNOW_BRIDGE=1"
append_platformio_build_src_filter "+<helpers/bridges/ESPNowBridge.cpp>"
record_build_capability "bridge.espnow"
# UDP is a default-off infrastructure repeater service. It shares saved
# station credentials and never belongs to companions or room servers.
if is_repeater_role_target "$env_name"; then
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DWITH_UDP_BRIDGE=1"
append_platformio_build_src_filter "+<helpers/bridges/UDPBridge.cpp>"
append_platformio_build_src_filter "+<helpers/UDPBridgeSettings.cpp>"
record_build_capability "bridge.udp"
fi
}
configure_unified_full_infrastructure_output() {
@@ -3544,6 +3552,10 @@ declare_build_capability_contract() {
# Verify that the actual ESP-NOW driver survived linking in every Full
# repeater/observer that claims it, including exact-identity source bases.
record_build_expectation "bridge.espnow" "_ZN12ESPNowBridge5beginEv"
if is_repeater_role_target "$pio_env_name"; then
record_build_expectation "bridge.udp" "_ZN9UDPBridge5beginEv"
record_application_expectation "bridge.udp.configuration" "udp.enabled"
fi
elif [ "$env_name_lc" = mke_s3_repeater ]; then
record_build_expectation "bridge.espnow" "_ZN12ESPNowBridge5beginEv"
fi
+67 -1
View File
@@ -5570,7 +5570,73 @@ still overrides the first-boot default after an update.
For a two-repeater setup and diagnostic flow, see the
[ESP-NOW bridge setup guide](espnow_bridge_setup.md) or
[RS-232/UART bridge guide](rs232_bridge_setup.md).
[RS-232/UART bridge guide](rs232_bridge_setup.md). For an existing WiFi network,
see the [eight-peer UDP bridge guide](udp_bridge_setup.md).
#### WiFi UDP bridge
Available on supported ESP32 repeater images with `WITH_UDP_BRIDGE`. UDP starts
off on new/upgraded installs. It requires an existing station WiFi connection,
and pauses during AP/AP+STA operation, disconnection, browser OTA and ownership
transitions. It does not start a network or require an MQTT broker.
```text
get udp.enabled
set udp.enabled on
set udp.enabled off
get udp.port
set udp.port 4404
get udp.peer.1
set udp.peer.1 192.168.1.51:4404
set udp.peer.1 off
get udp.status
get udp.stats
get udp.stats rx
get udp.stats tx
get udp.stats drops
get udp.stats test
```
Peer slots are `udp.peer.1` through `udp.peer.8`. Each destination is one
numeric unicast IPv4 address plus a port `1..65535`. The listening port defaults
to `4404`. Multicast, loopback, unspecified/broadcast and duplicate endpoints
are rejected. Both boards must list each other's listening address/port.
Reserve DHCP addresses so the stored destinations remain valid.
The transport reuses `bridge.secret` for authenticated sessions and packet
integrity; no additional password is introduced. UDP payloads are not
encrypted. Counter/status output does not reveal the secret. Config writes
use the existing authenticated CLI policy and a separate checked preferences
file; a successful save reports intent rather than reachability.
Local-only diagnostic actions:
```text
udp test 1
udp test 1 A1B2C3D4
udp resetstats
```
The peer number is `1..8`; the optional ID is exactly eight hexadecimal digits
without `0x`. A queued/sent probe is not a delivery acknowledgement. On the
destination, check `get udp.stats test` for an increased `rx` and matching
`rxid`, then reverse the test. An authenticated peer session must be ready.
The status `ready` count records established sessions, not current liveness;
use a fresh received probe to confirm reachability. Incoming UDP enters the
local LoRa mesh and is not relayed to other UDP peers.
Probes do not echo, retry, allocate mesh packets or transmit on LoRa. Resetting
statistics does not write preferences or restart the bridge.
Both `set usb.logging on` and `set usb.debug on` enable bounded runtime USB
summaries without requiring a special debug build. Restore both previous
settings after capture. CLI pages/probes work with logging disabled.
WiFi client isolation and firewall rules must allow peer-to-peer UDP; the
firmware cannot bypass an isolating guest network. Isolation is an optional
network policy, not an intrinsic WiFi restriction. See the
[setup and troubleshooting guide](udp_bridge_setup.md).
---
#### View the compiled bridge type
**Usage:** `get bridge.type`
+171
View File
@@ -0,0 +1,171 @@
<!-- meshcore-hosted-doc-link:start -->
<p class="meshcore-hosted-doc-link"><a href="https://mikecarper.github.io/MeshCore/udp_bridge_setup/">View this page on MeshCore Docs</a>.</p>
<!-- meshcore-hosted-doc-link:end -->
# WiFi UDP bridge: up to eight IP peers
Supported ESP32 repeater images can bridge MeshCore packets directly over an
existing WiFi network, without an MQTT broker. Each board has one UDP listening
port and up to eight configured IPv4 address/port destinations. Eight is the
per-board peer limit; it is not a limit on the router's number of WiFi clients.
```text
LoRa network A <--> Repeater A <-- existing WiFi/LAN --> Repeater B <--> LoRa network B
```
The bridge starts off on new and upgraded installations. It operates only with
a connected station interface and a valid local IPv4 address. It pauses during
SoftAP or AP+STA operation, WiFi disconnection, browser OTA, and incompatible
WiFi ownership transitions, then resumes when station operation is available.
Opening a captive configuration access point does not create a UDP bridge
network. USB/LoRa CLI access remains useful for inspecting a paused bridge.
## Router client isolation
Client isolation is an optional access-point or guest-network policy, not a
requirement of WiFi. When enabled it can prevent two wireless devices from
talking directly, even though both can reach the Internet. A similar effect can
come from VLAN rules or host firewalls. The bridge cannot bypass those rules.
Use a trusted SSID/VLAN that allows the two devices to exchange UDP, or ask the
network administrator to allow the bridge peers and port. Disable isolation
only where that network policy is appropriate. Simply changing the UDP port,
enabling MQTT on a repeater, or configuring a second peer address does not make
an isolation rule disappear. Some routers isolate only clients on the same
access point; others isolate all clients or the entire guest network. Verify
the policy on the router in use.
Reserve each board's DHCP address on the router. The bridge stores numeric
IPv4 addresses, not DNS names; a changed lease can otherwise point a peer at
the wrong device. No Internet port forwarding or public exposure is required.
## Configure two boards
Use firmware containing the UDP bridge. Connect both boards to the existing
WiFi network using the normal [WiFi settings](WiFi.md). Read `get wifi.status`
on each board to find its local address once connected. The commands below use
example addresses; substitute the actual reserved addresses.
Both boards use the existing `bridge.secret` as their shared authentication
secret. Set the same strong secret on both before enabling the transport. Do
not paste it into shared diagnostic captures. The transport authenticates
packets with HMAC-SHA256; it does not encrypt their contents. Use trusted LAN
access and WPA2/WPA3 protection as appropriate for the network.
On board A, whose reserved address is `192.168.1.50`:
```text
set udp.enabled off
set udp.port 4404
set udp.peer.1 192.168.1.51:4404
set bridge.source rx
set repeat on
set udp.enabled on
get udp.status
```
On board B, whose reserved address is `192.168.1.51`:
```text
set udp.enabled off
set udp.port 4404
set udp.peer.1 192.168.1.50:4404
set bridge.source rx
set repeat on
set udp.enabled on
get udp.status
```
Each receiving board must also list the sender's listening address/port as a
peer; this is an explicit allowlist, not discovery or a broadcast service.
Both ports can be changed independently, but each destination must match that
peer's actual listening port. Unicast IPv4 addresses and ports `1..65535` are
supported; multicast, loopback, unspecified and broadcast destinations are
rejected. Remove a destination with `set udp.peer.1 off`. Slots `1..8` have the
same behavior, and duplicate endpoints are rejected.
The UDP settings are saved separately from the historical common preferences.
An invalid or truncated record starts with UDP disabled. A rejected save keeps
the previous runtime preferences; an uncertain filesystem result requires a
reload before further changes. `OK - UDP settings saved` acknowledges saved
intent, not network connectivity or confirmed peer delivery.
## Test each IP direction without sending on LoRa
Wait for `get udp.status` to show ready peer sessions. Each peer establishes an
authenticated session using the shared secret, a fresh boot/session identity
and a challenge exchange. Replay checks reject stale data and diagnostic probes.
From board A's local USB console:
```text
udp test 1 A1B2C3D4
get udp.stats test
```
On board B:
```text
get udp.stats test
```
Board B's `rx` should increase and `rxid` should match `A1B2C3D4`. Reverse the
test with a different ID to check the other direction. `udp test 1` generates
an ID from uptime; an explicit ID must have exactly eight hexadecimal digits.
The peer number is the local configured slot, not a global board identifier.
A queued/sent result confirms only local acceptance by the UDP stack. UDP
itself does not guarantee delivery. A received probe proves authenticated
transport receipt on that peer; it does not prove LoRa forwarding. Probes do
not enter the mesh packet pool, echo, retransmit or transmit on LoRa. Test and
counter-reset commands require a local connection; normal configuration uses
the existing authenticated CLI access policy.
## Diagnose mesh traffic
```text
get udp.status
get udp.stats
get udp.stats rx
get udp.stats tx
get udp.stats drops
get udp.stats test
```
Status separates saved intent from network readiness and authenticated peer
sessions. `ready` counts established boot sessions, not current peer liveness;
an offline peer can retain its session until a restart or reconfiguration.
Use a fresh received probe to establish current reachability.
Counter pages distinguish framing/authentication problems, unknown
source endpoints, replay, mesh filters/duplicates, packet-pool pressure and
outbound queue/socket failures. `udp resetstats` starts a new counter window
without changing saved settings or restarting the bridge.
If no peer session becomes ready, check both stored endpoints, matching
`bridge.secret`, station connectivity, isolation/firewall policy and the peer's
actual firmware. If sessions/probes work but mesh traffic does not, check
`bridge.source`, filters, duplicate counters and each side's LoRa setup.
`bridge.source rx` is valid; `tx` instead selects packets after LoRa forwarding
decisions. These source/filter settings are shared with other compiled bridges.
The peer list contains direct destinations for locally received/transmitted
LoRa traffic. An incoming UDP packet enters the local LoRa mesh; it is not
relayed to other UDP peers, including through its later local LoRa TX callback.
A hub therefore does not provide UDP-to-UDP transit between its peers. Normal
mesh duplicate/filter checks still apply, including when several repeaters
hear the same LoRa packet. The configured eight-peer fan-out is bounded; it
does not increase LoRa capacity or promise delivery through an arbitrary IP
topology. Avoid unnecessary parallel paths and loops.
For a short USB diagnostic capture, record `get usb.logging` and
`get usb.debug`, then enable both:
```text
set usb.logging on
set usb.debug on
```
Restore both previous values afterward. Bounded `UDP:` updates are emitted
from the main loop at most once per second when diagnostic state changes.
The counter pages and probes work without either logging gate. Never include
WiFi passwords or `get bridge.secret` in logs you share.
+27 -5
View File
@@ -2,17 +2,20 @@
// Included by the infrastructure main files after the mesh and transport objects.
#include <helpers/WirelessControl.h>
#if defined(ESP32) && (defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE) || (defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW))
#if defined(ESP32) && (defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE) || defined(WITH_UDP_BRIDGE) || (defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW))
#include <WiFi.h>
#include <helpers/esp32/WiFiRadioPolicy.h>
#endif
class InfrastructureWirelessBackend : public mesh::wireless::Backend {
bool wifi_saved_ = false, resume_web_ = false, resume_mqtt_ = false;
#ifdef WITH_UDP_BRIDGE
bool resume_udp_ = false;
#endif
public:
uint8_t available() const override {
uint8_t mask = 0;
#if defined(ESP32) && (defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE))
#if defined(ESP32) && (defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE) || defined(WITH_UDP_BRIDGE))
mask |= mesh::wireless::WiFi;
#endif
#if defined(WITH_ESPNOW_BRIDGE) || (defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW)
@@ -31,6 +34,9 @@ public:
#ifdef WITH_MQTT_BRIDGE
if (the_mesh.isMqttBridgeRunning()) mask |= mesh::wireless::WiFi;
#endif
#ifdef WITH_UDP_BRIDGE
if (the_mesh.isUDPBridgeNetworkEnabled()) mask |= mesh::wireless::WiFi;
#endif
#ifdef WITH_ESPNOW_BRIDGE
if (the_mesh.isEspNowBridgeRunning()) mask |= mesh::wireless::EspNow;
#endif
@@ -56,7 +62,7 @@ public:
}
mesh::wireless::Result set(uint8_t service, bool on) override {
using mesh::wireless::Result;
#if defined(ESP32) && (defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE))
#if defined(ESP32) && (defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE) || defined(WITH_UDP_BRIDGE))
if (service == mesh::wireless::WiFi) {
if (!on) {
if (board.isOTAUpdateRunning()) {
@@ -69,9 +75,15 @@ public:
#endif
#ifdef WITH_MQTT_BRIDGE
resume_mqtt_ = the_mesh.isMqttBridgeRunning();
#endif
#ifdef WITH_UDP_BRIDGE
resume_udp_ = the_mesh.isUDPBridgeConfigured();
#endif
wifi_saved_ = true;
}
#ifdef WITH_UDP_BRIDGE
if (!the_mesh.setUDPBridgeNetworkState(false)) return Result::Failed;
#endif
#ifdef WITH_MQTT_BRIDGE
if (!the_mesh.setMqttBridgeState(false)) return Result::Failed;
#endif
@@ -97,8 +109,13 @@ public:
}
#ifdef WITH_WEBCONFIG
if (the_mesh.isWebConfigStopping()) return Result::Pending;
if (resume_web_ || (!wifi_saved_ && !(enabled() & mesh::wireless::WiFi))
|| (wifi_saved_ && !resume_web_ && !resume_mqtt_)) {
#ifdef WITH_UDP_BRIDGE
const bool udp_station_requested = resume_udp_ || (!wifi_saved_ && the_mesh.isUDPBridgeConfigured());
#else
const bool udp_station_requested = false;
#endif
if (resume_web_ || (!udp_station_requested && ((!wifi_saved_ && !(enabled() & mesh::wireless::WiFi))
|| (wifi_saved_ && !resume_web_ && !resume_mqtt_)))) {
char reply[160];
if (!the_mesh.startWebConfig(false, reply)) return Result::Failed;
}
@@ -111,6 +128,11 @@ public:
) {
if (!the_mesh.setMqttBridgeState(true)) return Result::Failed;
}
#endif
#ifdef WITH_UDP_BRIDGE
if ((resume_udp_ || (!wifi_saved_ && the_mesh.isUDPBridgeConfigured()))
&& !the_mesh.setUDPBridgeNetworkState(true)) return Result::Failed;
resume_udp_ = false;
#endif
wifi_saved_ = resume_web_ = resume_mqtt_ = false;
return (enabled() & mesh::wireless::WiFi) ? Result::Done : Result::Failed;
+198 -4
View File
@@ -6,12 +6,16 @@
#if defined(ESP32_PLATFORM)
#include <helpers/esp32/BootFileSystem.h>
#include <helpers/WiFiNetworkPowerPolicy.h>
#if defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) \
#if defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) || defined(WITH_UDP_BRIDGE) \
|| defined(LIGHTWEIGHT_WIFI_OTA) || (defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA))
#include <WiFi.h>
#include <esp_wifi.h>
#endif
#endif
#if defined(WITH_UDP_BRIDGE)
#include <helpers/esp32/WiFiCredentials.h>
#include <helpers/esp32/WiFiStationPolicy.h>
#endif
#include <helpers/UsbLogging.h>
#include <helpers/MorseLoopPolicy.h>
#include <helpers/HilStartupTrace.h>
@@ -763,7 +767,7 @@ uint8_t MyMesh::handleAnonClockReq(const mesh::Identity& sender, uint32_t sender
if (isEspNowBridgeRunning()) reply_data[8] |= 0x03; // ESP-NOW may run alongside UART
#endif
#elif WITH_ESPNOW_BRIDGE
if (isBridgeRunning()) reply_data[8] |= 0x03; // is bridge, type ESP-NOW
if (isEspNowBridgeRunning()) reply_data[8] |= 0x03; // is bridge, type ESP-NOW
#endif
if (_prefs.disable_fwd) { // is this repeater currently disabled
reply_data[8] |= 0x80; // is disabled
@@ -1237,6 +1241,9 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) {
}
void MyMesh::logRx(mesh::Packet *pkt, int len, float score) {
#ifdef WITH_UDP_BRIDGE
if (_prefs.bridge_pkt_src == 1 && isUDPBridgeRunning()) udp_bridge->sendPacket(pkt);
#endif
#ifdef DISPLAY_ACTIVITY_DASHBOARD
// Count valid, parsed RF packets before role-level filtering. The dashboard
// intentionally reflects radio activity, including packets this role later
@@ -1292,6 +1299,9 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) {
}
void MyMesh::logTx(mesh::Packet *pkt, int len) {
#ifdef WITH_UDP_BRIDGE
if (_prefs.bridge_pkt_src == 0 && isUDPBridgeRunning()) udp_bridge->sendPacket(pkt);
#endif
#if defined(WITH_MQTT_NEIGHBORS)
if (neighbor_discover_active && pkt == neighbor_discover_request
&& neighbor_discover_next < neighbor_discover_count) {
@@ -3885,6 +3895,9 @@ void MyMesh::begin(FILESYSTEM *fs) {
// load persisted prefs
mesh::hilStartupTrace("mesh_prefs_begin");
_cli.loadPrefs(_fs);
#ifdef WITH_UDP_BRIDGE
udp_bridge_settings.begin(_fs);
#endif
mesh::hilStartupTrace("mesh_prefs_ready");
mesh::hilStartupTrace("mesh_management_begin");
_cli.beginManagement(*this, _fs);
@@ -4049,6 +4062,9 @@ void MyMesh::begin(FILESYSTEM *fs) {
#if defined(WITH_ESPNOW_BRIDGE) \
&& (defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE))
if (_prefs.espnow_bridge_enabled) setEspNowBridgeState(true);
#endif
#ifdef WITH_UDP_BRIDGE
serviceUDPBridge();
#endif
mesh::hilStartupTrace("mesh_bridge_ready");
@@ -11531,6 +11547,11 @@ bool MyMesh::startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup
|| (!mqtt_bridge->isRunning() && !mqtt_bridge->isStopping()));
#endif
#ifdef WITH_UDP_BRIDGE
// The portal may switch to AP/setup mode synchronously. Close the station
// socket before that transition; saved UDP intent resumes in a later STA loop.
if (udp_bridge) udp_bridge->serviceNetwork(false);
#endif
bool started;
if (force_ap) {
started = _webconfig->startSetupMode(reply);
@@ -13497,6 +13518,10 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() {
expireRecentRepeatersIfDue();
#endif
#ifdef WITH_UDP_BRIDGE
// The UDP socket belongs to this loop and follows station/OTA ownership.
serviceUDPBridge();
#endif
#if defined(WITH_MQTT_BRIDGE)
// MQTT owns TLS on Core 0; the radio loop only reaps an acknowledged stop.
if (mqtt_bridge && mqtt_bridge->isRunning()) mqtt_bridge->loop();
@@ -13550,11 +13575,18 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() {
// duty-limited channel) is lost when the flash spins the loop and reboots.
drainOutbound(OTA_TX_DRAIN_TIMEOUT_MS);
const bool mqtt_was_running = mqtt_bridge && mqtt_bridge->isRunning();
#ifdef WITH_UDP_BRIDGE
const bool udp_was_enabled = isUDPBridgeNetworkEnabled();
setUDPBridgeNetworkState(false);
#endif
#ifdef WITH_ESPNOW_BRIDGE
const bool espnow_was_running = espnow_bridge.isRunning();
#endif
auto resume_paused_bridges = [&]() {
if (mqtt_was_running) setMqttBridgeState(true);
#ifdef WITH_UDP_BRIDGE
if (udp_was_enabled) setUDPBridgeNetworkState(true);
#endif
#ifdef WITH_ESPNOW_BRIDGE
if (espnow_was_running) setEspNowBridgeState(true);
#endif
@@ -14194,14 +14226,173 @@ bool MyMesh::startNeighborDiscover(char* reply) {
}
#endif // WITH_MQTT_NEIGHBORS
#ifdef WITH_UDP_BRIDGE
bool MyMesh::ensureUDPBridge() {
if (!udp_bridge) {
udp_bridge = new (std::nothrow) UDPBridge(
&_prefs, _mgr, getRTCClock(), &udp_bridge_settings.prefs());
if (!udp_bridge) return false;
configureBridgeFilter(udp_bridge);
}
return true;
}
bool MyMesh::isUDPBridgeNetworkEnabled() const {
if (!udp_bridge_settings.healthy() || !udp_bridge_settings.prefs().enabled
|| udp_network_paused || mesh::wireless::control().masterOff()
|| mesh::wireless::control().pendingDisable(mesh::wireless::WiFi)
|| mesh::wireless::control().blocked(mesh::wireless::WiFi)) return false;
const size_t secret_length = strnlen(_prefs.bridge_secret, sizeof(_prefs.bridge_secret));
if (secret_length == 0 || secret_length >= sizeof(_prefs.bridge_secret)) return false;
for (const auto& peer : udp_bridge_settings.prefs().peers) {
if (peer.enabled()) return true;
}
return false;
}
bool MyMesh::setUDPBridgeNetworkState(bool enabled) {
udp_network_paused = !enabled;
udp_station_retry_at = 0;
if (!enabled) {
if (udp_bridge) udp_bridge->serviceNetwork(false);
mesh::wifi::networkPower().setHold(mesh::wifi::NetworkPowerHold::UdpBridge, false);
} else {
serviceUDPBridge();
}
return true; // Saved intent may wait for WiFi, credentials or OTA ownership.
}
bool MyMesh::setUDPBridgePrefs(const mesh::bridge::udp::Prefs& value) {
if (!mesh::bridge::udp::validPrefs(value)) return false;
// Allocate before committing enabled intent. A failed allocation must not
// report a working saved change or disturb the old socket and preferences.
const bool had_transport = udp_bridge != nullptr;
if (value.enabled && !ensureUDPBridge()) return false;
if (!udp_bridge_settings.setPrefs(value)) {
if (!udp_bridge_settings.healthy()) setUDPBridgeNetworkState(false);
if (!had_transport) { delete udp_bridge; udp_bridge = nullptr; }
return false;
}
if (udp_bridge) udp_bridge->end();
if (!value.enabled) {
delete udp_bridge;
udp_bridge = nullptr;
}
udp_station_retry_at = 0;
udp_network_paused = false;
serviceUDPBridge();
return true;
}
void MyMesh::formatUDPBridgeStatus(char* reply, size_t capacity) {
if (!reply || !capacity) return;
const auto& prefs = udp_bridge_settings.prefs();
uint8_t peers = 0;
for (const auto& peer : prefs.peers) if (peer.enabled()) ++peers;
const size_t secret_length = strnlen(_prefs.bridge_secret, sizeof(_prefs.bridge_secret));
const auto& control = mesh::wireless::control();
const char* reason = !udp_bridge_settings.healthy() ? "prefs-error"
: !prefs.enabled ? "off"
: control.masterOff() || control.pendingDisable(mesh::wireless::WiFi)
|| control.blocked(mesh::wireless::WiFi) ? "wifi-paused"
: _cli.getBoard()->isOTAUpdateRunning() ? "ota-paused"
: udp_network_paused ? "paused"
: !secret_length || secret_length >= sizeof(_prefs.bridge_secret) ? "no-secret"
: !peers ? "no-peers"
: (WiFi.getMode() & WIFI_AP) ? "ap-mode"
: WiFi.getMode() != WIFI_STA || WiFi.status() != WL_CONNECTED
|| static_cast<uint32_t>(WiFi.localIP()) == 0 ? "disconnected"
: !udp_bridge ? "no-memory" : nullptr;
const auto ip = WiFi.localIP();
const uint32_t address = static_cast<uint32_t>(ip);
const auto* octets = reinterpret_cast<const uint8_t*>(&address);
if (reason) {
snprintf(reply, capacity, "> %s ip=%u.%u.%u.%u port=%u peers=%u", reason,
octets[0], octets[1], octets[2], octets[3], unsigned(prefs.port), unsigned(peers));
} else {
udp_bridge->formatStatus(reply, capacity);
}
}
void MyMesh::formatUDPBridgeStats(const char* page, char* reply, size_t capacity) {
if (udp_bridge) udp_bridge->formatStats(page, reply, capacity);
else snprintf(reply, capacity, "> off; no UDP bridge counters allocated");
}
bool MyMesh::sendUDPBridgeTest(uint8_t peer_index, uint32_t nonce) {
serviceUDPBridge();
return udp_bridge && udp_bridge->sendTest(peer_index, nonce);
}
bool MyMesh::resetUDPBridgeStats() {
if (udp_bridge) udp_bridge->resetStats();
return true;
}
void MyMesh::serviceUDPBridge() {
const bool requested = isUDPBridgeNetworkEnabled()
&& !_cli.getBoard()->isOTAUpdateRunning();
// MQTT's broker outage clock does not describe this independent LAN bridge.
// Publish a separate hold before asking its worker to resume a parked STA.
mesh::wifi::networkPower().setHold(mesh::wifi::NetworkPowerHold::UdpBridge, requested);
if (!requested) {
if (udp_bridge) udp_bridge->serviceNetwork(false);
return;
}
if (!ensureUDPBridge()) return;
wifi_mode_t sdk_mode = WIFI_MODE_NULL;
const bool station_ready = WiFi.getMode() == WIFI_STA
&& esp_wifi_get_mode(&sdk_mode) == ESP_OK && sdk_mode == WIFI_MODE_STA
&& WiFi.status() == WL_CONNECTED && static_cast<uint32_t>(WiFi.localIP()) != 0;
udp_bridge->serviceNetwork(station_ready, static_cast<uint32_t>(WiFi.localIP()),
static_cast<uint32_t>(WiFi.subnetMask()));
if (station_ready) {
udp_bridge->loop();
return;
}
// An AP, setup portal, OTA uploader or MQTT worker owns its WiFi lifecycle.
// UDP may share its connected station, but never reconnects underneath it.
if (!mesh::wifi::stationMutationAllowed()) return;
#ifdef WITH_WEBCONFIG
if (isWebConfigActive() || isWebConfigStopping()) return;
#endif
#ifdef WITH_MQTT_BRIDGE
if (mqtt_bridge && (mqtt_bridge->isRunning() || mqtt_bridge->isStopping())) return;
#endif
if (!millisHasNowPassed(udp_station_retry_at)) return;
udp_station_retry_at = millis() + 15000U;
mesh::wifi::Credentials credentials;
#ifdef WITH_MQTT_BRIDGE
const auto* legacy = _cli.getObserverPrefs();
const auto state = mesh::wifi::resolveCredentials(credentials,
legacy ? legacy->wifi_ssid : nullptr, legacy ? legacy->wifi_password : nullptr,
legacy ? legacy->wifi_power_save : mesh::wifi::kDefaultPowerSave);
#else
const auto state = mesh::wifi::resolveCredentials(credentials);
#endif
if (state != mesh::wifi::CredentialState::Ready) return;
WiFi.persistent(false);
if (!WiFi.mode(WIFI_STA) || mesh::wifi::applyProtocolMask(WIFI_IF_STA) != ESP_OK) return;
mesh::wifi::setStationAutoReconnect(true);
mesh::wifi::beginStation(credentials.ssid, credentials.password);
const uint8_t power_save = mesh::wifi::effectivePowerSave(credentials.power_save, false,
mesh::wifi::espNowChannelConstrained());
esp_wifi_set_ps(power_save == mesh::wifi::kPowerSaveNone ? WIFI_PS_NONE
: power_save == mesh::wifi::kPowerSaveMax ? WIFI_PS_MAX_MODEM : WIFI_PS_MIN_MODEM);
}
#endif
// To check if there is pending work
#if defined(ESP32_PLATFORM)
void MyMesh::serviceIdleWiFi() {
#if (defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) \
#if (defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) || defined(WITH_UDP_BRIDGE) \
|| defined(LIGHTWEIGHT_WIFI_OTA) || (defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA))) \
&& (!defined(MESH_PRIMARY_ESPNOW) || !MESH_PRIMARY_ESPNOW) \
&& (!defined(MESH_ESPNOW_RADIO) || !MESH_ESPNOW_RADIO)
if (_cli.getBoard()->isOTAUpdateRunning()) return;
#ifdef WITH_UDP_BRIDGE
if (isUDPBridgeNetworkEnabled()) return;
#endif
if (mesh::wifi::networkPower().foregroundActive()) return;
#ifdef WITH_WEBCONFIG
if (isWebConfigActive() && (!mesh::wifi::networkPower().backgroundSuspended()
@@ -14247,6 +14438,9 @@ mesh::powerdiag::Mask MyMesh::pendingWorkReason() const {
// probe additional hardware or change which work keeps the MCU awake.
if (!canIdleDualRadio()) return mask(Reason::DualRadio);
if (hasPendingOtaApply()) return mask(Reason::Ota);
#ifdef WITH_UDP_BRIDGE
if (isUDPBridgeRunning()) return mask(Reason::Bridge);
#endif
#if defined(ESP32_PLATFORM)
if (mesh::wifi::networkPower().foregroundActive()) return mask(Reason::Network);
#endif
@@ -14255,7 +14449,7 @@ mesh::powerdiag::Mask MyMesh::pendingWorkReason() const {
|| _webconfig->isNetworkForegroundActive(millis()))) return mask(Reason::Network);
#endif
#if defined(ESP32_PLATFORM) \
&& (defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) \
&& (defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) || defined(WITH_UDP_BRIDGE) \
|| defined(LIGHTWEIGHT_WIFI_OTA) || (defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA))) \
&& (!defined(MESH_PRIMARY_ESPNOW) || !MESH_PRIMARY_ESPNOW) \
&& (!defined(MESH_ESPNOW_RADIO) || !MESH_ESPNOW_RADIO)
+39 -1
View File
@@ -75,6 +75,11 @@
#define WITH_BRIDGE
#endif
#ifdef WITH_UDP_BRIDGE
#include "helpers/bridges/UDPBridge.h"
#include "helpers/UDPBridgeSettings.h"
#endif
#ifdef WITH_MQTT_BRIDGE
#include "helpers/bridges/MQTTBridge.h"
#define WITH_BRIDGE
@@ -629,6 +634,22 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
ESPNowBridge bridge;
uint32_t shared_espnow_retry_at = 0;
#endif
#ifdef WITH_UDP_BRIDGE
mesh::bridge::udp::Settings udp_bridge_settings;
UDPBridge* udp_bridge = nullptr;
uint32_t udp_station_retry_at = 0;
bool udp_network_paused = false;
void serviceUDPBridge();
bool ensureUDPBridge();
const mesh::bridge::udp::Prefs* getUDPBridgePrefs() const override {
return &udp_bridge_settings.prefs();
}
bool setUDPBridgePrefs(const mesh::bridge::udp::Prefs& value) override;
void formatUDPBridgeStatus(char* reply, size_t capacity) override;
void formatUDPBridgeStats(const char* page, char* reply, size_t capacity) override;
bool sendUDPBridgeTest(uint8_t peer_index, uint32_t nonce) override;
bool resetUDPBridgeStats() override;
#endif
#ifdef WITH_BRIDGE
AbstractBridge* activeBridge() {
#ifdef WITH_MQTT_BRIDGE
@@ -651,6 +672,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
#endif
#if defined(WITH_ESPNOW_BRIDGE)
#if !defined(WITH_MQTT_BRIDGE) && !defined(WITH_RS232_BRIDGE)
bool isEspNowBridgeRunning() override { return bridge.isRunning(); }
#endif
ESPNowBridge& diagnosticEspNowBridge() {
#if defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE)
return espnow_bridge;
@@ -1099,8 +1123,19 @@ protected:
bool sendClientReply(ClientInfo* client, mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size);
public:
#ifdef WITH_UDP_BRIDGE
bool setUDPBridgeNetworkState(bool enabled) override;
bool isUDPBridgeNetworkEnabled() const override;
bool isUDPBridgeConfigured() const { return udp_bridge_settings.prefs().enabled != 0; }
bool isUDPBridgeRunning() const { return udp_bridge && udp_bridge->isRunning(); }
#endif
MyMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables);
~MyMesh() { delete[] flood_packet_filters; }
~MyMesh() {
#ifdef WITH_UDP_BRIDGE
delete udp_bridge;
#endif
delete[] flood_packet_filters;
}
MyMesh(const MyMesh&) = delete;
MyMesh& operator=(const MyMesh&) = delete;
@@ -1489,6 +1524,9 @@ public:
#endif
bool isBridgeRunning() const override {
#ifdef WITH_UDP_BRIDGE
if (isUDPBridgeRunning()) return true;
#endif
#if defined(WITH_MQTT_BRIDGE)
return (mqtt_bridge && mqtt_bridge->isRunning())
#ifdef WITH_ESPNOW_BRIDGE
+1
View File
@@ -79,6 +79,7 @@ nav:
- carrier_wave.md
- espnow_bridge_setup.md
- rs232_bridge_setup.md
- udp_bridge_setup.md
- WiFi.md
- flood_filtering.md
- gps_tracking.md
+6 -1
View File
@@ -251,12 +251,17 @@ def requirements(platform, defines, target):
# MQTT joins with saved runtime credentials even without a compiled SSID
# or OTA seeder. Count its mandatory WiFi stack once with other WiFi uses.
wifi = 49152 if any(name in defines for name in (
"WIFI_SSID", "WIFI_OTA_SEEDER", "WITH_MQTT_BRIDGE")) else 0
"WIFI_SSID", "WIFI_OTA_SEEDER", "WITH_MQTT_BRIDGE", "WITH_UDP_BRIDGE")) else 0
ble = 32768 if "BLE_PIN_CODE" in defines else 0
parts["wireless_stacks"] = max(wifi, ble) if "COMPANION_EXCLUSIVE_WIFI_BLE" in defines else wifi + ble
if "WITH_MQTT_BRIDGE" in defines:
mqtt = mqtt_requirements(defines)
parts["mqtt_startup_allowance"] = mqtt["startup_allowance_bytes"]
if "WITH_UDP_BRIDGE" in defines:
# Lazy object plus one bounded raw UDP socket. Count the enabled
# service alongside MQTT/UART even though fresh installs default off.
# UDPBridge.cpp enforces the matching 3072-byte object boundary.
parts["udp_bridge_and_socket"] = 3072 + 2048
if "WITH_RS232_BRIDGE" in defines:
# The UART bridge is allocated on enable, alongside WiFi/MQTT.
# RS232Bridge.cpp bounds its object to 2816 bytes. Reserve 3072
+6
View File
@@ -991,6 +991,9 @@ bool Dispatcher::tryParsePacket(Packet* pkt, const uint8_t* raw, int len) {
pkt->radio_local = false;
pkt->tx_radio = RADIO_TX_AUTO;
pkt->radio_reply = pkt->radio_reply_force = false;
#if defined(WITH_UDP_BRIDGE)
pkt->udp_bridge_ingress = false;
#endif
pkt->radio_generation = pkt->radio_origin_generation = _radio->receiveProfileGeneration();
pkt->header = raw[i++];
if (pkt->getPayloadVer() > PAYLOAD_VER_1) {
@@ -1368,6 +1371,9 @@ Packet* Dispatcher::obtainNewPacket() {
pkt->tx_radio = RADIO_TX_AUTO;
pkt->radio_reply = receive_context_active;
pkt->radio_reply_force = false;
#if defined(WITH_UDP_BRIDGE)
pkt->udp_bridge_ingress = false;
#endif
}
return pkt;
}
+1 -1
View File
@@ -35,7 +35,7 @@
((defined(MESH_DEBUG) && MESH_DEBUG) || \
(defined(BRIDGE_DEBUG) && BRIDGE_DEBUG) || \
(defined(POWERSAVING_DEBUG) && POWERSAVING_DEBUG) || \
defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE))
defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE) || defined(WITH_UDP_BRIDGE))
#include <Arduino.h>
#include <atomic>
#include <stdarg.h>
+6
View File
@@ -17,6 +17,9 @@ Packet::Packet() {
radio_bound = false; radio_local = true;
tx_radio = RADIO_TX_AUTO;
radio_reply = radio_reply_force = false;
#if defined(WITH_UDP_BRIDGE)
udp_bridge_ingress = false;
#endif
}
bool Packet::isValidPathLen(uint8_t path_len) {
@@ -85,6 +88,9 @@ bool Packet::readFrom(const uint8_t src[], uint8_t len) {
header = src[i++];
tx_radio = RADIO_TX_AUTO;
radio_reply = radio_reply_force = false;
#if defined(WITH_UDP_BRIDGE)
udp_bridge_ingress = false;
#endif
if (getPayloadVer() > PAYLOAD_VER_1) return false;
if (hasTransportCodes()) {
+4
View File
@@ -69,6 +69,10 @@ public:
uint8_t tx_radio = RADIO_TX_AUTO; // local policy, never sent over the air
bool radio_reply = false; // locally generated response, never a forwarded packet
bool radio_reply_force = false; // captured reply-only RX-mode override
#if defined(WITH_UDP_BRIDGE)
// Volatile origin marker, copied with radio retries but never serialized.
bool udp_bridge_ingress = false;
#endif
/**
* \brief calculate the hash of payload + type
+109 -2
View File
@@ -3175,9 +3175,92 @@ bool CommonCLI::handleEspNowDiagnosticCommand(uint32_t sender_timestamp,
}
#endif
#if defined(WITH_UDP_BRIDGE)
bool CommonCLI::handleUDPBridgeCommand(uint32_t sender_timestamp,
const char* command, char* reply) {
constexpr size_t capacity = 157;
using namespace mesh::bridge::udp;
const bool get = strncmp(command, "get udp.", 8) == 0;
const bool set = strncmp(command, "set udp.", 8) == 0;
if (get || set) {
const char* key = command + 8;
if (get && strcmp(key, "status") == 0) {
_callbacks->formatUDPBridgeStatus(reply, capacity);
return true;
}
if (get && strncmp(key, "stats", 5) == 0) {
const char* page = key + 5;
if (*page == 0) _callbacks->formatUDPBridgeStats("", reply, capacity);
else if (strcmp(page, " rx") == 0 || strcmp(page, " tx") == 0
|| strcmp(page, " drops") == 0 || strcmp(page, " test") == 0)
_callbacks->formatUDPBridgeStats(page + 1, reply, capacity);
else snprintf(reply, capacity, "Error: use get udp.stats [rx|tx|drops|test]");
return true;
}
const Prefs* current = _callbacks->getUDPBridgePrefs();
if (!current) { snprintf(reply, capacity, "Error: UDP bridge unavailable"); return true; }
Prefs candidate = *current;
if (strcmp(key, "enabled") == 0 && get) {
snprintf(reply, capacity, "%s", current->enabled ? "on" : "off"); return true;
}
if (strcmp(key, "port") == 0 && get) {
snprintf(reply, capacity, "%u", current->port); return true;
}
if (strncmp(key, "peer.", 5) == 0 && key[5] >= '1' && key[5] <= '8'
&& (key[6] == 0 || key[6] == ' ')) {
const uint8_t index = static_cast<uint8_t>(key[5] - '1');
if (get && key[6] == 0) { formatPeer(current->peers[index], reply, capacity); return true; }
if (!set || key[6] != ' ' || !parsePeer(key + 7, candidate.peers[index])) {
snprintf(reply, capacity, "Error: use set udp.peer.1..8 <IPv4:port|off>"); return true;
}
} else if (set && strncmp(key, "enabled ", 8) == 0) {
if (strcmp(key + 8, "on") == 0) candidate.enabled = 1;
else if (strcmp(key + 8, "off") == 0) candidate.enabled = 0;
else { snprintf(reply, capacity, "Error: use set udp.enabled on|off"); return true; }
} else if (set && strncmp(key, "port ", 5) == 0) {
if (!parsePort(key + 5, candidate.port)) {
snprintf(reply, capacity, "Error: UDP port must be 1..65535"); return true;
}
} else {
snprintf(reply, capacity, "Error: use udp.enabled, udp.port, udp.peer.1..8, udp.status or udp.stats");
return true;
}
if (!validPrefs(candidate)) {
snprintf(reply, capacity, "Error: invalid or duplicate UDP peer"); return true;
}
snprintf(reply, capacity, "%s", _callbacks->setUDPBridgePrefs(candidate)
? "OK - UDP settings saved; use get udp.status" : "Error: UDP settings not saved");
return true;
}
if (strncmp(command, "udp", 3) != 0 || (command[3] != 0 && command[3] != ' ')) return false;
if (sender_timestamp != 0) {
snprintf(reply, capacity, "Error: UDP test/reset requires a local connection"); return true;
}
if (strcmp(command, "udp resetstats") == 0) {
snprintf(reply, capacity, "%s", _callbacks->resetUDPBridgeStats()
? "OK - UDP statistics reset" : "Error: UDP bridge unavailable"); return true;
}
if (strncmp(command, "udp test ", 9) != 0 || command[9] < '1' || command[9] > '8'
|| (command[10] != 0 && command[10] != ' ')) {
snprintf(reply, capacity, "Error: use udp test <1..8> [8 hex digits] or udp resetstats"); return true;
}
uint32_t nonce = millis();
if (command[10] != 0 && !parseNonce(command + 11, nonce)) {
snprintf(reply, capacity, "Error: use udp test <1..8> [8 hex digits] or udp resetstats"); return true;
}
if (_callbacks->sendUDPBridgeTest(static_cast<uint8_t>(command[9] - '1'), nonce))
snprintf(reply, capacity, "OK - test queued id=%08lX; confirm on peer", static_cast<unsigned long>(nonce));
else snprintf(reply, capacity, "Error: UDP test not queued; check status/stats");
return true;
}
#endif
void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* reply) {
PrefsSaveReplyGuard save_reply(_prefs_save_failures, reply);
mesh::cli::normalizeCommandVerb(command);
#if defined(WITH_UDP_BRIDGE)
if (handleUDPBridgeCommand(sender_timestamp, command, reply)) return;
#endif
#if defined(WITH_RS232_BRIDGE)
if (handleRs232DiagnosticCommand(sender_timestamp, command, reply)) return;
#endif
@@ -3328,7 +3411,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
return;
}
#endif
#if defined(WITH_ESPNOW_BRIDGE)
#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_UDP_BRIDGE)
const bool ota_was_running = _board->isOTAUpdateRunning();
// Keep an outstanding pause if an earlier resume failed. Only a
// successful resume or an explicitly disabled saved intent clears it.
@@ -3352,10 +3435,22 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
return;
}
_wifi_ota_resume_espnow = _wifi_ota_resume_espnow || espnow_paused;
#endif
#if defined(WITH_UDP_BRIDGE)
// Close the UDP socket before the browser uploader owns the driver.
const bool udp_paused = _callbacks->isUDPBridgeNetworkEnabled();
if (udp_paused) _callbacks->setUDPBridgeNetworkState(false);
_wifi_ota_resume_udp = _wifi_ota_resume_udp || udp_paused;
#endif
reply[0] = 0;
if (!_board->startOTAUpdate(_prefs->node_name, reply, force_ap)) {
if (!reply[0]) strcpy(reply, "Error");
#if defined(WITH_UDP_BRIDGE)
if (udp_paused && !ota_was_running) {
_callbacks->setUDPBridgeNetworkState(true);
_wifi_ota_resume_udp = false;
}
#endif
#if defined(WITH_ESPNOW_BRIDGE)
if (espnow_paused && !ota_was_running) {
if (_callbacks->setEspNowBridgeState(true)) {
@@ -3421,6 +3516,13 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
? "; ESP-NOW resumed" : "; ESP-NOW resume failed");
}
}
#endif
#if defined(WITH_UDP_BRIDGE)
if (ota_stopped && !_board->isOTAUpdateRunning() && _wifi_ota_resume_udp) {
const auto* udp_prefs = _callbacks->getUDPBridgePrefs();
if (udp_prefs && udp_prefs->enabled) _callbacks->setUDPBridgeNetworkState(true);
_wifi_ota_resume_udp = false;
}
#endif
} else if (memcmp(command, "clock", 5) == 0) {
uint32_t now = getRTCClock()->getCurrentTime();
@@ -6044,7 +6146,9 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
sprintf(reply, "> %s", _callbacks->getRole());
} else if (configKeyEquals(config, "bridge.type")) {
sprintf(reply, "> %s",
#if defined(WITH_MQTT_BRIDGE) && defined(WITH_RS232_BRIDGE) && defined(WITH_ESPNOW_BRIDGE)
#if defined(WITH_UDP_BRIDGE) && !defined(WITH_MQTT_BRIDGE) && !defined(WITH_RS232_BRIDGE) && !defined(WITH_ESPNOW_BRIDGE)
"udp"
#elif defined(WITH_MQTT_BRIDGE) && defined(WITH_RS232_BRIDGE) && defined(WITH_ESPNOW_BRIDGE)
"mqtt+rs232+espnow"
#elif defined(WITH_MQTT_BRIDGE) && defined(WITH_RS232_BRIDGE)
"mqtt+rs232"
@@ -6060,6 +6164,9 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
"mqtt"
#else
"none"
#endif
#if defined(WITH_UDP_BRIDGE) && (defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE) || defined(WITH_ESPNOW_BRIDGE))
"+udp"
#endif
);
#ifdef WITH_BRIDGE
+29 -1
View File
@@ -17,6 +17,9 @@
#include <helpers/MorseReplyHandoff.h>
#include <helpers/WirelessControl.h>
#include <helpers/FleetCommand.h>
#if defined(WITH_UDP_BRIDGE)
#include <helpers/UDPBridgePrefs.h>
#endif
#if MESH_ENABLE_FLEET_CONTROL
#include <helpers/FleetChannel.h>
#endif
@@ -32,7 +35,7 @@
#include <helpers/UserGpio.h>
#endif
#if defined(WITH_RS232_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) || defined(WITH_MQTT_BRIDGE)
#if defined(WITH_RS232_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) || defined(WITH_MQTT_BRIDGE) || defined(WITH_UDP_BRIDGE)
#define WITH_BRIDGE
#endif
@@ -645,6 +648,25 @@ public:
virtual bool restartRs232Bridge() { return restartBridge(); }
virtual bool isEspNowBridgeRunning() { return isBridgeRunning(); }
virtual bool isRs232BridgeRunning() const { return isBridgeRunning(); }
#if defined(WITH_UDP_BRIDGE)
virtual const mesh::bridge::udp::Prefs* getUDPBridgePrefs() const { return nullptr; }
virtual bool setUDPBridgePrefs(const mesh::bridge::udp::Prefs& prefs) {
(void)prefs; return false;
}
virtual void formatUDPBridgeStatus(char* reply, size_t capacity) {
snprintf(reply, capacity, "Error: UDP bridge unavailable");
}
virtual void formatUDPBridgeStats(const char* page, char* reply, size_t capacity) {
(void)page;
snprintf(reply, capacity, "Error: UDP bridge unavailable");
}
virtual bool sendUDPBridgeTest(uint8_t peer_index, uint32_t nonce) {
(void)peer_index; (void)nonce; return false;
}
virtual bool resetUDPBridgeStats() { return false; }
virtual bool setUDPBridgeNetworkState(bool enabled) { (void)enabled; return false; }
virtual bool isUDPBridgeNetworkEnabled() const { return false; }
#endif
virtual void formatRs232Status(char* reply) { strcpy(reply, "RS232 unavailable"); }
#if defined(WITH_RS232_BRIDGE)
virtual void formatRs232Transport(char* reply, size_t capacity) {
@@ -856,6 +878,9 @@ class CommonCLI {
bool _wifi_ota_resume_mqtt = false;
bool _wifi_ota_resume_espnow = false;
#endif
#if defined(WITH_UDP_BRIDGE)
bool _wifi_ota_resume_udp = false;
#endif
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
UserGpio _user_gpio;
#endif
@@ -924,6 +949,9 @@ class CommonCLI {
#endif
#if defined(WITH_ESPNOW_BRIDGE)
bool handleEspNowDiagnosticCommand(uint32_t sender_timestamp, const char* command, char* reply);
#endif
#if defined(WITH_UDP_BRIDGE)
bool handleUDPBridgeCommand(uint32_t sender_timestamp, const char* command, char* reply);
#endif
void handleDelCmd(char* command, char* reply);
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
+5 -1
View File
@@ -168,7 +168,11 @@ StaticPoolPacketManager::StaticPoolPacketManager(int pool_size)
}
mesh::Packet* StaticPoolPacketManager::allocNew() {
return unused.removeByIdx(0); // just get first one (returns NULL if empty)
auto* packet = unused.removeByIdx(0); // NULL if empty.
#if defined(WITH_UDP_BRIDGE)
if (packet) packet->udp_bridge_ingress = false;
#endif
return packet;
}
void StaticPoolPacketManager::free(mesh::Packet* packet) {
+150
View File
@@ -0,0 +1,150 @@
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
namespace mesh { namespace bridge { namespace udp {
static constexpr uint8_t MAX_PEERS = 8;
static constexpr uint16_t DEFAULT_PORT = 4404;
static constexpr size_t PREFS_IMAGE_SIZE = 64;
struct Peer {
uint8_t ip[4] = {};
uint16_t port = 0;
bool enabled() const { return port != 0; }
};
struct Prefs {
uint8_t enabled = 0;
uint16_t port = DEFAULT_PORT;
Peer peers[MAX_PEERS];
};
inline bool validAddress(const uint8_t ip[4]) {
// Subnet-directed broadcasts also depend on the connected interface mask;
// the runtime transport checks those before sending or accepting a peer.
return ip && ip[0] != 0 && ip[0] != 127 && ip[0] < 224;
}
inline bool equalPeer(const Peer& a, const Peer& b) {
return a.port == b.port && memcmp(a.ip, b.ip, 4) == 0;
}
inline bool validPrefs(const Prefs& prefs) {
if (prefs.enabled > 1 || prefs.port == 0) return false;
for (uint8_t i = 0; i < MAX_PEERS; ++i) {
const Peer& peer = prefs.peers[i];
if (!peer.enabled()) {
for (uint8_t octet : peer.ip) if (octet != 0) return false;
continue;
}
if (!validAddress(peer.ip)) return false;
for (uint8_t j = 0; j < i; ++j) {
if (prefs.peers[j].enabled() && equalPeer(peer, prefs.peers[j])) return false;
}
}
return true;
}
inline bool parsePort(const char* text, uint16_t& result) {
if (!text || !*text) return false;
uint32_t value = 0;
for (; *text; ++text) {
if (*text < '0' || *text > '9') return false;
value = value * 10 + static_cast<uint8_t>(*text - '0');
if (value > 65535) return false;
}
if (value == 0) return false;
result = static_cast<uint16_t>(value);
return true;
}
inline bool parsePeer(const char* text, Peer& result) {
if (!text) return false;
if (strcmp(text, "off") == 0) { result = Peer(); return true; }
Peer candidate;
const char* cursor = text;
for (uint8_t i = 0; i < 4; ++i) {
unsigned value = 0, digits = 0;
while (*cursor >= '0' && *cursor <= '9') {
value = value * 10 + static_cast<uint8_t>(*cursor++ - '0');
if (++digits > 3 || value > 255) return false;
}
if (digits == 0 || *cursor++ != (i == 3 ? ':' : '.')) return false;
candidate.ip[i] = static_cast<uint8_t>(value);
}
if (!validAddress(candidate.ip) || !parsePort(cursor, candidate.port)) return false;
result = candidate;
return true;
}
inline void formatPeer(const Peer& peer, char* reply, size_t capacity) {
if (!peer.enabled()) { snprintf(reply, capacity, "off"); return; }
snprintf(reply, capacity, "%u.%u.%u.%u:%u", peer.ip[0], peer.ip[1],
peer.ip[2], peer.ip[3], peer.port);
}
inline bool parseNonce(const char* text, uint32_t& result) {
if (!text || strlen(text) != 8) return false;
uint32_t value = 0;
for (uint8_t i = 0; i < 8; ++i) {
const char c = text[i];
uint8_t digit;
if (c >= '0' && c <= '9') digit = static_cast<uint8_t>(c - '0');
else if (c >= 'a' && c <= 'f') digit = static_cast<uint8_t>(c - 'a' + 10);
else if (c >= 'A' && c <= 'F') digit = static_cast<uint8_t>(c - 'A' + 10);
else return false;
value = (value << 4) | digit;
}
result = value;
return true;
}
inline uint32_t prefsChecksum(const uint8_t* data, size_t size) {
uint32_t crc = 0xffffffffU;
while (size--) {
crc ^= *data++;
for (uint8_t bit = 0; bit < 8; ++bit)
crc = (crc >> 1) ^ ((crc & 1) ? 0xedb88320U : 0);
}
return crc;
}
inline void encodePrefs(uint8_t image[PREFS_IMAGE_SIZE], const Prefs& prefs) {
memset(image, 0, PREFS_IMAGE_SIZE);
memcpy(image, "UDP1", 4);
image[4] = prefs.enabled;
image[6] = prefs.port >> 8; image[7] = prefs.port & 255;
for (uint8_t i = 0; i < MAX_PEERS; ++i) {
memcpy(image + 8 + i * 6, prefs.peers[i].ip, 4);
image[12 + i * 6] = prefs.peers[i].port >> 8;
image[13 + i * 6] = prefs.peers[i].port & 255;
}
const uint32_t crc = prefsChecksum(image, PREFS_IMAGE_SIZE - 4);
for (uint8_t i = 0; i < 4; ++i) image[60 + i] = (crc >> (24 - i * 8)) & 255;
}
inline bool decodePrefs(const uint8_t* image, size_t size, Prefs& prefs) {
if (!image || size != PREFS_IMAGE_SIZE || memcmp(image, "UDP1", 4) != 0
|| image[5] != 0) return false;
for (uint8_t i = 56; i < 60; ++i) if (image[i] != 0) return false;
uint32_t crc = 0;
for (uint8_t i = 0; i < 4; ++i) crc = (crc << 8) | image[60 + i];
if (crc != prefsChecksum(image, PREFS_IMAGE_SIZE - 4)) return false;
Prefs candidate;
candidate.enabled = image[4];
candidate.port = static_cast<uint16_t>((image[6] << 8) | image[7]);
for (uint8_t i = 0; i < MAX_PEERS; ++i) {
memcpy(candidate.peers[i].ip, image + 8 + i * 6, 4);
candidate.peers[i].port = static_cast<uint16_t>(
(image[12 + i * 6] << 8) | image[13 + i * 6]);
}
if (!validPrefs(candidate)) return false;
prefs = candidate;
return true;
}
}}} // namespace mesh::bridge::udp
+51
View File
@@ -0,0 +1,51 @@
#include "UDPBridgeSettings.h"
#if defined(WITH_UDP_BRIDGE)
#include "CheckedSettingsFile.h"
#include "ContactFileTransaction.h"
#include "FilePresence.h"
namespace mesh { namespace bridge { namespace udp {
namespace { constexpr char Path[] = "/udp_bridge"; }
bool Settings::begin(FILESYSTEM* fs) {
fs_ = fs;
prefs_ = Prefs();
healthy_ = false;
if (!fs_ || !ContactFileTransaction::recover(fs_, Path, filePresence<FILESYSTEM>)) return false;
bool present = false;
if (!filePresence(fs_, Path, present)) return false;
if (!present) { healthy_ = true; return true; }
uint8_t image[PREFS_IMAGE_SIZE];
const auto read = storage::readFixedSettingsFileResult(fs_, Path, image, sizeof(image));
if (read == storage::FixedSettingsRead::Unreadable) return false;
healthy_ = true;
return read == storage::FixedSettingsRead::Complete
&& decodePrefs(image, sizeof(image), prefs_);
}
bool Settings::setPrefs(const Prefs& prefs) {
if (!healthy_ || !fs_ || !validPrefs(prefs)) return false;
uint8_t image[PREFS_IMAGE_SIZE];
encodePrefs(image, prefs);
ContactFileTransaction writer(fs_, Path, filePresence<FILESYSTEM>);
if (!writer || writer.write(image, sizeof(image)) != sizeof(image)) {
if (writer.outcome() == ContactFileTransaction::Outcome::Indeterminate) healthy_ = false;
return false;
}
writer.commit();
if (writer.outcome() == ContactFileTransaction::Outcome::NotCommitted) return false;
if (writer.outcome() != ContactFileTransaction::Outcome::Committed) {
healthy_ = false;
return false;
}
uint8_t verified[PREFS_IMAGE_SIZE];
healthy_ = storage::readFixedSettingsFile(fs_, Path, verified, sizeof(verified))
&& memcmp(image, verified, sizeof(image)) == 0;
if (!healthy_) return false;
prefs_ = prefs;
return true;
}
}}} // namespace mesh::bridge::udp
#endif
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#if defined(WITH_UDP_BRIDGE)
#include <helpers/IdentityStore.h>
#include <helpers/UDPBridgePrefs.h>
namespace mesh { namespace bridge { namespace udp {
// This standalone record leaves the legacy /com_prefs binary layout intact.
class Settings {
FILESYSTEM* fs_ = nullptr;
Prefs prefs_;
bool healthy_ = false;
public:
// Missing/corrupt settings leave the transport off. A complete malformed
// record can be replaced explicitly; I/O uncertainty requires a reload.
bool begin(FILESYSTEM* fs);
const Prefs& prefs() const { return prefs_; }
bool healthy() const { return healthy_; }
// Publish and verify first; preserve runtime preferences on failed saves.
bool setPrefs(const Prefs& prefs);
};
}}} // namespace mesh::bridge::udp
#endif
+2 -1
View File
@@ -65,7 +65,8 @@ private:
enum class NetworkPowerHold : uint8_t {
CompanionClient = 1,
WebConfig = 2,
OtaSeeder = 4
OtaSeeder = 4,
UdpBridge = 8
};
// One program-wide handoff between the MQTT owner task and the station/UI
+5
View File
@@ -66,6 +66,11 @@ public:
void begin(Backend& backend) { backend_ = &backend; }
bool blocked(uint8_t service) const { return (blocked_.load() & service) != 0; }
bool pending() const { return pending_; }
// New background owners must honor the reply-drain grace period before
// the backend actually shuts down a requested service.
bool pendingDisable(uint8_t service) const {
return pending_ && (scope_ & service) && !(target_ & service);
}
bool masterOff() const { return master_off_; }
bool allowService(uint8_t service) {
if (master_off_ || pending_) return false;
+4 -4
View File
@@ -36,7 +36,7 @@ void BridgeBase::handleReceivedPacket(mesh::Packet *packet) {
if (_initialized == false) {
BRIDGE_DEBUG_PRINTLN("RX packet received before initialization\n");
_mgr->free(packet);
#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE)
#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE) || defined(WITH_UDP_BRIDGE)
onIngressResult(IngressResult::Stopped);
#endif
return;
@@ -44,7 +44,7 @@ void BridgeBase::handleReceivedPacket(mesh::Packet *packet) {
if (!preparePacketIngress(packet) || !allowsPacket(packet)) {
_mgr->free(packet);
#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE)
#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE) || defined(WITH_UDP_BRIDGE)
onIngressResult(IngressResult::Filtered);
#endif
return;
@@ -58,12 +58,12 @@ void BridgeBase::handleReceivedPacket(mesh::Packet *packet) {
if (!ota) _seen_packets.markSeen(packet);
// bridge_delay provides a buffer to prevent immediate processing conflicts in the mesh network.
_mgr->queueInbound(packet, millis() + _prefs->bridge_delay);
#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE)
#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE) || defined(WITH_UDP_BRIDGE)
onIngressResult(IngressResult::Queued);
#endif
} else {
_mgr->free(packet);
#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE)
#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE) || defined(WITH_UDP_BRIDGE)
onIngressResult(IngressResult::Duplicate);
#endif
}
+1 -1
View File
@@ -50,7 +50,7 @@ public:
static constexpr uint16_t BRIDGE_CHECKSUM_SIZE = sizeof(uint16_t);
protected:
#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE)
#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE) || defined(WITH_UDP_BRIDGE)
// Observe the existing admission decision without evaluating filters twice.
enum class IngressResult : uint8_t { Stopped, Filtered, Duplicate, Queued };
virtual void onIngressResult(IngressResult) {}
+340
View File
@@ -0,0 +1,340 @@
#include "UDPBridge.h"
#if defined(ESP32) && defined(WITH_UDP_BRIDGE)
#include <Arduino.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <lwip/sockets.h>
#include <SHA256.h>
#include <esp_system.h>
namespace udp = mesh::bridge::udp;
static_assert(sizeof(UDPBridge) <= 3072, "UDP bridge object exceeds its Full memory reserve");
UDPBridge::UDPBridge(NodePrefs* prefs, mesh::PacketManager* mgr, mesh::RTCClock* rtc,
const udp::Prefs* settings) : BridgeBase(prefs, mgr, rtc), _settings(settings) {}
UDPBridge::~UDPBridge() { end(); }
void UDPBridge::note(uint32_t& value) { udp::increment(value); _debug_dirty = true; }
void UDPBridge::error(int code) { _last_error = code; _debug_dirty = true; }
uint64_t UDPBridge::randomNonce() {
uint64_t value;
do { value = (uint64_t(esp_random()) << 32) | esp_random(); } while (!value);
return value;
}
bool UDPBridge::authenticate(void* context, const uint8_t* data, size_t size, uint8_t digest[32]) {
const auto* self = static_cast<const UDPBridge*>(context);
if (!self->_secret_valid) return false;
SHA256 sha;
static const uint8_t domain[] = "MeshCore UDP bridge v1";
sha.resetHMAC(self->_secret, self->_secret_length);
sha.update(domain, sizeof(domain));
sha.update(data, size);
sha.finalizeHMAC(self->_secret, self->_secret_length, digest, 32);
return true;
}
void UDPBridge::begin() {
if (_initialized || !_network_ready || !_settings || !_settings->enabled) return;
_active = *_settings;
_secret_length = 0;
while (_secret_length < sizeof(_prefs->bridge_secret) && _prefs->bridge_secret[_secret_length]) ++_secret_length;
_secret_valid = _secret_length > 0 && _secret_length < sizeof(_prefs->bridge_secret);
if (!_secret_valid || !udp::validPrefs(_active)) { error(EINVAL); return; }
for (const auto& peer : _active.peers) {
if (!peer.port) continue;
uint32_t address; memcpy(&address, peer.ip, sizeof(address));
if (_local_address && (address == _local_address || (_netmask
&& (address == (_local_address | ~_netmask)
|| address == (_local_address & _netmask))))) {
error(EADDRNOTAVAIL); return;
}
}
memcpy(_secret, _prefs->bridge_secret, _secret_length);
_socket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (_socket < 0) { error(errno); return; }
const int flags = fcntl(_socket, F_GETFL, 0);
if (flags < 0 || fcntl(_socket, F_SETFL, flags | O_NONBLOCK) < 0) {
const int code = errno; end(); error(code); return;
}
// Bound lwIP's receive-byte accounting where supported. Application ingress
// is additionally limited to two datagrams per loop and one owned RX slot.
#if defined(SO_RCVBUF)
const int receive_bytes = 768;
if (setsockopt(_socket, SOL_SOCKET, SO_RCVBUF, &receive_bytes, sizeof(receive_bytes)) != 0) {
const int code = errno; end(); error(code); return;
}
#else
end(); error(ENOTSUP); return;
#endif
sockaddr_in local{};
local.sin_family = AF_INET; local.sin_port = htons(_active.port); local.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(_socket, reinterpret_cast<sockaddr*>(&local), sizeof(local)) != 0) {
const int code = errno; end(); error(code); return;
}
_boot = randomNonce(); _sequence = 0;
for (auto& session : _sessions) { session = udp::Session{}; session.next_hello = millis() + esp_random() % 1000; }
_initialized = true; _last_error = 0; _debug_dirty = true;
}
void UDPBridge::end() {
if (_socket >= 0) { close(_socket); _socket = -1; }
_initialized = false; _tx_mask = 0; _tx_length = _rx_length = 0;
_boot = 0; _sequence = 0;
for (auto& session : _sessions) session = udp::Session{};
memset(_secret, 0, sizeof(_secret)); _secret_length = 0; _secret_valid = false;
_debug_dirty = true;
}
void UDPBridge::serviceNetwork(bool ready, uint32_t local_address, uint32_t netmask) {
// The shared bridge secret can change without a transport-specific restart
// callback. End the old authenticated session before any more socket work.
if (_initialized) {
const size_t length = strnlen(_prefs->bridge_secret, sizeof(_prefs->bridge_secret));
if (!length || length >= sizeof(_prefs->bridge_secret) || length != _secret_length
|| memcmp(_secret, _prefs->bridge_secret, length)) {
end(); _retry_at = millis();
}
}
if (_local_address != local_address || _netmask != netmask) {
_local_address = local_address; _netmask = netmask;
if (_initialized) end();
_retry_at = millis();
}
if (_network_ready != ready) { _network_ready = ready; _debug_dirty = true; _retry_at = millis(); }
if (!ready || !_settings || !_settings->enabled) {
if (_socket >= 0 || _initialized) end();
return;
}
const uint32_t now = millis();
if (!_initialized && udp::due(now, _retry_at)) { _retry_at = now + 5000; begin(); }
}
uint8_t UDPBridge::peerMask(bool established_only) const {
uint8_t mask = 0;
for (uint8_t i = 0; i < udp::MAX_PEERS; ++i)
if (_active.peers[i].port && (!established_only || _sessions[i].remote)) mask |= uint8_t(1U << i);
return mask;
}
uint32_t UDPBridge::nextSequence() {
// Never reuse a sequence within the same boot/session. Reopen on the next
// service iteration; queued data is explicitly best-effort and discarded.
if (_sequence == UINT32_MAX) { end(); error(EOVERFLOW); return 0; }
return ++_sequence;
}
bool UDPBridge::sendFrame(uint8_t peer, udp::Kind kind, uint64_t recipient,
const uint8_t* payload, uint16_t length, uint32_t sequence) {
if (!_initialized || peer >= udp::MAX_PEERS || !_active.peers[peer].port) return false;
if (!sequence) sequence = nextSequence();
if (!_initialized || !sequence) return false;
const udp::Frame frame{kind, _boot, recipient, sequence, payload, length};
uint8_t bytes[udp::MAX_FRAME];
const size_t size = udp::encode(bytes, sizeof(bytes), frame, authenticate, this);
if (!size) { error(EINVAL); return false; }
const auto& endpoint = _active.peers[peer];
sockaddr_in address{}; address.sin_family = AF_INET; address.sin_port = htons(endpoint.port);
memcpy(&address.sin_addr.s_addr, endpoint.ip, sizeof(endpoint.ip));
const int sent = sendto(_socket, bytes, size, MSG_DONTWAIT,
reinterpret_cast<const sockaddr*>(&address), sizeof(address));
if (sent != static_cast<int>(size)) { error(sent < 0 ? errno : EIO); return false; }
return true;
}
void UDPBridge::pumpHello() {
const uint32_t now = millis();
for (uint8_t attempts = 0; attempts < udp::MAX_PEERS; ++attempts) {
const uint8_t i = _hello_cursor++ % udp::MAX_PEERS;
if (!_active.peers[i].port) continue;
auto& session = _sessions[i];
if (session.candidate && !udp::due(now, session.challenge_deadline)
&& uint32_t(now - session.challenge_sent_at) >= 1000) {
uint8_t nonce[8]; udp::put64(nonce, session.challenge);
session.challenge_sent_at = now;
if (sendFrame(i, udp::Kind::Challenge, session.candidate, nonce, sizeof(nonce))) note(_stats.challenges);
return; // Retransmit handshake control only, never data or test probes.
}
if (!udp::due(now, session.next_hello)) continue;
_sessions[i].next_hello = now + (_sessions[i].remote ? 10000 : 1000) + esp_random() % 500;
if (sendFrame(i, udp::Kind::Hello, 0, nullptr, 0)) note(_stats.hellos);
return; // At most one periodic control datagram per main loop.
}
}
void UDPBridge::receiveFrame(uint8_t peer, const uint8_t* data, size_t length) {
udp::Frame frame;
const auto decoded = udp::decode(data, length, frame, authenticate, this);
if (decoded != udp::Decode::Ok) {
note(decoded == udp::Decode::Authentication ? _stats.authentication : _stats.malformed); return;
}
note(_stats.authenticated);
if (frame.sender == _boot) { note(_stats.foreign); return; }
auto& session = _sessions[peer];
const uint32_t now = millis();
if (frame.kind == udp::Kind::Hello) {
// Refreshing the same proven boot does not reset its replay high-water.
if (session.request(frame.sender, randomNonce(), now)) {
uint8_t nonce[8]; udp::put64(nonce, session.challenge);
if (sendFrame(peer, udp::Kind::Challenge, frame.sender, nonce, sizeof(nonce))) note(_stats.challenges);
}
return;
}
if (frame.recipient != _boot) { note(_stats.replay); return; }
if (frame.kind == udp::Kind::Challenge) {
// Proof is the only automatic control echo. Explicit probes never echo.
(void)sendFrame(peer, udp::Kind::Proof, frame.sender, frame.payload, frame.length);
return;
}
if (frame.kind == udp::Kind::Proof) {
const bool changed = session.remote != frame.sender;
if (session.prove(frame.sender, udp::get64(frame.payload), now)) {
note(_stats.established);
// Expedite the reciprocal challenge after first learning a boot. This
// also recovers a dropped startup Hello without a long ready interval.
if (changed) session.next_hello = now;
} else note(_stats.replay);
return;
}
if (!session.admit(frame.sender, frame.recipient, _boot, frame.sequence)) { note(_stats.replay); return; }
if (frame.kind == udp::Kind::Probe) {
_stats.last_rx_nonce = udp::get32(frame.payload); note(_stats.probe_received); return;
}
// Copy verified bytes into one bounded holding slot before allocating from
// the shared mesh pool. A full slot stops socket reads, preserving its owner.
memcpy(_rx_payload, frame.payload, frame.length); _rx_length = frame.length;
}
void UDPBridge::pumpReceive() {
for (uint8_t count = 0; count < 2 && !_rx_length && _initialized; ++count) {
uint8_t bytes[udp::MAX_FRAME + 1]; sockaddr_in source{}; socklen_t source_size = sizeof(source);
const int length = recvfrom(_socket, bytes, sizeof(bytes), MSG_DONTWAIT,
reinterpret_cast<sockaddr*>(&source), &source_size);
if (length < 0) {
if (errno != EAGAIN && errno != EWOULDBLOCK) error(errno);
return;
}
note(_stats.frames);
int peer = -1;
for (uint8_t i = 0; i < udp::MAX_PEERS; ++i)
if (_active.peers[i].port && source.sin_port == htons(_active.peers[i].port)
&& !memcmp(&source.sin_addr.s_addr, _active.peers[i].ip, 4)) { peer = i; break; }
if (source_size != sizeof(source) || source.sin_family != AF_INET || peer < 0) { note(_stats.foreign); continue; }
receiveFrame(static_cast<uint8_t>(peer), bytes, static_cast<size_t>(length));
drainReceive();
}
}
void UDPBridge::drainReceive() {
if (!_rx_length) return;
mesh::Packet* packet = _mgr->allocNew();
if (!packet) { note(_stats.pool); return; }
const bool parsed = packet->readFrom(_rx_payload, static_cast<uint8_t>(_rx_length));
_rx_length = 0;
if (!parsed) { _mgr->free(packet); note(_stats.parse); return; }
packet->udp_bridge_ingress = true;
onPacketReceived(packet);
}
void UDPBridge::onPacketReceived(mesh::Packet* packet) {
if (packet) { packet->udp_bridge_ingress = true; handleReceivedPacket(packet); }
}
void UDPBridge::onIngressResult(IngressResult result) {
switch (result) {
case IngressResult::Queued: note(_stats.delivered); break;
case IngressResult::Filtered: note(_stats.filtered); break;
case IngressResult::Duplicate: note(_stats.duplicate); break;
case IngressResult::Stopped: break;
}
}
void UDPBridge::sendPacket(mesh::Packet* packet) {
note(_stats.requested);
if (!_initialized) { note(_stats.stopped); return; }
if (!packet || packet->udp_bridge_ingress || !allowsPacket(packet)) { note(_stats.tx_filtered); return; }
const bool ota = packet->getPayloadType() == PAYLOAD_TYPE_OTA;
if (!ota && _seen_packets.wasSeen(packet)) { note(_stats.tx_duplicate); return; }
const uint8_t destinations = peerMask(true);
if (_tx_mask || !destinations) { note(_stats.busy); return; }
const int length = packet->getRawLength();
if (length < 2 || length > static_cast<int>(sizeof(_tx_payload))) { note(_stats.oversize); return; }
const uint32_t sequence = nextSequence();
if (!sequence) { note(_stats.stopped); return; }
if (packet->writeTo(_tx_payload) != length) { note(_stats.failed); return; }
_tx_length = length; _tx_mask = destinations; _tx_kind = udp::Kind::Mesh; _tx_sequence = sequence;
if (!ota) _seen_packets.markSeen(packet);
note(_stats.queued);
}
void UDPBridge::pumpTransmit() {
if (!_tx_mask) return;
uint8_t peer = 0;
while (!(_tx_mask & (1U << peer))) ++peer;
_tx_mask &= static_cast<uint8_t>(~(1U << peer));
const bool sent = sendFrame(peer, _tx_kind, _sessions[peer].remote, _tx_payload, _tx_length, _tx_sequence);
if (_tx_kind == udp::Kind::Probe) note(sent ? _stats.probe_sent : _stats.probe_failed);
else note(sent ? _stats.sent : _stats.failed);
if (!_tx_mask) _tx_length = 0;
}
bool UDPBridge::sendTest(uint8_t peer, uint32_t nonce) {
if (!_initialized || peer >= udp::MAX_PEERS || !(peerMask(true) & (1U << peer)) || _tx_mask) {
note(_stats.probe_failed); return false;
}
const uint32_t sequence = nextSequence();
if (!sequence) { note(_stats.probe_failed); return false; }
udp::put32(_tx_payload, nonce); _tx_length = 4;
_tx_mask = static_cast<uint8_t>(1U << peer); _tx_kind = udp::Kind::Probe; _tx_sequence = sequence;
_stats.last_tx_nonce = nonce; note(_stats.probe_queued); return true;
}
void UDPBridge::loop() {
if (_initialized) { drainReceive(); pumpReceive(); pumpTransmit(); pumpHello(); }
emitDebug();
}
void UDPBridge::resetStats() { _stats = Stats{}; _debug_dirty = true; }
void UDPBridge::formatStatus(char* out, size_t capacity) const {
if (!out || !capacity) return;
unsigned peers = 0, ready = 0;
for (uint8_t i = 0; i < udp::MAX_PEERS; ++i) {
const auto* prefs = _initialized ? &_active : _settings;
if (prefs && prefs->peers[i].port) ++peers;
if (_sessions[i].remote) ++ready;
}
const char* state = !_settings || !_settings->enabled ? "off" : !_network_ready ? "netwait"
: !_initialized ? "retrying" : "ready";
const uint8_t* ip = reinterpret_cast<const uint8_t*>(&_local_address);
snprintf(out, capacity, "> %s ip=%u.%u.%u.%u port=%u peers=%u ready=%u src=%s q=%u/%u last=%d", state,
unsigned(ip[0]), unsigned(ip[1]), unsigned(ip[2]), unsigned(ip[3]),
unsigned(_initialized ? _active.port : _settings ? _settings->port : 0), peers, ready,
_prefs->bridge_pkt_src ? "rx" : "tx", unsigned(_rx_length != 0), unsigned(_tx_mask != 0), _last_error);
out[capacity - 1] = 0;
}
void UDPBridge::formatStats(const char* page, char* out, size_t capacity) const {
if (!out || !capacity) return;
const auto& s = _stats;
if (!page || !page[0]) snprintf(out, capacity, "> rx=%lu mesh=%lu tx=%lu sent=%lu; pages: rx tx drops test",
(unsigned long)s.frames, (unsigned long)s.delivered, (unsigned long)s.queued, (unsigned long)s.sent);
else if (!strcmp(page, "rx")) snprintf(out, capacity, "> frames=%lu auth=%lu mesh=%lu parse=%lu pool=%lu hello=%lu proof=%lu",
(unsigned long)s.frames, (unsigned long)s.authenticated, (unsigned long)s.delivered,
(unsigned long)s.parse, (unsigned long)s.pool, (unsigned long)s.hellos, (unsigned long)s.established);
else if (!strcmp(page, "tx")) snprintf(out, capacity, "> req=%lu queued=%lu sent=%lu err=%lu busy=%lu stopped=%lu",
(unsigned long)s.requested, (unsigned long)s.queued, (unsigned long)s.sent,
(unsigned long)s.failed, (unsigned long)s.busy, (unsigned long)s.stopped);
else if (!strcmp(page, "drops")) snprintf(out, capacity, "> bad=%lu auth=%lu foreign=%lu replay=%lu filter=%lu dupe=%lu txfilter=%lu txdupe=%lu big=%lu",
(unsigned long)s.malformed, (unsigned long)s.authentication, (unsigned long)s.foreign, (unsigned long)s.replay,
(unsigned long)s.filtered, (unsigned long)s.duplicate, (unsigned long)s.tx_filtered,
(unsigned long)s.tx_duplicate, (unsigned long)s.oversize);
else if (!strcmp(page, "test")) snprintf(out, capacity, "> queued=%lu sent=%lu fail=%lu rx=%lu txid=%08lX rxid=%08lX",
(unsigned long)s.probe_queued, (unsigned long)s.probe_sent, (unsigned long)s.probe_failed,
(unsigned long)s.probe_received, (unsigned long)s.last_tx_nonce, (unsigned long)s.last_rx_nonce);
else snprintf(out, capacity, "Error: use get udp.stats [rx|tx|drops|test]");
out[capacity - 1] = 0;
}
void UDPBridge::emitDebug() {
if (!mesh::isUsbDebugLoggingEnabled()) { _debug_was_enabled = false; return; }
if (!_debug_was_enabled) _debug_dirty = true;
_debug_was_enabled = true;
const uint32_t now = millis();
if (!_debug_dirty || uint32_t(now - _debug_last_at) < 1000) return;
_debug_last_at = now; _debug_dirty = false;
char status[157]; formatStatus(status, sizeof(status));
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
mesh::nrf52DebugPrintf("UDP: %s rx=%lu mesh=%lu authbad=%lu replay=%lu tx=%lu sent=%lu\n", status + 2,
(unsigned long)_stats.frames, (unsigned long)_stats.delivered, (unsigned long)_stats.authentication,
(unsigned long)_stats.replay, (unsigned long)_stats.queued, (unsigned long)_stats.sent);
#else
char line[256];
const int length = snprintf(line, sizeof(line), "UDP: %s rx=%lu mesh=%lu authbad=%lu replay=%lu tx=%lu sent=%lu\n", status + 2,
(unsigned long)_stats.frames, (unsigned long)_stats.delivered, (unsigned long)_stats.authentication,
(unsigned long)_stats.replay, (unsigned long)_stats.queued, (unsigned long)_stats.sent);
if (length > 0 && size_t(length) < sizeof(line) && mesh::usbLoggingPort().availableForWrite() >= length)
mesh::usbLoggingPort().write(reinterpret_cast<const uint8_t*>(line), length);
#endif
}
#endif
+76
View File
@@ -0,0 +1,76 @@
#pragma once
#if defined(ESP32) && defined(WITH_UDP_BRIDGE)
#include "helpers/bridges/BridgeBase.h"
#include "helpers/bridges/UDPBridgeProtocol.h"
#include "helpers/UDPBridgePrefs.h"
// All methods run on the mesh loop. No WiFi callbacks own packet memory.
// Network ownership belongs to the role; this class never starts/stops WiFi.
class UDPBridge : public BridgeBase {
public:
UDPBridge(NodePrefs* prefs, mesh::PacketManager* mgr, mesh::RTCClock* rtc,
const mesh::bridge::udp::Prefs* settings);
~UDPBridge() override;
void begin() override;
void end() override;
void loop() override;
void serviceNetwork(bool ready, uint32_t local_address = 0, uint32_t netmask = 0);
void sendPacket(mesh::Packet* packet) override;
void onPacketReceived(mesh::Packet* packet) override;
void formatStatus(char* out, size_t capacity) const;
void formatStats(const char* page, char* out, size_t capacity) const;
bool sendTest(uint8_t peer_index, uint32_t nonce);
void resetStats();
private:
const mesh::bridge::udp::Prefs* _settings;
mesh::bridge::udp::Prefs _active{};
mesh::bridge::udp::Session _sessions[mesh::bridge::udp::MAX_PEERS]{};
uint64_t _boot = 0;
uint32_t _sequence = 0, _retry_at = 0;
uint32_t _local_address = 0, _netmask = 0;
int _socket = -1, _last_error = 0;
bool _network_ready = false, _secret_valid = false;
uint8_t _secret[16]{};
uint8_t _secret_length = 0, _hello_cursor = 0;
// One logical fan-out packet; each peer receives at most one send attempt.
uint8_t _tx_payload[mesh::bridge::udp::MAX_PAYLOAD]{};
uint16_t _tx_length = 0;
uint8_t _tx_mask = 0;
mesh::bridge::udp::Kind _tx_kind = mesh::bridge::udp::Kind::Mesh;
uint32_t _tx_sequence = 0;
// One verified receive slot survives packet-pool pressure; socket ingress
// stops until it drains. No packet allocation for probes or invalid frames.
uint8_t _rx_payload[mesh::bridge::udp::MAX_PAYLOAD]{};
uint16_t _rx_length = 0;
struct Stats {
uint32_t frames = 0, authenticated = 0, delivered = 0;
uint32_t malformed = 0, authentication = 0, foreign = 0, replay = 0;
uint32_t filtered = 0, duplicate = 0, pool = 0, parse = 0;
uint32_t requested = 0, queued = 0, sent = 0, failed = 0;
uint32_t busy = 0, stopped = 0, oversize = 0, tx_filtered = 0, tx_duplicate = 0;
uint32_t hellos = 0, challenges = 0, established = 0;
uint32_t probe_queued = 0, probe_sent = 0, probe_failed = 0, probe_received = 0;
uint32_t last_tx_nonce = 0, last_rx_nonce = 0;
} _stats;
bool _debug_dirty = true, _debug_was_enabled = false;
uint32_t _debug_last_at = 0;
static bool authenticate(void* context, const uint8_t* data, size_t size, uint8_t digest[32]);
static uint64_t randomNonce();
uint8_t peerMask(bool established_only) const;
uint32_t nextSequence();
bool sendFrame(uint8_t peer, mesh::bridge::udp::Kind kind, uint64_t recipient,
const uint8_t* payload, uint16_t length, uint32_t sequence = 0);
void receiveFrame(uint8_t peer, const uint8_t* data, size_t length);
void pumpReceive();
void drainReceive();
void pumpTransmit();
void pumpHello();
void emitDebug();
void note(uint32_t& value);
void error(int code);
void onIngressResult(IngressResult result) override;
};
#endif
+104
View File
@@ -0,0 +1,104 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
namespace mesh { namespace bridge { namespace udp {
// A LAN transport, independent of the selected ESP-NOW raw/wrapped format.
// Every field and payload is authenticated. HMAC does not encrypt contents.
static constexpr size_t HEADER_SIZE = 28;
static constexpr size_t TAG_SIZE = 16;
static constexpr size_t MAX_PAYLOAD = 255;
static constexpr size_t MAX_FRAME = HEADER_SIZE + MAX_PAYLOAD + TAG_SIZE;
enum class Kind : uint8_t { Hello = 1, Challenge = 2, Proof = 3, Mesh = 4, Probe = 5 };
using Authenticate = bool (*)(void*, const uint8_t*, size_t, uint8_t digest[32]);
struct Frame {
Kind kind = Kind::Hello;
uint64_t sender = 0, recipient = 0;
uint32_t sequence = 0;
const uint8_t* payload = nullptr;
uint16_t length = 0;
};
inline void put32(uint8_t* out, uint32_t v) {
out[0] = v >> 24; out[1] = v >> 16; out[2] = v >> 8; out[3] = v;
}
inline uint32_t get32(const uint8_t* in) {
return (uint32_t(in[0]) << 24) | (uint32_t(in[1]) << 16)
| (uint32_t(in[2]) << 8) | in[3];
}
inline void put64(uint8_t* out, uint64_t v) { put32(out, v >> 32); put32(out + 4, v); }
inline uint64_t get64(const uint8_t* in) { return (uint64_t(get32(in)) << 32) | get32(in + 4); }
inline bool validShape(const Frame& f) {
if (!f.sender || !f.sequence || f.length > MAX_PAYLOAD || (f.length && !f.payload)) return false;
switch (f.kind) {
case Kind::Hello: return !f.recipient && f.length == 0;
case Kind::Challenge: case Kind::Proof: return f.recipient && f.length == 8 && get64(f.payload);
case Kind::Mesh: return f.recipient && f.length >= 2;
case Kind::Probe: return f.recipient && f.length == 4;
default: return false;
}
}
inline size_t encode(uint8_t* out, size_t capacity, const Frame& f,
Authenticate auth, void* context) {
const size_t size = HEADER_SIZE + f.length + TAG_SIZE;
if (!out || !auth || capacity < size || !validShape(f)) return 0;
memcpy(out, "MCUD", 4); out[4] = 1; out[5] = uint8_t(f.kind);
out[6] = f.length >> 8; out[7] = f.length;
put64(out + 8, f.sender); put64(out + 16, f.recipient); put32(out + 24, f.sequence);
if (f.length) memcpy(out + HEADER_SIZE, f.payload, f.length);
uint8_t digest[32];
if (!auth(context, out, HEADER_SIZE + f.length, digest)) return 0;
memcpy(out + HEADER_SIZE + f.length, digest, TAG_SIZE);
memset(digest, 0, sizeof(digest));
return size;
}
enum class Decode { Ok, Malformed, Authentication };
inline Decode decode(const uint8_t* data, size_t size, Frame& result,
Authenticate auth, void* context) {
if (!data || !auth || size < HEADER_SIZE + TAG_SIZE || size > MAX_FRAME
|| memcmp(data, "MCUD", 4) || data[4] != 1) return Decode::Malformed;
Frame f;
f.kind = Kind(data[5]); f.length = (uint16_t(data[6]) << 8) | data[7];
f.sender = get64(data + 8); f.recipient = get64(data + 16); f.sequence = get32(data + 24);
f.payload = data + HEADER_SIZE;
if (size != HEADER_SIZE + f.length + TAG_SIZE || !validShape(f)) return Decode::Malformed;
uint8_t digest[32];
if (!auth(context, data, HEADER_SIZE + f.length, digest)) return Decode::Authentication;
uint8_t different = 0;
for (size_t i = 0; i < TAG_SIZE; ++i) different |= digest[i] ^ data[HEADER_SIZE + f.length + i];
memset(digest, 0, sizeof(digest));
if (different) return Decode::Authentication;
result = f;
return Decode::Ok;
}
inline bool due(uint32_t now, uint32_t deadline) { return int32_t(now - deadline) >= 0; }
inline void increment(uint32_t& value) { if (value != UINT32_MAX) ++value; }
// Each configured endpoint has its own authenticated receive session. A Hello
// never replaces a live session. Only proof of a fresh local challenge can do
// so, making recorded old sessions unable to flip the receiver backwards.
struct Session {
uint64_t remote = 0, candidate = 0, challenge = 0;
uint32_t received = 0, challenge_deadline = 0, next_hello = 0, challenge_sent_at = 0;
bool request(uint64_t sender, uint64_t nonce, uint32_t now) {
if (!sender || !nonce || (candidate && !due(now, challenge_deadline))) return false;
candidate = sender; challenge = nonce; challenge_deadline = now + 5000; challenge_sent_at = now;
return true;
}
bool prove(uint64_t sender, uint64_t nonce, uint32_t now) {
if (!candidate || sender != candidate || nonce != challenge || due(now, challenge_deadline)) return false;
if (remote != sender) { remote = sender; received = 0; }
candidate = challenge = 0;
return true;
}
bool admit(uint64_t sender, uint64_t recipient, uint64_t local, uint32_t sequence) {
if (!remote || sender != remote || recipient != local || !sequence || sequence <= received) return false;
received = sequence;
return true;
}
};
} } } // namespace mesh::bridge::udp
+1 -1
View File
@@ -7,7 +7,7 @@
// WiFi SDK check when a compiled service can own that driver. Use build flags,
// rather than WITH_WEBCONFIG, which is defined by header inclusion order.
#if defined(WIFI_SSID) || defined(ENABLE_WIFI_INTERFACE) \
|| defined(WITH_MQTT_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) \
|| defined(WITH_MQTT_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) || defined(WITH_UDP_BRIDGE) \
|| (defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW) \
|| defined(WIFI_OTA_SEEDER) || defined(ADMIN_PASSWORD) \
|| defined(COMPANION_RADIO_FULL)
+4
View File
@@ -0,0 +1,4 @@
#pragma once
#include <stdint.h>
inline uint32_t test_now = 0;
inline uint32_t millis() { return test_now; }
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <cstddef>
#include <cstdint>
// Print/Arduino defines these before ordinary firmware headers. Keep them in
// the host build too: a local variable named HEX would otherwise compile here
// yet be expanded to an invalid numeric token on the actual board.
#define HEX 16
#define DEC 10
#define OCT 8
#define BIN 2
uint32_t millis();
class Stream {
public:
virtual ~Stream() = default;
virtual int available() = 0;
virtual int read() = 0;
virtual size_t write(const uint8_t*, size_t) = 0;
virtual int availableForWrite() { return 4096; }
};
+93
View File
@@ -0,0 +1,93 @@
#include <helpers/UDPBridgePrefs.h>
#include <cassert>
#include <string>
#include <initializer_list>
static uint32_t millis() { return 0x11223344; }
struct Callbacks {
virtual ~Callbacks() = default;
CALLBACKS_HERE
};
struct Fake : Callbacks {
mesh::bridge::udp::Prefs prefs;
unsigned writes = 0, statuses = 0, stats = 0, sends = 0, resets = 0;
uint8_t peer = 255;
uint32_t nonce = 0;
bool save = true, available = true;
std::string page;
const mesh::bridge::udp::Prefs* getUDPBridgePrefs() const override { return available ? &prefs : nullptr; }
bool setUDPBridgePrefs(const mesh::bridge::udp::Prefs& value) override {
++writes; if (!save) return false; prefs = value; return true;
}
void formatUDPBridgeStatus(char* reply, size_t capacity) override {
++statuses; assert(capacity == 157); snprintf(reply, capacity, "%s", std::string(300, 'x').c_str());
}
void formatUDPBridgeStats(const char* value, char* reply, size_t capacity) override {
++stats; page = value; snprintf(reply, capacity, "stats");
}
bool sendUDPBridgeTest(uint8_t value, uint32_t id) override { ++sends; peer = value; nonce = id; return true; }
bool resetUDPBridgeStats() override { ++resets; return true; }
};
struct CommonCLI {
Callbacks* _callbacks;
bool handleUDPBridgeCommand(uint32_t, const char*, char*);
};
HANDLER_HERE
int main() {
Fake fake; CommonCLI cli{&fake};
char reply[160];
auto command = [&](const char* value, uint32_t remote = 0) {
memset(reply, 'Z', sizeof(reply));
const bool handled = cli.handleUDPBridgeCommand(remote, value, reply);
assert(reply[157] == 'Z' && reply[158] == 'Z' && reply[159] == 'Z');
if (handled) assert(memchr(reply, 0, 157));
return handled;
};
assert(command("get udp.status", 1) && strlen(reply) == 156);
for (const char* page : {"", "rx", "tx", "drops", "test"}) {
assert(command((std::string("get udp.stats") + (*page ? " " : "") + page).c_str(), 1));
assert(fake.page == page);
}
assert(command("get udp.enabled") && !strcmp(reply, "off"));
assert(command("get udp.port") && !strcmp(reply, "4404"));
for (char index = '1'; index <= '8'; ++index) {
std::string key = "udp.peer."; key += index;
assert(command(("get " + key).c_str()) && !strcmp(reply, "off"));
const std::string value = "192.168.1." + std::to_string(index - '0') + ":4404";
assert(command(("set " + key + " " + value).c_str(), 1) && strstr(reply, "saved"));
assert(command(("get " + key).c_str()) && value == reply);
assert(command(("set " + key + " off").c_str()));
}
assert(command("set udp.enabled on", 5) && fake.prefs.enabled == 1);
assert(command("set udp.port 65535", 5) && fake.prefs.port == 65535);
assert(command("set udp.peer.1 192.168.1.1:4404"));
unsigned writes = fake.writes;
assert(command("set udp.peer.2 192.168.1.1:4404") && strstr(reply, "duplicate") && fake.writes == writes);
for (const char* value : {"get udp.status x", "get udp.enabled ", "get udp.port 1", "get udp.statsrx",
"get udp.stats ", "get udp.stats xxx", "set udp.enabled ON", "set udp.enabled on ",
"set udp.port 0", "set udp.port -1", "set udp.port 65536", "set udp.port 1 ",
"get udp.peer.0", "get udp.peer.9", "get udp.peer.10", "get udp.peer.1 x",
"set udp.peer.1 off ", "set udp.peer.1 127.0.0.1:4404", "set udp.peer.1 224.1.2.3:1",
"udp", "udp test", "udp test 0", "udp test 9", "udp test 10", "udp test 1 ",
"udp test 1 1234567", "udp test 1 123456789", "udp test 1 ABCDEFGH", "udp test 1 12345678 ",
"udp resetstats ", "udp resetstats x"}) {
assert(command(value) && !strncmp(reply, "Error:", 6));
assert(fake.writes == writes && fake.sends == 0 && fake.resets == 0);
}
for (const char* value : {"udptest", "udpx test", "get repeat", "get espnow.status"}) assert(!command(value));
for (const char* value : {"udp test 1", "udp test 8 A1B2C3D4", "udp resetstats"}) {
assert(command(value, 1) && strstr(reply, "local connection"));
assert(fake.sends == 0 && fake.resets == 0);
}
assert(command("udp test 8 a1b2C3d4") && fake.peer == 7 && fake.nonce == 0xA1B2C3D4);
assert(command("udp test 1") && fake.peer == 0 && fake.nonce == millis());
assert(command("udp resetstats") && fake.resets == 1);
fake.save = false; writes = fake.writes;
assert(command("set udp.port 1234") && strstr(reply, "not saved") && fake.writes == writes + 1);
assert(fake.prefs.port == 65535);
fake.available = false;
assert(command("get udp.port") && strstr(reply, "unavailable"));
Callbacks unsupported; cli._callbacks = &unsupported;
assert(command("get udp.status") && strstr(reply, "unavailable"));
assert(command("get udp.stats") && strstr(reply, "unavailable"));
assert(command("udp test 1") && strstr(reply, "not queued"));
}
+4
View File
@@ -0,0 +1,4 @@
#pragma once
#include <stdint.h>
inline uint32_t random_state = 0x1678abcd;
inline uint32_t esp_random() { random_state ^= random_state << 13; random_state ^= random_state >> 17; random_state ^= random_state << 5; return random_state; }
+125
View File
@@ -0,0 +1,125 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <vector>
#if defined(ARDUINO)
#include <Stream.h>
#include <cstdarg>
#include <cstdio>
#endif
#define MAX_TRANS_UNIT 255
#define PAYLOAD_TYPE_OTA 0x0c
#define BRIDGE_DEBUG_PRINTLN(...) ((void)0)
struct NodePrefs { uint8_t bridge_pkt_src = 1; uint32_t bridge_delay = 0; char bridge_secret[16] = "unit-test-key"; };
namespace mesh {
#if defined(ARDUINO)
inline bool logging_enabled = false, debug_enabled = false;
class DebugStream : public Stream {
public:
int capacity = 4096;
unsigned writes = 0;
std::vector<uint8_t> bytes;
int available() override { return 0; }
int read() override { return -1; }
int availableForWrite() override { return capacity; }
size_t write(const uint8_t* data, size_t size) override {
++writes;
if (size > static_cast<size_t>(capacity)) return 0;
bytes.insert(bytes.end(), data, data + size); return size;
}
};
inline DebugStream debug_stream;
inline bool isUsbDebugLoggingEnabled() { return logging_enabled && debug_enabled; }
inline Stream& usbLoggingPort() { return debug_stream; }
inline size_t nrf52DebugPrintf(const char* format, ...) {
if (!isUsbDebugLoggingEnabled()) return 0;
char record[256];
va_list args; va_start(args, format);
int size = vsnprintf(record, sizeof(record), format, args); va_end(args);
if (size <= 0) return 0;
if (size >= static_cast<int>(sizeof(record))) size = sizeof(record) - 1;
return debug_stream.write(reinterpret_cast<const uint8_t*>(record), size);
}
#endif
struct Packet {
std::vector<uint8_t> payload{1, 2, 3};
bool allowed = true; bool udp_bridge_ingress = false;
uint8_t getPayloadType() const { return payload.empty() ? 0 : (payload[0] >> 2) & 0x0f; }
int getRawLength() const { return static_cast<int>(payload.size()); }
uint16_t writeTo(uint8_t* out) const {
for (size_t i = 0; i < payload.size(); ++i) out[i] = payload[i];
return static_cast<uint16_t>(payload.size());
}
bool readFrom(const uint8_t* bytes, size_t size) {
if (size < 2 || size > MAX_TRANS_UNIT || bytes[0] == 0xff) return false;
udp_bridge_ingress = false;
payload.assign(bytes, bytes + size); return true;
}
};
struct RTCClock {};
struct PacketManager {
bool starved = false;
unsigned allocations = 0, frees = 0;
std::vector<std::vector<uint8_t>> received;
Packet* allocNew() {
++allocations;
return starved ? nullptr : new Packet;
}
void free(Packet* packet) { ++frees; delete packet; }
};
}
struct SeenPackets {
std::vector<std::vector<uint8_t>> seen;
bool wasSeen(const mesh::Packet* packet) const {
for (const auto& bytes : seen) if (bytes == packet->payload) return true;
return false;
}
void markSeen(mesh::Packet* packet) { seen.push_back(packet->payload); }
};
class BridgeBase {
public:
static constexpr uint16_t BRIDGE_PACKET_MAGIC = 0xC03E;
static constexpr uint16_t BRIDGE_MAGIC_SIZE = 2, BRIDGE_LENGTH_SIZE = 2, BRIDGE_CHECKSUM_SIZE = 2;
virtual ~BridgeBase() = default;
virtual void begin() = 0;
virtual void end() = 0;
virtual void loop() = 0;
virtual void sendPacket(mesh::Packet*) = 0;
virtual void onPacketReceived(mesh::Packet*) = 0;
bool isRunning() const { return _initialized; }
size_t seenCount() const { return _seen_packets.seen.size(); }
protected:
enum class IngressResult : uint8_t { Stopped, Filtered, Duplicate, Queued };
virtual void onIngressResult(IngressResult) {}
bool _initialized = false;
NodePrefs* _prefs;
mesh::PacketManager* _mgr;
mesh::RTCClock* _rtc;
SeenPackets _seen_packets;
BridgeBase(NodePrefs* prefs, mesh::PacketManager* mgr, mesh::RTCClock* rtc)
: _prefs(prefs), _mgr(mgr), _rtc(rtc) {}
bool allowsPacket(const mesh::Packet* packet) { return packet->allowed; }
static uint16_t fletcher16(const uint8_t* bytes, size_t size) {
uint16_t a = 0, b = 0;
for (size_t i = 0; i < size; ++i) { a = (a + bytes[i]) % 255; b = (b + a) % 255; }
return (b << 8) | a;
}
bool validateChecksum(const uint8_t* bytes, size_t size, uint16_t checksum) {
return fletcher16(bytes, size) == checksum;
}
void handleReceivedPacket(mesh::Packet* packet) {
if (!allowsPacket(packet)) {
onIngressResult(IngressResult::Filtered); _mgr->free(packet); return;
}
if (packet->getPayloadType() != PAYLOAD_TYPE_OTA && _seen_packets.wasSeen(packet)) {
onIngressResult(IngressResult::Duplicate); _mgr->free(packet); return;
}
if (packet->getPayloadType() != PAYLOAD_TYPE_OTA) _seen_packets.markSeen(packet);
onIngressResult(IngressResult::Queued);
_mgr->received.push_back(packet->payload); _mgr->free(packet);
}
};
+69
View File
@@ -0,0 +1,69 @@
#pragma once
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <cstdarg>
#include <vector>
#include <deque>
#include <cstring>
namespace udp_test {
struct Datagram { sockaddr_in source{}, target{}; std::vector<uint8_t> bytes; };
struct Socket { bool open = false; uint32_t ip = 0; uint16_t port = 0; std::deque<Datagram> rx; };
inline Socket sockets[64];
inline int next_socket = 0, opens = 0, receives = 0, sends = 0, receive_cap = 0;
inline uint32_t creating_ip = 0;
inline bool fail_send = false, fail_rcvbuf = false;
inline std::vector<Datagram> sent;
inline void inject(int index, const Datagram& packet) { sockets[index].rx.push_back(packet); }
}
inline int udpTestSocket(int, int, int) {
const int fd = udp_test::next_socket++;
if (fd >= 64) { errno = EMFILE; return -1; }
udp_test::sockets[fd] = {}; udp_test::sockets[fd].open = true;
udp_test::sockets[fd].ip = udp_test::creating_ip; ++udp_test::opens;
return fd;
}
inline int udpTestClose(int fd) { udp_test::sockets[fd].open = false; udp_test::sockets[fd].rx.clear(); return 0; }
inline int udpTestFcntl(int, int, ...) { return 0; }
inline int udpTestSetsockopt(int, int, int, const void* value, socklen_t) {
if (udp_test::fail_rcvbuf) { errno = ENOPROTOOPT; return -1; }
udp_test::receive_cap = *static_cast<const int*>(value); return 0;
}
inline int udpTestBind(int fd, const sockaddr* address, socklen_t) {
udp_test::sockets[fd].port = reinterpret_cast<const sockaddr_in*>(address)->sin_port; return 0;
}
inline int udpTestSendto(int fd, const void* bytes, size_t size, int, const sockaddr* address, socklen_t) {
++udp_test::sends;
if (udp_test::fail_send) { udp_test::fail_send = false; errno = ENOMEM; return -1; }
udp_test::Datagram message;
message.source.sin_family = AF_INET; message.source.sin_port = udp_test::sockets[fd].port;
message.source.sin_addr.s_addr = udp_test::sockets[fd].ip;
message.target = *reinterpret_cast<const sockaddr_in*>(address);
message.bytes.assign(static_cast<const uint8_t*>(bytes), static_cast<const uint8_t*>(bytes) + size);
udp_test::sent.push_back(message);
for (auto& socket : udp_test::sockets)
if (socket.open && socket.ip == message.target.sin_addr.s_addr && socket.port == message.target.sin_port) {
size_t queued = 0; for (const auto& item : socket.rx) queued += item.bytes.size();
if (socket.rx.size() < 6 && queued + size <= 768) socket.rx.push_back(message);
}
return static_cast<int>(size);
}
inline int udpTestRecvfrom(int fd, void* out, size_t size, int, sockaddr* address, socklen_t* address_size) {
++udp_test::receives;
auto& queue = udp_test::sockets[fd].rx;
if (queue.empty()) { errno = EAGAIN; return -1; }
const auto message = queue.front(); queue.pop_front();
const size_t count = message.bytes.size() < size ? message.bytes.size() : size;
memcpy(out, message.bytes.data(), count);
memcpy(address, &message.source, sizeof(message.source)); *address_size = sizeof(message.source);
return static_cast<int>(count);
}
#define socket udpTestSocket
#define close udpTestClose
#define fcntl udpTestFcntl
#define setsockopt udpTestSetsockopt
#define bind udpTestBind
#define sendto udpTestSendto
#define recvfrom udpTestRecvfrom
+58
View File
@@ -0,0 +1,58 @@
#include <helpers/UDPBridgePrefs.h>
#include <cassert>
#include <cstring>
#include <cstdio>
struct Callbacks {
mesh::bridge::udp::Prefs prefs;
bool network = true;
unsigned closes = 0, resumes = 0;
bool isUDPBridgeNetworkEnabled() const { return network; }
bool setUDPBridgeNetworkState(bool enabled) { network = enabled; if (enabled) ++resumes; else ++closes; return true; }
const mesh::bridge::udp::Prefs* getUDPBridgePrefs() const { return &prefs; }
} callbacks;
struct Board {
bool running = false, allow_start = true, allow_stop = true;
unsigned starts = 0;
bool isOTAUpdateRunning() const { return running; }
bool startOTAUpdate(const char*, char* reply, bool) {
assert(!callbacks.network); ++starts;
if (allow_start) running = true;
strcpy(reply, allow_start ? "Started" : "Failed"); return allow_start;
}
bool stopOTAUpdate(char* reply) { if (allow_stop) running = false; strcpy(reply, "Stopped"); return allow_stop; }
} board;
struct NodePrefs { const char* node_name = "test"; } prefs;
struct CLI {
Board* _board = &board;
NodePrefs* _prefs = &prefs;
Callbacks* _callbacks = &callbacks;
bool _wifi_ota_resume_udp = false;
void run(const char* command, char* reply) {
if (false) {
OTA_HERE
}
}
};
int main() {
callbacks.prefs.enabled = 1;
CLI cli; char reply[160] = {};
cli.run("start ota invalid", reply); assert(board.starts == 0 && callbacks.closes == 0);
board.allow_start = false;
cli.run("start ota", reply);
assert(callbacks.closes == 1 && callbacks.resumes == 1 && callbacks.network && !cli._wifi_ota_resume_udp);
board.allow_start = true;
cli.run("start ota ap", reply);
assert(!callbacks.network && cli._wifi_ota_resume_udp);
cli.run("start ota ap", reply); // Repeating a start retains the original pause.
board.allow_stop = false;
cli.run("stop ota", reply); assert(!callbacks.network && cli._wifi_ota_resume_udp);
board.allow_stop = true;
cli.run("stop ota", reply); assert(callbacks.network && !cli._wifi_ota_resume_udp);
cli.run("start ota", reply); callbacks.prefs.enabled = 0;
cli.run("stop ota", reply); assert(!callbacks.network && !cli._wifi_ota_resume_udp);
callbacks.prefs.enabled = 1;
const unsigned resumes = callbacks.resumes;
cli.run("stop ota", reply); assert(!callbacks.network && callbacks.resumes == resumes);
cli.run("start ota", reply); cli.run("stop ota", reply);
assert(!callbacks.network && callbacks.resumes == resumes); // An initially stopped session remains stopped.
}
+102
View File
@@ -0,0 +1,102 @@
#include <helpers/UDPBridgeSettings.h>
#include <cassert>
#include <vector>
#include <initializer_list>
using namespace mesh::bridge::udp;
using Bytes = std::vector<uint8_t>;
constexpr char Path[] = "/udp_bridge";
Bytes image(const Prefs& prefs) {
Bytes result(PREFS_IMAGE_SIZE); encodePrefs(result.data(), prefs); return result;
}
Prefs configured() {
Prefs prefs; prefs.enabled = 1;
assert(parsePeer("192.168.1.22:4404", prefs.peers[0]));
assert(parsePeer("10.2.3.4:65535", prefs.peers[7]));
return prefs;
}
int main() {
Prefs defaults; assert(validPrefs(defaults) && defaults.port == 4404 && !defaults.enabled);
Peer peer;
assert(parsePeer("192.168.1.10:1", peer));
const Peer before = peer;
for (const char* value : {"", "off ", "0.1.2.3:1", "127.1.2.3:2", "224.1.2.3:1",
"255.255.255.255:3", "192.168.1.2", "1.2.3.4:0", "1.2.3.4:65536", "1.2.3.4:-1",
"1.2.3.4:123456789123456789123456789", "1.2.3.4:1 ", "1.2.3.4:1x", "1.2.3.4:1:2",
"1.2.3:1", "1.2.3.256:1", "999999999999999999999.1.2.3:2", "a.b.c.d:2", "1..3.4:2"}) {
assert(!parsePeer(value, peer) && equalPeer(peer, before));
}
char text[24]; formatPeer(peer, text, sizeof(text)); assert(!strcmp(text, "192.168.1.10:1"));
assert(parsePeer("off", peer) && !peer.enabled());
formatPeer(peer, text, sizeof(text)); assert(!strcmp(text, "off"));
Prefs good = configured(), loaded;
assert(decodePrefs(image(good).data(), PREFS_IMAGE_SIZE, loaded));
assert(image(loaded) == image(good));
for (size_t size = 0; size <= 128; ++size) {
if (size == PREFS_IMAGE_SIZE) continue;
Bytes bad = image(good); bad.resize(size);
loaded = defaults;
assert(!decodePrefs(bad.data(), bad.size(), loaded) && image(loaded) == image(defaults));
}
for (size_t byte = 0; byte < PREFS_IMAGE_SIZE; ++byte) {
Bytes bad = image(good); bad[byte] ^= 1;
assert(!decodePrefs(bad.data(), bad.size(), loaded));
}
Prefs invalid = good; invalid.peers[1] = invalid.peers[0]; assert(!validPrefs(invalid));
invalid = defaults; invalid.peers[0].ip[0] = 1; assert(!validPrefs(invalid));
invalid = defaults; invalid.enabled = 2; assert(!validPrefs(invalid));
invalid = defaults; invalid.port = 0; assert(!validPrefs(invalid));
GuestMemoryFS fs;
fs.files["/com_prefs"] = {1, 2, 3}; // The existing binary image is never rewritten.
Settings store;
assert(store.begin(&fs) && store.healthy() && !store.prefs().enabled && fs.writes == 0);
assert(store.setPrefs(good) && image(store.prefs()) == image(good));
assert(fs.files.at("/com_prefs") == Bytes({1, 2, 3}));
Settings reboot; assert(reboot.begin(&fs) && image(reboot.prefs()) == image(good));
for (unsigned fault = 0; fault < 10; ++fault) {
GuestMemoryFS faulty; faulty.files[Path] = image(good);
Settings current; assert(current.begin(&faulty));
switch (fault) {
case 0: faulty.fail_open_write = true; break;
case 1: faulty.short_write = 5; break;
case 2: faulty.corrupt_stage = true; break;
case 3: faulty.fail_stage_open = true; break;
case 4: faulty.fail_stage_read = true; break;
case 5: faulty.fail_publish = true; break;
case 6: faulty.false_publish = true; break;
case 7: faulty.fail_backup = true; break;
case 8: faulty.files["/udp_bridge.bak"] = image(defaults); faulty.fail_remove_backup = true; break;
case 9: faulty.files["/udp_bridge.tmp"] = image(defaults); faulty.fail_remove_temp = true; break;
}
assert(!current.setPrefs(defaults));
assert(image(current.prefs()) == image(good) && faulty.files.at(Path) == image(good));
GuestMemoryFS after; after.files = faulty.files;
Settings restored; assert(restored.begin(&after) && image(restored.prefs()) == image(good));
}
for (size_t size : {size_t(0), size_t(1), size_t(63), size_t(65), size_t(128)}) {
GuestMemoryFS broken; broken.files[Path] = image(good); broken.files[Path].resize(size);
Settings current; assert(!current.begin(&broken) && !current.prefs().enabled && current.healthy());
assert(current.setPrefs(defaults));
}
GuestMemoryFS uncertain; uncertain.files[Path] = image(good);
Settings previous; assert(previous.begin(&uncertain)); uncertain.fail_public_read = true;
assert(!previous.setPrefs(defaults) && !previous.healthy() && image(previous.prefs()) == image(good));
assert(!previous.setPrefs(good));
uncertain.fail_public_read = false;
assert(previous.begin(&uncertain) && !previous.prefs().enabled);
GuestMemoryFS interrupted; interrupted.files[Path] = image(good);
Settings writer; assert(writer.begin(&interrupted)); interrupted.capture = true;
assert(writer.setPrefs(defaults));
const auto snapshots = interrupted.snapshots;
assert(snapshots.size() > 10);
for (const auto& snapshot : snapshots) {
GuestMemoryFS after; after.files = snapshot;
Settings recovered; assert(recovered.begin(&after));
assert(image(recovered.prefs()) == image(good) || image(recovered.prefs()) == image(defaults));
}
GuestMemoryFS staged; staged.files["/udp_bridge.tmp"] = image(good);
Settings scratch; assert(scratch.begin(&staged) && !scratch.prefs().enabled);
GuestMemoryFS unreadable; unreadable.files[Path] = image(good); unreadable.fail_metadata = true;
Settings unavailable; assert(!unavailable.begin(&unreadable) && !unavailable.healthy());
assert(!unavailable.setPrefs(defaults));
}
+245
View File
@@ -0,0 +1,245 @@
#include <cassert>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <vector>
#include <memory>
#include "helpers/bridges/BridgeBase.h"
#include "Arduino.h"
#include "lwip/sockets.h"
#include "helpers/bridges/UDPBridge.h"
#include <SHA256.h>
namespace udp = mesh::bridge::udp;
static bool hmac(void* context, const uint8_t* bytes, size_t size, uint8_t digest[32]) {
const char* key = static_cast<const char*>(context);
SHA256 sha; sha.resetHMAC(reinterpret_cast<const uint8_t*>(key), strlen(key));
static const uint8_t domain[] = "MeshCore UDP bridge v1";
sha.update(domain, sizeof(domain)); sha.update(bytes, size);
sha.finalizeHMAC(reinterpret_cast<const uint8_t*>(key), strlen(key), digest, 32); return true;
}
static uint32_t address(uint8_t last) { uint8_t bytes[] = {10, 11, 12, last}; uint32_t value; memcpy(&value, bytes, 4); return value; }
static udp::Peer peer(uint8_t last) { udp::Peer value; value.ip[0] = 10; value.ip[1] = 11; value.ip[2] = 12; value.ip[3] = last; value.port = 4404; return value; }
struct Node {
NodePrefs prefs; mesh::PacketManager manager; mesh::RTCClock rtc; udp::Prefs config; UDPBridge bridge;
uint8_t id; int fd = -1;
Node(uint8_t number, uint8_t other) : bridge(&prefs, &manager, &rtc, &config), id(number) {
config.enabled = 1; config.peers[0] = peer(other);
}
void start() { udp_test::creating_ip = address(id); fd = udp_test::next_socket; bridge.serviceNetwork(true, address(id), 0x00ffffff); }
std::string status() { char value[157]; bridge.formatStatus(value, sizeof(value)); return value; }
std::string stats(const char* page) { char value[157]; bridge.formatStats(page, value, sizeof(value)); return value; }
};
static void run(Node& a, Node& b, unsigned count = 60) {
for (unsigned i = 0; i < count; ++i) { test_now += 100; a.bridge.loop(); b.bridge.loop(); }
}
static udp_test::Datagram lastKind(udp::Kind kind, uint8_t sender) {
for (auto it = udp_test::sent.rbegin(); it != udp_test::sent.rend(); ++it)
if (it->source.sin_addr.s_addr == address(sender) && it->bytes[5] == uint8_t(kind)) return *it;
assert(false); return {};
}
static void codec() {
const char* key = "unit-test-key"; uint8_t payload[255]; memset(payload, 7, sizeof(payload));
uint8_t bytes[udp::MAX_FRAME + 1];
udp::Frame frame{udp::Kind::Mesh, 17, 29, 1, payload, sizeof(payload)};
const size_t size = udp::encode(bytes, sizeof(bytes), frame, hmac, const_cast<char*>(key));
assert(size == 299);
// Independently calculated with Python hmac/hashlib, not this codec.
static const uint8_t expected_tag[] = {0x98,0xee,0xa8,0xf4,0x80,0x8e,0x94,0x55,
0x73,0x63,0xf1,0xf3,0xdb,0xbe,0xac,0x29};
assert(!memcmp(bytes + size - udp::TAG_SIZE, expected_tag, sizeof(expected_tag)));
udp::Frame decoded;
assert(udp::decode(bytes, size, decoded, hmac, const_cast<char*>(key)) == udp::Decode::Ok);
assert(decoded.length == 255 && decoded.sender == 17 && decoded.recipient == 29);
for (size_t i = 0; i < size; ++i) {
bytes[i] ^= 1;
udp::Frame untouched; untouched.sequence = 789;
assert(udp::decode(bytes, size, untouched, hmac, const_cast<char*>(key)) != udp::Decode::Ok);
assert(untouched.sequence == 789); bytes[i] ^= 1;
}
for (size_t n = 0; n < size; ++n)
assert(udp::decode(bytes, n, decoded, hmac, const_cast<char*>(key)) != udp::Decode::Ok);
assert(udp::decode(bytes, size + 1, decoded, hmac, const_cast<char*>(key)) != udp::Decode::Ok);
assert(udp::decode(bytes, size, decoded, hmac, const_cast<char*>("other-key")) == udp::Decode::Authentication);
assert(!udp::encode(bytes, size - 1, frame, hmac, const_cast<char*>(key)));
udp::Session session;
assert(session.request(11, 77, 0)); assert(!session.prove(11, 78, 1)); assert(session.prove(11, 77, 2));
assert(session.admit(11, 33, 33, 100)); assert(!session.admit(11, 33, 33, 100));
assert(!session.admit(11, 33, 33, 99)); assert(!session.admit(11, 34, 33, 101));
assert(session.request(22, 88, 3)); assert(!session.prove(22, 77, 4));
assert(session.remote == 11 && session.received == 100);
assert(session.prove(22, 88, 5)); assert(session.remote == 22 && !session.received);
assert(session.request(11, 99, 6)); assert(!session.prove(11, 77, 7));
assert(session.remote == 22); // Recorded retired sessions cannot flip it back.
assert(session.prove(11, 99, 8)); assert(session.admit(11, 33, 33, 5));
assert(session.request(11, 42, 9)); assert(session.prove(11, 42, 10)); assert(session.received == 5);
assert(session.request(22, 123, 11)); assert(!session.prove(22, 123, 5011));
uint32_t saturated = UINT32_MAX; udp::increment(saturated); assert(saturated == UINT32_MAX);
}
static void isolation() {
Node a(1,2), b(2,1); a.start(); b.start();
assert(udp_test::receive_cap == 768); assert(!a.bridge.sendTest(0, 1));
run(a,b); assert(a.status().find("ready=1") != std::string::npos);
assert(b.status().find("ready=1") != std::string::npos);
const unsigned allocations = b.manager.allocations;
assert(a.bridge.sendTest(0,0xa1b2c3d4)); run(a,b,5);
assert(b.stats("test").find("rx=1") != std::string::npos);
assert(b.stats("test").find("rxid=A1B2C3D4") != std::string::npos);
assert(b.manager.allocations == allocations && b.manager.received.empty());
const auto packet = lastKind(udp::Kind::Probe,1);
const auto before = udp_test::sent.size(); udp_test::inject(b.fd,packet); b.bridge.loop();
assert(udp_test::sent.size() == before); assert(b.stats("test").find("rx=1") != std::string::npos);
assert(b.stats("drops").find("replay=1") != std::string::npos);
auto forged = packet; forged.bytes.back() ^= 1; udp_test::inject(b.fd,forged); b.bridge.loop();
assert(b.stats("drops").find("auth=1") != std::string::npos);
forged = packet; forged.source.sin_port = htons(4405); udp_test::inject(b.fd,forged); b.bridge.loop();
assert(b.stats("drops").find("foreign=1") != std::string::npos);
assert(b.manager.allocations == allocations);
}
static void recovery() {
Node a(1,2), b(2,1); a.start(); b.start(); run(a,b);
assert(a.bridge.sendTest(0,1)); run(a,b,5); const auto old = lastKind(udp::Kind::Probe,1);
const auto old_hello = lastKind(udp::Kind::Hello,1);
const auto old_proof = lastKind(udp::Kind::Proof,1);
a.bridge.end(); a.start(); run(a,b,120);
assert(a.bridge.sendTest(0,2)); run(a,b,5);
assert(b.stats("test").find("rx=2") != std::string::npos);
udp_test::inject(b.fd,old); udp_test::inject(b.fd,old_hello); b.bridge.loop();
udp_test::inject(b.fd,old_proof); b.bridge.loop();
assert(b.stats("test").find("rx=2") != std::string::npos);
assert(a.bridge.sendTest(0,3)); run(a,b,5);
assert(b.stats("test").find("rx=3") != std::string::npos);
// A fresh receiver boot rejects all recorded old-recipient frames.
b.bridge.end(); b.start(); run(a,b,120);
udp_test::inject(b.fd,old); b.bridge.loop();
assert(b.stats("test").find("rx=3") != std::string::npos);
assert(a.bridge.sendTest(0,4)); run(a,b,5);
assert(b.stats("test").find("rx=4") != std::string::npos);
}
static void queues() {
Node a(1,2), b(2,1); a.start(); b.start(); run(a,b);
mesh::Packet packet; packet.payload = {1,0,3,4};
a.bridge.sendPacket(&packet); a.bridge.sendPacket(&packet); run(a,b,5);
assert(b.manager.received.size() == 1);
assert(a.stats("tx").find("queued=1") != std::string::npos);
// Same payload received through LoRa is deduplicated; received UDP packets
// are marked locally and cannot re-export even for OTA.
packet.udp_bridge_ingress = true; packet.payload[0] = PAYLOAD_TYPE_OTA << 2;
a.bridge.sendPacket(&packet); run(a,b,2); assert(b.manager.received.size() == 1);
packet.udp_bridge_ingress = false;
b.manager.starved = true;
a.bridge.sendPacket(&packet); run(a,b,3);
const unsigned held = b.manager.allocations; assert(held > 1 && b.manager.received.size() == 1);
const int reads = udp_test::receives; b.bridge.loop();
assert(udp_test::receives == reads); // Held authenticated bytes own the RX slot.
b.manager.starved = false; b.bridge.loop(); assert(b.manager.received.size() == 2);
// Failed UDP send is final, never automatically retried.
packet.payload[3]++; a.bridge.sendPacket(&packet); udp_test::fail_send = true;
a.bridge.loop(); const int sends = udp_test::sends; run(a,b,2);
assert(udp_test::sends == sends); assert(a.stats("tx").find("err=1") != std::string::npos);
// Unauthenticated noise, including oversized frames, never borrows the pool.
auto noise = lastKind(udp::Kind::Mesh,1); noise.bytes.assign(600,0xaa);
const unsigned allocations = b.manager.allocations; udp_test::inject(b.fd,noise); b.bridge.loop();
assert(b.manager.allocations == allocations);
// A valid maximum-size HMAC prefix followed by extra bytes must not become
// a valid datagram when recvfrom truncates the oversized UDP payload.
uint8_t payload[255]{}; payload[0] = 1;
udp::Frame shape{udp::Kind::Mesh,1,2,1,payload,sizeof(payload)};
uint8_t encoded[udp::MAX_FRAME];
const auto size = udp::encode(encoded,sizeof(encoded),shape,hmac,a.prefs.bridge_secret);
noise.bytes.assign(encoded,encoded+size); noise.bytes.push_back(0); noise.bytes.resize(600,0);
udp_test::inject(b.fd,noise); b.bridge.loop(); assert(b.manager.allocations == allocations);
packet.allowed = false; packet.payload[3]++; a.bridge.sendPacket(&packet);
assert(a.stats("drops").find("txfilter=2") != std::string::npos);
}
static void fanout() {
Node source(1,2); std::vector<std::unique_ptr<Node>> peers;
for (uint8_t i = 0; i < udp::MAX_PEERS; ++i) {
source.config.peers[i] = peer(i+2);
peers.emplace_back(new Node(i+2,1)); peers.back()->start();
}
source.start();
auto cycle = [&]() { test_now += 100; source.bridge.loop(); for (auto& peer : peers) peer->bridge.loop(); };
for (unsigned i=0;i<100;++i) cycle();
assert(source.status().find("ready=8") != std::string::npos);
mesh::Packet packet; packet.payload = {1,0,12}; source.bridge.sendPacket(&packet);
// New data cannot overwrite the bytes already owned by the fan-out slot.
packet.payload[2]++; source.bridge.sendPacket(&packet);
assert(source.stats("tx").find("busy=1") != std::string::npos);
for (unsigned i=0;i<10;++i) cycle();
for (auto& peer : peers) assert(peer->manager.received.size() == 1 && peer->manager.received[0][2] == 12);
assert(source.stats("tx").find("sent=8") != std::string::npos);
assert(source.bridge.sendTest(7,0x76543210));
for (unsigned i=0;i<4;++i) cycle();
for (unsigned i=0;i<8;++i) {
assert(peers[i]->stats("test").find(i==7 ? "rx=1" : "rx=0") != std::string::npos);
assert(peers[i]->manager.received.size() == 1);
}
}
static void rotation() {
Node a(1,2), b(2,1); a.start(); b.start(); run(a,b);
assert(a.bridge.sendTest(0,1)); run(a,b,5);
const auto recorded = lastKind(udp::Kind::Probe,1);
assert(a.bridge.sendTest(0,2)); // Old-key queued probe must be canceled.
strcpy(a.prefs.bridge_secret,"changed-key");
udp_test::creating_ip=address(a.id); a.bridge.serviceNetwork(true,address(a.id),0x00ffffff); a.fd=udp_test::next_socket-1;
run(a,b,60);
assert(b.stats("test").find("rx=1") != std::string::npos);
assert(a.status().find("ready=0") != std::string::npos);
strcpy(b.prefs.bridge_secret,"changed-key");
udp_test::creating_ip=address(b.id); b.bridge.serviceNetwork(true,address(b.id),0x00ffffff); b.fd=udp_test::next_socket-1; run(a,b,120);
assert(a.status().find("ready=1") != std::string::npos);
assert(a.bridge.sendTest(0,3)); run(a,b,5);
assert(b.stats("test").find("rx=2") != std::string::npos);
const auto allocations = b.manager.allocations;
b.bridge.resetStats();
udp_test::inject(b.fd,recorded); b.bridge.loop();
assert(b.stats("test").find("rx=0") != std::string::npos && b.manager.allocations == allocations);
assert(b.stats("drops").find("auth=1") != std::string::npos);
assert(a.bridge.sendTest(0,4)); a.prefs.bridge_secret[0]=0;
udp_test::creating_ip=address(a.id); a.bridge.serviceNetwork(true,address(a.id),0x00ffffff); a.fd=udp_test::next_socket-1;
assert(!a.bridge.isRunning()); run(a,b,5);
assert(b.stats("test").find("rx=0") != std::string::npos);
}
static void network() {
Node a(1,2), b(2,1);
a.bridge.begin(); a.bridge.serviceNetwork(false); assert(!a.bridge.isRunning() && !udp_test::opens);
a.start(); b.start(); run(a,b);
assert(a.bridge.sendTest(0,1)); a.bridge.serviceNetwork(false);
assert(!a.bridge.isRunning()); a.bridge.loop(); assert(a.stats("test").find("sent=0") != std::string::npos);
a.start(); run(a,b,120); assert(a.status().find("ready=1") != std::string::npos);
a.bridge.end(); a.prefs.bridge_secret[0] = 0; const int opened = udp_test::opens;
a.start(); assert(!a.bridge.isRunning() && udp_test::opens == opened);
strcpy(a.prefs.bridge_secret,"unit-test-key");
test_now += 5000; udp_test::fail_rcvbuf = true; a.start();
assert(!a.bridge.isRunning()); udp_test::fail_rcvbuf = false;
a.config.peers[0] = peer(255); test_now += 5000; a.start(); assert(!a.bridge.isRunning());
a.config.peers[0] = peer(1); test_now += 5000; a.start(); assert(!a.bridge.isRunning());
a.config.peers[0] = peer(2); test_now = 0xf0000000; a.bridge.serviceNetwork(false); a.start();
assert(a.bridge.isRunning()); const auto sends = udp_test::sends; test_now += 1000; a.bridge.loop(); assert(udp_test::sends > sends);
}
static void bounds() {
Node a(1,2), b(2,1); a.start(); b.start(); run(a,b);
for (const char* page : {"", "rx", "tx", "drops", "test", "unknown"}) {
for (size_t capacity = 1; capacity <= 157; ++capacity) {
char bytes[160]; memset(bytes,0x55,sizeof(bytes));
a.bridge.formatStats(page,bytes + 1,capacity);
assert(bytes[0] == 0x55 && bytes[capacity+1] == 0x55 && bytes[capacity] == 0);
a.bridge.formatStatus(bytes + 1,capacity);
assert(bytes[0] == 0x55 && bytes[capacity+1] == 0x55 && bytes[capacity] == 0);
}
}
mesh::logging_enabled = true; mesh::debug_enabled = false;
test_now += 1000; a.bridge.loop(); assert(!mesh::debug_stream.writes);
mesh::debug_enabled = true; a.bridge.loop(); assert(mesh::debug_stream.writes == 1);
const auto bytes = std::string(mesh::debug_stream.bytes.begin(),mesh::debug_stream.bytes.end());
assert(bytes.find("unit-test-key") == std::string::npos);
const unsigned count = mesh::debug_stream.writes; a.bridge.loop(); assert(mesh::debug_stream.writes == count);
a.bridge.resetStats(); assert(a.stats("test").find("rx=0") != std::string::npos);
}
int main(int argc, char** argv) {
assert(argc == 2); const std::string name = argv[1];
if (name == "codec") codec(); else if (name == "isolation") isolation();
else if (name == "recovery") recovery(); else if (name == "queues") queues();
else if (name == "rotation") rotation(); else if (name == "network") network(); else if (name == "fanout") fanout(); else if (name == "bounds") bounds(); else assert(false);
printf("%s passed\n",argv[1]);
}
+5 -4
View File
@@ -10,7 +10,7 @@ ROOT = Path(__file__).resolve().parents[1]
class BridgeDebugFormatterTests(unittest.TestCase):
def test_rs232_only_native_usb_has_bounded_runtime_gated_output(self):
def test_bridge_only_native_usb_has_bounded_runtime_gated_output(self):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler)
header = (ROOT / "src/MeshCore.h").read_text(encoding="ascii")
@@ -68,15 +68,16 @@ int main() {
source = work / "main.cpp"
source.write_text(harness, encoding="ascii")
for name, flags in (
("nrf52", ["-DNRF52_PLATFORM=1"]),
("esp32", ["-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=1"]),
("nrf52-rs232", ["-DNRF52_PLATFORM=1", "-DWITH_RS232_BRIDGE=1"]),
("esp32-rs232", ["-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=1", "-DWITH_RS232_BRIDGE=1"]),
("esp32-udp", ["-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=1", "-DWITH_UDP_BRIDGE=1"]),
):
with self.subTest(platform=name):
binary = work / name
built = subprocess.run([
compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie", "-DARDUINO=1", "-DWITH_RS232_BRIDGE=1",
"-fno-pie", "-no-pie", "-DARDUINO=1",
"-DMESH_DEBUG=0", "-DBRIDGE_DEBUG=0", "-DPOWERSAVING_DEBUG=0",
*flags, "-I" + str(work), str(source), "-o", str(binary),
], capture_output=True, text=True, timeout=30)
+1 -1
View File
@@ -139,7 +139,7 @@ def code():
"int esp_now_deinit() { assert(espnow_active); if(failure==18)return -1; espnow_active=false; return ESP_OK; }")
base = (ROOT / "src/helpers/bridges/BridgeBase.cpp").read_text()
base_header = (ROOT / "src/helpers/bridges/BridgeBase.h").read_text()
ingress_hook = "#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE)\n"
ingress_hook = "#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE) || defined(WITH_UDP_BRIDGE)\n"
ingress_hook += method(base_header, "enum class IngressResult") + ";\n"
ingress_hook += method(base_header, "virtual void onIngressResult(") + "\n#endif\n"
base_methods = "\n".join(method(base, signature) for signature in (
+25
View File
@@ -1364,6 +1364,23 @@ apply_esp32_full_shared_bridge_profile Station_G2_repeater_observer_mqtt
|| fail "Station G2 Full MQTT image did not report ESP-NOW capability"
[[ "$PLATFORMIO_BUILD_FLAGS" != *"ESPNOW_BRIDGE_MERGED"* ]] \
|| fail "Station G2 existing MQTT default changed during ESP-NOW composition"
[[ "$PLATFORMIO_BUILD_FLAGS" == *"-DWITH_UDP_BRIDGE=1"* ]] \
|| fail "Station G2 Full repeater did not enable UDP bridge support"
[[ "$PLATFORMIO_BUILD_SRC_FILTER" == *"helpers/bridges/UDPBridge.cpp"* \
&& "$PLATFORMIO_BUILD_SRC_FILTER" == *"helpers/UDPBridgeSettings.cpp"* ]] \
|| fail "Station G2 Full repeater did not include UDP bridge and settings sources"
[[ " ${BUILD_CAPABILITIES[*]} " == *" bridge.udp "* ]] \
|| fail "Station G2 Full repeater omitted the UDP capability"
(
PLATFORMIO_BUILD_FLAGS=""
PLATFORMIO_BUILD_SRC_FILTER=""
BUILD_CAPABILITIES=()
apply_esp32_full_shared_bridge_profile Station_G2_room_server_observer_mqtt
[[ "$PLATFORMIO_BUILD_FLAGS" != *"WITH_UDP_BRIDGE"* \
&& "$PLATFORMIO_BUILD_SRC_FILTER" != *"UDPBridge"* ]] \
|| fail "room server acquired unsupported UDP repeater wiring"
)
[[ "$(get_unified_full_infrastructure_target Station_G2_repeater_bridge_espnow)" \
= "Station_G2_repeater_observer_mqtt" ]] \
|| fail "Station G2 Full ESP-NOW target did not resolve to the combined MQTT recipe"
@@ -1389,12 +1406,20 @@ for plain_target in heltec_v4_tft_repeater Heltec_v2_repeater \
|| fail "$plain_target Full image omitted the actual ESP-NOW driver source"
[[ " ${BUILD_CAPABILITIES[*]} " == *" bridge.espnow "* ]] \
|| fail "$plain_target Full image omitted the ESP-NOW capability"
[[ "$PLATFORMIO_BUILD_FLAGS" == *"-DWITH_UDP_BRIDGE=1"* \
&& "$PLATFORMIO_BUILD_SRC_FILTER" == *"helpers/bridges/UDPBridge.cpp"* ]] \
|| fail "$plain_target Full image omitted default-off UDP support"
[[ " ${BUILD_CAPABILITIES[*]} " == *" bridge.udp "* ]] \
|| fail "$plain_target Full image omitted UDP capability"
(
BUILD_PROFILE_FOR_TARGET=full
BUILD_EXPECTATIONS=()
declare_build_capability_contract "$plain_target" ESP32_PLATFORM
[[ " ${BUILD_EXPECTATIONS[*]} " == *"bridge.espnow=_ZN12ESPNowBridge5beginEv"* ]] \
|| fail "$plain_target Full image does not require a linked ESP-NOW driver"
[[ " ${BUILD_EXPECTATIONS[*]} " == *"bridge.udp=_ZN9UDPBridge5beginEv"* ]] \
|| fail "$plain_target Full image does not require a linked UDP driver"
)
done
for existing_target in Heltec_v2_repeater_bridge_espnow \
+21 -4
View File
@@ -516,6 +516,19 @@ int main() {
}, "v3_repeater")
self.assertEqual(alternate, combined)
def test_udp_bridge_budget_counts_enabled_case_alongside_existing_owners(self):
flags = {"WITH_MQTT_BRIDGE": 1, "WITH_RS232_BRIDGE": "Serial2"}
previous = ram.requirements("ESP32_PLATFORM", flags, "v3_repeater")
udp = ram.requirements("ESP32_PLATFORM", {**flags, "WITH_UDP_BRIDGE": 1}, "v3_repeater")
self.assertEqual(udp["required_heap_bytes"] - previous["required_heap_bytes"], 5120)
self.assertEqual(udp["components"]["udp_bridge_and_socket"], 3072 + 2048)
self.assertEqual(udp["components"]["wireless_stacks"], previous["components"]["wireless_stacks"])
self.assertEqual(udp["components"]["uart_bridge_and_driver"], previous["components"]["uart_bridge_and_driver"])
# UDP without MQTT must still reserve the shared WiFi stack.
udp_only = ram.requirements("ESP32_PLATFORM", {"WITH_UDP_BRIDGE": 1}, "v4_repeater")
self.assertEqual(udp_only["components"]["wireless_stacks"], 49152)
self.assertEqual(udp_only["components"]["udp_bridge_and_socket"], 5120)
def test_mqtt_startup_gate_distinguishes_maximum_slot_stack_buffer_capacity(self):
flags = {"WITH_MQTT_BRIDGE": 1}
plain = ram.requirements("ESP32_PLATFORM", flags, "v4_companion")
@@ -890,6 +903,10 @@ int main() {
("src/helpers/bridges/RS232Bridge.cpp", "RS232Bridge::RS232Bridge(",
"struct RS232Bridge { unsigned char bytes[OBJECT_BYTES]; };\n",
((2816, 0, True), (2817, 0, False))),
("src/helpers/bridges/UDPBridge.cpp", "UDPBridge::UDPBridge(",
"namespace mesh { namespace bridge { namespace udp {} } }\n"
"struct UDPBridge { unsigned char bytes[OBJECT_BYTES]; };\n",
((3072, 0, True), (3073, 0, False))),
("src/helpers/ui/SSD1306Display.cpp", "bool SSD1306Display::i2c_probe(",
"struct SSD1306Display { unsigned char bytes[OBJECT_BYTES]; "
"static constexpr unsigned FRAMEBUFFER_BYTES = FRAME_BYTES; };\n",
@@ -904,8 +921,8 @@ int main() {
prefix = (ROOT / path).read_text().split(end, 1)[0]
prefix = "\n".join(line for line in prefix.splitlines()
if not line.lstrip().startswith("#include"))
if path.endswith("RS232Bridge.cpp"):
prefix += "\n#endif\n" # WITH_RS232_BRIDGE also encloses the methods below.
if path.endswith(("RS232Bridge.cpp", "UDPBridge.cpp")):
prefix += "\n#endif\n" # The feature guard also encloses the methods below.
for platform in (("ESP32=1", "ESP32_PLATFORM=1"),
("NRF52_PLATFORM=1", "RAK4631_COMBINED_ETHERNET=1"),
("NRF52_PLATFORM=1",)):
@@ -913,12 +930,12 @@ int main() {
with self.subTest(path=path, platform=platform, size=size, frame=frame):
result = subprocess.run([
"c++", "-std=c++17", "-fsyntax-only", "-x", "c++", "-",
"-DWITH_RS232_BRIDGE=1", f"-DOBJECT_BYTES={size}",
"-DWITH_RS232_BRIDGE=1", "-DWITH_UDP_BRIDGE=1", f"-DOBJECT_BYTES={size}",
f"-DFRAME_BYTES={frame}", *["-D" + flag for flag in platform],
], input=stand_in + prefix, text=True, capture_output=True)
enforced = "ESP32=1" in platform or (
"RAK4631_COMBINED_ETHERNET=1" in platform
and not path.endswith("SH1106Display.cpp"))
and not path.endswith(("SH1106Display.cpp", "UDPBridge.cpp")))
self.assertEqual(result.returncode == 0, bounded or not enforced,
result.stderr)
+2 -2
View File
@@ -216,7 +216,7 @@ int main() {
# Importers use this shared harness directly, so expand its production hook
# here rather than leaving fixture setup to one test's compilation path.
_base_header = (ROOT / 'src/helpers/bridges/BridgeBase.h').read_text(encoding='ascii')
_ingress_hook = '#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE)\n'
_ingress_hook = '#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE) || defined(WITH_UDP_BRIDGE)\n'
_ingress_hook += method(_base_header, 'enum class IngressResult') + ';\n'
_ingress_hook += method(_base_header, 'virtual void onIngressResult(') + '\n#endif\n'
HARNESS = HARNESS.replace('@INGRESS_HOOK@', _ingress_hook)
@@ -410,7 +410,7 @@ int main() {
(work/'Mesh.h').write_text(PACKET)
code = '#include <initializer_list>\n' + HARNESS.replace('@METHODS@', '\n'.join(methods))
(work/'test.cpp').write_text(code)
for macros in [[], ['-DWITH_ESPNOW_BRIDGE=1']]:
for macros in [[], ['-DWITH_ESPNOW_BRIDGE=1'], ['-DWITH_UDP_BRIDGE=1']]:
with self.subTest(macros=macros):
result = subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', '-Werror',
*macros, '-I', str(work), '-I', str(ROOT/'src'), str(work/'test.cpp'),
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Sanitized production UDP bridge protocol, sessions, queues and diagnostics."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
FIXTURES = ROOT / 'test/fixtures/udp_bridge'
class UDPBridgeTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
compiler = shutil.which('g++') or shutil.which('clang++')
if not compiler:
raise AssertionError('a host C++ compiler is required')
cls.temporary = tempfile.TemporaryDirectory(prefix='udp-bridge-')
cls.binaries = []
for cooperative in (0, 1):
binary = Path(cls.temporary.name) / f'bridge-{cooperative}'
built = subprocess.run([
compiler, '-std=c++17', '-Wall', '-Wextra', '-Werror', '-O1',
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie', '-DARDUINO=1', '-DESP32=1', '-DWITH_UDP_BRIDGE=1',
f'-DMESH_ESP32_USB_CONSOLE_COOPERATIVE={cooperative}',
'-I' + str(FIXTURES), '-I' + str(ROOT / 'test/mocks'), '-I' + str(ROOT / 'src'),
str(ROOT / 'test/fixtures/udp_bridge_runtime.cpp'),
str(ROOT / 'src/helpers/bridges/UDPBridge.cpp'), '-o', str(binary),
], capture_output=True, text=True, timeout=60)
if built.returncode:
cls.temporary.cleanup()
raise AssertionError(built.stdout + built.stderr)
cls.binaries.append(binary)
@classmethod
def tearDownClass(cls):
cls.temporary.cleanup()
def check(self, name):
for binary in self.binaries:
with self.subTest(console=binary.name):
result = subprocess.run([str(binary), name], capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn(name + ' passed', result.stdout)
def test_strict_authenticated_codec_and_fresh_challenge_replay_guard(self):
self.check('codec')
def test_authenticated_probes_never_allocate_echo_or_reach_lora(self):
self.check('isolation')
def test_peer_and_receiver_restart_reject_retired_sessions_and_recordings(self):
self.check('recovery')
def test_owned_queues_pool_pressure_filter_and_no_retry(self):
self.check('queues')
def test_eight_peer_fanout_owned_bytes_and_targeted_probe(self):
self.check('fanout')
def test_secret_rotation_cancels_queued_work_refreshes_sessions_and_rejects_old_key(self):
self.check('rotation')
def test_network_gate_disconnect_secret_broadcast_and_socket_budget_failure(self):
self.check('network')
def test_bounded_status_pages_and_both_usb_debug_gates(self):
self.check('bounds')
if __name__ == '__main__':
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
"""Exercise production UDP preference parsing, atomic persistence and CLI."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = ROOT / "test/fixtures/udp_bridge"
class UDPBridgeConfigTests(unittest.TestCase):
def compile_run(self, source, settings=False, extra_flags=()):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler)
with tempfile.TemporaryDirectory(prefix="udp-config-") as directory:
work = Path(directory)
flags = []
sources = []
if settings:
original = ROOT / "test/fixtures/guest_login/mocks"
mocks = work / "mocks"
shutil.copytree(original, mocks)
for path in mocks.rglob("*"):
if path.is_file():
path.write_text(path.read_text().replace("/guest_login", "/udp_bridge"))
flags = ["-DWITH_UDP_BRIDGE=1", "-DESP32_PLATFORM", "-DESP32", "-I", str(mocks)]
sources = [str(ROOT / "src/helpers/UDPBridgeSettings.cpp")]
main = work / "main.cpp"
main.write_text(source, encoding="ascii")
binary = work / "check"
result = subprocess.run([compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror",
"-Wno-misleading-indentation", "-Wno-sign-compare", "-fsanitize=address,undefined",
"-fno-omit-frame-pointer", "-no-pie", *flags, *extra_flags, "-I", str(ROOT / "src"), str(main), *sources,
"-o", str(binary)], capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_parser_codec_and_transaction_boundaries(self):
self.compile_run((FIXTURE / "settings.cpp").read_text(encoding="ascii"), settings=True)
def test_actual_cli_dispatch(self):
header = (ROOT / "src/helpers/CommonCLI.h").read_text(encoding="ascii")
source = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="ascii")
body = extract_braced(source, "bool CommonCLI::handleUDPBridgeCommand(")
callbacks = "\n".join(extract_braced(header, signature) for signature in (
"virtual const mesh::bridge::udp::Prefs* getUDPBridgePrefs(",
"virtual bool setUDPBridgePrefs(", "virtual void formatUDPBridgeStatus(",
"virtual void formatUDPBridgeStats(", "virtual bool sendUDPBridgeTest(",
"virtual bool resetUDPBridgeStats("))
self.assertIn("if (handleUDPBridgeCommand(sender_timestamp, command, reply)) return;", source)
fixture = (FIXTURE / "cli.cpp").read_text(encoding="ascii")
self.compile_run(fixture.replace("CALLBACKS_HERE", callbacks).replace("HANDLER_HERE", body))
def test_browser_ota_closes_udp_before_start_and_restores_only_saved_intent(self):
source = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="ascii")
start = source.index(' } else if (memcmp(command, "start ota", 9)')
end = source.index(' } else if (memcmp(command, "clock", 5)', start)
fixture = (FIXTURE / "ota.cpp").read_text(encoding="ascii")
self.compile_run(fixture.replace("OTA_HERE", source[start:end]), extra_flags=["-DWITH_UDP_BRIDGE=1"])
if __name__ == "__main__":
unittest.main()
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""Execute production repeater UDP lifecycle with shared station owners."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from cpp_source import body
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <cassert>
#include <cstdio>
#include <cstring>
#include <cstdint>
#include <new>
#include <initializer_list>
#include <helpers/UDPBridgePrefs.h>
#include <helpers/WiFiNetworkPowerPolicy.h>
#include <helpers/WirelessControl.h>
uint32_t now_ms=1000;
uint32_t millis(){return now_ms;}
bool millisHasNowPassed(uint32_t value){return static_cast<int32_t>(now_ms-value)>=0;}
enum {WIFI_OFF=0,WIFI_STA=1,WIFI_AP=2,WIFI_AP_STA=3,WL_IDLE_STATUS=0,WL_CONNECTED=3};
enum {WIFI_IF_STA=0,ESP_OK=0,WIFI_PS_NONE=0,WIFI_PS_MIN_MODEM=1,WIFI_PS_MAX_MODEM=2};
struct {
int current=WIFI_OFF,state=WL_IDLE_STATUS,begins=0,modes=0; uint32_t ip=0,mask=0x00ffffff;
int getMode() const{return current;}
int status()const{return state;}
uint32_t localIP()const{return ip;}
uint32_t subnetMask()const{return mask;}
void persistent(bool){}
bool mode(int value){++modes;current=value;return true;}
} WiFi;
using wifi_mode_t=int;
constexpr int WIFI_MODE_NULL=0,WIFI_MODE_STA=1;
int sdk_override=-1;
int esp_wifi_get_mode(wifi_mode_t*value){*value=sdk_override<0?WiFi.current:sdk_override;return ESP_OK;}
void esp_wifi_set_ps(int){}
namespace mesh { namespace wifi {
constexpr uint8_t kDefaultPowerSave=1,kPowerSaveNone=1,kPowerSaveMax=2;
struct Credentials {char ssid[32]="test";char password[65]="";uint8_t power_save=1;};
enum class CredentialState {Ready,Absent};
bool have_credentials=true;
CredentialState resolveCredentials(Credentials&,const char* =nullptr,const char* =nullptr,uint8_t=1){
return have_credentials?CredentialState::Ready:CredentialState::Absent;
}
bool stationMutationAllowed(){return !(WiFi.current&WIFI_AP);}
int applyProtocolMask(int){return ESP_OK;}
void setStationAutoReconnect(bool){}
void beginStation(const char*,const char*){++WiFi.begins;}
uint8_t effectivePowerSave(uint8_t value,bool,bool){return value;}
bool espNowChannelConstrained(){return false;}
}}
struct NodePrefs {char bridge_secret[16]="test-secret";};
struct FakeSettings {
mesh::bridge::udp::Prefs data;
bool health=true,save_ok=true;unsigned writes=0;
bool healthy()const{return health;}
const mesh::bridge::udp::Prefs& prefs()const{return data;}
bool setPrefs(const mesh::bridge::udp::Prefs& value){++writes;if(!save_ok)return false;data=value;return true;}
};
struct Board {bool ota=false;bool isOTAUpdateRunning()const{return ota;}};
struct ObserverPrefs {const char*wifi_ssid="test";const char*wifi_password="";uint8_t wifi_power_save=1;};
struct CLI {Board board;ObserverPrefs observer;Board*getBoard(){return &board;}const ObserverPrefs*getObserverPrefs(){return &observer;}};
struct MQTT {bool running=false,stopping=false;bool isRunning()const{return running;}bool isStopping()const{return stopping;}};
struct UDPBridge {
static int allocations;bool running=false;int loops=0,closes=0;
uint32_t address=0,mask=0;
UDPBridge(NodePrefs*,void*,void*,const mesh::bridge::udp::Prefs*){++allocations;}
~UDPBridge(){--allocations;}
bool isRunning()const{return running;}
void serviceNetwork(bool ready,uint32_t ip=0,uint32_t netmask=0){running=ready;address=ip;mask=netmask;if(!ready)++closes;}
void loop(){assert(running);++loops;}
void end(){running=false;++closes;}
bool sendTest(uint8_t,uint32_t){return running;}
void resetStats(){}
void formatStatus(char*out,size_t cap){snprintf(out,cap,"> ready ip=10.0.0.1");}
void formatStats(const char*,char*,size_t){}
}; int UDPBridge::allocations=0;
struct MyMesh {
NodePrefs _prefs;void* _mgr=nullptr;CLI _cli;FakeSettings udp_bridge_settings;
UDPBridge*udp_bridge=nullptr;uint32_t udp_station_retry_at=0;bool udp_network_paused=false;
MQTT* mqtt_bridge=nullptr;bool web_active=false,web_stopping=false;
~MyMesh(){delete udp_bridge;}
bool isWebConfigActive(){return web_active;}
bool isWebConfigStopping(){return web_stopping;}
void*getRTCClock(){return nullptr;}
void configureBridgeFilter(UDPBridge*){}
bool ensureUDPBridge();bool isUDPBridgeNetworkEnabled()const;
bool setUDPBridgeNetworkState(bool);bool setUDPBridgePrefs(const mesh::bridge::udp::Prefs&);
void serviceUDPBridge();void formatUDPBridgeStatus(char*,size_t);
};
@METHODS@
struct Backend:mesh::wireless::Backend {
uint8_t available()const override{return mesh::wireless::WiFi;}
uint8_t enabled()const override{return mesh::wireless::WiFi;}
uint8_t clients()const override{return mesh::wireless::Independent;}
mesh::wireless::Result set(uint8_t,bool)override{return mesh::wireless::Result::Done;}
};
int main(){
MyMesh node;
char status[157];node.formatUDPBridgeStatus(status,sizeof(status));assert(strstr(status,"off ip=0.0.0.0"));
node.serviceUDPBridge();
assert(!node.udp_bridge&&!mesh::wifi::networkPower().foregroundActive()&&WiFi.begins==0);
auto prefs=node.udp_bridge_settings.data;prefs.enabled=1;
for(unsigned i=0;i<8;++i){prefs.peers[i].ip[0]=10;prefs.peers[i].ip[3]=i+2;prefs.peers[i].port=4404;}
assert(node.setUDPBridgePrefs(prefs));
assert(node.udp_bridge&&UDPBridge::allocations==1&&WiFi.begins==1&&WiFi.current==WIFI_STA);
assert(!node.udp_bridge->running&&mesh::wifi::networkPower().foregroundActive());
WiFi.state=WL_CONNECTED;WiFi.ip=0x0100000a;
node.serviceUDPBridge();
node.formatUDPBridgeStatus(status,sizeof(status));assert(strstr(status,"ready ip=10.0.0.1"));
assert(node.udp_bridge->running&&node.udp_bridge->loops==1&&WiFi.begins==1);
assert(node.udp_bridge->address==WiFi.ip&&node.udp_bridge->mask==WiFi.mask);
for(int mode:{WIFI_AP,WIFI_AP_STA}){
WiFi.current=mode;const int starts=WiFi.begins,modes=WiFi.modes;
now_ms+=16000;node.serviceUDPBridge();
assert(!node.udp_bridge->running&&WiFi.begins==starts&&WiFi.modes==modes);
}
WiFi.current=WIFI_STA;sdk_override=WIFI_AP_STA;node.serviceUDPBridge();assert(!node.udp_bridge->running);
sdk_override=-1;WiFi.ip=0;now_ms+=16000;node.serviceUDPBridge();
assert(!node.udp_bridge->running); // Association alone is not a usable IP.
WiFi.ip=0x0100000a;node.serviceUDPBridge();assert(node.udp_bridge->running);
node._cli.board.ota=true;node.serviceUDPBridge();
assert(!node.udp_bridge->running&&!mesh::wifi::networkPower().foregroundActive());
node._cli.board.ota=false;node.serviceUDPBridge();assert(node.udp_bridge->running);
assert(node.setUDPBridgeNetworkState(false));node.serviceUDPBridge();assert(!node.udp_bridge->running);
assert(node.setUDPBridgeNetworkState(true)&&node.udp_bridge->running);
WiFi.state=WL_IDLE_STATUS;now_ms+=16000;const int before_owner=WiFi.begins;
#ifdef WITH_WEBCONFIG
node.web_active=true;node.serviceUDPBridge();assert(WiFi.begins==before_owner&&!node.udp_bridge->running);
node.web_active=false;node.web_stopping=true;node.serviceUDPBridge();assert(WiFi.begins==before_owner);
node.web_stopping=false;
#endif
#ifdef WITH_MQTT_BRIDGE
MQTT mqtt;node.mqtt_bridge=&mqtt;mqtt.running=true;node.serviceUDPBridge();
assert(WiFi.begins==before_owner&&!node.udp_bridge->running);
mqtt.running=false;mqtt.stopping=true;node.serviceUDPBridge();assert(WiFi.begins==before_owner);
mqtt.stopping=false;
#endif
node.serviceUDPBridge();assert(WiFi.begins==before_owner+1);
WiFi.state=WL_CONNECTED;node.serviceUDPBridge();assert(node.udp_bridge->running);
// A failed MQTT destination must not park a healthy independent LAN bridge.
mesh::wifi::NetworkPowerPolicy policy;
assert(policy.update(now_ms,false,mesh::wifi::networkPower().foregroundActive())==mesh::wifi::NetworkPowerPolicy::Action::Run);
assert(policy.update(now_ms+600000,false,mesh::wifi::networkPower().foregroundActive())==mesh::wifi::NetworkPowerPolicy::Action::Run);
Backend backend;auto& control=mesh::wireless::control();control.begin(backend);char reply[160];
assert(control.handle("set wifi off force",reply,sizeof(reply),now_ms,mesh::wireless::Independent));
node.serviceUDPBridge();assert(!node.udp_bridge->running); // Honor off before reply-drain grace ends.
node.formatUDPBridgeStatus(status,sizeof(status));assert(strstr(status,"wifi-paused"));
now_ms+=250;control.service(now_ms);assert(control.blocked(mesh::wireless::WiFi));
node.serviceUDPBridge();assert(!node.udp_bridge->running&&!mesh::wifi::networkPower().foregroundActive());
node.setUDPBridgeNetworkState(true);assert(!node.udp_bridge->running); // No silent master-off bypass.
assert(control.handle("set wifi on",reply,sizeof(reply),now_ms,mesh::wireless::Independent));
now_ms+=250;control.service(now_ms);node.serviceUDPBridge();assert(node.udp_bridge->running);
assert(control.handle("set 2.4ghz off force",reply,sizeof(reply),now_ms,mesh::wireless::Independent));
node.serviceUDPBridge();assert(!node.udp_bridge->running&&control.masterOff());
now_ms+=250;control.service(now_ms);
assert(control.handle("set 2.4ghz on",reply,sizeof(reply),now_ms,mesh::wireless::Independent));
now_ms+=250;control.service(now_ms);node.serviceUDPBridge();assert(node.udp_bridge->running);
node._prefs.bridge_secret[0]=0;node.formatUDPBridgeStatus(status,sizeof(status));assert(strstr(status,"no-secret"));
node.serviceUDPBridge();assert(!node.udp_bridge->running);
strcpy(node._prefs.bridge_secret,"test-secret");node.serviceUDPBridge();assert(node.udp_bridge->running);
const auto before=node.udp_bridge_settings.data;node.udp_bridge_settings.save_ok=false;
auto failed=before;failed.port=1234;assert(!node.setUDPBridgePrefs(failed));
assert(node.udp_bridge_settings.data.port==before.port&&node.udp_bridge->running);
node.udp_bridge_settings.save_ok=true;prefs.enabled=0;
assert(node.setUDPBridgePrefs(prefs)&&!node.udp_bridge&&UDPBridge::allocations==0);
assert(!mesh::wifi::networkPower().foregroundActive());
}
'''
class UDPBridgeIntegrationTest(unittest.TestCase):
def test_wireless_master_restores_udp_station_without_starting_setup_ap(self):
backend=body((ROOT/'examples/InfrastructureWireless.h').read_text(),
'class InfrastructureWirelessBackend')+';'
fixture=r'''
#include <cassert>
#include <cstdint>
#include <vector>
#include <helpers/WirelessControl.h>
enum {WIFI_OFF=0,WIFI_STA=1,WIFI_IF_STA=0};
std::vector<int> events;
struct {bool ota=false;bool isOTAUpdateRunning()const{return ota;}
bool stopOTAUpdate(char*){ota=false;return true;}} board;
struct {int mode_=WIFI_STA;void setAutoReconnect(bool){}
void disconnect(bool,bool){events.push_back(2);}
void mode(int value){mode_=value;events.push_back(value==WIFI_OFF?3:4);}} WiFi;
namespace mesh {namespace wifi {void applyProtocolMask(int){} void restoreEspNowChannel(){}}}
struct {
bool paused=false,configured=true,mqtt=false,web=false;unsigned web_starts=0;
bool isUDPBridgeNetworkEnabled()const{return configured&&!paused;}
bool isUDPBridgeConfigured()const{return configured;}
bool setUDPBridgeNetworkState(bool enabled){paused=!enabled;events.push_back(enabled?5:1);if(enabled)WiFi.mode(WIFI_STA);return true;}
bool isMqttBridgeRunning()const{return mqtt;}
bool setMqttBridgeState(bool value){mqtt=value;return true;}
bool isWebConfigActive()const{return web;}
bool isWebConfigStopping()const{return false;}
bool hasWirelessNetworkClient()const{return false;}
bool stopWebConfig(char*){web=false;return true;}
bool startWebConfig(bool,char*){++web_starts;web=true;return true;}
} the_mesh;
@BACKEND@
int main(){
InfrastructureWirelessBackend backend;
assert(backend.enabled()&mesh::wireless::WiFi);
assert(backend.set(mesh::wireless::WiFi,false)==mesh::wireless::Result::Done);
assert(the_mesh.paused&&WiFi.mode_==WIFI_OFF&&events.front()==1);
assert(backend.set(mesh::wireless::WiFi,true)==mesh::wireless::Result::Done);
assert(!the_mesh.paused&&WiFi.mode_==WIFI_STA&&the_mesh.web_starts==0);
// An explicit first WiFi-on with configured UDP must not launch a setup AP.
InfrastructureWirelessBackend initial;
assert(initial.set(mesh::wireless::WiFi,true)==mesh::wireless::Result::Done);
assert(the_mesh.web_starts==0);
}
'''.replace('@BACKEND@',backend)
with tempfile.TemporaryDirectory() as tmp:
path=Path(tmp);(path/'backend.cpp').write_text(fixture)
binary=path/'backend'
subprocess.run([os.environ.get('CXX','g++'),'-std=c++17','-DESP32=1','-DWITH_UDP_BRIDGE=1',
'-DWITH_WEBCONFIG=1','-DWITH_MQTT_BRIDGE=1','-fsanitize=address,undefined',
'-fno-omit-frame-pointer','-fno-pie','-no-pie','-I',str(ROOT/'src'),
str(path/'backend.cpp'),'-o',str(binary)],check=True)
subprocess.run([str(binary)],check=True)
def test_station_owners_master_off_reconnect_and_broker_independence(self):
source=(ROOT/'examples/simple_repeater/MyMesh.cpp').read_text()
methods='\n'.join(body(source,signature) for signature in (
'bool MyMesh::ensureUDPBridge(', 'bool MyMesh::isUDPBridgeNetworkEnabled(',
'bool MyMesh::setUDPBridgeNetworkState(', 'bool MyMesh::setUDPBridgePrefs(',
'void MyMesh::serviceUDPBridge(', 'void MyMesh::formatUDPBridgeStatus('))
for flags in ((),('WITH_MQTT_BRIDGE',),('WITH_WEBCONFIG',),('WITH_MQTT_BRIDGE','WITH_WEBCONFIG')):
with self.subTest(flags=flags),tempfile.TemporaryDirectory() as tmp:
path=Path(tmp);(path/'test.cpp').write_text(HARNESS.replace('@METHODS@',methods))
binary=path/'test'
subprocess.run([os.environ.get('CXX','g++'),'-std=c++17','-fsanitize=address,undefined',
'-fno-omit-frame-pointer','-fno-pie','-no-pie','-I',str(ROOT/'src'),
*['-D'+flag+'=1' for flag in flags],str(path/'test.cpp'),'-o',str(binary)],check=True)
subprocess.run([str(binary)],check=True)
if __name__=='__main__':unittest.main()
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Exercise the UDP origin marker through real packet, pool and radio paths."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class UDPPacketOriginTests(unittest.TestCase):
def test_origin_is_local_copies_with_retries_and_clears_on_reuse(self):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler)
fixture = (ROOT / "test/fixtures/dispatcher/startup_watchdog_test.cpp").read_text()
support = fixture[:fixture.index("static void delayedSetup()")]
support = support.replace(" using Dispatcher::setRadioAvailable;",
" using Dispatcher::setRadioAvailable;\n"
" using Dispatcher::tryParsePacket;\n"
" using Dispatcher::obtainNewPacket;")
checks = r'''
int main() {
mesh::Packet original;
original.header = (PAYLOAD_TYPE_OTA << PH_TYPE_SHIFT) | ROUTE_TYPE_FLOOD;
original.path_len = 0;
original.payload_len = 1;
original.payload[0] = 42;
uint8_t before[MAX_TRANS_UNIT], after[MAX_TRANS_UNIT];
const auto length = original.writeTo(before);
#if defined(WITH_UDP_BRIDGE)
assert(!original.udp_bridge_ingress);
original.udp_bridge_ingress = true;
mesh::Packet retry = original;
assert(retry.udp_bridge_ingress);
assert(original.writeTo(after) == length && memcmp(before, after, length) == 0);
assert(retry.readFrom(before, length) && !retry.udp_bridge_ingress);
retry.udp_bridge_ingress = true;
const uint8_t invalid[] = {0xC1, 0};
assert(!retry.readFrom(invalid, sizeof(invalid)) && !retry.udp_bridge_ingress);
StaticPoolPacketManager pool(1);
auto* packet = pool.allocNew();
assert(packet && !packet->udp_bridge_ingress);
packet->udp_bridge_ingress = true;
pool.free(packet);
auto* recycled = pool.allocNew();
assert(recycled == packet && !recycled->udp_bridge_ingress);
assert(pool.allocNew() == nullptr);
pool.free(recycled);
TestClock clock; TestRadio radio;
TestDispatcher dispatcher(radio, clock, pool);
auto* local = dispatcher.obtainNewPacket();
assert(local && !local->udp_bridge_ingress);
local->udp_bridge_ingress = true;
assert(dispatcher.tryParsePacket(local, before, length));
assert(!local->udp_bridge_ingress);
pool.free(local);
#else
assert(original.writeTo(after) == length && memcmp(before, after, length) == 0);
#endif
printf("%zu\n", sizeof(mesh::Packet));
}
'''
with tempfile.TemporaryDirectory(prefix="udp-packet-origin-") as temporary:
work = Path(temporary)
source = work / "main.cpp"
source.write_text(support + checks, encoding="ascii")
sizes = []
for enabled in (False, True):
with self.subTest(udp=enabled):
binary = work / ("udp" if enabled else "ordinary")
built = subprocess.run([
compiler, "-std=c++17", "-Wall", "-Wextra",
"-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie", *(["-DWITH_UDP_BRIDGE=1"] if enabled else []),
"-I" + str(ROOT / "test/mocks"), "-I" + str(ROOT / "src"),
str(ROOT / "src/Dispatcher.cpp"), str(ROOT / "src/Packet.cpp"),
str(ROOT / "src/helpers/StaticPoolPacketManager.cpp"),
str(source), "-o", str(binary),
], capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
environment = os.environ.copy()
# Firmware packet pools live for the entire boot.
environment["ASAN_OPTIONS"] = environment.get("ASAN_OPTIONS", "") + ":detect_leaks=0"
checked = subprocess.run([str(binary)], capture_output=True, text=True,
env=environment, timeout=10)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
sizes.append(int(checked.stdout.strip()))
self.assertEqual(sizes[0], sizes[1], "UDP metadata must fit existing packet padding")
if __name__ == "__main__":
unittest.main()