diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index cffc53a52..466c7bf4a 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -474,6 +474,7 @@ jobs: python3 -B test/test_clock_transit_profile_timing.py -v 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 - name: Verify signed private fleet management run: | diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index 7a071820b..ded327ece 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -252,6 +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.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 | @@ -431,6 +432,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 | 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.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 97447173a..082349562 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -6793,6 +6793,40 @@ updated active peer can still answer a valid incoming ping in passive mode. --- +#### Select the radio for incoming bridge packets + +```text +get bridge.ingress +set bridge.ingress radio2 +set bridge.ingress radio +``` + +This receiver-side setting applies to incoming mesh packets from ESP-NOW, UDP +and RS-232 bridges. It is saved immediately and needs no restart. Packets +already queued retain their original route. `radio` is +the default, preserving the existing primary-radio behavior and crossing rules. +Existing senders and bridge wire formats remain compatible. +An unreadable or corrupt saved selection blocks bridge input rather than +silently restoring the primary; `get bridge.ingress` reports the fault. + +`radio2` assigns incoming packets to the active MeshCore second profile and +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. +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. + +For a MediaMesh receiver feeding a repeater whose primary is USA 910.525 MHz, +see the [MediaMesh bridge example](espnow_bridge_setup.md#send-bridge-traffic-only-on-mediamesh). +`bridge.source` controls traffic leaving for the bridge and is independent of +`bridge.ingress`. This does not add MQTT packet injection or change MQTT +publication settings. + +--- + #### View or change the MQTT master switch **Search terms:** enable MQTT, disable MQTT, MQTT on, MQTT off, WiFi logging. diff --git a/docs/espnow_bridge_setup.md b/docs/espnow_bridge_setup.md index 761c9fb6a..714a05e8d 100644 --- a/docs/espnow_bridge_setup.md +++ b/docs/espnow_bridge_setup.md @@ -94,6 +94,7 @@ Stopping it first lets you finish configuring both sides before connecting them. | `bridge.format` | `wrapped` is the default for this two-repeater setup and uses the shared secret. `raw` connects to primary-ESP-NOW nodes such as `Generic_ESPNOW` or `SenseCapIndicator-ESPNow`; it ignores the secret. Both peers must use compatible formats. | | `bridge.secret` | A matching, case-sensitive value of 1-15 characters for `wrapped` mode. It separates bridge groups; it is not strong encryption. MeshCore's own message encryption remains in place. | | `bridge.source` | `tx` crosses packets this repeater transmits on LoRa, after its forwarding decisions; use this for the setup above. `rx` crosses received packets before those decisions and can pass traffic the local repeater would not forward. | +| `bridge.ingress` | Selects the local radio for incoming bridge packets: `radio` preserves the default primary-radio behavior; `radio2` forwards only on the active MeshCore second profile, with no primary fallback. | | `bridge.delay` | Wait before processing a packet received from the other bridge, in milliseconds. Range: 0-10000. Start with the default `500`. | | `repeat` | `on` enables normal LoRa forwarding; `off` disables forwarding. Keep it `on` on both bridge repeaters. Normal routing, scope, and packet-filter rules still apply. | | `bridge.type` | Read-only firmware capability, including combined transports. | @@ -105,6 +106,46 @@ use `logTx` and `logRx`; those are not the command values used here. For a LoRa gateway connected to primary-ESP-NOW Companions instead of another LoRa repeater, see [raw ESP-NOW compatibility](WiFi.md#wifi-companion-setup). +### Send bridge traffic only on MediaMesh + +Keep the receiving repeater's primary USA profile at 910.525 MHz while sending +packets arriving over the bridge only on MediaMesh: + +```text +MediaMesh 911.150 RX --> Repeater A --> ESP-NOW --> Repeater B --> radio2 911.150 TX +``` + +Configure the matching ESP-NOW channel, format and secret as above. On the +source repeater already receiving MediaMesh, export receptions without local +relay forwarding: + +```text +set bridge.source rx +set repeat off +``` + +`repeat off` still allows this source's own adverts and replies. For an RX-only +MediaMesh profile, use `set radio2 911.150,250,7,5,rx` and keep crossing off; +the bridge also exports receptions from any other active profile. This setting +does not filter bridge output by the source frequency. + +On the destination repeater, leave its existing primary USA settings and use: + +```text +set radio2 mediamesh +set radio2.cross off +set bridge.ingress radio2 +set repeat on +get bridge.ingress +``` + +The incoming bridge packets are marked as radio2 traffic locally; the sender +does not need updated firmware or an extra wire-format field. Radio2 must be +available in `rxtx` MeshCore mode. Otherwise packets are dropped rather than +sent on the primary. This saved setting covers all incoming ESP-NOW, UDP and +RS-232 mesh traffic on the destination; `set bridge.ingress radio` restores +the original behavior. Normal scope and filtering rules still apply. + ## 4. Check the link Run on both boards: diff --git a/docs/radio_profiles.md b/docs/radio_profiles.md index d1ce79a95..f42cc8795 100644 --- a/docs/radio_profiles.md +++ b/docs/radio_profiles.md @@ -102,6 +102,12 @@ If the second profile cannot transmit MeshCore, media uses the normal channel/global route; `set tx.media radio2` instead requires radio2 with no primary fallback. See [application TX routing](#companion-media-and-ssh-tx-routing). +Infrastructure repeaters can separately mark packets arriving over ESP-NOW, +UDP or RS-232 with `set bridge.ingress radio2`. Those packets forward only on +the second MeshCore profile; they are dropped if it cannot transmit, without +primary fallback. The default `bridge.ingress radio` preserves existing bridge +behavior. See the [MediaMesh bridge example](espnow_bridge_setup.md#send-bridge-traffic-only-on-mediamesh). + ### Discover the local radio2 profile Clients can request a repeater's current MeshCore radio2 profile through public, diff --git a/docs/rs232_bridge_setup.md b/docs/rs232_bridge_setup.md index 818031533..87119b60e 100644 --- a/docs/rs232_bridge_setup.md +++ b/docs/rs232_bridge_setup.md @@ -55,6 +55,13 @@ bridges received packets before those decisions. Normal scope, filters, duplicate suppression and repeat settings still affect mesh delivery. Configure each side's LoRa network separately; see the [bridge CLI reference](cli_commands.md#bridge-when-bridge-support-is-compiled-in). +Incoming mesh packets use the local primary profile by default. Save +`set bridge.ingress radio2` on the receiver to mark them for forwarding only on +its active MeshCore radio2; an unavailable or RX-only radio2 drops them rather +than falling back to the primary. The serial format and older peer remain +compatible. See [bridge ingress](cli_commands.md#select-the-radio-for-incoming-bridge-packets) +for the shared setting and `set bridge.ingress radio` to restore the default. + ## Distinguish power state from transport activity `get rs232.status` retains the existing heartbeat and automatic-standby report. diff --git a/docs/udp_bridge_setup.md b/docs/udp_bridge_setup.md index 34e2440fc..926a52533 100644 --- a/docs/udp_bridge_setup.md +++ b/docs/udp_bridge_setup.md @@ -163,6 +163,14 @@ actual firmware. If sessions/probes work but mesh traffic does not, check `bridge.source rx` is valid; `tx` instead selects packets after LoRa forwarding decisions. These source/filter settings are shared with other compiled bridges. +Incoming UDP mesh packets use `bridge.ingress radio` by default. To forward +them only on an already configured MeshCore radio2, save +`set bridge.ingress radio2` on the receiving board. Radio2 must permit TX; +unavailable profiles cause drops, never a fallback to the primary. The sender, +UDP framing, peer sessions and authentication stay unchanged. See the +[MediaMesh example](espnow_bridge_setup.md#send-bridge-traffic-only-on-mediamesh) +and [bridge-ingress reference](cli_commands.md#select-the-radio-for-incoming-bridge-packets). + The peer list contains direct destinations for locally received/transmitted LoRa traffic. An incoming UDP packet enters the local LoRa mesh; it is not relayed to other UDP peers, including through its later local LoRa TX callback. diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 1ae5fd6d2..2093e6f79 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -892,6 +892,7 @@ public: void configureBridgeFilter(AbstractBridge* active_bridge) { + active_bridge->setIngressProfiles(_radio->profiles()); #if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE active_bridge->setPacketFilter([](void* context, const mesh::Packet* packet) { return static_cast(context)->allowTransportPacket(packet, 2); // RULE_MODE_BRIDGE diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 2dd0f6a79..f5c878815 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -1607,6 +1607,7 @@ DispatcherAction Mesh::forwardMultipartDirect(Packet* pkt, uint32_t delay_millis _tables->markSeen(pkt); Packet tmp; tmp.header = pkt->header; + tmp.tx_radio = pkt->tx_radio; tmp.path_len = Packet::copyPath(tmp.path, pkt->path, pkt->path_len); tmp.payload_len = pkt->payload_len - 1; memcpy(tmp.payload, &pkt->payload[1], tmp.payload_len); @@ -1625,6 +1626,7 @@ void Mesh::routeDirectRecvAcks(Packet* packet, uint32_t delay_millis) { if (a1) { a1->radio_reply = false; // relayed ACK, not a local response a1->forwarded = true; + a1->tx_radio = packet->tx_radio; a1->path_len = Packet::copyPath(a1->path, packet->path, packet->path_len); a1->header &= ~PH_ROUTE_MASK; a1->header |= ROUTE_TYPE_DIRECT; @@ -1639,6 +1641,7 @@ void Mesh::routeDirectRecvAcks(Packet* packet, uint32_t delay_millis) { if (a2) { a2->radio_reply = false; // relayed ACK, not a local response a2->forwarded = true; + a2->tx_radio = packet->tx_radio; a2->path_len = Packet::copyPath(a2->path, packet->path, packet->path_len); a2->header &= ~PH_ROUTE_MASK; a2->header |= ROUTE_TYPE_DIRECT; diff --git a/src/helpers/AbstractBridge.h b/src/helpers/AbstractBridge.h index 39304168a..0e1f89f55 100644 --- a/src/helpers/AbstractBridge.h +++ b/src/helpers/AbstractBridge.h @@ -1,6 +1,7 @@ #pragma once #include +#include class AbstractBridge { using PacketFilter = bool (*)(void*, const mesh::Packet*); @@ -9,18 +10,25 @@ class AbstractBridge { const mesh::RadioProfiles* _ingress_profiles = nullptr; protected: - // Bridges enter the local primary MeshCore network, not whichever tuple - // the physical scanner currently visits. Snapshot its generation at queue - // admission so a delayed frame cannot acquire a newer temporary lease. + // 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 { - if (!packet || (_ingress_profiles - && _ingress_profiles->params(0).protocol != mesh::RadioProfileProtocol::MeshCore)) return false; - packet->radio_profile = packet->radio_origin = 0; + const auto target = mesh::bridge::ingressProfile(); + if (!packet || target == mesh::bridge::IngressProfile::Blocked) 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))) + || (_ingress_profiles && _ingress_profiles->params(profile).protocol + != mesh::RadioProfileProtocol::MeshCore)) return false; + packet->radio_profile = packet->radio_origin = profile; packet->radio_generation = packet->radio_origin_generation = - _ingress_profiles ? _ingress_profiles->generation[0] : 0; + _ingress_profiles ? _ingress_profiles->generation[profile] : 0; packet->radio_bound = false; packet->radio_local = false; - packet->tx_radio = mesh::RADIO_TX_AUTO; + // 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->radio_reply = packet->radio_reply_force = false; packet->forwarded = true; return true; diff --git a/src/helpers/BridgeIngressProfile.h b/src/helpers/BridgeIngressProfile.h new file mode 100644 index 000000000..b756d4b75 --- /dev/null +++ b/src/helpers/BridgeIngressProfile.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +// WITH_BRIDGE is also synthesized by CommonCLI.h. This feature must not +// depend on that header having been included first in a translation unit. +#ifndef MESH_ENABLE_BRIDGE_INGRESS +#if defined(WITH_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) || \ + defined(WITH_RS232_BRIDGE) || defined(WITH_MQTT_BRIDGE) || defined(WITH_UDP_BRIDGE) +#define MESH_ENABLE_BRIDGE_INGRESS 1 +#else +#define MESH_ENABLE_BRIDGE_INGRESS 0 +#endif +#endif + +namespace mesh { namespace bridge { + +enum class IngressProfile : uint8_t { Radio = 0, Radio2 = 1, Blocked = 2 }; + +#if MESH_ENABLE_BRIDGE_INGRESS +IngressProfile ingressProfile(); +#else +inline IngressProfile ingressProfile() { return IngressProfile::Radio; } +#endif + +}} // namespace mesh::bridge diff --git a/src/helpers/BridgeIngressSettings.cpp b/src/helpers/BridgeIngressSettings.cpp new file mode 100644 index 000000000..cf6a3deb4 --- /dev/null +++ b/src/helpers/BridgeIngressSettings.cpp @@ -0,0 +1,148 @@ +#include "BridgeIngressSettings.h" + +#if MESH_ENABLE_BRIDGE_INGRESS +#include "CheckedSettingsFile.h" +#include "FilePresence.h" +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#include "AtomicFileWriter.h" +#else +#include "ContactFileTransaction.h" +#endif +#include +#include + +namespace mesh { namespace bridge { +namespace { +constexpr char Path[] = "/bridge_ingress"; +constexpr size_t ImageSize = 10; +FILESYSTEM* settings_fs = nullptr; +IngressProfile selected = IngressProfile::Blocked; +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], IngressProfile profile) { + memcpy(image, "BIN\1", 4); + image[4] = static_cast(profile); + 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], IngressProfile& profile) { + if (memcmp(image, "BIN\1", 4) != 0 || image[4] > 1 || + 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; + profile = static_cast(image[4]); + return true; +} + +void quarantine() { + selected = IngressProfile::Blocked; + writable = false; +} + +bool saveSetting(IngressProfile profile) { + if (!settings_fs || !writable) return false; + uint8_t image[ImageSize]; + encode(image, profile); +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + AtomicFileWriter file(settings_fs, Path); +#else + ContactFileTransaction file(settings_fs, Path, filePresence); +#endif + using Outcome = decltype(file.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 = profile; + return true; +} +} // namespace + +IngressProfile ingressProfile() { return selected; } + +void loadIngressSettings(FILESYSTEM* fs) { + settings_fs = fs; + quarantine(); + if (!fs) return; +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + if (!ContactFileTransaction::recover(fs, Path, filePresence)) return; +#endif + bool present = false; + if (!filePresence(fs, Path, present)) return; + if (!present) { + selected = IngressProfile::Radio; + 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 can be explicitly repaired. Until then it must + // not silently turn a saved radio2-only ingress into primary-radio traffic. + writable = true; + if (read == storage::FixedSettingsRead::Complete) decode(image, selected); +} + +bool handleIngressCommand(const char* command, char* reply, size_t capacity) { + if (!command || !reply || !capacity) return false; + 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 && + (command[18] == 0 || command[18] == ' ' || command[18] == '\t'); + if (!get && !set) return false; + if (get) { + if (command[18] != 0) + snprintf(reply, capacity, "Error: use get bridge.ingress"); + else if (selected == IngressProfile::Blocked) + snprintf(reply, capacity, "Error: bridge ingress blocked; settings invalid or unreadable"); + else + snprintf(reply, capacity, "> %s", selected == IngressProfile::Radio2 ? "radio2" : "radio"); + 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"); + return true; + } + if (!writable) { + snprintf(reply, capacity, "Error: bridge ingress storage unavailable; reboot first"); + } else if (!saveSetting(profile)) { + snprintf(reply, capacity, "Error: bridge ingress not saved%s", + writable ? "" : "; storage uncertain, ingress blocked until reload"); + } else { + snprintf(reply, capacity, "OK - bridge.ingress %s", value); + } + return true; +} + +}} // namespace mesh::bridge +#endif diff --git a/src/helpers/BridgeIngressSettings.h b/src/helpers/BridgeIngressSettings.h new file mode 100644 index 000000000..bc8cb0385 --- /dev/null +++ b/src/helpers/BridgeIngressSettings.h @@ -0,0 +1,17 @@ +#pragma once + +#include "BridgeIngressProfile.h" +#include + +#if MESH_ENABLE_BRIDGE_INGRESS +#include "IdentityStore.h" + +namespace mesh { namespace bridge { + +// Separate from /com_prefs so old firmware and existing bridge frames keep +// their original layouts. Missing settings retain primary-radio ingress. +void loadIngressSettings(FILESYSTEM* fs); +bool handleIngressCommand(const char* command, char* reply, size_t capacity); + +}} // namespace mesh::bridge +#endif diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index f37aa68b5..5f299c331 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -20,6 +20,7 @@ #include "PowerSavingDiagnostics.h" #include "HilStartupTrace.h" #include "FilePresence.h" +#include "BridgeIngressSettings.h" #if defined(ESP32_PLATFORM) #include "BootSlot.h" #include @@ -1017,6 +1018,9 @@ static void formatSnrDbX4Short(char* dest, size_t dest_len, int16_t snr_x4) { } void CommonCLI::loadPrefs(FILESYSTEM* fs) { +#if MESH_ENABLE_BRIDGE_INGRESS + mesh::bridge::loadIngressSettings(fs); +#endif #if MESH_ENABLE_BATTERY_FORWARDING loadBatteryForwarding(fs); #endif @@ -3563,6 +3567,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_BRIDGE_INGRESS + if (mesh::bridge::handleIngressCommand(command, reply, 157)) return; +#endif #if MESH_ENABLE_HOST_KISS if (mesh::hostkiss::handleCommand(command, reply, 157, sender_timestamp == 0)) return; #endif diff --git a/src/helpers/bridges/BridgeBase.h b/src/helpers/bridges/BridgeBase.h index b239e6279..54e471bc2 100644 --- a/src/helpers/bridges/BridgeBase.h +++ b/src/helpers/bridges/BridgeBase.h @@ -116,7 +116,7 @@ protected: * @brief Common packet handling for received packets * * Implements the standard pattern used by all bridges: - * - Bind ingress to the current local primary-profile generation + * - Bind ingress to the selected local radio-profile generation * - Apply transport policy before queueing or marking a packet * - Deduplicate ordinary packets using _seen_packets.wasSeen() * - Leave resumable OTA retries to the mesh's timed retry/echo policy diff --git a/test/fixtures/cli_reply_text.json b/test/fixtures/cli_reply_text.json index b7fc31910..51b4db218 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": "ffde525e649544c1e7821f45810860f555bb4b2bfb9484923682c55830eee9ad", + "src/helpers/CommonCLI.cpp": "cd2bcd278e1b01e0e7195306aa67d0a00070183543c8d61a9724958058ead6b7", "examples/simple_repeater/MyMesh.cpp": "8567eeef2f327100e397c40b0aaf5b675fcdfea254987437d1ca8d005ad3a6f0", "src/helpers/RadioProfileCLI.cpp": "d641d16e872eba3e73b9b5a50f9718c10b0b7da72782f793706ac36b3724a624", "src/helpers/WirelessControl.h": "f19bb602999118cc423ec74c7de7100ed91cb2c553295038077e52e321bb4e73", diff --git a/test/test_bridge_ingress.py b/test/test_bridge_ingress.py index af62aa7b6..0d72b29b2 100644 --- a/test/test_bridge_ingress.py +++ b/test/test_bridge_ingress.py @@ -12,6 +12,312 @@ ROOT = Path(__file__).resolve().parents[1] class BridgeIngressTests(unittest.TestCase): + def test_radio2_selection_is_local_explicit_and_never_falls_back(self): + base = (ROOT / "src/helpers/bridges/BridgeBase.cpp").read_text() + dispatcher = (ROOT / "src/Dispatcher.cpp").read_text() + receive = method(dispatcher, "void Dispatcher::processRecvPacket(") + guard = receive[receive.index(" const auto* profiles ="):receive.index(" const uint8_t previous_profile")] + code = '#include \n' + HARNESS[:HARNESS.index("@METHODS@")] + code += method(base, "void BridgeBase::handleReceivedPacket(") + code += r''' +#define MESH_DEBUG_PRINTLN(...) ((void)0) +#define MAX_PACKET_PAYLOAD 64 +using mesh::Packet; +using mesh::RadioProfileProtocol; +using mesh::RADIO_TX_AUTO; +using mesh::explicitRadioTxMask; +struct Radio { + mesh::RadioProfiles* state; + mesh::RadioProfiles* profiles() const {return state;} +}; +struct Dispatcher { + Radio* _radio; + bool radio_available=true; + unsigned accepted=0,released=0,failed=0,copies=0; + std::vector outbound; + mesh::Packet copy_pool[4]; unsigned allocated=0; + explicit Dispatcher(Radio* radio):_radio(radio) {} + void releasePacket(mesh::Packet*) {++released;} + void receive(mesh::Packet* pkt) { +@DISPATCH_GUARD@ + ++accepted; + } + bool queueTransmitCandidate(mesh::Packet* packet,uint8_t,uint32_t) { + outbound.push_back(*packet);return true; + } + unsigned futureMillis(unsigned delay) {return delay;} + bool allowRadioProfileCross(const mesh::Packet*) const {return true;} + void onSendFail(mesh::Packet*) {++failed;} + void onRadioProfileCopyQueued(mesh::Packet*,mesh::Packet*,uint8_t) {++copies;} + void onTracePacketQueuedForSend(mesh::Packet*) {} + mesh::Packet* obtainNewPacket() {assert(allocated<4);return ©_pool[allocated++];} + bool queueOutboundPacket(mesh::Packet*,uint8_t,uint32_t); + uint8_t getTransmitProfileMask(const mesh::Packet*) const; + bool isPacketRadioCurrent(const mesh::Packet*) const; +}; +@OUTBOUND@ +@MASK@ +@CURRENT@ +int main() { + using mesh::RadioCrossMode; + using mesh::RadioProfileMode; + using mesh::bridge::IngressProfile; + auto& selection=mesh::bridge::requested_ingress; + mesh::RadioProfiles profiles; + profiles.generation[0]=12;profiles.generation[1]=93; + profiles.secondary.mode=RadioProfileMode::RxTx; + profiles.cross=RadioCrossMode::Off; + BridgeBase bridge;bridge.setIngressProfiles(&profiles); + Radio radio{&profiles};Dispatcher dispatch{&radio}; + + // A missing setting means primary/AUTO exactly as before, including the + // operator's existing crossover policy. Merely selecting another RX tuple + // does not make a bridge follow the scanner. + assert(selection==IngressProfile::Radio); + mesh::Packet legacy; + legacy.radio_profile=legacy.radio_origin=1; + legacy.radio_bound=legacy.radio_local=legacy.radio_reply=legacy.radio_reply_force=true; + legacy.tx_radio=mesh::RADIO_TX_BOTH; + bridge.handleReceivedPacket(&legacy); + assert(legacy.radio_profile==0&&legacy.radio_origin==0&&legacy.radio_generation==12); + assert(legacy.tx_radio==mesh::RADIO_TX_AUTO&&!legacy.radio_local&&!legacy.radio_bound); + assert(!legacy.radio_reply&&!legacy.radio_reply_force&&legacy.forwarded); + profiles.cross=RadioCrossMode::On; + assert(dispatch.queueOutboundPacket(&legacy,0,0)); + assert(dispatch.outbound.size()==2&&dispatch.outbound[0].radio_profile==0&&dispatch.outbound[1].radio_profile==1); + dispatch.outbound.clear(); + + // The destination setting applies before admission filters/deduplication. + // The source wire packet remains ordinary MeshCore bytes, so old bridge + // senders do not need new headers or firmware to use the selected target. + selection=IngressProfile::Radio2; + unsigned filter_calls=0; + bridge.setPacketFilter([](void* context,const mesh::Packet* packet) { + ++*static_cast(context); + assert(packet->radio_profile==1&&packet->radio_origin==1); + assert(packet->radio_generation==93&&packet->radio_origin_generation==93); + assert(packet->tx_radio==mesh::RADIO_TX_SECONDARY&&packet->forwarded); + assert(!packet->radio_local&&!packet->radio_bound&&!packet->radio_reply&&!packet->radio_reply_force); + return true; + },&filter_calls); + mesh::Packet media; + media.radio_reply=media.radio_reply_force=media.radio_local=media.radio_bound=true; + media.tx_radio=mesh::RADIO_TX_BOTH; + uint8_t before[32],after[32];media.writeTo(before); + bridge.handleReceivedPacket(&media); + media.writeTo(after);assert(memcmp(before,after,sizeof(before))==0); + assert(filter_calls==1&&media.radio_profile==1&&media.tx_radio==mesh::RADIO_TX_SECONDARY); + assert(dispatch.getTransmitProfileMask(&media)==2); + assert(dispatch.queueOutboundPacket(&media,0,0)); + assert(dispatch.outbound.size()==1&&dispatch.outbound[0].radio_profile==1); + assert(dispatch.isPacketRadioCurrent(&media)); + // Changing the main frequency cannot invalidate or redirect a queued + // secondary packet. Retuning/disabling that secondary must invalidate it. + ++profiles.generation[0];assert(dispatch.isPacketRadioCurrent(&media)); + ++profiles.generation[1];assert(!dispatch.isPacketRadioCurrent(&media)); + const auto sent=dispatch.outbound.size(); + assert(!dispatch.queueOutboundPacket(&media,0,0)&&dispatch.outbound.size()==sent); + profiles.generation[1]=93; + profiles.secondary.mode=RadioProfileMode::Rx; + assert(!dispatch.isPacketRadioCurrent(&media)); + profiles.secondary.mode=RadioProfileMode::RxTx; + profiles.secondary.params.protocol=RadioProfileProtocol::Meshtext; + assert(!dispatch.isPacketRadioCurrent(&media)); + profiles.secondary.params.protocol=RadioProfileProtocol::MeshCore; + + // Bridge-delay work holds the generation selected on receipt, not the + // generation or target selected when it is processed later. + mesh::Packet delayed;bridge.handleReceivedPacket(&delayed); + dispatch.receive(&delayed);assert(dispatch.accepted==1&&dispatch.released==0); + ++profiles.generation[1]; + dispatch.receive(&delayed);assert(dispatch.accepted==1&&dispatch.released==1); + assert(!dispatch.queueOutboundPacket(&delayed,0,0)); + profiles.generation[1]=93; + + // Invalid selected targets are rejected before filters, dedup marking and + // queueing. There is no primary fallback even with crossover enabled. + const auto queued=bridge.manager.queued,checks=bridge._seen_packets.checks,marks=bridge._seen_packets.marks; + const auto filters=filter_calls; + unsigned drops=bridge.manager.freed; + for(auto mode:{RadioProfileMode::Off,RadioProfileMode::Rx}) { + profiles.secondary.mode=mode; + mesh::Packet p;bridge.handleReceivedPacket(&p);assert(bridge.manager.freed==++drops); + } + profiles.secondary.mode=RadioProfileMode::RxTx; + for(auto protocol:{RadioProfileProtocol::Meshtext,RadioProfileProtocol::Ethernet,RadioProfileProtocol::Kiss}) { + profiles.secondary.params.protocol=protocol; + mesh::Packet p;bridge.handleReceivedPacket(&p);assert(bridge.manager.freed==++drops); + } + profiles.secondary.params.protocol=RadioProfileProtocol::MeshCore; + 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); + assert(bridge.manager.queued==queued&&bridge._seen_packets.checks==checks&&bridge._seen_packets.marks==marks); + assert(filter_calls==filters&&dispatch.outbound.size()==sent); + + // Selecting radio2 for incoming bridge traffic does not change the route + // of unrelated local/primary traffic or serialize a profile hint onto RF. + selection=IngressProfile::Radio2; + profiles.cross=RadioCrossMode::Off; + mesh::Packet normal;normal.radio_generation=profiles.generation[0]; + assert(dispatch.getTransmitProfileMask(&normal)==1); + assert(dispatch.queueOutboundPacket(&normal,0,0)); + assert(dispatch.outbound.back().radio_profile==0); +} +''' + code = code.replace("@DISPATCH_GUARD@", guard) + for marker, signature in (("@OUTBOUND@", "bool Dispatcher::queueOutboundPacket("), + ("@MASK@", "uint8_t Dispatcher::getTransmitProfileMask("), + ("@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 +namespace mesh { namespace bridge { +enum class IngressProfile : uint8_t {Radio=0,Radio2=1,Blocked=2}; +static IngressProfile requested_ingress=IngressProfile::Radio; +inline IngressProfile ingressProfile() {return requested_ingress;} +}} +''' + with tempfile.TemporaryDirectory(prefix="meshcore-bridge-radio2-") as directory: + work = Path(directory) + (work / "Mesh.h").write_text(packet) + (work / "helpers").mkdir() + (work / "helpers/AbstractBridge.h").write_bytes( + (ROOT / "src/helpers/AbstractBridge.h").read_bytes()) + (work / "helpers/BridgeIngressProfile.h").write_text(selector) + source, binary = work / "main.cpp", work / "test" + source.write_text(code) + built = subprocess.run([ + os.environ.get("CXX", "g++"), "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror", + "-DWITH_BRIDGE=1", "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", + "-I", str(work), "-I", str(ROOT / "src"), str(source), "-o", str(binary), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=15) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + + def test_regenerated_direct_and_multipart_acks_retain_ingress_tx_policy(self): + production = (ROOT / "src/Mesh.cpp").read_text() + code = r''' +#include +#include +#include +#include +#include +#undef MESH_DEBUG_PRINTLN +#define MESH_DEBUG_PRINTLN(...) ((void)0) +using mesh::Packet; +using DispatcherAction=unsigned; +constexpr DispatcherAction ACTION_RELEASE=0; +struct Seen { + bool seen=false; + unsigned marked=0; + bool wasSeen(Packet*) {return seen;} + void markSeen(Packet*) {seen=true;++marked;} +}; +struct Mesh { + Packet pool[12];unsigned used=0,retries=0; + Seen tables;Seen* _tables=&tables; + uint8_t expected_policy=mesh::RADIO_TX_AUTO; + std::vector sent; + std::vector delays; + Packet* obtainNewPacket() { + assert(used<12);auto* p=&pool[used++]; + // The dispatcher allocator inherits the radio receive context. The + // regenerated ACK must independently retain the explicit TX selection. + p->radio_profile=p->radio_origin=1; + p->radio_generation=p->radio_origin_generation=93; + p->radio_local=false; + return p; + } + uint8_t getExtraAckTransmitCount() const {return 2;} + void maybeScheduleDirectRetry(Packet* p,uint8_t) { + assert(p->tx_radio==expected_policy);++retries; + } + void sendPacket(Packet* p,uint8_t,uint32_t delay) {sent.push_back(*p);delays.push_back(delay);} + Packet* createAck(const uint8_t*,uint8_t); + Packet* createMultiAck(const uint8_t*,uint8_t,uint8_t); + void removePathPrefix(Packet*,uint8_t); + void routeDirectRecvAcks(Packet*,uint32_t); + DispatcherAction forwardMultipartDirect(Packet*,uint32_t); +}; +@METHODS@ +int main() { + for(uint8_t policy:{mesh::RADIO_TX_AUTO,mesh::RADIO_TX_PRIMARY, + mesh::RADIO_TX_SECONDARY,mesh::RADIO_TX_BOTH}) { + Packet source; + source.header=(PAYLOAD_TYPE_ACK<setIngressProfiles(_radio->profiles());", configure) - self.assertLess(configure.index("setIngressProfiles"), configure.index("#if MESH_ENABLE_FLOOD_RULE_ENGINE")) + def test_every_role_bridge_configuration_binds_current_profiles(self): + for role in ("simple_repeater", "simple_room_server"): + with self.subTest(role=role): + header = (ROOT / f"examples/{role}/MyMesh.h").read_text() + configure = method(header, "void configureBridgeFilter(") + self.assertIn("active_bridge->setIngressProfiles(_radio->profiles());", configure) + if role == "simple_repeater": + self.assertLess(configure.index("setIngressProfiles"), configure.index("#if MESH_ENABLE_FLOOD_RULE_ENGINE")) if __name__ == "__main__": diff --git a/test/test_bridge_ingress_settings.py b/test/test_bridge_ingress_settings.py new file mode 100644 index 000000000..72718695f --- /dev/null +++ b/test/test_bridge_ingress_settings.py @@ -0,0 +1,236 @@ +"""Exercise saved bridge ingress and storage failures using the real writers.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + +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_ingress"; + const std::vector legacy = {1, 8, 4, 7, 9}; + fs.files["/com_prefs"] = legacy; + assert(ingressProfile() == IngressProfile::Blocked); + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Radio); + assert(fs.files.size() == 1 && fs.files.at("/com_prefs") == legacy); + char reply[160]; + auto run = [&](const char* command) { + strcpy(reply, "untouched"); + return handleIngressCommand(command, reply, sizeof(reply)); + }; + assert(run("get bridge.ingress") && !strcmp(reply, "> radio")); + assert(!run("get bridge.ingressXYZ") && !strcmp(reply, "untouched")); + assert(run("set bridge.ingress radio2") && !strncmp(reply, "OK", 2)); + assert(ingressProfile() == IngressProfile::Radio2); + const auto good = fs.files.at(path); + assert(good.size() == 10 && !memcmp(good.data(), "BIN\1", 4)); + assert(good[4] == 1 && good[5] == 0xfe); + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Radio2); + assert(run("get bridge.ingress") && !strcmp(reply, "> radio2")); + + assert(run("set bridge.ingress radio") && !strncmp(reply, "OK", 2)); + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Radio); + assert(run("set bridge.ingress\tradio2") && !strncmp(reply, "OK", 2)); + assert(fs.files.at(path) == good); + for (const char* invalid : {"set bridge.ingress", "set bridge.ingress both", + "set bridge.ingress Radio2", "set bridge.ingress radio2junk", + "set bridge.ingress radio trailing", "get bridge.ingress extra"}) { + assert(run(invalid) && !strncmp(reply, "Error:", 6)); + assert(ingressProfile() == IngressProfile::Radio2); + assert(fs.files.at(path) == good); + } + assert(!run("set bridge.ingress2 radio")); + assert(!handleIngressCommand(nullptr, reply, sizeof(reply))); + assert(!handleIngressCommand("set bridge.ingress radio", nullptr, sizeof(reply))); + assert(!handleIngressCommand("set bridge.ingress radio", reply, 0)); + char bounded[] = {'x', 'x', 'x', 'z'}; + assert(handleIngressCommand("get bridge.ingress", bounded, 3)); + assert(bounded[2] == 0 && bounded[3] == 'z'); + + // Failed staging leaves the public preference and live target unchanged. + fs.fail_write = true; + assert(run("set bridge.ingress radio") && !strncmp(reply, "Error:", 6)); + assert(ingressProfile() == IngressProfile::Radio2 && fs.files.at(path) == good); + fs.fail_write = false; + fs.fail_write_after = 4; + assert(run("set bridge.ingress radio") && !strncmp(reply, "Error:", 6)); + assert(ingressProfile() == IngressProfile::Radio2 && fs.files.at(path) == good); + fs.fail_write_after = -1; + assert(run("set bridge.ingress radio2") && !strncmp(reply, "OK", 2)); + + // Publication may happen before readback fails. Quarantine until a reload; + // neither ingress nor another setter may silently fall back to primary. + fs.fail_after_publication = true; + assert(run("set bridge.ingress radio") && !strncmp(reply, "Error:", 6)); + assert(ingressProfile() == IngressProfile::Blocked); + const auto published = fs.files.at(path); + assert(published != good); + fs.fail_after_publication = false; + fs.fail_read_open = false; + assert(run("set bridge.ingress radio2") && !strncmp(reply, "Error:", 6)); + assert(ingressProfile() == IngressProfile::Blocked && fs.files.at(path) == published); + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Radio); + assert(run("set bridge.ingress radio2") && !strncmp(reply, "OK", 2)); + + // Complete malformed records remain blocked but permit explicit repair. + 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(ingressProfile() == IngressProfile::Blocked); + assert(run("get bridge.ingress") && strstr(reply, "blocked")); + } + assert(run("set bridge.ingress radio2") && !strncmp(reply, "OK", 2)); + for (size_t size = 0; size < good.size(); ++size) { + fs.files[path] = std::vector(good.begin(), good.begin() + size); + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Blocked); + } + assert(run("set bridge.ingress radio2") && !strncmp(reply, "OK", 2)); + fs.files[path].push_back(0); + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Blocked); + assert(run("set bridge.ingress radio2") && !strncmp(reply, "OK", 2)); + + // Read/metadata uncertainty requires reclassification before another write. + fs.fail_read_open = true; + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Blocked); + fs.fail_read_open = false; + assert(run("set bridge.ingress radio") && !strncmp(reply, "Error:", 6)); + assert(fs.files.at(path) == good); + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Radio2); + fs.fail_read_after = 4; + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Blocked); + fs.fail_read_after = -1; + assert(run("set bridge.ingress radio") && !strncmp(reply, "Error:", 6)); + loadIngressSettings(&fs); + fs.fail_metadata = true; + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Blocked); + fs.fail_metadata = false; + assert(run("set bridge.ingress radio") && !strncmp(reply, "Error:", 6)); + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Radio2); + +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + // Recover only the old complete record across the SPIFFS rename gap. + fs.files["/bridge_ingress.bak"] = good; + fs.files.erase(path); + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Radio2 && fs.files.at(path) == good); + fs.files["/bridge_ingress.bak"] = good; + fs.files.erase(path); + fs.fail_rename = 1; + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Blocked); + assert(fs.files.at("/bridge_ingress.bak") == good); + assert(run("set bridge.ingress radio") && !strncmp(reply, "Error:", 6)); + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Radio2); +#endif + loadIngressSettings(nullptr); + assert(ingressProfile() == IngressProfile::Blocked); + assert(run("set bridge.ingress radio") && !strncmp(reply, "Error:", 6)); + assert(fs.files.at("/com_prefs") == legacy); +} +''' + + +class BridgeIngressSettingsTests(unittest.TestCase): + def test_real_settings_and_transactions_across_filesystems(self): + with tempfile.TemporaryDirectory(prefix="bridge-ingress-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, \"/bridge_ingress\")) fail_read_open = true;\n" + " return true;") + (work / "IdentityStore.h").write_text(mock) + (work / "helpers/IdentityStore.h").write_text('#include "../IdentityStore.h"\n') + (work / "helpers/AtomicFileWriter.h").write_text('#include "../AtomicFileWriter.h"\n') + # Replace only the hardware metadata adapter; both production + # publication algorithms and all setting/parser bytes are unchanged. + (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 ("BridgeIngressSettings.h", "BridgeIngressProfile.h", + "CheckedSettingsFile.h", "ContactFileTransaction.h", "AtomicFileWriter.h"): + (work / name).write_bytes((ROOT / "src/helpers" / name).read_bytes()) + (work / "settings_under_test.cpp").write_bytes( + (ROOT / "src/helpers/BridgeIngressSettings.cpp").read_bytes()) + (work / "test.cpp").write_text(HARNESS) + # CommonCLI.h synthesizes WITH_BRIDGE for these raw transport + # flags. Standalone settings/transport translation units have not + # included it yet, so each flag must activate the shared selector. + cases = [(platform, "WITH_BRIDGE") for platform in + ("ESP32_PLATFORM", "RP2040_PLATFORM", "NRF52_PLATFORM", "STM32_PLATFORM")] + cases += [("ESP32_PLATFORM", transport) for transport in + ("WITH_ESPNOW_BRIDGE", "WITH_RS232_BRIDGE", "WITH_MQTT_BRIDGE", "WITH_UDP_BRIDGE")] + for platform, transport in cases: + with self.subTest(platform=platform, transport=transport): + executable = work / (platform + "_" + transport) + 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", "-D" + platform, "-D" + transport + "=1", + "-I" + str(work), "-I" + str(ROOT / "src"), "-I" + str(ROOT / "src/helpers"), + str(work / "test.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) + self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr) + + def test_bridge_free_build_has_no_storage_or_runtime_selector(self): + with tempfile.TemporaryDirectory(prefix="no-bridge-ingress-") as temporary: + work = Path(temporary) + obj = work / "empty.o" + built = subprocess.run([os.environ.get("CXX", "c++"), "-std=c++17", + "-DNRF52_PLATFORM", "-c", str(ROOT / "src/helpers/BridgeIngressSettings.cpp"), + "-o", str(obj)], capture_output=True, text=True, timeout=30) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + symbols = subprocess.check_output(["nm", "-C", str(obj)], text=True) + for name in ("settings_fs", "selected", "writable", "loadIngressSettings", "handleIngressCommand"): + 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 477c43c51..7b6177290 100644 --- a/test/test_transport_filter_wiring.py +++ b/test/test_transport_filter_wiring.py @@ -409,6 +409,13 @@ int main() { work = Path(directory) (work/'Mesh.h').write_text(PACKET) code = '#include \n' + HARNESS.replace('@METHODS@', '\n'.join(methods)) + code += ''' +#if MESH_ENABLE_BRIDGE_INGRESS +namespace mesh { namespace bridge { +IngressProfile ingressProfile() { return IngressProfile::Radio; } +}} +#endif +''' (work/'test.cpp').write_text(code) for macros in [[], ['-DWITH_ESPNOW_BRIDGE=1'], ['-DWITH_UDP_BRIDGE=1']]: with self.subTest(macros=macros):