diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 6d95f8de5..4ec2aef61 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -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: | diff --git a/build_legacy.sh b/build_legacy.sh index 1c06f3549..80c95fd0b 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -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 "+" 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 "+" + append_platformio_build_src_filter "+" + 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 diff --git a/docs/cli_commands.md b/docs/cli_commands.md index cc56e5df1..247da2492 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -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` diff --git a/docs/udp_bridge_setup.md b/docs/udp_bridge_setup.md new file mode 100644 index 000000000..1101a8c9a --- /dev/null +++ b/docs/udp_bridge_setup.md @@ -0,0 +1,171 @@ + + + + +# 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. diff --git a/examples/InfrastructureWireless.h b/examples/InfrastructureWireless.h index f20f14955..b208fe250 100644 --- a/examples/InfrastructureWireless.h +++ b/examples/InfrastructureWireless.h @@ -2,17 +2,20 @@ // Included by the infrastructure main files after the mesh and transport objects. #include -#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 #include #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; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 7d6dfc4c5..feafa2b17 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -6,12 +6,16 @@ #if defined(ESP32_PLATFORM) #include #include -#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 #include #endif #endif +#if defined(WITH_UDP_BRIDGE) +#include +#include +#endif #include #include #include @@ -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(WiFi.localIP()) == 0 ? "disconnected" + : !udp_bridge ? "no-memory" : nullptr; + const auto ip = WiFi.localIP(); + const uint32_t address = static_cast(ip); + const auto* octets = reinterpret_cast(&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(WiFi.localIP()) != 0; + udp_bridge->serviceNetwork(station_ready, static_cast(WiFi.localIP()), + static_cast(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) diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index c4603d62b..f169e38ee 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -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 diff --git a/mkdocs.yml b/mkdocs.yml index 9f80ab7cc..acf2dec5a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/scripts/check_firmware_ram.py b/scripts/check_firmware_ram.py index 58ead5f0d..ce260de52 100644 --- a/scripts/check_firmware_ram.py +++ b/scripts/check_firmware_ram.py @@ -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 diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index ed3fb145a..07c9a8362 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -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; } diff --git a/src/MeshCore.h b/src/MeshCore.h index 65308e449..96bce2d75 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -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 #include #include diff --git a/src/Packet.cpp b/src/Packet.cpp index a1672e34f..591f4dd16 100644 --- a/src/Packet.cpp +++ b/src/Packet.cpp @@ -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()) { diff --git a/src/Packet.h b/src/Packet.h index 0770645a3..8b70970b1 100644 --- a/src/Packet.h +++ b/src/Packet.h @@ -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 diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 3c9a2ecb6..2458e4304 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -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(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 "); 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(command[9] - '1'), nonce)) + snprintf(reply, capacity, "OK - test queued id=%08lX; confirm on peer", static_cast(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 diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 311b52073..3493cc052 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -17,6 +17,9 @@ #include #include #include +#if defined(WITH_UDP_BRIDGE) +#include +#endif #if MESH_ENABLE_FLEET_CONTROL #include #endif @@ -32,7 +35,7 @@ #include #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) diff --git a/src/helpers/StaticPoolPacketManager.cpp b/src/helpers/StaticPoolPacketManager.cpp index 600ae19d6..c94c721d7 100644 --- a/src/helpers/StaticPoolPacketManager.cpp +++ b/src/helpers/StaticPoolPacketManager.cpp @@ -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) { diff --git a/src/helpers/UDPBridgePrefs.h b/src/helpers/UDPBridgePrefs.h new file mode 100644 index 000000000..21aec1c27 --- /dev/null +++ b/src/helpers/UDPBridgePrefs.h @@ -0,0 +1,150 @@ +#pragma once + +#include +#include +#include +#include + +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(*text - '0'); + if (value > 65535) return false; + } + if (value == 0) return false; + result = static_cast(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(*cursor++ - '0'); + if (++digits > 3 || value > 255) return false; + } + if (digits == 0 || *cursor++ != (i == 3 ? ':' : '.')) return false; + candidate.ip[i] = static_cast(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(c - '0'); + else if (c >= 'a' && c <= 'f') digit = static_cast(c - 'a' + 10); + else if (c >= 'A' && c <= 'F') digit = static_cast(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((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( + (image[12 + i * 6] << 8) | image[13 + i * 6]); + } + if (!validPrefs(candidate)) return false; + prefs = candidate; + return true; +} + +}}} // namespace mesh::bridge::udp diff --git a/src/helpers/UDPBridgeSettings.cpp b/src/helpers/UDPBridgeSettings.cpp new file mode 100644 index 000000000..1fccf6986 --- /dev/null +++ b/src/helpers/UDPBridgeSettings.cpp @@ -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)) 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); + 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 diff --git a/src/helpers/UDPBridgeSettings.h b/src/helpers/UDPBridgeSettings.h new file mode 100644 index 000000000..a08e18a63 --- /dev/null +++ b/src/helpers/UDPBridgeSettings.h @@ -0,0 +1,25 @@ +#pragma once + +#if defined(WITH_UDP_BRIDGE) +#include +#include + +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 diff --git a/src/helpers/WiFiNetworkPowerPolicy.h b/src/helpers/WiFiNetworkPowerPolicy.h index 0a1538ced..beb257eb4 100644 --- a/src/helpers/WiFiNetworkPowerPolicy.h +++ b/src/helpers/WiFiNetworkPowerPolicy.h @@ -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 diff --git a/src/helpers/WirelessControl.h b/src/helpers/WirelessControl.h index 8fb632791..8f8e1520e 100644 --- a/src/helpers/WirelessControl.h +++ b/src/helpers/WirelessControl.h @@ -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; diff --git a/src/helpers/bridges/BridgeBase.cpp b/src/helpers/bridges/BridgeBase.cpp index 2d8ebac71..1d00ac4a3 100644 --- a/src/helpers/bridges/BridgeBase.cpp +++ b/src/helpers/bridges/BridgeBase.cpp @@ -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 } diff --git a/src/helpers/bridges/BridgeBase.h b/src/helpers/bridges/BridgeBase.h index 579875116..b239e6279 100644 --- a/src/helpers/bridges/BridgeBase.h +++ b/src/helpers/bridges/BridgeBase.h @@ -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) {} diff --git a/src/helpers/bridges/UDPBridge.cpp b/src/helpers/bridges/UDPBridge.cpp new file mode 100644 index 000000000..04ada2950 --- /dev/null +++ b/src/helpers/bridges/UDPBridge.cpp @@ -0,0 +1,340 @@ +#include "UDPBridge.h" +#if defined(ESP32) && defined(WITH_UDP_BRIDGE) +#include +#include +#include +#include +#include +#include +#include +#include + +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(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(&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(&address), sizeof(address)); + if (sent != static_cast(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(&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(peer), bytes, static_cast(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(_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(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(~(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(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(&_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(line), length); +#endif +} +#endif diff --git a/src/helpers/bridges/UDPBridge.h b/src/helpers/bridges/UDPBridge.h new file mode 100644 index 000000000..f11a7850d --- /dev/null +++ b/src/helpers/bridges/UDPBridge.h @@ -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 diff --git a/src/helpers/bridges/UDPBridgeProtocol.h b/src/helpers/bridges/UDPBridgeProtocol.h new file mode 100644 index 000000000..8c296f163 --- /dev/null +++ b/src/helpers/bridges/UDPBridgeProtocol.h @@ -0,0 +1,104 @@ +#pragma once + +#include +#include +#include + +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 diff --git a/src/helpers/esp32/LightSleepPolicy.h b/src/helpers/esp32/LightSleepPolicy.h index b5e8d8eb4..fba2592ca 100644 --- a/src/helpers/esp32/LightSleepPolicy.h +++ b/src/helpers/esp32/LightSleepPolicy.h @@ -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) diff --git a/test/fixtures/udp_bridge/Arduino.h b/test/fixtures/udp_bridge/Arduino.h new file mode 100644 index 000000000..48c29a4ec --- /dev/null +++ b/test/fixtures/udp_bridge/Arduino.h @@ -0,0 +1,4 @@ +#pragma once +#include +inline uint32_t test_now = 0; +inline uint32_t millis() { return test_now; } diff --git a/test/fixtures/udp_bridge/Stream.h b/test/fixtures/udp_bridge/Stream.h new file mode 100644 index 000000000..338e26487 --- /dev/null +++ b/test/fixtures/udp_bridge/Stream.h @@ -0,0 +1,22 @@ +#pragma once +#include +#include + +// 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; } +}; diff --git a/test/fixtures/udp_bridge/cli.cpp b/test/fixtures/udp_bridge/cli.cpp new file mode 100644 index 000000000..f3de5568c --- /dev/null +++ b/test/fixtures/udp_bridge/cli.cpp @@ -0,0 +1,93 @@ +#include +#include +#include +#include +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")); +} diff --git a/test/fixtures/udp_bridge/esp_system.h b/test/fixtures/udp_bridge/esp_system.h new file mode 100644 index 000000000..196db9203 --- /dev/null +++ b/test/fixtures/udp_bridge/esp_system.h @@ -0,0 +1,4 @@ +#pragma once +#include +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; } diff --git a/test/fixtures/udp_bridge/helpers/bridges/BridgeBase.h b/test/fixtures/udp_bridge/helpers/bridges/BridgeBase.h new file mode 100644 index 000000000..b0901c311 --- /dev/null +++ b/test/fixtures/udp_bridge/helpers/bridges/BridgeBase.h @@ -0,0 +1,125 @@ +#pragma once +#include +#include +#include +#if defined(ARDUINO) +#include +#include +#include +#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 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(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(sizeof(record))) size = sizeof(record) - 1; + return debug_stream.write(reinterpret_cast(record), size); +} +#endif +struct Packet { + std::vector 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(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(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> received; + Packet* allocNew() { + ++allocations; + return starved ? nullptr : new Packet; + } + void free(Packet* packet) { ++frees; delete packet; } +}; +} + +struct SeenPackets { + std::vector> 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); + } +}; diff --git a/test/fixtures/udp_bridge/lwip/sockets.h b/test/fixtures/udp_bridge/lwip/sockets.h new file mode 100644 index 000000000..a2a359b63 --- /dev/null +++ b/test/fixtures/udp_bridge/lwip/sockets.h @@ -0,0 +1,69 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace udp_test { +struct Datagram { sockaddr_in source{}, target{}; std::vector bytes; }; +struct Socket { bool open = false; uint32_t ip = 0; uint16_t port = 0; std::deque 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 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(value); return 0; +} +inline int udpTestBind(int fd, const sockaddr* address, socklen_t) { + udp_test::sockets[fd].port = reinterpret_cast(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(address); + message.bytes.assign(static_cast(bytes), static_cast(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(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(count); +} +#define socket udpTestSocket +#define close udpTestClose +#define fcntl udpTestFcntl +#define setsockopt udpTestSetsockopt +#define bind udpTestBind +#define sendto udpTestSendto +#define recvfrom udpTestRecvfrom diff --git a/test/fixtures/udp_bridge/ota.cpp b/test/fixtures/udp_bridge/ota.cpp new file mode 100644 index 000000000..9148e4d73 --- /dev/null +++ b/test/fixtures/udp_bridge/ota.cpp @@ -0,0 +1,58 @@ +#include +#include +#include +#include +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. +} diff --git a/test/fixtures/udp_bridge/settings.cpp b/test/fixtures/udp_bridge/settings.cpp new file mode 100644 index 000000000..bfc5bfab3 --- /dev/null +++ b/test/fixtures/udp_bridge/settings.cpp @@ -0,0 +1,102 @@ +#include +#include +#include +#include +using namespace mesh::bridge::udp; +using Bytes = std::vector; +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)); +} diff --git a/test/fixtures/udp_bridge_runtime.cpp b/test/fixtures/udp_bridge_runtime.cpp new file mode 100644 index 000000000..546fe48fc --- /dev/null +++ b/test/fixtures/udp_bridge_runtime.cpp @@ -0,0 +1,245 @@ +#include +#include +#include +#include +#include +#include +#include "helpers/bridges/BridgeBase.h" +#include "Arduino.h" +#include "lwip/sockets.h" +#include "helpers/bridges/UDPBridge.h" +#include +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(context); + SHA256 sha; sha.resetHMAC(reinterpret_cast(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(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(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(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(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(key)) != udp::Decode::Ok); + assert(udp::decode(bytes, size + 1, decoded, hmac, const_cast(key)) != udp::Decode::Ok); + assert(udp::decode(bytes, size, decoded, hmac, const_cast("other-key")) == udp::Decode::Authentication); + assert(!udp::encode(bytes, size - 1, frame, hmac, const_cast(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> 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]); +} diff --git a/test/test_bridge_debug_formatter.py b/test/test_bridge_debug_formatter.py index fe0883c89..ae07b1d82 100644 --- a/test/test_bridge_debug_formatter.py +++ b/test/test_bridge_debug_formatter.py @@ -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) diff --git a/test/test_bridge_recovery.py b/test/test_bridge_recovery.py index f62ceea56..eaa7229b1 100644 --- a/test/test_bridge_recovery.py +++ b/test/test_bridge_recovery.py @@ -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 ( diff --git a/test/test_build_profiles.sh b/test/test_build_profiles.sh index 29dd29305..a7061eed7 100644 --- a/test/test_build_profiles.sh +++ b/test/test_build_profiles.sh @@ -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 \ diff --git a/test/test_firmware_ram.py b/test/test_firmware_ram.py index 5be349c7e..28f8a2b2a 100644 --- a/test/test_firmware_ram.py +++ b/test/test_firmware_ram.py @@ -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) diff --git a/test/test_transport_filter_wiring.py b/test/test_transport_filter_wiring.py index 1af57f506..d68472e0d 100644 --- a/test/test_transport_filter_wiring.py +++ b/test/test_transport_filter_wiring.py @@ -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 \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'), diff --git a/test/test_udp_bridge.py b/test/test_udp_bridge.py new file mode 100644 index 000000000..89a4af3ce --- /dev/null +++ b/test/test_udp_bridge.py @@ -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() diff --git a/test/test_udp_bridge_config.py b/test/test_udp_bridge_config.py new file mode 100644 index 000000000..a8923c12f --- /dev/null +++ b/test/test_udp_bridge_config.py @@ -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() diff --git a/test/test_udp_bridge_integration.py b/test/test_udp_bridge_integration.py new file mode 100644 index 000000000..c5083eef5 --- /dev/null +++ b/test/test_udp_bridge_integration.py @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +uint32_t now_ms=1000; +uint32_t millis(){return now_ms;} +bool millisHasNowPassed(uint32_t value){return static_cast(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 +#include +#include +#include +enum {WIFI_OFF=0,WIFI_STA=1,WIFI_IF_STA=0}; +std::vector 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() diff --git a/test/test_udp_packet_origin.py b/test/test_udp_packet_origin.py new file mode 100644 index 000000000..e34dedc6f --- /dev/null +++ b/test/test_udp_packet_origin.py @@ -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()