From d270cd611a13430db57055fcb10e0022a954aed3 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 15:34:42 -0700 Subject: [PATCH] Add split RX and TX repeater modes with source-based bridge routing --- .github/workflows/run-unit-tests.yml | 4 + docs/cli_command_availability.md | 6 +- docs/cli_commands.md | 59 +++- docs/espnow_bridge_setup.md | 3 + docs/split_radio_repeater.md | 164 +++++++++ docs/udp_bridge_setup.md | 3 + examples/simple_repeater/main.cpp | 7 + examples/simple_room_server/main.cpp | 6 + mkdocs.yml | 1 + src/Dispatcher.cpp | 52 ++- src/Dispatcher.h | 1 + src/DispatcherBeacon.cpp | 6 + src/helpers/AbstractBridge.h | 23 +- src/helpers/BridgeIngressProfile.h | 39 ++- src/helpers/BridgeIngressSettings.cpp | 144 +++++++- src/helpers/BridgeSourceRoutes.h | 155 +++++++++ src/helpers/CommonCLI.cpp | 12 +- src/helpers/RadioDirectionPolicy.h | 33 ++ src/helpers/RadioDirectionSettings.cpp | 154 +++++++++ src/helpers/RadioDirectionSettings.h | 16 + src/helpers/bridges/ESPNowBridge.cpp | 8 + src/helpers/bridges/UDPBridge.cpp | 4 + src/helpers/bridges/UDPBridge.h | 1 + src/helpers/radiolib/RadioLibWrappers.cpp | 159 ++++++++- src/helpers/radiolib/RadioLibWrappers.h | 5 + test/fixtures/cli_reply_text.json | 2 +- .../espnow_bridge_diagnostic_runtime.cpp | 3 +- .../udp_bridge/helpers/bridges/BridgeBase.h | 11 + test/fixtures/udp_bridge_runtime.cpp | 8 + test/test_bridge_ingress.py | 35 +- test/test_bridge_ingress_settings.py | 2 +- test/test_bridge_recovery.py | 22 +- test/test_bridge_source_routes.py | 162 +++++++++ test/test_radio_direction_dispatcher.py | 150 +++++++++ test/test_radio_direction_driver.py | 314 ++++++++++++++++++ test/test_radio_direction_settings.py | 216 ++++++++++++ test/test_transport_filter_wiring.py | 6 + 37 files changed, 1949 insertions(+), 47 deletions(-) create mode 100644 docs/split_radio_repeater.md create mode 100644 src/helpers/BridgeSourceRoutes.h create mode 100644 src/helpers/RadioDirectionPolicy.h create mode 100644 src/helpers/RadioDirectionSettings.cpp create mode 100644 src/helpers/RadioDirectionSettings.h create mode 100644 test/test_bridge_source_routes.py create mode 100644 test/test_radio_direction_dispatcher.py create mode 100644 test/test_radio_direction_driver.py create mode 100644 test/test_radio_direction_settings.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 466c7bf4a..6075053d8 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -475,6 +475,8 @@ jobs: python3 -B test/test_bridge_recovery.py -v python3 -B test/test_bridge_ingress.py -v python3 -B test/test_bridge_ingress_settings.py -v + python3 -B test/test_bridge_source_routes.py -v + python3 -B test/test_radio_direction_settings.py -v - name: Verify signed private fleet management run: | @@ -614,6 +616,8 @@ jobs: python3 -B test/test_esp32_cpu_clock.py -v python3 -B test/test_esp32_cpu_clock_board.py -v python3 -B test/test_boot_slot_cli.py -v + python3 -B test/test_radio_direction_driver.py -v + python3 -B test/test_radio_direction_dispatcher.py -v python3 -B test/test_esp32_power_test_controls.py -v python3 -B test/test_esp32_uart_quiet_session.py -v python3 -B test/test_companion_rxps_getters.py -v diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index ded327ece..ed31c22b8 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -252,7 +252,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto | Bridge | [`get/set bridge.enabled`; `get bridge.running`](cli_commands.md#view-or-change-the-bridge-enabled-flag) | Compiled bridge | Feature | Feature | Feature | | Bridge | [`get/set bridge.delay`](cli_commands.md#add-a-delay-to-packets-routed-through-this-bridge) | Compiled bridge | Feature | Feature | Feature | | Bridge | [`get/set bridge.source`](cli_commands.md#view-or-change-the-source-of-packets-bridged-to-the-external-interface) | Compiled bridge | Feature | Feature | Feature | -| Bridge | [`get/set bridge.ingress`](cli_commands.md#select-the-radio-for-incoming-bridge-packets) | Compiled incoming packet bridge; saved primary or radio2 selection | Feature | Feature | Feature | +| Bridge | [`get/set bridge.ingress`](cli_commands.md#select-the-radio-for-incoming-bridge-packets) | Compiled incoming packet bridge; saved primary/radio2 target or off | Feature | Feature | Feature | | Bridge | [`get/set bridge.baud`](cli_commands.md#view-or-change-the-speed-of-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature | | Bridge | [`get/set bridge.uart`](cli_commands.md#view-or-change-the-uart-used-by-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature | | Bridge | [`get/set bridge.channel`](cli_commands.md#view-or-change-the-channel-used-for-bridging-espnow-only) | ESP-NOW is ESP32 only | No | No | No | @@ -348,6 +348,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto | Logging | [`get/set usb.watchdog off\|on\|auto`](usb_logging_watchdog.md) | Current source; recovery requires native USB, live logging, and durable storage; Auto qualifies from USB stats polling | Yes | Yes | Feature | Yes | Yes | | Logging | [`get usb.watchdog.last`](usb_logging_watchdog.md#last-event-review) | Current source; latest USB watchdog event with advisory timestamp, uptime, action, reasons, and durability; native USB required | Yes | Yes | Feature | Yes | Yes | | Radio | [`get radio`; `set radio ...`](cli_commands.md#view-or-change-this-nodes-radio-parameters) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | +| Radio | [`get/set radio.mode`](cli_commands.md#select-lora-receive-and-transmit-directions) | ESP32 infrastructure LoRa direction: both, rx, tx or off | Yes | Yes | Yes | Yes | Yes | | Radio | [`get tx`; `set tx `](cli_commands.md#view-or-change-this-nodes-transmit-power) | Board TX-power limits apply; busy changes apply automatically | Yes | Yes | Yes | Yes | Yes | | Radio | [`tempradio ...`; `normalradio`](cli_commands.md#change-the-radio-parameters-for-a-set-duration) | Full parser | Yes | Yes | Yes | Yes | Yes | | Radio | [`get/set/del radioat`; `get/set/del tempradioat`](cli_commands.md#schedule-radio-parameter-changes) | Full parser | Yes | Yes | Yes | Yes | Yes | @@ -432,7 +433,8 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto | Bridge | [`get/set bridge.enabled`; `get bridge.running`](cli_commands.md#view-or-change-the-bridge-enabled-flag) | Compiled bridge | Feature | Feature | Feature | Yes | Feature | | Bridge | [`get/set bridge.delay`](cli_commands.md#add-a-delay-to-packets-routed-through-this-bridge) | Compiled bridge | Feature | Feature | Feature | Yes | Feature | | Bridge | [`get/set bridge.source`](cli_commands.md#view-or-change-the-source-of-packets-bridged-to-the-external-interface) | Compiled bridge | Feature | Feature | Feature | Yes | Feature | -| Bridge | [`get/set bridge.ingress`](cli_commands.md#select-the-radio-for-incoming-bridge-packets) | Compiled incoming packet bridge; saved primary or radio2 selection | Feature | Feature | Feature | Feature | Feature | +| Bridge | [`get/set bridge.ingress`](cli_commands.md#select-the-radio-for-incoming-bridge-packets) | Compiled incoming packet bridge; saved primary/radio2 target or off | Feature | Feature | Feature | Feature | Feature | +| Bridge | [`get/set/del bridge.route.1..8`; `clear bridge.routes`](cli_commands.md#route-incoming-bridge-packets-by-sender) | Exact ESP-NOW station MAC or UDP endpoint routing; compiled bridge required | Feature | Feature | Feature | Feature | Feature | | Bridge | [`get/set bridge.baud`](cli_commands.md#view-or-change-the-speed-of-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature | No | Feature | | Bridge | [`get/set bridge.uart`](cli_commands.md#view-or-change-the-uart-used-by-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature | No | Feature | | Bridge | [`get/set bridge.channel`](cli_commands.md#view-or-change-the-channel-used-for-bridging-espnow-only) | ESP-NOW bridge | No | No | No | Feature | Feature | diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 082349562..7916f943a 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -2009,6 +2009,35 @@ capacity. Remote use follows the existing administrator-command permissions. ### Radio +#### Select LoRa receive and transmit directions + +```text +get radio.mode +set radio.mode both +set radio.mode rx +set radio.mode tx +set radio.mode off +``` + +Supported ESP32 infrastructure images save this setting across reboot. The +default is `both`. `rx` permits LoRa reception and blocks all LoRa transmission, +including forwarding, adverts and replies. `tx` accepts no LoRa packets while +allowing bridge-fed and locally generated transmissions. Queued packets require a clear +CAD result on the selected profile before sending and keep backing off while +busy, without a timeout that bypasses CAD. `get cad` reports the effective +mandatory check in TX-only mode, even if its saved preference is off. A clear +check cannot guarantee collision-free delivery. `off` disables both packet directions. These +choices apply to the primary and second LoRa profiles; they do not change the +WiFi, ESP-NOW or serial bridge directions, or make an RX-only radio2 profile +transmit-capable. + +Use USB or a direct WiFi CLI when changing direction. A receive-only node +cannot send its LoRa command reply, and a transmit-only node cannot receive a +LoRa command. Invalid or unreadable saved settings leave LoRa off rather than +silently enabling transmission. A known malformed record can be replaced +explicitly; uncertain storage requires a reboot before another save. +See [two RX-only V4s feeding one TX-only V4](split_radio_repeater.md). + #### Tune and save radio switching acceleration Use local USB or BLE to change these settings on supported radio HALs. @@ -6799,6 +6828,7 @@ updated active peer can still answer a valid incoming ping in passive mode. get bridge.ingress set bridge.ingress radio2 set bridge.ingress radio +set bridge.ingress off ``` This receiver-side setting applies to incoming mesh packets from ESP-NOW, UDP @@ -6813,8 +6843,8 @@ silently restoring the primary; `get bridge.ingress` reports the fault. keeps their forwarding on that profile, even with `radio2.cross on`. If radio2 is off, RX-only, owned by another protocol, or changed while a packet waits, the packet is dropped; it never falls back to the primary radio. Normal repeat, -scope, duplicate and filter checks still apply. This setting selects every -incoming packet on these bridges, not individual senders or payload types. +scope, duplicate and filter checks still apply. A matching source route below +overrides this default. `off` drops incoming packets without a matching route. The selection follows forwarded packets, retries and relayed ACKs. Locally generated adverts and admin replies keep their existing routing, including `tx.reply`; this is not a global transmit override. @@ -6825,6 +6855,31 @@ see the [MediaMesh bridge example](espnow_bridge_setup.md#send-bridge-traffic-on `bridge.ingress`. This does not add MQTT packet injection or change MQTT publication settings. +#### Route incoming bridge packets by sender + +```text +get bridge.route +get bridge.route.1 +set bridge.route.1 mac:AA:BB:CC:DD:EE:10 radio2 +set bridge.route.2 udp:192.168.1.51:4404 radio +del bridge.route.1 +clear bridge.routes +``` + +Slots `1..8` match an exact ESP-NOW station MAC (`mac:`) or UDP sender address +and listening port (`udp:`). Each source can appear once. A matching rule +selects only `radio`, only `radio2`, or drops that source with `off`, regardless +of crossing settings. Unmatched packets use `bridge.ingress`; set that to +`off` to accept only listed senders. `get bridge.route` reports the count and +default; query each numbered slot for its saved rule. `get espnow.stats mac` +reads the local station MAC once the ESP-NOW/WiFi driver is active. + +Rules are saved separately from the default and from legacy node preferences. +Missing routes preserve existing behavior and bridge frames remain unchanged. +New admissions use the saved route; queued packets retain their original +profile and generation. Unavailable, RX-only or foreign-protocol targets drop +the packet rather than falling back. See the [three-V4 example](split_radio_repeater.md). + --- #### View or change the MQTT master switch diff --git a/docs/espnow_bridge_setup.md b/docs/espnow_bridge_setup.md index 714a05e8d..7c03a3a86 100644 --- a/docs/espnow_bridge_setup.md +++ b/docs/espnow_bridge_setup.md @@ -4,6 +4,9 @@ # ESP-NOW bridge: simple setup +For two dedicated receivers feeding one dual-profile transmitter, see +[the three-V4 split repeater setup](split_radio_repeater.md). + Use two ESP32 LoRa boards to join two MeshCore networks with different radio settings. Each board repeats on its own LoRa network, and the boards exchange packets directly over 2.4 GHz ESP-NOW. No router, internet connection, or manual diff --git a/docs/split_radio_repeater.md b/docs/split_radio_repeater.md new file mode 100644 index 000000000..dbfacaf69 --- /dev/null +++ b/docs/split_radio_repeater.md @@ -0,0 +1,164 @@ + + + + +# Two receive-only V4s feeding one transmit-only V4 + +Use the exact board's updated **Full Repeater** image on all three V4s. The +left V4 receives MediaMesh, the right V4 receives USA, and both feed the center +over ESP-NOW or WiFi UDP: + +```text +MediaMesh 911.150 --> left V4 (LoRa RX only) --+ + +--> center V4 (LoRa TX only) +USA 910.525 --> right V4 (LoRa RX only) --+ radio: USA 910.525 + radio2: MediaMesh 911.150 +``` + +The center routes by each bridge sender's identity. It never needs to infer +the source frequency from encrypted packet contents. Its single LoRa chip +transmits the two profiles sequentially; they cannot transmit simultaneously. + +Configure over USB or a direct WiFi CLI. `radio.mode rx` also prevents LoRa +admin replies, and `tx` prevents receiving LoRa commands. These modes apply to +both LoRa profiles and persist across reboot. They leave the 2.4 GHz bridge +able to transmit and receive. TX-only requires a clear-channel CAD check on +the selected LoRa profile before forwarding a packet. If busy, the center keeps +backing off; it does not bypass CAD after a timeout. A clear check cannot +guarantee collision-free delivery. The center does not accept LoRa packets. + +## ESP-NOW setup + +On **all three V4s**, prepare the same bridge group. Replace `YourSharedKey` +with the same 1–15 character secret, and use one common 2.4 GHz channel: + +```text +set espnow.enabled off +set bridge.channel 6 +set bridge.format wrapped +set bridge.secret YourSharedKey +set bridge.source rx +set bridge.delay 500 +clear bridge.routes +set bridge.ingress off +``` + +`clear bridge.routes` replaces any existing source mappings. With ingress +`off`, a sender without an explicit rule cannot enter the local LoRa mesh. +Using source `rx` on the center also prevents its LoRa transmissions being +exported back to the receivers. + +On the **left V4**: + +```text +set radio.mode rx +set radio 911.150,250,7,5 +set radio2 off +set radio2.cross off +set repeat off +set espnow.enabled on +get espnow.stats mac +``` + +On the **right V4**: + +```text +set radio.mode rx +set radio 910.525,62.5,7,5 +set radio2 off +set radio2.cross off +set repeat off +set espnow.enabled on +get espnow.stats mac +``` + +Record each returned `mac:AA:BB:CC:DD:EE:FF` value. This is the WiFi station +MAC used by ESP-NOW, not the node's MeshCore public key or Bluetooth address. + +On the **center V4**, substitute those actual MAC addresses: + +```text +set radio.mode tx +set radio 910.525,62.5,7,5 +set radio2 mediamesh +set radio2.cross off +set bridge.route.1 mac:AA:BB:CC:DD:EE:10 radio2 +set bridge.route.2 mac:AA:BB:CC:DD:EE:20 radio +set repeat on +set espnow.enabled on +``` + +The first example MAC represents the left V4; the second represents the right. +Forwarded left traffic stays on MediaMesh, and forwarded right traffic on USA. +If a selected profile is unavailable, incoming packets are dropped rather +than moved to the other channel. Normal duplicate, scope, filter and airtime +checks still apply. The `mediamesh` preset also enables the normal MediaMesh +forwarding region. The center's own adverts and replies retain their existing +routing policies, including `tx.reply`; source rules select forwarded traffic. + +## WiFi UDP alternative + +Keep the LoRa settings and direction modes above. Disable ESP-NOW on all +three, connect them to an existing WiFi network using the [WiFi settings](WiFi.md), +and reserve their DHCP addresses. These examples use left `192.168.1.50`, +right `192.168.1.51`, center `192.168.1.52`, all on UDP port `4404`. +Keep the same `bridge.secret` on all three. + +On **each receiver**, configure the center as its only UDP destination: + +```text +set espnow.enabled off +set udp.enabled off +set udp.port 4404 +set udp.peer.1 192.168.1.52:4404 +set udp.enabled on +get udp.status +``` + +Remove any other saved `udp.peer.2` through `.8` destinations if present. On +the **center**, replace the MAC rules with endpoint rules and allow both peers: + +```text +set espnow.enabled off +set udp.enabled off +set udp.port 4404 +set udp.peer.1 192.168.1.50:4404 +set udp.peer.2 192.168.1.51:4404 +clear bridge.routes +set bridge.ingress off +set bridge.route.1 udp:192.168.1.50:4404 radio2 +set bridge.route.2 udp:192.168.1.51:4404 radio +set udp.enabled on +get udp.status +``` + +The route matches the sender's IP address **and UDP listening port**. Both +ends must allow each other's endpoint for the authenticated bridge session. +See [UDP bridge setup](udp_bridge_setup.md) for station readiness, router +isolation and transport probes. + +## Check the result + +On each receiver, `get radio.mode` should report `rx`. On the center: + +```text +get radio.mode +get cad +get radio +get radio2 +get bridge.ingress +get bridge.route.1 +get bridge.route.2 +get radio2.status +``` + +Expect center mode `tx`, effective CAD on, default ingress `off`, the left rule targeting +`radio2`, and the right rule targeting `radio`. Send a packet on each LoRa +network and check receipt on that same network beyond the center. Bridge +counters show transport activity but do not by themselves prove LoRa delivery. + +To return a V4 to normal LoRa operation, use `set radio.mode both`. Existing +installations default to `both`, with no source routes and primary-radio +bridge ingress. Existing bridge wire formats are unchanged. Strict direction +modes require updated firmware on each configured V4; old bridge senders can +still feed an updated center using the source mappings. diff --git a/docs/udp_bridge_setup.md b/docs/udp_bridge_setup.md index 926a52533..0358beec7 100644 --- a/docs/udp_bridge_setup.md +++ b/docs/udp_bridge_setup.md @@ -4,6 +4,9 @@ # WiFi UDP bridge: up to eight IP peers +For two receive-only V4s feeding one transmit-only V4, see +[the split repeater setup](split_radio_repeater.md#wifi-udp-alternative). + 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 diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 93a945a79..9da6ba5be 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -5,6 +5,9 @@ #include #include #include // needed for PlatformIO +#if defined(ESP32_PLATFORM) +#include +#endif #include #include #include @@ -223,6 +226,10 @@ void setup() { store.begin(); #else #error "need to define filesystem" +#endif +#if defined(ESP32_PLATFORM) + // Load direction before initializing the radio, including its first RX arm. + mesh::radioDirection::loadDirectionSettings(fs); #endif FILESYSTEM* regulator_prefs_fs = fs; #if defined(NRF52_PLATFORM) diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index f5cc3339f..0e5ad2081 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -5,6 +5,9 @@ #include #include #include // needed for PlatformIO +#if defined(ESP32_PLATFORM) +#include +#endif #include #include #include @@ -154,6 +157,9 @@ void setup() { IdentityStore store(SPIFFS, "/identity"); #else #error "need to define filesystem" +#endif +#if defined(ESP32_PLATFORM) + mesh::radioDirection::loadDirectionSettings(fs); #endif FILESYSTEM* regulator_prefs_fs = fs; #if defined(NRF52_PLATFORM) diff --git a/mkdocs.yml b/mkdocs.yml index 6303c01b1..e6f693fde 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -81,6 +81,7 @@ nav: - room_services.md - carrier_wave.md - espnow_bridge_setup.md + - split_radio_repeater.md - rs232_bridge_setup.md - udp_bridge_setup.md - WiFi.md diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index a283b334a..875044f35 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -1,6 +1,7 @@ #include "Dispatcher.h" #include "helpers/HostKissHooks.h" #include "helpers/ota/OtaTiming.h" +#include "helpers/RadioDirectionPolicy.h" #if MESH_ENABLE_OTA_KISS_DIRECT #include "helpers/ota/OtaDirectWire.h" #endif @@ -184,6 +185,7 @@ bool Dispatcher::restoreOutboundTxOverrides() { } bool Dispatcher::startOutboundTransmit() { + if (!radioDirection::radioTransmitAllowed()) return false; if (outbound == NULL) return false; if (!restoreOutboundTxOverrides()) return false; if (!isPacketRadioCurrent(outbound) @@ -299,6 +301,7 @@ bool Dispatcher::supportsForeignRadioProtocol(RadioProfileProtocol protocol) con } bool Dispatcher::isForeignRadioCurrent() const { + if (!radioDirection::radioTransmitAllowed()) return false; const auto* profiles = _radio->profiles(); #if MESH_ENABLE_OTA_KISS_DIRECT if (foreign_radio_protocol == RadioProfileProtocol::MeshCore) { @@ -321,6 +324,7 @@ bool Dispatcher::isForeignRadioCurrent() const { #if MESH_ENABLE_OTA_KISS_DIRECT bool Dispatcher::queueDirectOtaRadioFrame(const uint8_t* message, uint16_t length, uint8_t profile, uint32_t generation) { + if (!radioDirection::radioTransmitAllowed()) return false; #if MESH_ENABLE_TX_BEACONS if (hasBeaconRadioFrame()) return false; #endif @@ -355,6 +359,7 @@ __attribute__((noinline)) bool Dispatcher::queueForeignRadioFrame(const uint8_t* bytes, size_t length, uint8_t profile, uint32_t generation, RadioProfileProtocol protocol) { + if (!radioDirection::radioTransmitAllowed()) return false; #if MESH_ENABLE_TX_BEACONS if (hasBeaconRadioFrame()) return false; #endif @@ -504,7 +509,8 @@ bool Dispatcher::checkForeignRadioSend() { uint32_t& busy_since = profile_cad_busy[foreign_radio_profile]; if (_radio->isReceiving()) { if (!busy_since) busy_since = now ? now : 1; - if ((uint32_t)(now - busy_since) <= getCADFailMaxDuration()) { + if (radioDirection::mode() == radioDirection::Mode::Tx || + (uint32_t)(now - busy_since) <= getCADFailMaxDuration()) { foreign_radio_retry_at = now + getCADFailRetryDelay(); return false; } @@ -567,12 +573,12 @@ void Dispatcher::logPacketLine(const char* direction, const Packet* packet, #endif bool Dispatcher::getNextNoiseFloorWakeDelay(uint32_t& delay_millis) const { - return radio_available + return radioDirection::radioReceiveAllowed() && radio_available && _radio->getNoiseFloorWakeDelay(_ms->getMillis(), delay_millis); } bool Dispatcher::getNextQueueWakeDelay(uint32_t& delay_millis) const { - const bool dual_profile = isDualRadioActive(); + const bool dual_profile = radioDirection::radioReceiveAllowed() && isDualRadioActive(); uint32_t profile_delay = 0; if (dual_profile && (!radio_available || !_radio->getProfileIdleDelay(profile_delay) || profile_delay == 0)) { @@ -600,7 +606,7 @@ bool Dispatcher::getNextQueueWakeDelay(uint32_t& delay_millis) const { #endif // Timed MCU sleep must not skip the adaptive maintenance/retry deadline. // Preserve the legacy queue-only query when power saving is off. - if (radio_available && useAdaptiveAGC()) { + if (radioDirection::radioReceiveAllowed() && radio_available && useAdaptiveAGC()) { uint32_t agc_delay; const bool agc_found = agc_reset.nextDelay(now, agc_delay); if (agc_found && (!found || agc_delay < shortest_delay)) shortest_delay = agc_delay; @@ -614,7 +620,7 @@ bool Dispatcher::getNextQueueWakeDelay(uint32_t& delay_millis) const { } uint32_t receive_retry_delay = 0; - const bool receive_retry = radio_available + const bool receive_retry = radioDirection::radioReceiveAllowed() && radio_available && _radio->getReceiveRetryDelay(now, receive_retry_delay); if (outbound != NULL) { if (outbound_radio_retry_pending) { @@ -700,11 +706,14 @@ void Dispatcher::loop() { // Restore before any calibration/scan/watchdog can observe a foreign tuple. if (serviceBeaconRadioFrame()) return; #endif - if (millisHasNowPassed(next_floor_calib_time)) { + if (radioDirection::radioReceiveAllowed() && millisHasNowPassed(next_floor_calib_time)) { _radio->triggerNoiseFloorCalibrate(getInterferenceThreshold()); _radio->setCADEnabled(getCADEnabled()); next_floor_calib_time = futureMillis(NOISE_FLOOR_CALIB_INTERVAL); } + // TX-only skips receive calibration, but keeps its saved CAD policy. + if (!radioDirection::radioReceiveAllowed()) _radio->setCADEnabled( + radioDirection::mode() == radioDirection::Mode::Tx || getCADEnabled()); _radio->loop(); // A maintenance carrier has no TxDone packet. Keep its deadline serviced, // but leave queued packets and RX/AGC watchdogs alone until normal RX returns. @@ -716,8 +725,9 @@ void Dispatcher::loop() { #endif const unsigned long now = _ms->getMillis(); - const int agc_interval = getAGCResetInterval(); - const bool adaptive = useAdaptiveAGC(); + const bool receive_allowed = radioDirection::radioReceiveAllowed(); + const int agc_interval = receive_allowed ? getAGCResetInterval() : 0; + const bool adaptive = receive_allowed && useAdaptiveAGC(); agc_reset.configure(now, agc_interval > 0 ? agc_interval : 0, adaptive, adaptive ? _radio->getRecoveryCount() : 0); if (!radio_nonrx_timer_armed) { @@ -741,7 +751,17 @@ void Dispatcher::loop() { #endif } bool recovered_this_loop = false; - if (!is_recv && outbound == NULL && now - radio_nonrx_start > 8000) { + if (!receive_allowed) { + // Intentional TX-only/disabled operation is not a deaf receiver. Give RX + // a fresh watchdog allowance when it is enabled again. + radio_nonrx_start = now; +#ifdef RADIO_LIVENESS_SOFT_ONLY + rx_watchdog_window_start = now; +#else + radio_liveness.begin(now); +#endif + } + if (receive_allowed && !is_recv && outbound == NULL && now - radio_nonrx_start > 8000) { _err_flags |= ERR_EVENT_STARTRX_TIMEOUT; #ifdef RADIO_LIVENESS_SOFT_ONLY MESH_DEBUG_PRINTLN("Radio watchdog: radio outside RX for %lu ms; soft recovery", @@ -938,7 +958,7 @@ void Dispatcher::loop() { // a packet arriving at the deadline prevents unnecessary recovery, and a // continuous TX queue cannot hide a deaf receiver. The driver defers any // recovery that would interrupt a pending packet or an active reception. - if (_radio->isInRecvMode() && outbound == NULL && !recovered_this_loop) { + if (receive_allowed && _radio->isInRecvMode() && outbound == NULL && !recovered_this_loop) { const unsigned long now = _ms->getMillis(); uint32_t soft_liveness_ms = RADIO_LIVENESS_SOFT_MS; #ifdef WITH_MQTT_BRIDGE @@ -1061,6 +1081,7 @@ bool Dispatcher::tryParsePacket(Packet* pkt, const uint8_t* raw, int len) { } void Dispatcher::checkRecv() { + if (!radioDirection::radioReceiveAllowed()) return; if (_radio->isCarrierWaveActive()) return; Packet* pkt; float score; @@ -1193,6 +1214,12 @@ void Dispatcher::checkRecv() { } void Dispatcher::processRecvPacket(Packet* pkt) { + // Bridge admissions are marked forwarded before they enter this queue. + // Retire RF receptions delayed before a switch to TX-only/off. + if (!radioDirection::radioReceiveAllowed() && !pkt->forwarded) { + releasePacket(pkt); + return; + } const auto* profiles = _radio->profiles(); if (profiles && (pkt->radio_profile > 1 || (pkt->radio_profile == 1 && !profiles->enabled()) @@ -1247,7 +1274,7 @@ bool Dispatcher::isTransmitChannelReady(const Packet* packet, uint32_t& retry_de } const uint32_t maximum = scaleCADDelayForQueue( getCADFailMaxDuration(), ready_count, MIN_CAD_FAIL_MAX_DURATION_MS); - if (now - cad_busy_start <= maximum) { + if (radioDirection::mode() == radioDirection::Mode::Tx || now - cad_busy_start <= maximum) { retry_delay = scaleCADDelayForQueue( getCADFailRetryDelay(), ready_count, MIN_CAD_FAIL_RETRY_DELAY_MS); return false; @@ -1434,6 +1461,7 @@ bool Dispatcher::queueTransmitCandidate(Packet* packet, uint8_t priority, uint32 } bool Dispatcher::queueOutboundPacket(Packet* packet, uint8_t priority, uint32_t delay_millis) { + if (!radioDirection::radioTransmitAllowed()) return false; if (packet->violatesRoutePolicy()) { return false; } @@ -1509,6 +1537,7 @@ bool Dispatcher::queueOutboundPacket(Packet* packet, uint8_t priority, uint32_t } uint8_t Dispatcher::getTransmitProfileMask(const Packet* packet) const { + if (!radioDirection::radioTransmitAllowed()) return 0; const auto* profiles = _radio->profiles(); if (!profiles) return packet->tx_radio == RADIO_TX_AUTO ? 1 : explicitRadioTxMask(packet->tx_radio, false); if (packet->radio_profile > 1) return 0; @@ -1544,6 +1573,7 @@ uint32_t Dispatcher::getTransmitAirtime(const Packet* packet, int wire_length) c } bool Dispatcher::isPacketRadioCurrent(const Packet* packet) const { + if (!radioDirection::radioTransmitAllowed()) return false; const auto* p = _radio->profiles(); if (!p) return true; const uint8_t target = packet->radio_profile; diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 1c1784089..811b44b69 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -13,6 +13,7 @@ #include #include #include +#include #ifndef RADIO_LIVENESS_SOFT_ONLY #include #endif diff --git a/src/DispatcherBeacon.cpp b/src/DispatcherBeacon.cpp index a595048df..c684bca26 100644 --- a/src/DispatcherBeacon.cpp +++ b/src/DispatcherBeacon.cpp @@ -1,4 +1,5 @@ #include "Dispatcher.h" +#include "helpers/RadioDirectionPolicy.h" #if MESH_ENABLE_TX_BEACONS #include @@ -18,6 +19,7 @@ Dispatcher::~Dispatcher() { delete beacon_tx; } bool Dispatcher::queueBeaconRadioFrame(const uint8_t* bytes, size_t length, const RadioProfileParams& params, int8_t power) { + if (!radioDirection::radioTransmitAllowed()) return false; if (!bytes || !length || length > MAX_TRANS_UNIT || beacon_tx || outbound || !radio_available || _radio->isCarrierWaveActive() || _radio->isBeaconRadioOwned() @@ -93,6 +95,10 @@ bool Dispatcher::serviceBeaconRadioFrame() { } bool Dispatcher::checkBeaconRadioSend() { + if (!radioDirection::radioTransmitAllowed()) { + if (beacon_tx) cancelBeaconRadioFrame(); + return beacon_tx != nullptr; + } if (!beacon_tx || outbound || beacon_tx->phase != BeaconTransmit::Pending) return false; const uint32_t now = _ms->getMillis(); if (int32_t(now - beacon_tx->prepare_expiry) >= 0) { diff --git a/src/helpers/AbstractBridge.h b/src/helpers/AbstractBridge.h index 0e1f89f55..89b6287ad 100644 --- a/src/helpers/AbstractBridge.h +++ b/src/helpers/AbstractBridge.h @@ -8,14 +8,30 @@ class AbstractBridge { PacketFilter _packet_filter = nullptr; void* _packet_filter_context = nullptr; const mesh::RadioProfiles* _ingress_profiles = nullptr; + const mesh::bridge::IngressSource* _ingress_source = nullptr; protected: + // Source identity is borrowed only while the main loop admits one frame. + // The queued Packet retains the selected profile, not this stack pointer. + class IngressSourceScope { + AbstractBridge& owner; + const mesh::bridge::IngressSource* previous; + public: + IngressSourceScope(AbstractBridge& bridge, const mesh::bridge::IngressSource& source) + : owner(bridge), previous(bridge._ingress_source) { owner._ingress_source = &source; } + ~IngressSourceScope() { owner._ingress_source = previous; } + IngressSourceScope(const IngressSourceScope&) = delete; + IngressSourceScope& operator=(const IngressSourceScope&) = delete; + }; + // The receiver selects a local MeshCore profile, independent of the wire // format and the scanner's current tuple. Legacy bridges default to primary. // Snapshot the generation so delayed frames cannot acquire a newer lease. bool preparePacketIngress(mesh::Packet* packet) const { - const auto target = mesh::bridge::ingressProfile(); - if (!packet || target == mesh::bridge::IngressProfile::Blocked) return false; + const auto route = mesh::bridge::ingressRoute(_ingress_source); + const auto target = route.profile; + if (!packet || target == mesh::bridge::IngressProfile::Blocked + || target == mesh::bridge::IngressProfile::Off) return false; const uint8_t profile = target == mesh::bridge::IngressProfile::Radio2 ? 1 : 0; if ((profile == 1 && (!_ingress_profiles || !_ingress_profiles->enabled() || !_ingress_profiles->canTransmit(1))) @@ -28,7 +44,8 @@ protected: packet->radio_local = false; // Explicit secondary ingress stays secondary even if crossing is enabled. // The default primary selector retains its historical crossing behavior. - packet->tx_radio = profile == 1 ? mesh::RADIO_TX_SECONDARY : mesh::RADIO_TX_AUTO; + packet->tx_radio = profile == 1 ? mesh::RADIO_TX_SECONDARY + : route.explicit_profile ? mesh::RADIO_TX_PRIMARY : mesh::RADIO_TX_AUTO; packet->radio_reply = packet->radio_reply_force = false; packet->forwarded = true; return true; diff --git a/src/helpers/BridgeIngressProfile.h b/src/helpers/BridgeIngressProfile.h index b756d4b75..c59695f7f 100644 --- a/src/helpers/BridgeIngressProfile.h +++ b/src/helpers/BridgeIngressProfile.h @@ -1,4 +1,6 @@ #pragma once +#ifndef MESH_BRIDGE_INGRESS_PROFILE_H +#define MESH_BRIDGE_INGRESS_PROFILE_H #include @@ -15,12 +17,47 @@ namespace mesh { namespace bridge { -enum class IngressProfile : uint8_t { Radio = 0, Radio2 = 1, Blocked = 2 }; +enum class IngressProfile : uint8_t { Radio = 0, Radio2 = 1, Blocked = 2, Off = 3 }; + +struct IngressSource { + enum class Kind : uint8_t { None = 0, Mac = 1, Udp = 2 }; + Kind kind = Kind::None; + uint8_t bytes[6] = {}; + + static IngressSource mac(const uint8_t* address) { + IngressSource source; + if (address) { + source.kind = Kind::Mac; + for (unsigned i = 0; i < 6; ++i) source.bytes[i] = address[i]; + } + return source; + } + static IngressSource udp(const uint8_t* address, uint16_t port) { + IngressSource source; + if (address && port) { + source.kind = Kind::Udp; + for (unsigned i = 0; i < 4; ++i) source.bytes[i] = address[i]; + source.bytes[4] = port >> 8; + source.bytes[5] = port; + } + return source; + } +}; + +struct IngressRoute { + IngressProfile profile; + bool explicit_profile; +}; #if MESH_ENABLE_BRIDGE_INGRESS IngressProfile ingressProfile(); +IngressRoute ingressRoute(const IngressSource* source = nullptr); #else inline IngressProfile ingressProfile() { return IngressProfile::Radio; } +inline IngressRoute ingressRoute(const IngressSource* = nullptr) { + return {IngressProfile::Radio, false}; +} #endif }} // namespace mesh::bridge +#endif diff --git a/src/helpers/BridgeIngressSettings.cpp b/src/helpers/BridgeIngressSettings.cpp index cf6a3deb4..7e133a07d 100644 --- a/src/helpers/BridgeIngressSettings.cpp +++ b/src/helpers/BridgeIngressSettings.cpp @@ -3,6 +3,7 @@ #if MESH_ENABLE_BRIDGE_INGRESS #include "CheckedSettingsFile.h" #include "FilePresence.h" +#include "BridgeSourceRoutes.h" #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #include "AtomicFileWriter.h" #else @@ -18,6 +19,10 @@ constexpr size_t ImageSize = 10; FILESYSTEM* settings_fs = nullptr; IngressProfile selected = IngressProfile::Blocked; bool writable = false; +constexpr char RoutesPath[] = "/bridge_routes"; +IngressRule routes[MaxIngressRoutes]; +bool routes_healthy = false; +bool routes_writable = false; uint32_t checksum(const uint8_t* data, size_t size) { uint32_t crc = 0xffffffffU; @@ -30,7 +35,7 @@ uint32_t checksum(const uint8_t* data, size_t size) { } void encode(uint8_t image[ImageSize], IngressProfile profile) { - memcpy(image, "BIN\1", 4); + memcpy(image, profile == IngressProfile::Off ? "BIN\2" : "BIN\1", 4); image[4] = static_cast(profile); image[5] = image[4] ^ 0xff; const uint32_t crc = checksum(image, 6); @@ -38,7 +43,9 @@ void encode(uint8_t image[ImageSize], IngressProfile profile) { } bool decode(const uint8_t image[ImageSize], IngressProfile& profile) { - if (memcmp(image, "BIN\1", 4) != 0 || image[4] > 1 || + const bool v1 = memcmp(image, "BIN\1", 4) == 0; + const bool v2 = memcmp(image, "BIN\2", 4) == 0; + if ((!v1 && !v2) || (image[4] > 1 && !(v2 && image[4] == 3)) || image[5] != (image[4] ^ 0xff)) return false; const uint32_t saved_crc = uint32_t(image[6]) | (uint32_t(image[7]) << 8) | (uint32_t(image[8]) << 16) | (uint32_t(image[9]) << 24); @@ -81,13 +88,133 @@ bool saveSetting(IngressProfile profile) { selected = profile; return true; } + +void loadRoutes(FILESYSTEM* fs) { + for (auto& rule : routes) rule = IngressRule(); + routes_healthy = routes_writable = false; + if (!fs) return; +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + if (!ContactFileTransaction::recover(fs, RoutesPath, filePresence)) return; +#endif + bool present = false; + if (!filePresence(fs, RoutesPath, present)) return; + if (!present) { routes_healthy = routes_writable = true; return; } + uint8_t image[RoutesImageSize]; + const auto read = storage::readFixedSettingsFileResult(fs, RoutesPath, image, sizeof(image)); + if (read == storage::FixedSettingsRead::Unreadable) return; + routes_writable = true; + routes_healthy = read == storage::FixedSettingsRead::Complete && decodeIngressRules(image, routes); +} + +bool saveRoutes(const IngressRule* candidate) { + if (!settings_fs || !routes_writable || !validIngressRules(candidate)) return false; + uint8_t image[RoutesImageSize]; + encodeIngressRules(image, candidate); +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + AtomicFileWriter file(settings_fs, RoutesPath); +#else + ContactFileTransaction file(settings_fs, RoutesPath, filePresence); +#endif + using Outcome = decltype(file.outcome()); + if (!file || file.write(image, sizeof(image)) != sizeof(image)) { + if (file.outcome() == Outcome::Indeterminate) routes_healthy = routes_writable = false; + return false; + } + file.commit(); + if (file.outcome() == Outcome::NotCommitted) return false; + uint8_t verified[RoutesImageSize]; + if (file.outcome() != Outcome::Committed || + !storage::readFixedSettingsFile(settings_fs, RoutesPath, verified, sizeof(verified)) || + memcmp(verified, image, sizeof(image)) != 0) { + routes_healthy = routes_writable = false; + return false; + } + memcpy(routes, candidate, sizeof(routes)); + routes_healthy = true; + return true; +} + +bool handleRouteCommand(const char* command, char* reply, size_t capacity) { + const bool get = !strncmp(command, "get bridge.route", 16); + const bool set = !strncmp(command, "set bridge.route", 16); + const bool del = !strncmp(command, "del bridge.route", 16); + const bool clear = !strcmp(command, "clear bridge.routes"); + if (!clear && ((!get && !set && !del) || + (command[16] && command[16] != '.' && command[16] != ' ' && command[16] != '\t'))) return false; + if (!routes_writable) { + snprintf(reply, capacity, "Error: bridge route storage unavailable; reboot first"); + return true; + } + IngressRule candidate[MaxIngressRoutes]; + memcpy(candidate, routes, sizeof(candidate)); + if (clear) { + for (auto& rule : candidate) rule = IngressRule(); + } else { + if (!routes_healthy) { + snprintf(reply, capacity, "Error: bridge routes invalid; use clear bridge.routes"); + return true; + } + if (get && !command[16]) { + unsigned count = 0; + for (const auto& rule : routes) if (rule.source.kind != IngressSource::Kind::None) ++count; + snprintf(reply, capacity, "> %u/8 routes; get bridge.route.1..8; default=%s", count, + ingressProfileName(selected)); + return true; + } + const char* cursor = command + 16; + if (*cursor != '.' || cursor[1] < '1' || cursor[1] > '8' || + (cursor[2] && cursor[2] != ' ' && cursor[2] != '\t')) { + snprintf(reply, capacity, "Error: use get/set/del bridge.route.1..8"); + return true; + } + const unsigned index = cursor[1] - '1'; + cursor += 2; + if (get && !*cursor) { formatIngressRule(index + 1, routes[index], reply, capacity); return true; } + if (del && !*cursor) candidate[index] = IngressRule(); + else if (set && (*cursor == ' ' || *cursor == '\t')) { + while (*cursor == ' ' || *cursor == '\t') ++cursor; + char source_text[40]; + size_t length = 0; + while (*cursor && *cursor != ' ' && *cursor != '\t' && length + 1 < sizeof(source_text)) + source_text[length++] = *cursor++; + source_text[length] = 0; + while (*cursor == ' ' || *cursor == '\t') ++cursor; + IngressRule rule; + if (!parseIngressSource(source_text, rule.source) || !parseIngressProfile(cursor, rule.profile)) { + snprintf(reply, capacity, "Error: use set bridge.route.N mac:AA:BB:CC:DD:EE:FF|udp:IPv4:port radio|radio2|off"); + return true; + } + candidate[index] = rule; + if (!validIngressRules(candidate)) { + snprintf(reply, capacity, "Error: duplicate bridge route source"); + return true; + } + } else { + snprintf(reply, capacity, "Error: use get/set/del bridge.route.1..8"); + return true; + } + } + if (!saveRoutes(candidate)) + snprintf(reply, capacity, "Error: bridge routes not saved%s", routes_writable ? "" : "; ingress blocked until reboot"); + else snprintf(reply, capacity, clear ? "OK - bridge routes cleared" : "OK - bridge route saved"); + return true; +} } // namespace -IngressProfile ingressProfile() { return selected; } +IngressProfile ingressProfile() { return routes_healthy ? selected : IngressProfile::Blocked; } + +IngressRoute ingressRoute(const IngressSource* source) { + if (!routes_healthy || selected == IngressProfile::Blocked) return {IngressProfile::Blocked, false}; + if (source && source->kind != IngressSource::Kind::None) + for (const auto& rule : routes) + if (sameSource(*source, rule.source)) return {rule.profile, true}; + return {selected, false}; +} void loadIngressSettings(FILESYSTEM* fs) { settings_fs = fs; quarantine(); + loadRoutes(fs); if (!fs) return; #if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) if (!ContactFileTransaction::recover(fs, Path, filePresence)) return; @@ -110,6 +237,7 @@ void loadIngressSettings(FILESYSTEM* fs) { bool handleIngressCommand(const char* command, char* reply, size_t capacity) { if (!command || !reply || !capacity) return false; + if (handleRouteCommand(command, reply, capacity)) return true; const bool get = strncmp(command, "get bridge.ingress", 18) == 0 && (command[18] == 0 || command[18] == ' ' || command[18] == '\t'); const bool set = strncmp(command, "set bridge.ingress", 18) == 0 && @@ -118,19 +246,17 @@ bool handleIngressCommand(const char* command, char* reply, size_t capacity) { if (get) { if (command[18] != 0) snprintf(reply, capacity, "Error: use get bridge.ingress"); - else if (selected == IngressProfile::Blocked) + else if (ingressProfile() == IngressProfile::Blocked) snprintf(reply, capacity, "Error: bridge ingress blocked; settings invalid or unreadable"); else - snprintf(reply, capacity, "> %s", selected == IngressProfile::Radio2 ? "radio2" : "radio"); + snprintf(reply, capacity, "> %s", ingressProfileName(selected)); return true; } const char* value = command + 18; while (*value == ' ' || *value == '\t') ++value; IngressProfile profile; - if (strcmp(value, "radio") == 0) profile = IngressProfile::Radio; - else if (strcmp(value, "radio2") == 0) profile = IngressProfile::Radio2; - else { - snprintf(reply, capacity, "Error: use set bridge.ingress radio|radio2"); + if (!parseIngressProfile(value, profile)) { + snprintf(reply, capacity, "Error: use set bridge.ingress radio|radio2|off"); return true; } if (!writable) { diff --git a/src/helpers/BridgeSourceRoutes.h b/src/helpers/BridgeSourceRoutes.h new file mode 100644 index 000000000..2c5ee4a6a --- /dev/null +++ b/src/helpers/BridgeSourceRoutes.h @@ -0,0 +1,155 @@ +#pragma once + +#include "BridgeIngressProfile.h" +#include +#include +#include + +namespace mesh { namespace bridge { + +constexpr size_t MaxIngressRoutes = 8; +constexpr size_t RoutesImageSize = 4 + MaxIngressRoutes * 8 + 4; + +struct IngressRule { + IngressSource source; + IngressProfile profile = IngressProfile::Radio; +}; + +inline bool sameSource(const IngressSource& a, const IngressSource& b) { + return a.kind == b.kind && memcmp(a.bytes, b.bytes, sizeof(a.bytes)) == 0; +} + +inline bool validSource(const IngressSource& source) { + const auto* b = source.bytes; + if (source.kind == IngressSource::Kind::Mac) { + uint8_t any = 0; + for (uint8_t value : source.bytes) any |= value; + return any && !(b[0] & 1); // a sender must have an individual MAC address + } + return source.kind == IngressSource::Kind::Udp && b[0] && b[0] != 127 + && b[0] < 224 && (b[4] || b[5]); +} + +inline bool parseIngressProfile(const char* text, IngressProfile& profile) { + if (!text) return false; + if (!strcmp(text, "radio")) profile = IngressProfile::Radio; + else if (!strcmp(text, "radio2")) profile = IngressProfile::Radio2; + else if (!strcmp(text, "off")) profile = IngressProfile::Off; + else return false; + return true; +} + +inline const char* ingressProfileName(IngressProfile profile) { + return profile == IngressProfile::Radio ? "radio" + : profile == IngressProfile::Radio2 ? "radio2" + : profile == IngressProfile::Off ? "off" : "blocked"; +} + +inline int ingressHex(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +inline bool parseIngressSource(const char* text, IngressSource& source) { + if (!text) return false; + IngressSource candidate; + if (!strncmp(text, "mac:", 4)) { + if (strlen(text + 4) != 17) return false; + candidate.kind = IngressSource::Kind::Mac; + for (unsigned i = 0; i < 6; ++i) { + const int hi = ingressHex(text[4 + i * 3]), lo = ingressHex(text[5 + i * 3]); + if (hi < 0 || lo < 0 || (i < 5 && text[6 + i * 3] != ':')) return false; + candidate.bytes[i] = (hi << 4) | lo; + } + } else if (!strncmp(text, "udp:", 4)) { + candidate.kind = IngressSource::Kind::Udp; + const char* cursor = text + 4; + for (unsigned 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 || *cursor++ != (i == 3 ? ':' : '.')) return false; + candidate.bytes[i] = value; + } + unsigned port = 0, digits = 0; + while (*cursor >= '0' && *cursor <= '9') { + port = port * 10 + static_cast(*cursor++ - '0'); + if (++digits > 5 || port > 65535) return false; + } + if (!digits || *cursor || !port) return false; + candidate.bytes[4] = port >> 8; + candidate.bytes[5] = port; + } else return false; + if (!validSource(candidate)) return false; + source = candidate; + return true; +} + +inline bool validIngressRules(const IngressRule* rules) { + for (unsigned i = 0; i < MaxIngressRoutes; ++i) { + if (rules[i].source.kind == IngressSource::Kind::None) { + if (rules[i].profile != IngressProfile::Radio) return false; + for (uint8_t value : rules[i].source.bytes) if (value) return false; + continue; + } + if (!validSource(rules[i].source) || + (rules[i].profile != IngressProfile::Radio && rules[i].profile != IngressProfile::Radio2 + && rules[i].profile != IngressProfile::Off)) return false; + for (unsigned j = 0; j < i; ++j) + if (sameSource(rules[i].source, rules[j].source)) return false; + } + return true; +} + +inline uint32_t ingressRoutesChecksum(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 encodeIngressRules(uint8_t image[RoutesImageSize], const IngressRule* rules) { + memcpy(image, "BRT\1", 4); + for (unsigned i = 0; i < MaxIngressRoutes; ++i) { + image[4 + i * 8] = static_cast(rules[i].source.kind); + memcpy(image + 5 + i * 8, rules[i].source.bytes, 6); + image[11 + i * 8] = static_cast(rules[i].profile); + } + const uint32_t crc = ingressRoutesChecksum(image, RoutesImageSize - 4); + for (unsigned i = 0; i < 4; ++i) image[RoutesImageSize - 4 + i] = crc >> (i * 8); +} + +inline bool decodeIngressRules(const uint8_t image[RoutesImageSize], IngressRule* rules) { + if (memcmp(image, "BRT\1", 4)) return false; + uint32_t crc = 0; + for (unsigned i = 0; i < 4; ++i) crc |= uint32_t(image[RoutesImageSize - 4 + i]) << (i * 8); + if (crc != ingressRoutesChecksum(image, RoutesImageSize - 4)) return false; + IngressRule candidate[MaxIngressRoutes]; + for (unsigned i = 0; i < MaxIngressRoutes; ++i) { + candidate[i].source.kind = static_cast(image[4 + i * 8]); + memcpy(candidate[i].source.bytes, image + 5 + i * 8, 6); + candidate[i].profile = static_cast(image[11 + i * 8]); + } + if (!validIngressRules(candidate)) return false; + memcpy(rules, candidate, sizeof(candidate)); + return true; +} + +inline void formatIngressRule(unsigned number, const IngressRule& rule, char* reply, size_t capacity) { + const auto* b = rule.source.bytes; + if (rule.source.kind == IngressSource::Kind::None) snprintf(reply, capacity, "> %u off", number); + else if (rule.source.kind == IngressSource::Kind::Mac) + snprintf(reply, capacity, "> %u mac:%02X:%02X:%02X:%02X:%02X:%02X %s", number, + b[0], b[1], b[2], b[3], b[4], b[5], ingressProfileName(rule.profile)); + else snprintf(reply, capacity, "> %u udp:%u.%u.%u.%u:%u %s", number, + b[0], b[1], b[2], b[3], unsigned((b[4] << 8) | b[5]), ingressProfileName(rule.profile)); +} + +}} // namespace mesh::bridge diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 5f299c331..6832a9b0b 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -21,6 +21,7 @@ #include "HilStartupTrace.h" #include "FilePresence.h" #include "BridgeIngressSettings.h" +#include "RadioDirectionSettings.h" #if defined(ESP32_PLATFORM) #include "BootSlot.h" #include @@ -1018,6 +1019,9 @@ static void formatSnrDbX4Short(char* dest, size_t dest_len, int16_t snr_x4) { } void CommonCLI::loadPrefs(FILESYSTEM* fs) { +#if MESH_ENABLE_RADIO_DIRECTION + mesh::radioDirection::loadDirectionSettings(fs); +#endif #if MESH_ENABLE_BRIDGE_INGRESS mesh::bridge::loadIngressSettings(fs); #endif @@ -3567,6 +3571,9 @@ bool CommonCLI::handleUDPBridgeCommand(uint32_t sender_timestamp, void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* reply) { PrefsSaveReplyGuard save_reply(_prefs_save_failures, reply); mesh::cli::normalizeCommandVerb(command); +#if MESH_ENABLE_RADIO_DIRECTION + if (mesh::radioDirection::handleDirectionCommand(command, reply, 157)) return; +#endif #if MESH_ENABLE_BRIDGE_INGRESS if (mesh::bridge::handleIngressCommand(command, reply, 157)) return; #endif @@ -6230,7 +6237,10 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep } else if (configKeyEquals(config, "int.thresh")) { sprintf(reply, "> %d", (uint32_t) _prefs->interference_threshold); } else if (configKeyEquals(config, "cad")) { - sprintf(reply, "> %s. # channel busy: %u", _prefs->cad_enabled ? "on" : "off", _board->n_cad_busy); + const bool required = mesh::radioDirection::mode() == mesh::radioDirection::Mode::Tx; + snprintf(reply, 160, "> %s%s. # channel busy: %u", + (_prefs->cad_enabled || required) ? "on" : "off", + required ? " (required by radio.mode tx)" : "", _board->n_cad_busy); } else if (configKeyEquals(config, "agc.reset.interval")) { sprintf(reply, "> %d", ((uint32_t) _prefs->agc_reset_interval) * 4); } else if (configKeyEquals(config, "multi.acks")) { diff --git a/src/helpers/RadioDirectionPolicy.h b/src/helpers/RadioDirectionPolicy.h new file mode 100644 index 000000000..473d6af7d --- /dev/null +++ b/src/helpers/RadioDirectionPolicy.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#ifndef MESH_ENABLE_RADIO_DIRECTION +#if defined(ESP32_PLATFORM) +#define MESH_ENABLE_RADIO_DIRECTION 1 +#else +#define MESH_ENABLE_RADIO_DIRECTION 0 +#endif +#endif + +namespace mesh { namespace radioDirection { + +enum class Mode : uint8_t { Both = 0, Rx = 1, Tx = 2, Off = 3 }; + +#if MESH_ENABLE_RADIO_DIRECTION +Mode mode(); +#else +inline Mode mode() { return Mode::Both; } +#endif + +inline bool radioReceiveAllowed() { + const Mode value = mode(); + return value == Mode::Both || value == Mode::Rx; +} + +inline bool radioTransmitAllowed() { + const Mode value = mode(); + return value == Mode::Both || value == Mode::Tx; +} + +}} // namespace mesh::radioDirection diff --git a/src/helpers/RadioDirectionSettings.cpp b/src/helpers/RadioDirectionSettings.cpp new file mode 100644 index 000000000..97b4b1480 --- /dev/null +++ b/src/helpers/RadioDirectionSettings.cpp @@ -0,0 +1,154 @@ +#include "RadioDirectionSettings.h" + +#if MESH_ENABLE_RADIO_DIRECTION +#include "CheckedSettingsFile.h" +#include "ContactFileTransaction.h" +#include "FilePresence.h" +#include +#include + +namespace mesh { namespace radioDirection { +namespace { +constexpr char Path[] = "/radio_mode"; +constexpr size_t ImageSize = 10; +FILESYSTEM* settings_fs = nullptr; +// Companions do not load this infrastructure setting. Preserve their existing +// behavior until an infrastructure role explicitly initializes the record. +Mode selected = Mode::Both; +bool valid = true; +bool writable = false; + +uint32_t checksum(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; +} + +void encode(uint8_t image[ImageSize], Mode value) { + memcpy(image, "RDM\1", 4); + image[4] = static_cast(value); + image[5] = image[4] ^ 0xff; + const uint32_t crc = checksum(image, 6); + for (uint8_t i = 0; i < 4; ++i) image[6 + i] = crc >> (i * 8); +} + +bool decode(const uint8_t image[ImageSize], Mode& value) { + if (memcmp(image, "RDM\1", 4) != 0 || image[4] > 3 || + image[5] != (image[4] ^ 0xff)) return false; + const uint32_t saved_crc = uint32_t(image[6]) | (uint32_t(image[7]) << 8) | + (uint32_t(image[8]) << 16) | (uint32_t(image[9]) << 24); + if (saved_crc != checksum(image, 6)) return false; + value = static_cast(image[4]); + return true; +} + +void quarantine() { + selected = Mode::Off; + valid = false; + writable = false; +} + +bool saveSetting(Mode value) { + if (!settings_fs || !writable) return false; + uint8_t image[ImageSize]; + encode(image, value); + ContactFileTransaction file(settings_fs, Path, filePresence); + using Outcome = ContactFileTransaction::Outcome; + if (!file || file.write(image, sizeof(image)) != sizeof(image)) { + if (file.outcome() == Outcome::Indeterminate) quarantine(); + return false; + } + file.commit(); + if (file.outcome() == Outcome::NotCommitted) return false; + if (file.outcome() != Outcome::Committed) { + quarantine(); + return false; + } + uint8_t verified[ImageSize]; + if (!storage::readFixedSettingsFile(settings_fs, Path, verified, sizeof(verified)) || + memcmp(verified, image, sizeof(image)) != 0) { + quarantine(); + return false; + } + selected = value; + valid = true; + return true; +} + +const char* modeName(Mode value) { + switch (value) { + case Mode::Both: return "both"; + case Mode::Rx: return "rx"; + case Mode::Tx: return "tx"; + case Mode::Off: return "off"; + } + return "off"; +} +} // namespace + +Mode mode() { return selected; } + +void loadDirectionSettings(FILESYSTEM* fs) { + settings_fs = fs; + quarantine(); + if (!fs || !ContactFileTransaction::recover(fs, Path, filePresence)) return; + bool present = false; + if (!filePresence(fs, Path, present)) return; + if (!present) { + selected = Mode::Both; + valid = writable = true; + return; + } + uint8_t image[ImageSize]; + const auto read = storage::readFixedSettingsFileResult(fs, Path, image, sizeof(image)); + if (read == storage::FixedSettingsRead::Unreadable) return; + // A known malformed record permits explicit repair, while uncertainty must + // be reclassified before another write can replace the saved authority. + writable = true; + if (read == storage::FixedSettingsRead::Complete) valid = decode(image, selected); +} + +bool handleDirectionCommand(const char* command, char* reply, size_t capacity) { + if (!command || !reply || !capacity) return false; + const bool get = strncmp(command, "get radio.mode", 14) == 0 && + (command[14] == 0 || command[14] == ' ' || command[14] == '\t'); + const bool set = strncmp(command, "set radio.mode", 14) == 0 && + (command[14] == 0 || command[14] == ' ' || command[14] == '\t'); + if (!get && !set) return false; + if (get) { + if (command[14] != 0) + snprintf(reply, capacity, "Error: use get radio.mode"); + else if (!valid) + snprintf(reply, capacity, "Error: radio off; mode settings invalid or unreadable"); + else + snprintf(reply, capacity, "> %s", modeName(selected)); + return true; + } + const char* value = command + 14; + while (*value == ' ' || *value == '\t') ++value; + Mode candidate; + if (strcmp(value, "both") == 0) candidate = Mode::Both; + else if (strcmp(value, "rx") == 0) candidate = Mode::Rx; + else if (strcmp(value, "tx") == 0) candidate = Mode::Tx; + else if (strcmp(value, "off") == 0) candidate = Mode::Off; + else { + snprintf(reply, capacity, "Error: use set radio.mode both|rx|tx|off"); + return true; + } + if (!writable) { + snprintf(reply, capacity, "Error: radio mode storage unavailable; reboot first"); + } else if (!saveSetting(candidate)) { + snprintf(reply, capacity, "Error: radio mode not saved%s", + writable ? "" : "; storage uncertain, radio off until reload"); + } else { + snprintf(reply, capacity, "OK - radio.mode %s", value); + } + return true; +} + +}} // namespace mesh::radioDirection +#endif diff --git a/src/helpers/RadioDirectionSettings.h b/src/helpers/RadioDirectionSettings.h new file mode 100644 index 000000000..c3de6f8bb --- /dev/null +++ b/src/helpers/RadioDirectionSettings.h @@ -0,0 +1,16 @@ +#pragma once + +#include "RadioDirectionPolicy.h" +#include + +#if MESH_ENABLE_RADIO_DIRECTION +#include "IdentityStore.h" + +namespace mesh { namespace radioDirection { + +// Separate from common preferences; older installations retain both directions. +void loadDirectionSettings(FILESYSTEM* fs); +bool handleDirectionCommand(const char* command, char* reply, size_t capacity); + +}} // namespace mesh::radioDirection +#endif diff --git a/src/helpers/bridges/ESPNowBridge.cpp b/src/helpers/bridges/ESPNowBridge.cpp index 3a51c6838..d7b866c00 100644 --- a/src/helpers/bridges/ESPNowBridge.cpp +++ b/src/helpers/bridges/ESPNowBridge.cpp @@ -628,6 +628,8 @@ void ESPNowBridge::queueReceivedFrame(const uint8_t *mac, const uint8_t *data, bool ESPNowBridge::processReceivedFrame(mesh::Packet* packet_slot, const uint8_t *mac, const uint8_t *data, size_t len) { + const auto ingress_source = mesh::bridge::IngressSource::mac(mac); + IngressSourceScope ingress_scope(*this, ingress_source); if (data == nullptr || len == 0 || len > MAX_ESPNOW_PACKET_SIZE) { noteRx(&mesh::bridge::diag::RxStats::badframe); return false; @@ -920,6 +922,12 @@ void ESPNowBridge::formatStats(const char* page, char* reply, size_t reply_size) snprintf(reply, reply_size, "> rx=%lu mesh=%lu tx=%lu sent=%lu; pages: rx tx drops test", (unsigned long)s.rx.frames, (unsigned long)s.rx.delivered, (unsigned long)s.tx.queued, (unsigned long)s.tx.callback_ok); + } else if (strcmp(page, "mac") == 0) { + uint8_t mac[6]; + if (esp_wifi_get_mac(WIFI_IF_STA, mac) != ESP_OK) + snprintf(reply, reply_size, "Error: ESP-NOW station MAC unavailable; start the bridge first"); + else snprintf(reply, reply_size, "> mac:%02X:%02X:%02X:%02X:%02X:%02X", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); } else if (strcmp(page, "rx") == 0) { snprintf(reply, reply_size, "> frames=%lu decoded=%lu mesh=%lu crc=%lu bad=%lu parse=%lu", (unsigned long)s.rx.frames, (unsigned long)s.rx.packet_ok, diff --git a/src/helpers/bridges/UDPBridge.cpp b/src/helpers/bridges/UDPBridge.cpp index 4c46f0088..08499c9cd 100644 --- a/src/helpers/bridges/UDPBridge.cpp +++ b/src/helpers/bridges/UDPBridge.cpp @@ -201,6 +201,7 @@ void UDPBridge::receiveFrame(uint8_t peer, const uint8_t* data, size_t length) { // 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; + _rx_peer = peer; } void UDPBridge::pumpReceive() { for (uint8_t count = 0; count < 2 && !_rx_length && _initialized; ++count) { @@ -228,6 +229,9 @@ void UDPBridge::drainReceive() { const bool parsed = packet->readFrom(_rx_payload, static_cast(_rx_length)); _rx_length = 0; if (!parsed) { _mgr->free(packet); note(_stats.parse); return; } + const auto ingress_source = mesh::bridge::IngressSource::udp( + _active.peers[_rx_peer].ip, _active.peers[_rx_peer].port); + IngressSourceScope ingress_scope(*this, ingress_source); packet->udp_bridge_ingress = true; onPacketReceived(packet); } diff --git a/src/helpers/bridges/UDPBridge.h b/src/helpers/bridges/UDPBridge.h index f11a7850d..22184d64f 100644 --- a/src/helpers/bridges/UDPBridge.h +++ b/src/helpers/bridges/UDPBridge.h @@ -44,6 +44,7 @@ private: // 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; + uint8_t _rx_peer = 0; struct Stats { uint32_t frames = 0, authenticated = 0, delivered = 0; uint32_t malformed = 0, authentication = 0, foreign = 0, replay = 0; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index bbc20ef2b..093aaf546 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -237,6 +237,9 @@ bool RadioLibWrapper::claimRadioInterrupt() { } void RadioLibWrapper::begin() { +#if MESH_ENABLE_RADIO_DIRECTION + _direction_receive_disabled = false; +#endif #if MESH_RADIO_TRACE_ENABLED mesh::radiotrace::trace().context(_radio, _active_profile, _profile_generation); #endif @@ -365,6 +368,10 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetTxPower(int8_t dbm) { ) && _cw_active) return Result::BUSY; #if MESH_ENABLE_CARRIER_TOOLS if (_cw_active) { + if (!mesh::radioDirection::radioTransmitAllowed()) { + stopCarrierWave(); + return Result::FAILED; + } if (_cw_stopping) return Result::BUSY; #if defined(MESH_HIL_TX_FREQ_MHZ) if (!hilPacketTransmitAllowed() || _cw_profile != _active_profile @@ -467,7 +474,13 @@ uint8_t RadioLibWrapper::beginReconfigure() { _rx_ps_armed = false; claimRadioInterrupt(); state = STATE_IDLE; - return 1; // A repair must verify RX re-entry even if software was idle. + // A receive-capable repair verifies re-entry; TX-only repairs stop after + // restoring the modem, without opening an unsolicited receive window. +#if MESH_ENABLE_RADIO_DIRECTION + return mesh::radioDirection::radioReceiveAllowed() ? 1 : 0; +#else + return 1; +#endif } // On SX126x/LR11xx duty-cycle RX, BUSY may remain asserted during the sleep // side of the cycle. Do not issue an IRQ/preamble query over SPI then; due @@ -493,8 +506,13 @@ uint8_t RadioLibWrapper::beginReconfigure() { #if MESH_HIL_PROFILE_TIMING profile_timing.admissionComplete(); #endif - const bool resume_rx = base_state == STATE_RX; - if (_rx_ps_armed || resume_rx) { + const bool was_rx = base_state == STATE_RX; + const bool resume_rx = was_rx +#if MESH_ENABLE_RADIO_DIRECTION + && mesh::radioDirection::radioReceiveAllowed() +#endif + ; + if (_rx_ps_armed || was_rx) { const int16_t standby_status = #if MESH_HIL_PROFILE_TIMING profile_timing.standbyResult(_radio->standby()); @@ -597,6 +615,9 @@ bool RadioLibWrapper::configureBootSwitchTuning(const mesh::RadioSwitchTuning& v #endif bool RadioLibWrapper::restoreAfterDeepInit() { +#if MESH_ENABLE_RADIO_DIRECTION + _direction_receive_disabled = false; +#endif #if MESH_ENABLE_TX_BEACONS if (_beacon_owned) cacheBeaconRestoreParams(); #endif @@ -1030,6 +1051,9 @@ void RadioLibWrapper::cacheBeaconRestoreParams() { mesh::RadioParamApplyResult RadioLibWrapper::prepareBeaconTransmit( const mesh::RadioProfileParams& p, int8_t power) { using Result = mesh::RadioParamApplyResult; +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioTransmitAllowed()) return Result::FAILED; +#endif if (_beacon_owned || _cw_active || _profile_repair_required) return Result::BUSY; #if defined(MESH_HIL_TX_FREQ_MHZ) _hil_beacon_tx_frequency_mhz = 0; @@ -1118,11 +1142,19 @@ mesh::RadioParamApplyResult RadioLibWrapper::restoreBeaconReceive() { _profile_repair_required = false; // Full normal tuple/protocol/power/IRQ restore succeeded. state = STATE_IDLE; startRecv(); - restored = isInRecvMode(); + restored = isInRecvMode() +#if MESH_ENABLE_RADIO_DIRECTION + || !mesh::radioDirection::radioReceiveAllowed() +#endif + ; if (!restored && restoreAfterDeepInit()) { state = STATE_IDLE; startRecv(); - restored = isInRecvMode(); + restored = isInRecvMode() +#if MESH_ENABLE_RADIO_DIRECTION + || !mesh::radioDirection::radioReceiveAllowed() +#endif + ; } } _beacon_restoring = false; @@ -1143,6 +1175,9 @@ mesh::RadioParamApplyResult RadioLibWrapper::prepareTransmitProfile(uint8_t prof } mesh::RadioParamApplyResult RadioLibWrapper::prepareTransmitProfile(uint8_t profile, bool reply_rx_override) { +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioTransmitAllowed()) return mesh::RadioParamApplyResult::FAILED; +#endif #if defined(MESH_HIL_TX_FREQ_MHZ) if (profile > 1 || !mesh::hilTransmitFrequencyAllowed(_profiles.params(profile).freq)) return mesh::RadioParamApplyResult::FAILED; @@ -1168,6 +1203,9 @@ mesh::RadioParamApplyResult RadioLibWrapper::tryRestoreCodingRate(uint8_t cr) { } void RadioLibWrapper::serviceProfileScan() { +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioReceiveAllowed()) return; +#endif #if MESH_RADIO_TRACE_ENABLED if (mesh::radiotrace::trace().pending()) { mesh::radiotrace::trace().context(_radio, _active_profile, _profile_generation); @@ -1549,6 +1587,9 @@ bool RadioLibWrapper::setTrackerSleep(bool sleeping) { void RadioLibWrapper::triggerNoiseFloorCalibrate(int threshold) { _threshold = threshold; +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioReceiveAllowed()) return; +#endif // The Dispatcher calls this every two seconds. Repeatedly recalibrating a // dual-profile scanner would repeatedly lengthen its visits. Once both // baselines are valid, retain them until a real RF-path change—except for a @@ -1894,6 +1935,9 @@ uint32_t RadioLibWrapper::carrierWaveRemainingMillis() const { mesh::RadioParamApplyResult RadioLibWrapper::setCarrierWave(uint8_t profile, uint32_t duration_ms) { using Result = mesh::RadioParamApplyResult; +#if MESH_ENABLE_RADIO_DIRECTION + if (duration_ms && !mesh::radioDirection::radioTransmitAllowed()) return Result::FAILED; +#endif #if MESH_ENABLE_CARRIER_TOOLS // The shared emergency stop also covers chips exposing Morse without the // older profile-CW CLI capability. @@ -1974,6 +2018,9 @@ bool RadioLibWrapper::supportsMorseTx() const { mesh::MorseTxResult RadioLibWrapper::startMorseTx(float frequency_mhz, uint8_t wpm, const char* text) { using Result = mesh::MorseTxResult; +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioTransmitAllowed()) return Result::Failed; +#endif if (!supportsMorseTx()) return Result::Unsupported; if (_cw_active) return Result::Busy; const Result valid_text = mesh::MorseTx::validate(text, wpm); @@ -2126,6 +2173,9 @@ bool RadioLibWrapper::canAllocateMorseReceiver() const { mesh::MorseRxResult RadioLibWrapper::startMorseRx(float frequency_mhz, uint8_t wpm, uint32_t duration_ms) { using Result = mesh::MorseRxResult; +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioReceiveAllowed()) return Result::Failed; +#endif if (!supportsMorseRx()) return Result::Unsupported; if (_cw_active) return Result::Busy; const Result valid = mesh::MorseRx::validate(wpm, duration_ms); @@ -2316,8 +2366,16 @@ bool RadioLibWrapper::stopCarrierWave() { const bool tuned = tuneProfile(restore) == mesh::RadioParamApplyResult::APPLIED; recalibrateNoiseFloor(); startRecv(); - if (!isInRecvMode() && restoreAfterDeepInit()) startRecv(); - if (!tuned || !isInRecvMode()) { + bool receive_ready = isInRecvMode() +#if MESH_ENABLE_RADIO_DIRECTION + || !mesh::radioDirection::radioReceiveAllowed() +#endif + ; + if (!receive_ready && restoreAfterDeepInit()) { + startRecv(); + receive_ready = isInRecvMode(); + } + if (!tuned || !receive_ready) { // A stopped carrier is not sufficient to hand ownership back if the // configured packet modem/profile could not be restored. Keep the guard // and watchdog wake veto until the bounded retry repairs reception. @@ -2362,7 +2420,58 @@ bool RadioLibWrapper::serviceCarrierWave() { } #endif +void RadioLibWrapper::serviceDirectionPolicy() { +#if MESH_ENABLE_RADIO_DIRECTION + const bool receive = mesh::radioDirection::radioReceiveAllowed(); +#if MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX + if (_cw_active) { + bool receiver = false; +#if MESH_ENABLE_MORSE_RX + receiver = _morse_rx_owned; +#endif + if ((receiver && !receive) + || (!receiver && !mesh::radioDirection::radioTransmitAllowed())) stopCarrierWave(); + if (_cw_active) return; + } +#endif + if (receive) { + _direction_receive_disabled = false; + return; + } + if (state == STATE_TX_WAIT) return; +#if MESH_ENABLE_TX_BEACONS + if (_beacon_owned) return; // Its owner restores the saved tuple first. +#endif + // A direction change deliberately retires any unread receive. No RX retry, + // scanner or calibration may reopen it. Ordinary TX/CAD stays available. + if (!_direction_receive_disabled || state != STATE_IDLE || _rx_ps_armed + || hasPendingRadioInterrupt()) { + if (_rx_ps_armed) stopReceiveDutyCycle(); + if (_radio->standby() != RADIOLIB_ERR_NONE + || _radio->clearIrqFlags(UINT32_MAX) != RADIOLIB_ERR_NONE) return; + claimRadioInterrupt(); + _irq_probe_failures = 0; + state = STATE_IDLE; + _direction_receive_disabled = true; + } + _rx_ps_armed = _rx_hold_continuous = false; + _rx_retry_pending = false; + _rx_retry_delay_ms = 0; + _nf_calib_active = _nf_refresh_requested = _nf_window_armed = false; + _wd_observe_until = 0; +#endif +} + void RadioLibWrapper::loop() { +#if MESH_ENABLE_RADIO_DIRECTION + serviceDirectionPolicy(); + if (!mesh::radioDirection::radioReceiveAllowed()) { +#if MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX + serviceCarrierWave(); +#endif + return; + } +#endif #if MESH_RADIO_TRACE_ENABLED if (mesh::radiotrace::trace().pending()) { mesh::radiotrace::trace().context(_radio, _active_profile, _profile_generation); @@ -2539,6 +2648,9 @@ void RadioLibWrapper::loop() { } void RadioLibWrapper::startRecv() { +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioReceiveAllowed()) return; +#endif #if MESH_ENABLE_TX_BEACONS if (_beacon_owned && !_beacon_restoring) return; #endif @@ -2678,6 +2790,9 @@ bool RadioLibWrapper::isInRecvMode() const { } bool RadioLibWrapper::getReceiveRetryDelay(uint32_t now, uint32_t& delay_ms) const { +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioReceiveAllowed()) return false; +#endif #if MESH_ENABLE_TX_BEACONS if (_beacon_owned) return false; #endif @@ -2695,6 +2810,9 @@ bool RadioLibWrapper::getReceiveRetryDelay(uint32_t now, uint32_t& delay_ms) con } bool RadioLibWrapper::getNoiseFloorWakeDelay(uint32_t now, uint32_t& delay_ms) const { +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioReceiveAllowed()) return false; +#endif #if MESH_ENABLE_TX_BEACONS if (_beacon_owned) return false; #endif @@ -2773,6 +2891,9 @@ RxPowerSavingStatus RadioLibWrapper::getRxPowerSavingStatus() const { } int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioReceiveAllowed()) return 0; +#endif #if MESH_RADIO_TRACE_ENABLED auto& radio_trace = mesh::radiotrace::trace(); uint16_t trace_rx = mesh::radiotrace::NoIndex; @@ -2967,6 +3088,9 @@ bool RadioLibWrapper::hilPacketTransmitAllowed() const { #endif bool RadioLibWrapper::startSendRaw(const uint8_t* bytes, int len) { +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioTransmitAllowed()) return false; +#endif #if defined(MESH_HIL_TX_FREQ_MHZ) if (!hilPacketTransmitAllowed()) { MESH_DEBUG_PRINTLN("HIL: packet TX blocked outside the proven allowed frequency"); @@ -3079,6 +3203,22 @@ int16_t RadioLibWrapper::performChannelScanWithTimeout(unsigned long timeout_ms) } bool RadioLibWrapper::isChannelActive() { +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioReceiveAllowed()) { + if (_cw_active || state == STATE_TX_WAIT || isChipBusy()) return true; + if (!_cad_enabled) return false; + const int16_t result = performChannelScan(); + const bool stopped = _radio->standby() == RADIOLIB_ERR_NONE; + const bool cleared = _radio->clearIrqFlags(UINT32_MAX) == RADIOLIB_ERR_NONE; + claimRadioInterrupt(); + state = STATE_IDLE; + if (result != RADIOLIB_CHANNEL_FREE || !stopped || !cleared) { + _board->n_cad_busy++; + return true; + } + return false; + } +#endif if (_cw_active) return true; if (isPacketPendingOrReceiving() || state == STATE_TX_WAIT) return true; // int.thresh: RSSI-based interference detection (relative to noise floor). @@ -3111,6 +3251,11 @@ bool RadioLibWrapper::isChannelActive() { } bool RadioLibWrapper::isReceivingPassive(int interference_margin_db) { +#if MESH_ENABLE_RADIO_DIRECTION + // A TX-only node has no continuous-RX noise baseline or forwarding echoes. + // Use bounded CAD instead of waiting forever for either of those RX states. + if (!mesh::radioDirection::radioReceiveAllowed()) return isChannelActive(); +#endif if (_cw_active) return true; // RX duty-cycle radios hold BUSY high while asleep. Do not attempt an SPI // IRQ/RSSI read until the next listen window. Treat the unknown channel as diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 3ea00756d..c8cda76c3 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -5,6 +5,7 @@ #include "NoiseFloorEstimator.h" #include "../TelemetryHistoryConfig.h" #include "../CarrierTools.h" +#include "../RadioDirectionPolicy.h" #include #include @@ -47,6 +48,9 @@ protected: uint8_t state = 0; uint8_t _interrupt_slot = 0xff; bool _loop_event_pending = false; +#if MESH_ENABLE_RADIO_DIRECTION + bool _direction_receive_disabled = false; +#endif uint8_t _irq_probe_failures = 0; bool registerInterruptAction(); void unregisterInterruptAction(); @@ -311,6 +315,7 @@ protected: void idle() override; void startRecv() override; + void serviceDirectionPolicy(); void rxPsWatchdogCheck(); void requestPeriodicNoiseFloorRefresh(); void requestNoiseFloorRefresh(); diff --git a/test/fixtures/cli_reply_text.json b/test/fixtures/cli_reply_text.json index 51b4db218..9601a51b6 100644 --- a/test/fixtures/cli_reply_text.json +++ b/test/fixtures/cli_reply_text.json @@ -941,7 +941,7 @@ } ], "noncompact_tokens_sha256": { - "src/helpers/CommonCLI.cpp": "cd2bcd278e1b01e0e7195306aa67d0a00070183543c8d61a9724958058ead6b7", + "src/helpers/CommonCLI.cpp": "17b89894dbcb4998f430d8bd6f3478b903f30bcfa633a7750c7771534a546548", "examples/simple_repeater/MyMesh.cpp": "8567eeef2f327100e397c40b0aaf5b675fcdfea254987437d1ca8d005ad3a6f0", "src/helpers/RadioProfileCLI.cpp": "d641d16e872eba3e73b9b5a50f9718c10b0b7da72782f793706ac36b3724a624", "src/helpers/WirelessControl.h": "f19bb602999118cc423ec74c7de7100ed91cb2c553295038077e52e321bb4e73", diff --git a/test/fixtures/espnow_bridge_diagnostic_runtime.cpp b/test/fixtures/espnow_bridge_diagnostic_runtime.cpp index 4a213d3bf..6d3b5c972 100644 --- a/test/fixtures/espnow_bridge_diagnostic_runtime.cpp +++ b/test/fixtures/espnow_bridge_diagnostic_runtime.cpp @@ -274,13 +274,14 @@ static void boundedReplies() { memset(&bridge._stats.tx, 0xFF, sizeof(bridge._stats.tx)); memset(&bridge._stats.probe, 0xFF, sizeof(bridge._stats.probe)); bridge._stats.error = {diag::Stage::ChannelConflict, INT32_MIN, UINT32_MAX}; - for (const char* page : {"rx", "tx", "drops", "test"}) { + for (const char* page : {"rx", "tx", "drops", "test", "mac"}) { char reply[160]; memset(reply, 0xA5, sizeof(reply)); bridge.formatStats(page, reply, 157); assert(memchr(reply, 0, 157) != nullptr); assert(static_cast(reply[157]) == 0xA5); assert(strlen(reply) < 157); + if (!strcmp(page, "mac")) assert(!strcmp(reply, "> mac:02:10:20:30:40:50")); printf("%s: %s\n", page, reply); } char status[160]; diff --git a/test/fixtures/udp_bridge/helpers/bridges/BridgeBase.h b/test/fixtures/udp_bridge/helpers/bridges/BridgeBase.h index f5f3df0e2..d2099bafa 100644 --- a/test/fixtures/udp_bridge/helpers/bridges/BridgeBase.h +++ b/test/fixtures/udp_bridge/helpers/bridges/BridgeBase.h @@ -2,6 +2,7 @@ #include #include #include +#include #if defined(ARDUINO) #include #include @@ -63,6 +64,7 @@ struct PacketManager { bool starved = false; unsigned allocations = 0, frees = 0; std::vector> received; + std::vector received_sources; Packet* allocNew() { ++allocations; return starved ? nullptr : new Packet; @@ -93,6 +95,14 @@ public: bool isRunning() const { return _initialized; } size_t seenCount() const { return _seen_packets.seen.size(); } protected: + const mesh::bridge::IngressSource* ingress_source = nullptr; + struct IngressSourceScope { + BridgeBase& owner; + const mesh::bridge::IngressSource* previous; + IngressSourceScope(BridgeBase& bridge, const mesh::bridge::IngressSource& source) + : owner(bridge), previous(bridge.ingress_source) { owner.ingress_source = &source; } + ~IngressSourceScope() { owner.ingress_source = previous; } + }; enum class IngressResult : uint8_t { Stopped, Filtered, Duplicate, Queued }; virtual void onIngressResult(IngressResult) {} bool _initialized = false; @@ -120,6 +130,7 @@ protected: } if (packet->getPayloadType() != PAYLOAD_TYPE_OTA) _seen_packets.markSeen(packet); onIngressResult(IngressResult::Queued); + _mgr->received_sources.push_back(ingress_source ? *ingress_source : mesh::bridge::IngressSource()); _mgr->received.push_back(packet->payload); _mgr->free(packet); } }; diff --git a/test/fixtures/udp_bridge_runtime.cpp b/test/fixtures/udp_bridge_runtime.cpp index 54dff0866..b6c00cd36 100644 --- a/test/fixtures/udp_bridge_runtime.cpp +++ b/test/fixtures/udp_bridge_runtime.cpp @@ -133,6 +133,12 @@ static void queues() { 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(b.manager.received_sources.size() == 1); + const auto first_source = b.manager.received_sources.front(); + assert(first_source.kind == mesh::bridge::IngressSource::Kind::Udp); + assert(first_source.bytes[0] == 10 && first_source.bytes[1] == 11 && + first_source.bytes[2] == 12 && first_source.bytes[3] == 1); + assert(first_source.bytes[4] == (4404 >> 8) && first_source.bytes[5] == (4404 & 255)); 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. @@ -145,6 +151,8 @@ static void queues() { 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); + assert(b.manager.received_sources.size() == 2); + assert(!memcmp(b.manager.received_sources.back().bytes, first_source.bytes, 6)); // 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); diff --git a/test/test_bridge_ingress.py b/test/test_bridge_ingress.py index 0d72b29b2..05913a09a 100644 --- a/test/test_bridge_ingress.py +++ b/test/test_bridge_ingress.py @@ -152,8 +152,10 @@ int main() { bridge.setIngressProfiles(nullptr); mesh::Packet missing;bridge.handleReceivedPacket(&missing);assert(bridge.manager.freed==++drops); bridge.setIngressProfiles(&profiles); - selection=IngressProfile::Blocked; - mesh::Packet blocked;bridge.handleReceivedPacket(&blocked);assert(bridge.manager.freed==++drops); + for (auto target : {IngressProfile::Blocked, IngressProfile::Off}) { + selection=target; + mesh::Packet blocked;bridge.handleReceivedPacket(&blocked);assert(bridge.manager.freed==++drops); + } assert(bridge.manager.queued==queued&&bridge._seen_packets.checks==checks&&bridge._seen_packets.marks==marks); assert(filter_calls==filters&&dispatch.outbound.size()==sent); @@ -165,6 +167,24 @@ int main() { assert(dispatch.getTransmitProfileMask(&normal)==1); assert(dispatch.queueOutboundPacket(&normal,0,0)); assert(dispatch.outbound.back().radio_profile==0); + + // Explicit source mappings can also pin USA to primary while crossing is + // enabled. The source identity is visible only during this admission. + bridge.setPacketFilter(nullptr,nullptr); + selection=IngressProfile::Radio; + mesh::bridge::requested_explicit=true; + profiles.cross=RadioCrossMode::On; + const uint8_t address[]={2,16,32,48,64,81}; + const auto source=mesh::bridge::IngressSource::mac(address); + mesh::Packet usa;bridge.receiveFrom(&usa,source); + assert(mesh::bridge::observed_source.kind==mesh::bridge::IngressSource::Kind::Mac); + assert(memcmp(mesh::bridge::observed_source.bytes,address,6)==0); + assert(usa.radio_profile==0&&usa.radio_generation==profiles.generation[0]&&usa.tx_radio==mesh::RADIO_TX_PRIMARY); + assert(dispatch.getTransmitProfileMask(&usa)==1); + mesh::bridge::requested_explicit=false; + mesh::Packet next;bridge.handleReceivedPacket(&next); + assert(mesh::bridge::observed_source.kind==mesh::bridge::IngressSource::Kind::None); + assert(next.tx_radio==mesh::RADIO_TX_AUTO&&dispatch.getTransmitProfileMask(&next)==3); } ''' code = code.replace("@DISPATCH_GUARD@", guard) @@ -173,13 +193,16 @@ int main() { ("@CURRENT@", "bool Dispatcher::isPacketRadioCurrent(")): code = code.replace(marker, method(dispatcher, signature)) packet = PACKET.replace(" uint8_t payload[64]", " uint8_t path_len=0;\n static bool isValidPathLen(uint8_t n) {return n<64;}\n uint8_t payload[64]") - selector = r''' -#pragma once -#include + selector = (ROOT / "src/helpers/BridgeIngressProfile.h").read_text() + r''' namespace mesh { namespace bridge { -enum class IngressProfile : uint8_t {Radio=0,Radio2=1,Blocked=2}; static IngressProfile requested_ingress=IngressProfile::Radio; +static bool requested_explicit=false; +static IngressSource observed_source; inline IngressProfile ingressProfile() {return requested_ingress;} +inline IngressRoute ingressRoute(const IngressSource* source) { + observed_source=source?*source:IngressSource(); + return {requested_ingress, requested_explicit}; +} }} ''' with tempfile.TemporaryDirectory(prefix="meshcore-bridge-radio2-") as directory: diff --git a/test/test_bridge_ingress_settings.py b/test/test_bridge_ingress_settings.py index 72718695f..d378ce4f8 100644 --- a/test/test_bridge_ingress_settings.py +++ b/test/test_bridge_ingress_settings.py @@ -174,7 +174,7 @@ class BridgeIngressSettingsTests(unittest.TestCase): " bool fail_after_publication = false;\n bool fail_metadata = false;") mock = mock.replace(" files[to] = files[from]; files.erase(from); return true;", " files[to] = files[from]; files.erase(from);\n" - " if (fail_after_publication && !strcmp(to, \"/bridge_ingress\")) fail_read_open = true;\n" + " if (fail_after_publication && (!strcmp(to, \"/bridge_ingress\") || !strcmp(to, \"/bridge_routes\"))) fail_read_open = true;\n" " return true;") (work / "IdentityStore.h").write_text(mock) (work / "helpers/IdentityStore.h").write_text('#include "../IdentityStore.h"\n') diff --git a/test/test_bridge_recovery.py b/test/test_bridge_recovery.py index 8952aed2a..93e3f5f5b 100644 --- a/test/test_bridge_recovery.py +++ b/test/test_bridge_recovery.py @@ -20,6 +20,7 @@ MOCKS = r''' #include #include #include +#include #define MAX_TRANS_UNIT 255 #define PAYLOAD_TYPE_OTA 0x0c using portMUX_TYPE=int; @@ -32,6 +33,9 @@ constexpr int ESP_ERR_TIMEOUT=-80; enum wifi_ps_type_t {WIFI_PS_NONE=0,WIFI_PS_MIN_MODEM=1}; int esp_wifi_get_ps(wifi_ps_type_t* value){*value=WIFI_PS_NONE;return sdk_initialized?ESP_OK:-1;} int esp_wifi_get_protocol(int,uint8_t* value){*value=15;return sdk_initialized?ESP_OK:-1;} +int esp_wifi_get_mac(int,uint8_t* value){ + const uint8_t mac[]={2,16,32,48,64,80};memcpy(value,mac,sizeof(mac));return sdk_initialized?ESP_OK:-1; +} uint32_t now_ms=100; uint32_t millis(){return now_ms;} int send_result=0,send_calls=0; @@ -61,6 +65,7 @@ size_t nrf52DebugPrintf(const char* format,...){ } struct RTCClock {}; struct Packet { + mesh::bridge::IngressSource ingress_source; uint8_t data[MAX_TRANS_UNIT]={};uint16_t length=4; bool readFrom(const uint8_t* bytes,size_t len){ if(len==0||len>sizeof(data)||bytes[0]==0xee)return false; @@ -75,13 +80,14 @@ struct PacketManager { std::array pool;std::array used{}; bool exhausted=false;unsigned frees=0,allocs=0; std::vector received; + std::vector received_sources; Packet* allocNew(){ if(exhausted)return nullptr; for(size_t i=0;idata[0]);free(p);} + void queueInbound(Packet* p,uint32_t){received.push_back(p->data[0]);received_sources.push_back(p->ingress_source);free(p);} }; } struct NodePrefs { @@ -112,7 +118,16 @@ public: 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 allowsPacket(const mesh::Packet*)const{return allowed;} - bool preparePacketIngress(mesh::Packet*)const{return true;} + bool preparePacketIngress(mesh::Packet* packet)const{ + packet->ingress_source=ingress_source?*ingress_source:mesh::bridge::IngressSource();return true; + } + const mesh::bridge::IngressSource* ingress_source=nullptr; + struct IngressSourceScope { + BridgeBase& owner;const mesh::bridge::IngressSource* previous; + IngressSourceScope(BridgeBase& bridge,const mesh::bridge::IngressSource& source) + :owner(bridge),previous(bridge.ingress_source){owner.ingress_source=&source;} + ~IngressSourceScope(){owner.ingress_source=previous;} + }; static uint16_t fletcher16(const uint8_t*,size_t); bool validateChecksum(const uint8_t*,size_t,uint16_t); void handleReceivedPacket(mesh::Packet*); @@ -350,12 +365,15 @@ int main(){ bridge.queueReceivedFrame(mac,p.data,p.length);mgr.exhausted=true;bridge.loop(); assert(bridge._rx_count==1&&mgr.received.empty()); mgr.exhausted=false;bridge.loop();assert((mgr.received==std::vector{1})); + assert(mgr.received_sources.size()==1&&mgr.received_sources.back().kind==mesh::bridge::IngressSource::Kind::Mac); + assert(memcmp(mgr.received_sources.back().bytes,mac,6)==0&&bridge.ingress_source==nullptr); mesh::espnow::ESPNowRawFrames fragments;auto full=packet(2,255); assert(mesh::espnow::encodeEspNowRawFrames(full.data,full.length,fragments)); bridge.queueReceivedFrame(mac,fragments.data[0],fragments.lengths[0]);bridge.loop(); bridge.queueReceivedFrame(mac,fragments.data[1],fragments.lengths[1]);mgr.exhausted=true; bridge.loop();assert(bridge._rx_count==1&&mgr.received.size()==1); mgr.exhausted=false;bridge.loop();assert((mgr.received==std::vector{1,2})); + assert(mgr.received_sources.size()==2&&memcmp(mgr.received_sources.back().bytes,mac,6)==0); auto blocked=packet(3);bridge.allowed=false;bridge.queueReceivedFrame(mac,blocked.data,blocked.length); bridge.loop();assert(mgr.received.size()==2);bridge.allowed=true; auto bad=packet(0xee);bridge.queueReceivedFrame(mac,bad.data,bad.length);bridge.loop(); diff --git a/test/test_bridge_source_routes.py b/test/test_bridge_source_routes.py new file mode 100644 index 000000000..23ea1bda9 --- /dev/null +++ b/test/test_bridge_source_routes.py @@ -0,0 +1,162 @@ +"""Exercise per-sender bridge routing with the real parser and storage writers.""" +import unittest +from unittest.mock import patch + +import test_bridge_ingress_settings as settings + + +HARNESS = r''' +#include +#include +#include +#include "BridgeIngressSettings.h" +#if !MESH_ENABLE_BRIDGE_INGRESS +#error "Raw bridge transport flags must activate settings before CommonCLI.h" +#endif +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#define ATOMIC_FILE_WRITER_IMPLEMENTATION +#include "AtomicFileWriter.h" +#endif +#include "settings_under_test.cpp" + +int main() { + using namespace mesh::bridge; + MemoryFS fs; +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + fs.rename_replaces = true; +#endif + const char* path = "/bridge_routes"; + const std::vector legacy = {9, 8, 7, 6, 5}; + fs.files["/com_prefs"] = legacy; + loadIngressSettings(&fs); + assert(fs.files.size() == 1); + auto route = ingressRoute(); + assert(route.profile == IngressProfile::Radio && !route.explicit_profile); + char reply[160]; + auto run = [&](const char* command) { + strcpy(reply, "untouched"); + return handleIngressCommand(command, reply, sizeof(reply)); + }; + auto okay = [&](const char* command) { + assert(run(command) && !strncmp(reply, "OK", 2)); + }; + auto rejects = [&](const char* command) { + assert(run(command) && !strncmp(reply, "Error:", 6)); + }; + okay("set bridge.ingress off"); + assert(fs.files.at("/bridge_ingress")[3] == 2); // legacy v1 remains readable + assert(ingressRoute().profile == IngressProfile::Off); + assert(run("get bridge.ingress") && !strcmp(reply, "> off")); + + IngressSource media_mac, usa_mac, media_udp, usa_udp, wrong_port; + assert(parseIngressSource("mac:02:10:20:30:40:50", media_mac)); + assert(parseIngressSource("mac:02:10:20:30:40:51", usa_mac)); + assert(parseIngressSource("udp:192.168.1.50:4404", media_udp)); + assert(parseIngressSource("udp:192.168.1.51:4404", usa_udp)); + assert(parseIngressSource("udp:192.168.1.50:4405", wrong_port)); + okay("set bridge.route.1 mac:02:10:20:30:40:50 radio2"); + okay("set bridge.route.2 mac:02:10:20:30:40:51 radio"); + okay("set bridge.route.3 udp:192.168.1.50:4404 radio2"); + okay("set bridge.route.4 udp:192.168.1.51:4404 radio"); + for (const auto* source : {&media_mac, &media_udp}) { + route = ingressRoute(source); + assert(route.profile == IngressProfile::Radio2 && route.explicit_profile); + } + for (const auto* source : {&usa_mac, &usa_udp}) { + route = ingressRoute(source); + assert(route.profile == IngressProfile::Radio && route.explicit_profile); + } + route = ingressRoute(&wrong_port); + assert(route.profile == IngressProfile::Off && !route.explicit_profile); + assert(run("get bridge.route") && strstr(reply, "4/8")); + assert(run("get bridge.route.1") && !strcmp(reply, "> 1 mac:02:10:20:30:40:50 radio2")); + assert(run("get bridge.route.3") && !strcmp(reply, "> 3 udp:192.168.1.50:4404 radio2")); + const auto good = fs.files.at(path); + assert(good.size() == RoutesImageSize); + loadIngressSettings(&fs); + assert(ingressRoute(&media_mac).profile == IngressProfile::Radio2); + assert(ingressRoute(&usa_udp).profile == IngressProfile::Radio); + assert(fs.files.at("/com_prefs") == legacy); + + for (const char* bad : { + "set bridge.route.0 mac:02:10:20:30:40:50 radio", "set bridge.route.9 mac:02:10:20:30:40:50 radio", + "set bridge.route.1 mac:02:10:20:30:40:50 both", "set bridge.route.1 mac:02:10:20:30:40:50 radio junk", + "set bridge.route.5 mac:02:10:20:30:40:50 radio", "set bridge.route.1 mac:FF:FF:FF:FF:FF:FF radio", + "set bridge.route.1 mac:00:00:00:00:00:00 radio", "set bridge.route.1 udp:0.0.0.0:4404 radio", + "set bridge.route.1 udp:224.1.2.3:4404 radio", "set bridge.route.1 udp:192.168.1.50:0 radio", + "set bridge.route.1 udp:192.168.1.50:65536 radio", "set bridge.route.1 udp:192.168.1.500:4404 radio", + "get bridge.route.1 extra", "del bridge.route.1 extra", "set bridge.route.1"}) { + rejects(bad); + assert(fs.files.at(path) == good && ingressRoute(&media_mac).profile == IngressProfile::Radio2); + } + assert(!run("get bridge.router") && !strcmp(reply, "untouched")); + assert(!run("set bridge.routes.1 mac:02:10:20:30:40:50 radio")); + char bounded[] = {'x', 'x', 'x', 'z'}; + assert(handleIngressCommand("get bridge.route.1", bounded, 3)); + assert(bounded[2] == 0 && bounded[3] == 'z'); + + fs.fail_write = true; + rejects("set bridge.route.1 mac:02:10:20:30:40:50 radio"); + assert(fs.files.at(path) == good && ingressRoute(&media_mac).profile == IngressProfile::Radio2); + fs.fail_write = false; + fs.fail_write_after = 7; + rejects("del bridge.route.1"); + assert(fs.files.at(path) == good && ingressRoute(&media_mac).profile == IngressProfile::Radio2); + fs.fail_write_after = -1; + + // Every corrupt or incomplete routes image blocks matched AND unmatched inputs. + for (size_t bit = 0; bit < good.size() * 8; ++bit) { + fs.files[path] = good; + fs.files[path][bit / 8] ^= uint8_t(1U << (bit % 8)); + loadIngressSettings(&fs); + assert(ingressRoute(&media_mac).profile == IngressProfile::Blocked); + assert(ingressRoute(&wrong_port).profile == IngressProfile::Blocked); + } + rejects("set bridge.route.1 mac:02:10:20:30:40:50 radio2"); + okay("clear bridge.routes"); + assert(ingressRoute(&media_mac).profile == IngressProfile::Off); + for (size_t size = 0; size < good.size(); ++size) { + fs.files[path] = std::vector(good.begin(), good.begin() + size); + loadIngressSettings(&fs); + assert(ingressRoute().profile == IngressProfile::Blocked); + } + okay("clear bridge.routes"); + fs.files[path] = good; + loadIngressSettings(&fs); + + // A write can publish before its readback fails. Block until storage reloads. + fs.fail_after_publication = true; + rejects("set bridge.route.1 mac:02:10:20:30:40:50 off"); + assert(ingressRoute(&media_mac).profile == IngressProfile::Blocked); + fs.fail_after_publication = false; + fs.fail_read_open = false; + rejects("clear bridge.routes"); + loadIngressSettings(&fs); + route = ingressRoute(&media_mac); + assert(route.profile == IngressProfile::Off && route.explicit_profile); + okay("del bridge.route.1"); + route = ingressRoute(&media_mac); + assert(route.profile == IngressProfile::Off && !route.explicit_profile); + okay("clear bridge.routes"); + okay("set bridge.ingress radio2"); + assert(fs.files.at("/bridge_ingress")[3] == 1); + assert(ingressRoute().profile == IngressProfile::Radio2); + okay("set bridge.ingress radio"); + fs.files.erase(path); + loadIngressSettings(&fs); + assert(ingressRoute().profile == IngressProfile::Radio && !ingressRoute().explicit_profile); + assert(fs.files.at("/com_prefs") == legacy); +} +''' + + +class BridgeSourceRouteTests(unittest.TestCase): + def test_saved_source_routes_and_faults_across_filesystems(self): + # Reuse the real filesystem/writer fixture and its eight transport / + # platform builds. Only the test main changes; production code is copied. + with patch.object(settings, "HARNESS", HARNESS): + settings.BridgeIngressSettingsTests().test_real_settings_and_transactions_across_filesystems() + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_radio_direction_dispatcher.py b/test/test_radio_direction_dispatcher.py new file mode 100644 index 000000000..86f74a82f --- /dev/null +++ b/test/test_radio_direction_dispatcher.py @@ -0,0 +1,150 @@ +"""Exercise the actual dispatcher with split receivers and a bridge-fed transmitter.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + +SOURCE = r''' +#include +#include +#include +#include +#include +namespace mesh { namespace radioDirection { +Mode current = Mode::Both; +Mode mode() { return current; } +}} +namespace mesh { namespace bridge { +IngressRoute current{IngressProfile::Radio, true}; +IngressProfile ingressProfile() { return current.profile; } +IngressRoute ingressRoute(const IngressSource*) { return current; } +}} +struct Clock : mesh::MillisecondClock { + uint32_t now=100; + unsigned long getMillis() override { return now; } +}; +struct Radio : mesh::Radio { + mesh::RadioProfiles p; + unsigned rx=0,tx=0,calibrations=0,recoveries=0,loops=0; + uint8_t prepared=0; + bool cad=false; + unsigned busy_mask=0; + std::vector sent; + mesh::RadioProfiles* profiles() override { return &p; } + const mesh::RadioProfiles* profiles() const override { return &p; } + int recvRaw(uint8_t*,int) override { ++rx; return 0; } + uint32_t getEstAirtimeFor(int) override { return 10; } + float packetScore(float,int) override { return 0; } + bool startSendRaw(const uint8_t*,int) override { ++tx; sent.push_back(prepared); return true; } + bool isSendComplete() override { return true; } + void onSendFinished() override {} + bool isInRecvMode() const override { return mesh::radioDirection::radioReceiveAllowed(); } + void loop() override { ++loops; } + void setCADEnabled(bool value) override { cad=value; } + bool isReceiving() override { return busy_mask & (1U << prepared); } + void triggerNoiseFloorCalibrate(int) override { ++calibrations; } + bool recoverRadio(bool) override { ++recoveries; return true; } + mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t value) override { + prepared=value; return mesh::RadioParamApplyResult::APPLIED; + } +}; +struct Dispatcher : mesh::Dispatcher { + unsigned inbound=0; + Dispatcher(Radio&r,Clock&c,StaticPoolPacketManager&m):mesh::Dispatcher(r,c,m){} + mesh::DispatcherAction onRecvPacket(mesh::Packet*) override { + ++inbound; return ACTION_RETRANSMIT_DELAYED(0,0); + } + using mesh::Dispatcher::getTransmitProfileMask; + using mesh::Dispatcher::getNextQueueWakeDelay; + bool getCADEnabled() const override { return true; } +}; +struct Bridge : AbstractBridge { + StaticPoolPacketManager& m; + explicit Bridge(StaticPoolPacketManager& pool):m(pool){} + bool receive(mesh::Packet* p) { if(!preparePacketIngress(p))return false; m.queueInbound(p,0);return true; } + void begin() override {} void end() override {} void loop() override {} + bool isRunning() const override { return true; } + void sendPacket(mesh::Packet*) override {} void onPacketReceived(mesh::Packet*) override {} +}; +int main() { + using namespace mesh::radioDirection; + Radio r; Clock c; StaticPoolPacketManager pool(12); Dispatcher d(r,c,pool); + r.p.secondary.mode=mesh::RadioProfileMode::RxTx; + r.p.cross=mesh::RadioCrossMode::On; + r.p.generation[0]=11;r.p.generation[1]=33; + current=Mode::Tx; d.begin(); Bridge b(pool);b.setIngressProfiles(&r.p); + for(unsigned profile:{0U,1U}) { + mesh::bridge::current={profile?mesh::bridge::IngressProfile::Radio2:mesh::bridge::IngressProfile::Radio,true}; + auto* p=d.obtainNewPacket();assert(p);p->header=ROUTE_TYPE_FLOOD|(PAYLOAD_TYPE_TXT_MSG<payload_len=1;p->payload[0]=profile; + assert(b.receive(p)); + } + for(unsigned i=0;i<100;++i){c.now+=10;d.loop();} + assert(d.inbound==2&&r.tx==2&&r.sent[0]==0&&r.sent[1]==1); + assert(r.rx==0&&r.calibrations==0&&r.recoveries==0&&r.loops>0&&r.cad); + c.now+=24UL*60*60*1000; d.loop();assert(r.recoveries==0&&r.rx==0); + uint32_t delay=0;assert(!d.getNextNoiseFloorWakeDelay(delay)); + assert(!d.getNextQueueWakeDelay(delay)); + // A busy MediaMesh channel must neither time out into a forced send nor + // starve a ready USA packet. Then send the retained frame when CAD clears. + const auto initial=r.tx; + r.busy_mask=2; + for(unsigned profile:{1U,0U}) { + mesh::bridge::current={profile?mesh::bridge::IngressProfile::Radio2:mesh::bridge::IngressProfile::Radio,true}; + auto* p=d.obtainNewPacket();assert(p);p->header=ROUTE_TYPE_FLOOD; + p->payload_len=1;assert(b.receive(p)); + } + for(unsigned i=0;i<1000;++i){c.now+=50;d.loop();} + assert(r.tx==initial+1&&r.sent.back()==0); + r.busy_mask=0; + for(unsigned i=0;i<100;++i){c.now+=50;d.loop();} + assert(r.tx==initial+2&&r.sent.back()==1); + // RF already delayed before a mode change cannot be mistaken for bridge + // input after RX is disabled. + const auto received=d.inbound; + auto* stale=d.obtainNewPacket();assert(stale); + stale->header=ROUTE_TYPE_FLOOD;stale->forwarded=false; + stale->radio_profile=0;stale->radio_generation=r.p.generation[0]; + pool.queueInbound(stale,0);c.now+=10;d.loop();assert(d.inbound==received); + current=Mode::Rx; + auto* blocked=d.obtainNewPacket();assert(blocked);blocked->header=ROUTE_TYPE_FLOOD; + assert(d.getTransmitProfileMask(blocked)==0); + assert(!d.sendPacket(blocked,0,0)); + const auto before=r.tx; c.now+=10;d.loop();assert(r.tx==before&&r.rx>0); + current=Mode::Off;const auto reads=r.rx; + for(unsigned i=0;i<10;++i){c.now+=10000;d.loop();} + assert(r.tx==before&&r.rx==reads&&r.recoveries==0); + current=Mode::Both;c.now+=10;d.loop();assert(r.rx>reads); +} +''' + + +class RadioDirectionDispatcherTests(unittest.TestCase): + def test_split_bridge_forwarding_direction_and_watchdog_lifecycle(self): + for soft in (False, True): + with self.subTest(soft=soft), tempfile.TemporaryDirectory() as temporary: + work = Path(temporary) + source = work / 'main.cpp' + source.write_text(SOURCE) + (work/'Mesh.h').write_text('#pragma once\n#include \n') + executable = work / 'test' + flags = ['-DMESH_ENABLE_RADIO_DIRECTION=1', '-DMESH_ENABLE_BRIDGE_INGRESS=1'] + if soft: + flags += ['-DRADIO_LIVENESS_SOFT_ONLY=1'] + built = subprocess.run([os.environ.get('CXX', 'c++'), '-std=c++17', '-O1', + '-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie', + *flags, '-I', str(work), '-I', str(ROOT/'test/mocks'), '-I', str(ROOT/'src'), str(source), + str(ROOT/'src/Dispatcher.cpp'), str(ROOT/'src/Packet.cpp'), + str(ROOT/'src/helpers/StaticPoolPacketManager.cpp'), '-o', str(executable)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout+built.stderr) + tested = subprocess.run([str(executable)], capture_output=True, text=True, + timeout=20, env=dict(os.environ, ASAN_OPTIONS='detect_leaks=0')) + self.assertEqual(tested.returncode, 0, tested.stdout+tested.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_radio_direction_driver.py b/test/test_radio_direction_driver.py new file mode 100644 index 000000000..bc7b2f802 --- /dev/null +++ b/test/test_radio_direction_driver.py @@ -0,0 +1,314 @@ +"""Exercise real radio direction, receive cleanup and CAD paths without RF.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +from test_radio_receive_contract import ROOT, method, transition_harness +from test_carrier_wave import production_harness +import test_beacon_radio + + +class RadioDirectionDriverTests(unittest.TestCase): + def test_beacon_direction_gate_and_standby_restoration(self): + code = test_beacon_radio.BeaconRadioTest().source().replace( + "int main()", "int legacy_main()", 1) + end = code.rfind("}") + code = code[:end] + "return 0;\n" + code[end:] + code = r''' +#define MESH_ENABLE_RADIO_DIRECTION 1 +#include +namespace mesh { namespace radioDirection { +static Mode current = Mode::Both; +Mode mode() { return current; } +}} +''' + code + code = code.replace(" uint8_t state = STATE_RX, _active_profile = 0;", + " bool _direction_receive_disabled = false;\n" + " uint8_t state = STATE_RX, _active_profile = 0;") + code += r''' +int main() { + using namespace mesh::radioDirection; + assert(legacy_main() == 0); + current = Mode::Rx; + { + RadioLibWrapper w; + assert(w.prepareBeaconTransmit(beacon(), 10) == Result::FAILED); + assert(!w._beacon_owned && !w.applications && !w.power_sets && !w.board.before); + } + current = Mode::Tx; + { + RadioLibWrapper w; + assert(w.prepareBeaconTransmit(beacon(), 10) == Result::APPLIED); + assert(w._beacon_owned && w.state == STATE_IDLE && w.arms == 0); + assert(!w.isBeaconChannelBusy() && w.arms == 0); + assert(w.startSendRaw(nullptr, 0) && w.state == STATE_TX_WAIT); + w.onSendFinished(); + assert(w.restoreBeaconReceive() == Result::APPLIED); + assert(!w._beacon_owned && w.state == STATE_IDLE && w.arms == 0); + assert(w.modem.freq == 906 && w.modem.power == 20 && w.isTxPowerApplied(20)); + } + current = Mode::Both; + { + RadioLibWrapper w; + assert(w.prepareBeaconTransmit(beacon(), 10) == Result::APPLIED); + current = Mode::Off; + assert(!w.startSendRaw(nullptr, 0)); + assert(w.restoreBeaconReceive() == Result::APPLIED); + assert(!w._beacon_owned && w.state == STATE_IDLE && w.arms == 0); + } +} +''' + test_beacon_radio.BeaconRadioTest().run_harness(code) + + def test_carrier_and_morse_direction_gates_and_owned_cleanup(self): + source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + code = production_harness().replace("int main()", "int legacy_main()", 1) + end = code.rfind("}") + code = code[:end] + "return 0;\n" + code[end:] + code = r''' +#define MESH_ENABLE_RADIO_DIRECTION 1 +#define MESH_ENABLE_TX_BEACONS 0 +#include +namespace mesh { namespace radioDirection { +static Mode current = Mode::Both; +Mode mode() { return current; } +}} +''' + code + code = code.replace(" uint8_t state = STATE_RX;", r''' + uint8_t state = STATE_RX; + bool _direction_receive_disabled = false, _rx_retry_pending = false; + bool _nf_refresh_requested = false, _nf_window_armed = false; + uint8_t _irq_probe_failures = 0; + uint16_t _rx_retry_delay_ms = 0; + void serviceDirectionPolicy(); +''') + code = code.replace(" void startRecv() override {", r''' + void startRecv() override { + if (!mesh::radioDirection::radioReceiveAllowed()) return; +''') + code += method(source, "void RadioLibWrapper::serviceDirectionPolicy(") + code += r''' +int main() { + using namespace mesh::radioDirection; + using Result = mesh::RadioParamApplyResult; + assert(legacy_main() == 0); + current = Mode::Rx; + { + RadioLibWrapper w; + assert(w.setCarrierWave(0, 1000) == Result::FAILED); + assert(w.startMorseTx(902.1, 12, "E") == mesh::MorseTxResult::Failed); + assert(w.chip.starts == 0 && w.board.before == 0 && !w._cw_active); + } + current = Mode::Tx; + { + RadioLibWrapper w; + assert(w.startMorseRx(902.1, 25, 1000) == mesh::MorseRxResult::Failed); + assert(w.chip.raw_rx_starts == 0 && !w._cw_active); + assert(w.setCarrierWave(0, 1000) == Result::APPLIED); + assert(w.chip.carrier); + const auto receivers = w.rx_starts; + assert(w.setCarrierWave(0, 0) == Result::APPLIED); + assert(!w.chip.carrier && !w._cw_active && w.state == STATE_IDLE); + assert(w.rx_starts == receivers); + } + current = Mode::Both; + { + RadioLibWrapper w; + assert(w.setCarrierWave(0, 1000) == Result::APPLIED); + current = Mode::Rx; + w.serviceDirectionPolicy(); + assert(!w.chip.carrier && !w._cw_active && w.state == STATE_RX); + assert(!w.board.awake && w.board.after == 1); + } + current = Mode::Both; + { + RadioLibWrapper w; + assert(w.startMorseTx(902.1, 12, "E") == mesh::MorseTxResult::Started); + current = Mode::Rx; + w.serviceDirectionPolicy(); + assert(!w.chip.carrier && !w._cw_active && !w._morse_owned); + assert(w.state == STATE_RX && !w.board.awake); + } + current = Mode::Both; + { + RadioLibWrapper w; + assert(w.startMorseRx(902.1, 25, 1000) == mesh::MorseRxResult::Started); + current = Mode::Tx; + const auto receivers = w.rx_starts; + w.serviceDirectionPolicy(); + assert(!w._cw_active && !w._morse_rx_owned && w.state == STATE_IDLE); + assert(w.rx_starts == receivers && !w.board.awake); + } + current = Mode::Both; + { + RadioLibWrapper w; + assert(w.setCarrierWave(0, 1000) == Result::APPLIED); + current = Mode::Off; + w.serviceDirectionPolicy(); + assert(!w.chip.carrier && !w._cw_active && w.state == STATE_IDLE); + assert(w._direction_receive_disabled); + } +} +''' + with tempfile.TemporaryDirectory(prefix="radio-direction-carrier-") as temporary: + work = Path(temporary) + cpp, executable = work / "test.cpp", work / "test" + cpp.write_text(code) + built = subprocess.run([os.environ.get("CXX", "c++"), "-std=c++17", "-O1", + "-Wall", "-Wextra", "-fsanitize=address,undefined", + "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", "-I", str(ROOT / "test/mocks"), + "-I", str(ROOT / "src"), str(cpp), "-o", str(executable)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + ran = subprocess.run([str(executable)], capture_output=True, text=True, timeout=30) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + + def test_direction_cleanup_cad_and_packet_last_gate(self): + source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + code = transition_harness(source).replace("int main()", "int legacy_main()", 1) + end = code.rfind("}") + code = code[:end] + "return 0;\n" + code[end:] + code = r''' +#define MESH_ENABLE_RADIO_DIRECTION 1 +#define MESH_ENABLE_CARRIER_TOOLS 0 +#define MESH_ENABLE_MORSE_RX 0 +#define MESH_ENABLE_TX_BEACONS 0 +#define RADIOLIB_ERR_SPI_CMD_TIMEOUT -705 +#include +namespace mesh { namespace radioDirection { +static Mode current = Mode::Both; +Mode mode() { return current; } +}} +''' + code + code = code.replace(" unsigned scans = 0;", r''' + unsigned scans = 0, standbys = 0, clears = 0, transmissions = 0; + int standby_error = 0, clear_error = 0, transmit_error = 0; + int standby() { ++standbys; return standby_error; } + int clearIrqFlags(uint32_t) { ++clears; return clear_error; } + int startTransmit(uint8_t*, int) { ++transmissions; return transmit_error; } +''') + code = code.replace(" unsigned n_cad_busy = 0, services = 0;", r''' + unsigned n_cad_busy = 0, services = 0, before_tx = 0, after_tx = 0; + void onBeforeTransmit() { ++before_tx; } + void onAfterTransmit() { ++after_tx; } +''') + code = code.replace(" uint8_t state = STATE_RX;", r''' + uint8_t state = STATE_RX; + bool _direction_receive_disabled = false; + uint8_t _irq_probe_failures = 0; + unsigned long _wd_observe_until = 0; + void serviceDirectionPolicy(); + bool startSendRaw(const uint8_t*, int); + bool getReceiveRetryDelay(uint32_t, uint32_t&) const; + void idle() { _radio->standby(); state = STATE_IDLE; } +''') + for signature in ("void RadioLibWrapper::serviceDirectionPolicy(", + "bool RadioLibWrapper::startSendRaw(", + "bool RadioLibWrapper::getReceiveRetryDelay("): + code += "\n" + method(source, signature) + code += r''' +int main() { + using namespace mesh::radioDirection; + assert(legacy_main() == 0); // Both preserves normal RX/CAD/calibration. + const uint8_t message[] = {1, 2, 3}; + current = Mode::Rx; + { + RadioLibWrapper w; + assert(!w.startSendRaw(message, sizeof(message))); + assert(w.radio.transmissions == 0 && w.board.before_tx == 0); + const auto old_arms = w.arms; + w.startRecv(); + assert(w.arms == old_arms + 1 && w.state == STATE_RX); + } + current = Mode::Tx; + { + RadioLibWrapper w; + w._rx_ps_enabled = w._rx_ps_armed = true; + w._rx_retry_pending = w._rx_hold_continuous = true; + w._nf_calib_active = w._nf_refresh_requested = w._nf_window_armed = true; + w._wd_observe_until = 1000; + w.events.record(); + w.loop(); + assert(w.radio.standbys == 1 && w.radio.clears == 1 && w.stops == 1); + assert(w.state == STATE_IDLE && !w.hasPendingRadioInterrupt()); + assert(w._direction_receive_disabled && !w._rx_ps_armed && !w._rx_retry_pending); + assert(!w._rx_hold_continuous && !w._nf_calib_active && !w._nf_refresh_requested); + assert(!w._nf_window_armed && !w._wd_observe_until); + for (unsigned i = 0; i < 1000; ++i) { ++now_ms; w.loop(); w.startRecv(); } + assert(w.radio.standbys == 1 && w.radio.clears == 1 && w.arms == 0 && w.reads == 0); + uint32_t delay = 123; + assert(!w.getNoiseFloorWakeDelay(now_ms, delay)); + assert(!w.getReceiveRetryDelay(now_ms, delay)); + w.triggerNoiseFloorCalibrate(7); + assert(!w._nf_refresh_requested && w._threshold == 7); + // Free CAD must not require RX re-entry, even with no RSSI baseline. + w._noise_floor_valid = false; + assert(!w.isChannelActive()); + assert(w.radio.scans == 1 && w.arms == 0 && w.state == STATE_IDLE); + assert(!w.isReceivingPassive(7)); + assert(w.radio.scans == 2 && w.reads == 0 && w.arms == 0); + w.radio.scan_result = -9; + assert(w.isChannelActive() && w.board.n_cad_busy == 1); + w.radio.scan_result = RADIOLIB_CHANNEL_FREE; + w.radio.clear_error = -8; + assert(w.isChannelActive() && w.board.n_cad_busy == 2); + w.radio.clear_error = 0; + assert(w.startSendRaw(message, sizeof(message))); + assert(w.state == STATE_TX_WAIT && w.radio.transmissions == 1); + const auto standbys = w.radio.standbys; + w.loop(); + assert(w.state == STATE_TX_WAIT && w.radio.standbys == standbys); + current = Mode::Rx; + w.serviceDirectionPolicy(); + assert(w.state == STATE_TX_WAIT && w.radio.standbys == standbys); + assert(!w._direction_receive_disabled); + w.state = STATE_IDLE; // Existing TxDone owner retires the on-air frame. + w.startRecv(); + assert(w.state == STATE_RX && w.arms == 1); + } + current = Mode::Tx; + { + RadioLibWrapper w; + w.state = STATE_IDLE; // Software idle may still conceal hardware RX. + w.radio.standby_error = -7; + w.serviceDirectionPolicy(); + assert(!w._direction_receive_disabled && w.radio.standbys == 1); + w.radio.standby_error = 0; + w.radio.clear_error = -8; + w.serviceDirectionPolicy(); + assert(!w._direction_receive_disabled && w.radio.standbys == 2); + w.radio.clear_error = 0; + w.serviceDirectionPolicy(); + assert(w._direction_receive_disabled && w.radio.standbys == 3); + current = Mode::Both; + w.serviceDirectionPolicy(); + assert(!w._direction_receive_disabled); + w.startRecv(); + assert(w.state == STATE_RX && w.arms == 1); + } + current = Mode::Off; + { + RadioLibWrapper w; + w.loop(); w.startRecv(); + assert(w.state == STATE_IDLE && w.arms == 0 && w._direction_receive_disabled); + assert(!w.startSendRaw(message, sizeof(message)) && w.radio.transmissions == 0); + } +} +''' + with tempfile.TemporaryDirectory(prefix="radio-direction-driver-") as temporary: + work = Path(temporary) + cpp, executable = work / "test.cpp", work / "test" + cpp.write_text(code) + built = subprocess.run([os.environ.get("CXX", "c++"), "-std=c++17", "-O1", + "-Wall", "-Wextra", "-Werror", "-DUSE_SX1262=1", "-fsanitize=address,undefined", + "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", "-I", str(ROOT / "src"), + str(cpp), "-o", str(executable)], capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + ran = subprocess.run([str(executable)], capture_output=True, text=True, timeout=30) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_radio_direction_settings.py b/test/test_radio_direction_settings.py new file mode 100644 index 000000000..3958e7860 --- /dev/null +++ b/test/test_radio_direction_settings.py @@ -0,0 +1,216 @@ +"""Exercise the saved LoRa direction gate and real publication failures.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include "RadioDirectionSettings.h" +#include "settings_under_test.cpp" + +int main() { + using namespace mesh::radioDirection; + // Uninitialized users such as Companion retain their existing direction. + assert(mode() == Mode::Both && radioReceiveAllowed() && radioTransmitAllowed()); + MemoryFS fs; + const char* path = "/radio_mode"; + const std::vector legacy = {9, 7, 5, 3}; + fs.files["/com_prefs"] = legacy; + loadDirectionSettings(&fs); + assert(mode() == Mode::Both && fs.files.size() == 1); + char reply[160]; + auto run = [&](const char* command) { + strcpy(reply, "untouched"); + return handleDirectionCommand(command, reply, sizeof(reply)); + }; + assert(run("get radio.mode") && !strcmp(reply, "> both")); + assert(!run("get radio.model") && !strcmp(reply, "untouched")); + + struct Choice { const char* text; Mode value; bool rx, tx; }; + for (const Choice& choice : {Choice{"rx", Mode::Rx, true, false}, + Choice{"tx", Mode::Tx, false, true}, Choice{"off", Mode::Off, false, false}, + Choice{"both", Mode::Both, true, true}}) { + char command[32]; + snprintf(command, sizeof(command), "set radio.mode %s", choice.text); + assert(run(command) && !strncmp(reply, "OK", 2)); + assert(mode() == choice.value); + assert(radioReceiveAllowed() == choice.rx && radioTransmitAllowed() == choice.tx); + loadDirectionSettings(&fs); + assert(mode() == choice.value); + assert(radioReceiveAllowed() == choice.rx && radioTransmitAllowed() == choice.tx); + char expected[16]; snprintf(expected, sizeof(expected), "> %s", choice.text); + assert(run("get radio.mode") && !strcmp(reply, expected)); + } + assert(run("set radio.mode\trx") && !strncmp(reply, "OK", 2)); + const auto good = fs.files.at(path); + assert(good.size() == 10 && !memcmp(good.data(), "RDM\1", 4)); + assert(good[4] == 1 && good[5] == 0xfe); + for (const char* invalid : {"set radio.mode", "set radio.mode receive", + "set radio.mode Rx", "set radio.mode rxtx", "set radio.mode txjunk", + "set radio.mode rx trailing", "get radio.mode extra"}) { + assert(run(invalid) && !strncmp(reply, "Error:", 6)); + assert(mode() == Mode::Rx && fs.files.at(path) == good); + } + assert(!run("set radio.mode2 off")); + assert(!handleDirectionCommand(nullptr, reply, sizeof(reply))); + assert(!handleDirectionCommand("set radio.mode off", nullptr, sizeof(reply))); + assert(!handleDirectionCommand("set radio.mode off", reply, 0)); + char bounded[] = {'x', 'x', 'x', 'z'}; + assert(handleDirectionCommand("get radio.mode", bounded, 3)); + assert(bounded[2] == 0 && bounded[3] == 'z'); + + // A known failed save must keep both persisted and effective directions. + fs.fail_write = true; + assert(run("set radio.mode tx") && !strncmp(reply, "Error:", 6)); + assert(mode() == Mode::Rx && fs.files.at(path) == good); + fs.fail_write = false; + fs.fail_write_after = 4; + assert(run("set radio.mode off") && !strncmp(reply, "Error:", 6)); + assert(mode() == Mode::Rx && fs.files.at(path) == good); + fs.fail_write_after = -1; + assert(run("set radio.mode rx") && !strncmp(reply, "OK", 2)); + + // Publication uncertainty cannot enable LoRa TX or silently restore both. + fs.fail_after_publication = true; + assert(run("set radio.mode tx") && !strncmp(reply, "Error:", 6)); + assert(mode() == Mode::Off && !radioReceiveAllowed() && !radioTransmitAllowed()); + const auto published = fs.files.at(path); + assert(published != good); + fs.fail_after_publication = false; fs.fail_read_open = false; + assert(run("set radio.mode both") && !strncmp(reply, "Error:", 6)); + assert(mode() == Mode::Off && fs.files.at(path) == published); + loadDirectionSettings(&fs); + assert(mode() == Mode::Tx && !radioReceiveAllowed() && radioTransmitAllowed()); + assert(run("set radio.mode rx") && !strncmp(reply, "OK", 2)); + + // Corruption is distinguishable from deliberately saved off, and explicit + // repair is allowed only when the record's existence/read was determinate. + for (size_t bit = 0; bit < good.size() * 8; ++bit) { + fs.files[path] = good; + fs.files[path][bit / 8] ^= uint8_t(1U << (bit % 8)); + loadDirectionSettings(&fs); + assert(mode() == Mode::Off && !radioReceiveAllowed() && !radioTransmitAllowed()); + assert(run("get radio.mode") && strstr(reply, "invalid or unreadable")); + } + assert(run("set radio.mode rx") && !strncmp(reply, "OK", 2)); + for (size_t size = 0; size < good.size(); ++size) { + fs.files[path] = std::vector(good.begin(), good.begin() + size); + loadDirectionSettings(&fs); + assert(mode() == Mode::Off); + } + assert(run("set radio.mode rx") && !strncmp(reply, "OK", 2)); + fs.files[path].push_back(0); + loadDirectionSettings(&fs); assert(mode() == Mode::Off); + assert(run("set radio.mode off") && !strncmp(reply, "OK", 2)); + assert(run("get radio.mode") && !strcmp(reply, "> off")); + assert(run("set radio.mode rx") && !strncmp(reply, "OK", 2)); + + fs.fail_read_open = true; + loadDirectionSettings(&fs); assert(mode() == Mode::Off); + fs.fail_read_open = false; + assert(run("set radio.mode both") && !strncmp(reply, "Error:", 6)); + assert(fs.files.at(path) == good); + loadDirectionSettings(&fs); assert(mode() == Mode::Rx); + fs.fail_read_after = 4; + loadDirectionSettings(&fs); assert(mode() == Mode::Off); + fs.fail_read_after = -1; + assert(run("set radio.mode both") && !strncmp(reply, "Error:", 6)); + loadDirectionSettings(&fs); assert(mode() == Mode::Rx); + fs.fail_metadata = true; + loadDirectionSettings(&fs); assert(mode() == Mode::Off); + fs.fail_metadata = false; + assert(run("set radio.mode both") && !strncmp(reply, "Error:", 6)); + loadDirectionSettings(&fs); assert(mode() == Mode::Rx); + + fs.files["/radio_mode.bak"] = good; fs.files.erase(path); + loadDirectionSettings(&fs); + assert(mode() == Mode::Rx && fs.files.at(path) == good); + fs.files["/radio_mode.bak"] = good; fs.files.erase(path); + fs.fail_rename = 1; + loadDirectionSettings(&fs); assert(mode() == Mode::Off); + assert(fs.files.at("/radio_mode.bak") == good); + assert(run("set radio.mode tx") && !strncmp(reply, "Error:", 6)); + loadDirectionSettings(&fs); assert(mode() == Mode::Rx); + loadDirectionSettings(nullptr); + assert(mode() == Mode::Off && !radioReceiveAllowed() && !radioTransmitAllowed()); + assert(run("set radio.mode both") && !strncmp(reply, "Error:", 6)); + assert(fs.files.at("/com_prefs") == legacy); +} +''' + + +class RadioDirectionSettingsTests(unittest.TestCase): + def test_saved_modes_and_real_storage_faults(self): + with tempfile.TemporaryDirectory(prefix="radio-direction-settings-") as temporary: + work = Path(temporary) + (work / "helpers").mkdir() + mock = (ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h").read_text() + mock = mock.replace(" bool fail_read_open = false;", + " bool fail_read_open = false;\n" + " bool fail_after_publication = false;\n bool fail_metadata = false;") + mock = mock.replace(" files[to] = files[from]; files.erase(from); return true;", + " files[to] = files[from]; files.erase(from);\n" + " if (fail_after_publication && !strcmp(to, \"/radio_mode\")) fail_read_open = true;\n" + " return true;") + (work / "IdentityStore.h").write_text(mock) + (work / "helpers/IdentityStore.h").write_text('#include "../IdentityStore.h"\n') + (work / "FilePresence.h").write_text(r''' +#pragma once +#include "IdentityStore.h" +namespace mesh { +template bool filePresence(FS* fs, const char* path, bool& present) { + if (!fs || !path || fs->fail_metadata) return false; + present = fs->exists(path); return true; +} +} +''') + for name in ("RadioDirectionSettings.h", "RadioDirectionPolicy.h", + "CheckedSettingsFile.h", "ContactFileTransaction.h"): + (work / name).write_bytes((ROOT / "src/helpers" / name).read_bytes()) + (work / "settings_under_test.cpp").write_bytes( + (ROOT / "src/helpers/RadioDirectionSettings.cpp").read_bytes()) + source, executable = work / "test.cpp", work / "test" + source.write_text(HARNESS) + built = subprocess.run([os.environ.get("CXX", "c++"), "-std=c++17", "-O1", + "-Wall", "-Wextra", "-Werror", "-Wno-sign-compare", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", + "-DESP32_PLATFORM=1", "-I" + str(work), "-I" + str(ROOT / "src"), + "-I" + str(ROOT / "src/helpers"), str(source), "-o", str(executable)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + checked = subprocess.run([str(executable)], capture_output=True, text=True, timeout=20) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + + def test_other_platforms_have_both_directions_without_storage(self): + with tempfile.TemporaryDirectory(prefix="radio-direction-default-") as temporary: + work = Path(temporary) + source, executable, obj = work / "test.cpp", work / "test", work / "settings.o" + source.write_text(''' +#include +#include "RadioDirectionPolicy.h" +int main() { + using namespace mesh::radioDirection; + assert(mode() == Mode::Both && radioReceiveAllowed() && radioTransmitAllowed()); +} +''') + for platform in ("NRF52_PLATFORM", "STM32_PLATFORM", "RP2040_PLATFORM"): + flags = ["-std=c++17", "-D" + platform, "-I" + str(ROOT / "src/helpers")] + subprocess.run([os.environ.get("CXX", "c++"), *flags, + str(source), "-o", str(executable)], check=True, capture_output=True) + subprocess.run([str(executable)], check=True, capture_output=True) + subprocess.run([os.environ.get("CXX", "c++"), *flags, "-c", + str(ROOT / "src/helpers/RadioDirectionSettings.cpp"), "-o", str(obj)], + check=True, capture_output=True) + symbols = subprocess.check_output(["nm", "-C", str(obj)], text=True) + for name in ("settings_fs", "selected", "writable", "valid", "loadDirectionSettings", "handleDirectionCommand"): + self.assertNotIn(name, symbols) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_transport_filter_wiring.py b/test/test_transport_filter_wiring.py index 7b6177290..816de1313 100644 --- a/test/test_transport_filter_wiring.py +++ b/test/test_transport_filter_wiring.py @@ -36,6 +36,8 @@ struct Packet { HARNESS = r''' #include +#include +namespace radioDirection = mesh::radioDirection; #include #include #include @@ -88,6 +90,9 @@ struct BridgeBase : AbstractBridge { bool isRunning() const override {return _initialized;} void sendPacket(mesh::Packet*) override {} void onPacketReceived(mesh::Packet* p) override {handleReceivedPacket(p);} + void receiveFrom(mesh::Packet* p, const mesh::bridge::IngressSource& source) { + IngressSourceScope scope(*this, source); handleReceivedPacket(p); + } void handleReceivedPacket(mesh::Packet*); uint16_t fletcher16(const uint8_t*,size_t) {return 0;} }; @@ -413,6 +418,7 @@ int main() { #if MESH_ENABLE_BRIDGE_INGRESS namespace mesh { namespace bridge { IngressProfile ingressProfile() { return IngressProfile::Radio; } +IngressRoute ingressRoute(const IngressSource*) { return {IngressProfile::Radio, false}; } }} #endif '''