Add station-only WiFi UDP bridge with eight authenticated peers
This commit is contained in:
1 parent
7bc3aeb234
commit
5e25faa3d0
45 files changed
+2699
-35
No files matched your search
@@ -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: |
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
inline uint32_t test_now = 0;
|
||||
inline uint32_t millis() { return test_now; }
|
||||
Vendored
+22
@@ -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; }
|
||||
};
|
||||
Vendored
+93
@@ -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
@@ -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; }
|
||||
@@ -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
@@ -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
|
||||
Vendored
+58
@@ -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
@@ -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));
|
||||
}
|
||||
Vendored
+245
@@ -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]);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user