diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml
index 4e85d4b00..6d95f8de5 100644
--- a/.github/workflows/run-unit-tests.yml
+++ b/.github/workflows/run-unit-tests.yml
@@ -409,6 +409,9 @@ jobs:
python3 -B test/test_rs232_heartbeat.py -v
python3 -B test/test_rs232_heartbeat_owner.py -v
python3 -B test/test_rs232_power_settings.py -v
+ python3 -B test/test_rs232_bridge_diagnostics.py -v
+ python3 -B test/test_rs232_diagnostic_cli.py -v
+ python3 -B test/test_bridge_debug_formatter.py -v
- name: Verify saved repeat-off trace exception and relay guards
run: |
diff --git a/docs/cli_commands.md b/docs/cli_commands.md
index f6142624f..cc56e5df1 100644
--- a/docs/cli_commands.md
+++ b/docs/cli_commands.md
@@ -5568,8 +5568,9 @@ still overrides the first-boot default after an update.
### Bridge (When bridge support is compiled in)
-For a complete two-repeater setup with each option explained, see the
-[ESP-NOW bridge setup guide](espnow_bridge_setup.md).
+For a two-repeater setup and diagnostic flow, see the
+[ESP-NOW bridge setup guide](espnow_bridge_setup.md) or
+[RS-232/UART bridge guide](rs232_bridge_setup.md).
#### View the compiled bridge type
**Usage:** `get bridge.type`
@@ -5687,6 +5688,102 @@ MQTT preferences and retains explicit settings across reboots.
---
+#### Diagnose the RS232 bridge
+
+Available on repeater builds containing the optional RS-232 bridge. These
+runtime diagnostics require no `BRIDGE_DEBUG` build flag. Builds without the
+transport do not gain UART support from these commands.
+
+**Read-only commands:**
+
+```text
+get rs232.transport
+get rs232.stats
+get rs232.stats rx
+get rs232.stats tx
+get rs232.stats drops
+get rs232.stats test
+```
+
+The existing `get rs232.status` keeps its heartbeat/automatic-standby report.
+If no UART bridge object exists, such as after explicitly disabling it,
+the transport and counter commands report `RS232 diagnostics unavailable`;
+use `rs232.enabled`, `rs232.running` and `rs232.status` to check saved intent
+and power state.
+`get rs232.transport` reports the local transport state (`ready`, `off`,
+`standby`, `probing` or `retrying`) and these fields:
+
+| Field | Meaning |
+| --- | --- |
+| `baud` | Configured serial baud rate. |
+| `pins` | Selected RX/TX pin numbers. |
+| `src` | Configured LoRa packet source, `rx` or `tx`. |
+| `rxpart`, `rxready` | Bytes held in the current RX frame and whether a complete frame awaits mesh-pool space. |
+| `tx` | Bytes remaining in the owned TX frame. |
+| `heartbeat` | `on` initiates heartbeats; `passive` can still answer a valid ping. |
+| `last` | Last recorded diagnostic error: `none`, `pins`, `driver`, `test.stopped`, `test.busy` or `test.timeout`. It can remain after recovery. |
+
+A running UART means local initialization succeeded, not that a peer is
+reachable. Counter pages separate ordinary mesh traffic from explicit probes:
+
+| Page | Keys and meaning |
+| --- | --- |
+| Default | `rx`: received data frames; `mesh`: inbound packets admitted to mesh processing; `tx`: outbound mesh packets queued; `sent`: mesh frames fully accepted by the local UART. |
+| `rx` | `bytes`: all consumed UART bytes; `frames`: checksum-valid data frames; `crc`: checksum failures; `length`: invalid lengths; `parse`: mesh-packet parse failures; `timeout`: incomplete-frame expiry; `pool`: complete frames held for mesh-pool space. |
+| `tx` | `req`: mesh TX requests; `queued`: mesh frames accepted into the owned buffer; `sent`: fully UART-accepted mesh frames; `bytes`: all UART-accepted bytes; `stall`, `short`: blocked drain attempts or short-write events. |
+| `drops` | `noise`: synchronization rejects; `filter`/`dupe`: inbound filter/duplicate drops; `busy`/`stopped`: mesh TX rejected by transport state; `txfilter`/`txdupe`: outbound filter/duplicate drops; `big`: invalid or oversize TX packets. |
+| `test` | `queued`, `sent`, `fail`: probe TX stages and recorded failures; a late write can count as both failed and sent; `rx`, `bad`: valid/invalid probes received; `txid`, `rxid`: last queued/received IDs in hexadecimal. |
+
+Mesh admission means queued for normal processing, not confirmed LoRa
+forwarding or end-to-end delivery. Local write acceptance does not prove the
+bytes reached the peer. Byte totals include data, heartbeat and test traffic;
+RX bytes also include discarded noise. A frame can cause several write stalls.
+Counters survive same-object UART stop/start, including
+automatic standby. Disabling or reconfiguring the bridge can delete and replace
+that object, starting a new window; reboot and explicit reset also clear the
+counters. Stopping a transport can discard unfinished work without determining
+delivery, so the pages are not a complete delivery ledger.
+
+**Local-only actions:**
+
+```text
+rs232 test
+rs232 test A1B2C3D4
+rs232 resetstats
+```
+
+The optional ID is exactly eight hexadecimal digits, case-insensitive, without
+`0x`, spaces or trailing arguments. Omitting it uses uptime in milliseconds.
+`OK - test queued id=...; confirm on peer` confirms local queueing only. On
+the other updated board, `get rs232.stats test` must show an increased `rx`
+and matching `rxid`. Use a different ID in the reverse direction.
+
+Probes use a reserved UART-local control frame. They do not allocate a mesh
+packet, enter duplicate tracking, refresh peer liveness, authenticate a peer,
+or get forwarded onto LoRa. There is no automatic echo or retransmission.
+A probe that misses its write deadline records one failure. If no byte was
+accepted by the UART, it is cancelled. If a prefix was already accepted, the
+bridge retains and drains only the original suffix, with bounded work per
+main-loop pass, before another frame can start. This preserves framing for
+older receivers. A later completion also increments `sent`; the historical
+failure is retained and neither result proves peer receipt.
+A stopped, transitioning
+or busy UART, including a due heartbeat, rejects the test rather than waking or
+taking over the port. Older
+firmware ignores the extension. The test and counter reset do not write
+preferences and are rejected over LoRa; use a local console.
+
+For a short USB capture, record both `get usb.logging` and `get usb.debug`,
+then enable both `set usb.logging on` and `set usb.debug on`. The ordinary
+image emits bounded `RS232:` updates from its main loop at most once per
+second when diagnostic state changes. Busy USB output can skip updates; use
+the counter pages for exact values. Restore both original settings afterward.
+The CLI pages and test probes need neither logging gate.
+
+See the [two-board serial diagnostic flow](rs232_bridge_setup.md#test-each-serial-direction-without-lora-forwarding).
+
+---
+
#### RS232 heartbeat and automatic standby
Available on repeater builds with an RS232 bridge. This is a UART-local control
diff --git a/docs/rs232_bridge_setup.md b/docs/rs232_bridge_setup.md
new file mode 100644
index 000000000..818031533
--- /dev/null
+++ b/docs/rs232_bridge_setup.md
@@ -0,0 +1,157 @@
+
+
View this page on MeshCore Docs.
+
+
+# RS-232/UART bridge: setup and diagnostics
+
+Two supported repeaters can exchange MeshCore packets over a wired UART link.
+Each repeater can use the LoRa settings of the network on its side:
+
+```text
+LoRa network A <--> Repeater A <-- serial link --> Repeater B <--> LoRa network B
+```
+
+Use the exact board's repeater image with RS-232 support. Check
+`get bridge.type`: its answer must include `rs232`. This transport is optional;
+its presence in an image does not mean its UART is enabled. A Companion image
+or a room-server build without RS-232 does not gain the transport from these
+commands.
+
+## Configure the serial link
+
+Use the UART and pin map for the exact board and image. Connect the two sides
+with the appropriate serial wiring and any required line drivers; a board's
+UART pins do not automatically provide RS-232 voltage levels. Avoid a UART
+already occupied by another peripheral.
+
+Open each board's USB console. Run the following on both boards, using the same
+baud rate:
+
+```text
+set rs232.enabled off
+set bridge.baud 115200
+set bridge.source tx
+set bridge.delay 500
+set repeat on
+set rs232.enabled on
+get rs232.enabled
+get rs232.running
+get rs232.status
+get rs232.transport
+```
+
+Use `bridge.uart` only where the board offers a supported choice, while RS-232
+is stopped. Canonical GPS-enabled RAK4631 builds use UART2
+(`set bridge.uart 2`); UART1 requires a compatible GPS-free image. The command
+rejects UART choices unavailable in the build or unsafe for an active peripheral.
+
+`rs232.enabled` selects the UART independently of MQTT and ESP-NOW in combined
+images. The historical `bridge.enabled` alias can select ESP-NOW in MQTT images,
+so use the transport-specific `rs232.enabled` control for these checks.
+
+`bridge.source tx` bridges packets transmitted on LoRa after the repeater's
+forwarding decisions. An existing `bridge.source rx` setup is also valid: it
+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).
+
+## Distinguish power state from transport activity
+
+`get rs232.status` retains the existing heartbeat and automatic-standby report.
+It separates saved intent, actual UART state, standby/probe phase and confirmed
+versus legacy/unknown peer liveness. `get rs232.transport` adds the serial
+transport diagnostic view. A running UART means local startup succeeded; it
+does not prove the other board received a frame. With RS-232 explicitly
+disabled, its transport/counter commands may report diagnostics unavailable
+because there is no current UART bridge object.
+
+Heartbeat and negotiated automatic standby are on by default. A fresh,
+session-matched heartbeat reply proves bidirectional communication with a
+supporting peer. Ordinary incoming mesh data proves only inbound reachability.
+See [heartbeat and automatic standby](cli_commands.md#rs232-heartbeat-and-automatic-standby)
+before changing power settings. A diagnostic probe does not replace heartbeat
+negotiation, enable a disabled UART or establish peer authentication.
+
+## Test each serial direction without LoRa forwarding
+
+Updated RS-232 bridge images include runtime diagnostics without a
+`BRIDGE_DEBUG` build flag or continuous USB logging. On both boards:
+
+```text
+rs232 resetstats
+get rs232.transport
+get rs232.stats test
+```
+
+On A, send one probe:
+
+```text
+rs232 test A1B2C3D4
+```
+
+`OK - test queued id=A1B2C3D4; confirm on peer` confirms local queueing only.
+On B, read `get rs232.stats test`: `rx` must increase and `rxid` must match
+`A1B2C3D4`. Then send `rs232 test B1C2D3E4` on B and confirm that ID on A.
+These are two one-way tests. Local UART write completion alone is not peer
+receipt.
+
+The optional ID is exactly eight hexadecimal digits, without `0x`; omitting
+it uses uptime in milliseconds. Probes are transport diagnostics, not security
+tokens or MeshCore packets. They do not prove normal mesh delivery or
+authenticate a peer. There is no automatic echo, retry or LoRa forwarding.
+Older firmware ignores the extension and cannot report a received ID; use an
+updated peer on both sides. Sending a test and resetting counters require a
+local console and do not save preferences.
+
+A write deadline records one failure. An entirely unsent probe is cancelled;
+if the UART already accepted a prefix, the bridge retains and drains the
+original suffix before starting another frame. Each main-loop pass does
+bounded work, without retransmitting the prefix. This avoids leaving an older
+receiver partway through a control frame. A late completion can increment
+`sent` while the earlier `fail` remains; confirm receipt using the other board's
+`rx` change and matching `rxid`.
+
+## Locate a mesh forwarding failure
+
+If both probe directions work, send one fresh identifiable message from a
+Companion on network A and compare these pages on both repeaters:
+
+```text
+get rs232.stats
+get rs232.stats rx
+get rs232.stats tx
+get rs232.stats drops
+```
+
+Repeat in the other direction with a different message. Compare counter
+changes for the attempt rather than treating old totals as its result. Admission
+to the mesh means queued for normal processing; it does not prove a completed
+LoRa transmission or end-to-end delivery. If both repeaters already heard a
+message directly on LoRa, duplicate suppression can reject a later serial copy.
+
+The RX page separates framing, checksum, packet parsing, partial-frame timeout
+and mesh-pool pressure. The TX and drops pages distinguish UART backpressure
+from stopped/busy state, filters and duplicate suppression. Byte counters include
+heartbeat and diagnostic traffic as well as mesh frames; they do not measure
+mesh payload alone.
+
+Counters belong to the current bridge object. They survive automatic standby,
+but disabling or reconfiguring RS-232 can replace the object and clear them.
+Reboot and `rs232 resetstats` also start a new observation window.
+
+The [RS-232 diagnostic CLI reference](cli_commands.md#diagnose-the-rs232-bridge)
+describes the status fields, counter pages and their observation window.
+
+## Optional short USB debug capture
+
+Record both `get usb.logging` and `get usb.debug`, then enable both gates with
+`set usb.logging on` and `set usb.debug on`. Send the probes or one fresh mesh
+message and restore both original settings afterward. The ordinary image emits
+bounded `RS232:` updates from its main loop at most once per second when
+diagnostic state changes. A busy USB console can skip an update; use the counter
+pages for exact values. Neither logging gate is required for the CLI counters
+or test probes.
+
+These diagnostics identify local serial activity and rejection stages. They do
+not certify a physical cable, line driver, or end-to-end LoRa path without the
+corresponding peer and message checks.
diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h
index 6863620fa..c4603d62b 100644
--- a/examples/simple_repeater/MyMesh.h
+++ b/examples/simple_repeater/MyMesh.h
@@ -1441,6 +1441,24 @@ public:
static_cast(_prefs.rs232_idle_seconds), heartbeat);
}
+ // Diagnostics address the UART transport even when MQTT and ESP-NOW also run.
+ void formatRs232Transport(char* reply, size_t capacity) override {
+ if (bridge) bridge->formatStatus(reply, capacity);
+ else snprintf(reply, capacity, "Error: RS232 diagnostics unavailable");
+ }
+ void formatRs232Stats(const char* page, char* reply, size_t capacity) override {
+ if (bridge) bridge->formatStats(page, reply, capacity);
+ else snprintf(reply, capacity, "Error: RS232 diagnostics unavailable");
+ }
+ bool sendRs232Test(uint32_t nonce) override {
+ return bridge && bridge->sendTest(nonce);
+ }
+ bool resetRs232Stats() override {
+ if (!bridge) return false;
+ bridge->resetStats();
+ return true;
+ }
+
bool setRs232BridgeState(bool enable) override {
if (!enable) {
const bool stopped = endRS232Bridge();
diff --git a/mkdocs.yml b/mkdocs.yml
index 24ecdedac..9f80ab7cc 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -78,6 +78,7 @@ nav:
- room_services.md
- carrier_wave.md
- espnow_bridge_setup.md
+ - rs232_bridge_setup.md
- WiFi.md
- flood_filtering.md
- gps_tracking.md
diff --git a/src/MeshCore.h b/src/MeshCore.h
index 52f0485c9..65308e449 100644
--- a/src/MeshCore.h
+++ b/src/MeshCore.h
@@ -35,7 +35,7 @@
((defined(MESH_DEBUG) && MESH_DEBUG) || \
(defined(BRIDGE_DEBUG) && BRIDGE_DEBUG) || \
(defined(POWERSAVING_DEBUG) && POWERSAVING_DEBUG) || \
- defined(WITH_ESPNOW_BRIDGE))
+ defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE))
#include
#include
#include
diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp
index c1a3819f0..3c9a2ecb6 100644
--- a/src/helpers/CommonCLI.cpp
+++ b/src/helpers/CommonCLI.cpp
@@ -3051,6 +3051,77 @@ uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) {
}
}
+#if defined(WITH_RS232_BRIDGE)
+bool CommonCLI::handleRs232DiagnosticCommand(uint32_t sender_timestamp,
+ const char* command, char* reply) {
+ constexpr size_t capacity = 157; // Leave room for an infrastructure reply prefix.
+ if (strncmp(command, "get rs232.transport", 19) == 0) {
+ if (strcmp(command, "get rs232.transport") != 0) {
+ snprintf(reply, capacity, "Error: use get rs232.transport");
+ } else {
+ _callbacks->formatRs232Transport(reply, capacity);
+ }
+ return true;
+ }
+ if (strncmp(command, "get rs232.stats", 15) == 0) {
+ const char* page = command + 15;
+ if (*page == 0) {
+ _callbacks->formatRs232Stats("", reply, capacity);
+ } else if (strcmp(page, " rx") == 0 || strcmp(page, " tx") == 0
+ || strcmp(page, " drops") == 0 || strcmp(page, " test") == 0) {
+ _callbacks->formatRs232Stats(page + 1, reply, capacity);
+ } else {
+ snprintf(reply, capacity, "Error: use get rs232.stats [rx|tx|drops|test]");
+ }
+ return true;
+ }
+ if (strncmp(command, "rs232", 5) != 0
+ || (command[5] != 0 && command[5] != ' ')) return false;
+ if (sender_timestamp != 0) {
+ snprintf(reply, capacity, "Error: RS232 test/reset requires a local connection");
+ return true;
+ }
+ if (strcmp(command, "rs232 resetstats") == 0) {
+ snprintf(reply, capacity, "%s", _callbacks->resetRs232Stats()
+ ? "OK - RS232 statistics reset" : "Error: RS232 diagnostics unavailable");
+ return true;
+ }
+ uint32_t nonce = 0;
+ if (strcmp(command, "rs232 test") == 0) {
+ nonce = millis(); // Correlation only; this is not a security token.
+ } else {
+ if (strncmp(command, "rs232 test ", 11) != 0) {
+ snprintf(reply, capacity, "Error: use rs232 test [8 hex digits] or rs232 resetstats");
+ return true;
+ }
+ const char* value = command + 11;
+ if (strlen(value) != 8) {
+ snprintf(reply, capacity, "Error: use rs232 test [8 hex digits] or rs232 resetstats");
+ return true;
+ }
+ for (size_t i = 0; i < 8; ++i) {
+ const char c = value[i];
+ uint8_t digit;
+ if (c >= '0' && c <= '9') digit = static_cast(c - '0');
+ else if (c >= 'a' && c <= 'f') digit = static_cast(c - 'a' + 10);
+ else if (c >= 'A' && c <= 'F') digit = static_cast(c - 'A' + 10);
+ else {
+ snprintf(reply, capacity, "Error: use rs232 test [8 hex digits] or rs232 resetstats");
+ return true;
+ }
+ nonce = (nonce << 4) | digit;
+ }
+ }
+ if (_callbacks->sendRs232Test(nonce)) {
+ snprintf(reply, capacity, "OK - test queued id=%08lX; confirm on peer",
+ static_cast(nonce));
+ } else {
+ snprintf(reply, capacity, "Error: RS232 test not queued; check status/stats");
+ }
+ return true;
+}
+#endif
+
#if defined(WITH_ESPNOW_BRIDGE)
bool CommonCLI::handleEspNowDiagnosticCommand(uint32_t sender_timestamp,
const char* command, char* reply) {
@@ -3107,6 +3178,9 @@ bool CommonCLI::handleEspNowDiagnosticCommand(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 defined(WITH_RS232_BRIDGE)
+ if (handleRs232DiagnosticCommand(sender_timestamp, command, reply)) return;
+#endif
#if defined(WITH_ESPNOW_BRIDGE)
if (handleEspNowDiagnosticCommand(sender_timestamp, command, reply)) return;
#endif
diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h
index e91da7832..311b52073 100644
--- a/src/helpers/CommonCLI.h
+++ b/src/helpers/CommonCLI.h
@@ -646,6 +646,17 @@ public:
virtual bool isEspNowBridgeRunning() { return isBridgeRunning(); }
virtual bool isRs232BridgeRunning() const { return isBridgeRunning(); }
virtual void formatRs232Status(char* reply) { strcpy(reply, "RS232 unavailable"); }
+#if defined(WITH_RS232_BRIDGE)
+ virtual void formatRs232Transport(char* reply, size_t capacity) {
+ snprintf(reply, capacity, "Error: RS232 diagnostics unavailable");
+ }
+ virtual void formatRs232Stats(const char* page, char* reply, size_t capacity) {
+ (void)page;
+ snprintf(reply, capacity, "Error: RS232 diagnostics unavailable");
+ }
+ virtual bool sendRs232Test(uint32_t nonce) { (void)nonce; return false; }
+ virtual bool resetRs232Stats() { return false; }
+#endif
#if defined(WITH_ESPNOW_BRIDGE)
virtual void formatEspNowStatus(char* reply, size_t capacity) {
snprintf(reply, capacity, "Error: ESP-NOW diagnostics unavailable");
@@ -908,6 +919,9 @@ class CommonCLI {
void handleRegionCmd(char* command, char* reply);
void handleGetCmd(uint32_t sender_timestamp, char* command, char* reply);
void handleSetCmd(uint32_t sender_timestamp, char* command, char* reply);
+#if defined(WITH_RS232_BRIDGE)
+ bool handleRs232DiagnosticCommand(uint32_t sender_timestamp, const char* command, char* reply);
+#endif
#if defined(WITH_ESPNOW_BRIDGE)
bool handleEspNowDiagnosticCommand(uint32_t sender_timestamp, const char* command, char* reply);
#endif
diff --git a/src/helpers/bridges/BridgeBase.cpp b/src/helpers/bridges/BridgeBase.cpp
index e98546160..2d8ebac71 100644
--- a/src/helpers/bridges/BridgeBase.cpp
+++ b/src/helpers/bridges/BridgeBase.cpp
@@ -36,7 +36,7 @@ void BridgeBase::handleReceivedPacket(mesh::Packet *packet) {
if (_initialized == false) {
BRIDGE_DEBUG_PRINTLN("RX packet received before initialization\n");
_mgr->free(packet);
-#if defined(WITH_ESPNOW_BRIDGE)
+#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE)
onIngressResult(IngressResult::Stopped);
#endif
return;
@@ -44,7 +44,7 @@ void BridgeBase::handleReceivedPacket(mesh::Packet *packet) {
if (!preparePacketIngress(packet) || !allowsPacket(packet)) {
_mgr->free(packet);
-#if defined(WITH_ESPNOW_BRIDGE)
+#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE)
onIngressResult(IngressResult::Filtered);
#endif
return;
@@ -58,12 +58,12 @@ void BridgeBase::handleReceivedPacket(mesh::Packet *packet) {
if (!ota) _seen_packets.markSeen(packet);
// bridge_delay provides a buffer to prevent immediate processing conflicts in the mesh network.
_mgr->queueInbound(packet, millis() + _prefs->bridge_delay);
-#if defined(WITH_ESPNOW_BRIDGE)
+#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE)
onIngressResult(IngressResult::Queued);
#endif
} else {
_mgr->free(packet);
-#if defined(WITH_ESPNOW_BRIDGE)
+#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE)
onIngressResult(IngressResult::Duplicate);
#endif
}
diff --git a/src/helpers/bridges/BridgeBase.h b/src/helpers/bridges/BridgeBase.h
index 8afd9305b..579875116 100644
--- a/src/helpers/bridges/BridgeBase.h
+++ b/src/helpers/bridges/BridgeBase.h
@@ -50,7 +50,7 @@ public:
static constexpr uint16_t BRIDGE_CHECKSUM_SIZE = sizeof(uint16_t);
protected:
-#if defined(WITH_ESPNOW_BRIDGE)
+#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE)
// Observe the existing admission decision without evaluating filters twice.
enum class IngressResult : uint8_t { Stopped, Filtered, Duplicate, Queued };
virtual void onIngressResult(IngressResult) {}
diff --git a/src/helpers/bridges/RS232Bridge.cpp b/src/helpers/bridges/RS232Bridge.cpp
index e5801addd..d5d3b29e4 100644
--- a/src/helpers/bridges/RS232Bridge.cpp
+++ b/src/helpers/bridges/RS232Bridge.cpp
@@ -23,12 +23,18 @@ RS232Bridge::RS232Bridge(NodePrefs *prefs, Stream &serial, int16_t rx_pin,
: BridgeBase(prefs, mgr, rtc), _serial(&serial), _rx_pin(rx_pin),
_tx_pin(tx_pin) {}
+namespace rsdiag = mesh::bridge::rs232diag;
+
void RS232Bridge::begin() {
_initialized = false;
resetTransportState();
BRIDGE_DEBUG_PRINTLN("Initializing at %d baud...\n", _prefs->bridge_baud);
#if defined(ESP32)
- if (!((HardwareSerial *)_serial)->setPins(_rx_pin, _tx_pin)) return;
+ if (!((HardwareSerial *)_serial)->setPins(_rx_pin, _tx_pin)) {
+ _stats.error = rsdiag::Error::Pins;
+ _debug_dirty = true;
+ return;
+ }
#elif defined(NRF52_PLATFORM)
// Tested with RAK_4631 and T114
// The Adafruit Uart object may already be active on its variant defaults.
@@ -52,6 +58,8 @@ void RS232Bridge::begin() {
#else
_initialized = true;
#endif
+ if (!_initialized) _stats.error = rsdiag::Error::Driver;
+ _debug_dirty = true;
}
void RS232Bridge::end() {
@@ -61,14 +69,23 @@ void RS232Bridge::end() {
// Update bridge state
_initialized = false;
resetTransportState();
+ _debug_dirty = true;
}
void RS232Bridge::resetTransportState() {
_rx_buffer_pos = 0;
_rx_packet_ready = false;
+ _rx_pool_blocked = false;
+ // Stop/reconfiguration discards pending transport work. As with mesh data,
+ // an accepted prefix cannot be classified as peer delivery or non-delivery.
+ if (_tx_is_probe && !_probe_timed_out && _tx_size != 0)
+ rsdiag::increment(_stats.probe.failed);
_tx_size = _tx_pos = 0;
_tx_wire_pending = false;
_tx_is_ping = false;
+ _tx_is_probe = false;
+ _probe_timed_out = false;
+ _tx_is_data = false;
_tx_ping_challenge = 0;
_outstanding_challenge = 0;
_pong_target = 0;
@@ -120,6 +137,8 @@ void RS232Bridge::expirePartialReceive(uint32_t now) {
if (_rx_buffer_pos != 0 && !_rx_packet_ready
&& static_cast(now - _rx_started) >= frameAssemblyMs()) {
_rx_buffer_pos = 0;
+ rsdiag::increment(_stats.rx.timeout);
+ _debug_dirty = true;
}
}
@@ -195,16 +214,26 @@ void RS232Bridge::formatHeartbeatStatus(char* out, size_t capacity, uint32_t now
}
void RS232Bridge::drainTransmit(uint32_t now, bool blocking) {
+ expireProbeTransmit(now);
if (!_initialized || _tx_size == 0) return;
size_t amount = static_cast(_tx_size - _tx_pos);
if (!blocking) {
const int available = _serial->availableForWrite();
- if (available <= 0) return;
+ if (available <= 0) {
+ rsdiag::increment(_stats.tx.stalls); _debug_dirty = true;
+ return;
+ }
if (amount > static_cast(available)) amount = static_cast(available);
}
size_t sent = _serial->write(_tx_buffer + _tx_pos, amount);
if (sent > amount) sent = amount;
- if (sent == 0) return;
+ if (sent == 0) {
+ rsdiag::increment(_stats.tx.stalls); _debug_dirty = true;
+ return;
+ }
+ if (sent < amount) rsdiag::increment(_stats.tx.short_writes);
+ rsdiag::add(_stats.tx.bytes, sent);
+ _debug_dirty = true;
_tx_pos = static_cast(_tx_pos + sent);
const uint32_t baud = _prefs->bridge_baud ? _prefs->bridge_baud : 115200;
uint32_t base = now;
@@ -214,12 +243,16 @@ void RS232Bridge::drainTransmit(uint32_t now, bool blocking) {
_tx_wire_until = base + static_cast((sent * 10000UL + baud - 1) / baud) + 1;
_tx_wire_pending = true;
if (_tx_pos == _tx_size) {
+ if (_tx_is_probe) rsdiag::increment(_stats.probe.sent);
+ if (_tx_is_data) rsdiag::increment(_stats.tx.sent);
if (_tx_is_ping) {
_outstanding_challenge = _tx_ping_challenge;
_outstanding_since = now;
}
_tx_size = _tx_pos = 0;
_tx_is_ping = false;
+ _tx_is_probe = false;
+ _tx_is_data = false;
}
}
@@ -236,6 +269,8 @@ bool RS232Bridge::queueControl(char opcode, uint64_t target, uint32_t challenge)
_tx_size = mesh::bridge::rs232heartbeat::FRAME_SIZE;
_tx_pos = 0;
_tx_is_ping = opcode == 'P';
+ _tx_is_probe = false;
+ _tx_is_data = false;
_tx_ping_challenge = challenge;
return true;
}
@@ -261,6 +296,17 @@ void RS232Bridge::serviceHeartbeat(uint32_t now) {
}
void RS232Bridge::handleControl(uint32_t now) {
+ if (rsdiag::isProbeCandidate(_rx_buffer + 4)) {
+ uint32_t nonce;
+ if (rsdiag::decodeProbe(_rx_buffer + 4, rsdiag::BodySize, nonce)) {
+ rsdiag::increment(_stats.probe.received);
+ _stats.probe.last_rx_nonce = nonce;
+ } else rsdiag::increment(_stats.probe.bad);
+ _debug_dirty = true;
+ // Deliberately do not alter heartbeat, peer timestamps, power policy,
+ // PacketManager or duplicate tables. A probe is never echoed or forwarded.
+ return;
+ }
mesh::bridge::rs232heartbeat::Control control;
if (_session == 0 || !mesh::bridge::rs232heartbeat::decode(_rx_buffer + 4, control)
|| control.sender == _session) return;
@@ -286,17 +332,25 @@ void RS232Bridge::handleControl(uint32_t now) {
void RS232Bridge::drainReceivedPackets() {
if (!_rx_packet_ready) return;
mesh::Packet* pkt = _mgr->allocNew();
- if (!pkt) return;
+ if (!pkt) {
+ if (!_rx_pool_blocked) {
+ rsdiag::increment(_stats.rx.pool_wait); _debug_dirty = true;
+ }
+ _rx_pool_blocked = true;
+ return;
+ }
const uint16_t len = (_rx_buffer[2] << 8) | _rx_buffer[3];
const bool valid = pkt->readFrom(_rx_buffer + 4, len);
_rx_buffer_pos = 0;
_rx_packet_ready = false;
+ _rx_pool_blocked = false;
if (valid) {
// A cable loopback or replay of our own accepted mesh TX is not peer proof.
// OTA must still reach the mesh's timed retry/echo policy, but it cannot
// refresh UART peer liveness merely by reflecting our own transmission.
const bool seen = _seen_packets.wasSeen(pkt);
if (pkt->getPayloadType() != PAYLOAD_TYPE_OTA && seen) {
+ rsdiag::increment(_stats.rx.duplicate); _debug_dirty = true;
_mgr->free(pkt);
return;
}
@@ -321,12 +375,16 @@ void RS232Bridge::drainReceivedPackets() {
}
onPacketReceived(pkt);
}
- else _mgr->free(pkt);
+ else {
+ rsdiag::increment(_stats.rx.packet_bad); _debug_dirty = true;
+ _mgr->free(pkt);
+ }
}
void RS232Bridge::loop() {
// Guard against uninitialized state
if (_initialized == false) {
+ emitDebug();
return;
}
@@ -347,6 +405,7 @@ void RS232Bridge::loop() {
const int value = _serial->read();
if (value < 0) break;
const uint8_t b = static_cast(value);
+ rsdiag::increment(_stats.rx.bytes); _debug_dirty = true;
if (_rx_buffer_pos < 2) {
// Waiting for magic word
@@ -356,6 +415,7 @@ void RS232Bridge::loop() {
if (_rx_buffer_pos == 1) _rx_started = now;
} else {
// Invalid magic byte, reset and start over
+ rsdiag::increment(_stats.rx.noise);
_rx_buffer_pos = 0;
// Check if this byte could be the start of a new magic word
if (b == ((BRIDGE_PACKET_MAGIC >> 8) & 0xFF)) {
@@ -372,6 +432,7 @@ void RS232Bridge::loop() {
// Validate length field
if (len > (MAX_TRANS_UNIT + 1)) {
+ rsdiag::increment(_stats.rx.length_bad);
BRIDGE_DEBUG_PRINTLN("RX invalid length %d, resetting\n", len);
_rx_buffer_pos = 0; // Invalid length, reset
continue;
@@ -395,11 +456,13 @@ void RS232Bridge::loop() {
uint16_t received_checksum = (_rx_buffer[4 + len] << 8) | _rx_buffer[5 + len];
if (validateChecksum(_rx_buffer + 4, len, received_checksum)) {
+ rsdiag::increment(_stats.rx.frames);
BRIDGE_DEBUG_PRINTLN("RX, len=%d crc=0x%04x\n", len, received_checksum);
_rx_packet_ready = true;
_rx_packet_received_ms = now;
drainReceivedPackets(); // continue this bounded batch when the pool is available
} else {
+ rsdiag::increment(_stats.rx.checksum_bad);
BRIDGE_DEBUG_PRINTLN("RX checksum mismatch, rcv=0x%04x\n", received_checksum);
}
if (!_rx_packet_ready) _rx_buffer_pos = 0; // Keep a complete valid frame
@@ -411,11 +474,14 @@ void RS232Bridge::loop() {
expirePartialReceive(now);
serviceHeartbeat(now);
drainTransmit(now);
+ emitDebug();
}
void RS232Bridge::sendPacket(mesh::Packet *packet) {
+ rsdiag::increment(_stats.tx.requested); _debug_dirty = true;
// Guard against uninitialized state
if (_initialized == false) {
+ rsdiag::increment(_stats.tx.stopped);
return;
}
@@ -425,26 +491,32 @@ void RS232Bridge::sendPacket(mesh::Packet *packet) {
return;
}
- if (!allowsPacket(packet)) return;
+ if (!allowsPacket(packet)) { rsdiag::increment(_stats.tx.filtered); return; }
// Preserve the legacy data path's blocking UART acceptance/throughput.
// Finish a partially emitted control/data frame first, never interleave it.
// A genuine failed/short hardware write is retained for later loop service;
// only that failure case cannot accept this caller's additional packet.
drainTransmit(millis(), true);
- if (_tx_size != 0) return; // Never interleave mesh traffic with a partial frame.
+ if (_tx_size != 0) {
+ rsdiag::increment(_stats.tx.busy);
+ return; // Never interleave mesh traffic with a partial frame.
+ }
// The mesh's timed OTA retry cache, not the permanent bridge FIFO, owns
// duplicate suppression for resumable request/data packets.
const bool ota = packet->getPayloadType() == PAYLOAD_TYPE_OTA;
if (ota || !_seen_packets.wasSeen(packet)) {
const int expected = packet->getRawLength();
- if (expected <= 0 || expected > MAX_TRANS_UNIT + 1) return;
+ if (expected <= 0 || expected > MAX_TRANS_UNIT + 1) {
+ rsdiag::increment(_stats.tx.oversize); return;
+ }
uint8_t* buffer = _tx_buffer;
uint16_t len = packet->writeTo(buffer + 4);
// Check if packet fits within our maximum payload size
if (len == 0 || len > (MAX_TRANS_UNIT + 1)) {
+ rsdiag::increment(_stats.tx.oversize);
BRIDGE_DEBUG_PRINTLN("TX packet too large (payload=%d, max=%d)\n", len, MAX_TRANS_UNIT + 1);
return;
}
@@ -466,17 +538,157 @@ void RS232Bridge::sendPacket(mesh::Packet *packet) {
_tx_size = static_cast(len + SERIAL_OVERHEAD);
_tx_pos = 0;
_tx_is_ping = false;
+ _tx_is_probe = false;
+ _tx_is_data = true;
+ rsdiag::increment(_stats.tx.queued);
// Retain an outgoing OTA hash solely to reject reflected data as UART
// peer-liveness proof. It never suppresses OTA delivery in either direction.
_seen_packets.markSeen(packet);
drainTransmit(millis(), true);
BRIDGE_DEBUG_PRINTLN("TX, len=%d crc=0x%04x\n", len, checksum);
- }
+ } else rsdiag::increment(_stats.tx.duplicate);
}
void RS232Bridge::onPacketReceived(mesh::Packet *packet) {
handleReceivedPacket(packet);
}
+void RS232Bridge::onIngressResult(IngressResult result) {
+ switch (result) {
+ case IngressResult::Filtered: rsdiag::increment(_stats.rx.filtered); break;
+ case IngressResult::Duplicate: rsdiag::increment(_stats.rx.duplicate); break;
+ case IngressResult::Queued: rsdiag::increment(_stats.rx.delivered); break;
+ case IngressResult::Stopped: break;
+ }
+ _debug_dirty = true;
+}
+
+void RS232Bridge::resetStats() {
+ _stats = rsdiag::Stats{};
+ _debug_dirty = true;
+}
+
+bool RS232Bridge::sendTest(uint32_t nonce) {
+ _debug_dirty = true;
+ if (!_initialized || _power.suspended() || _power.retrying() || _power.probing()) {
+ _stats.error = rsdiag::Error::TestStopped;
+ rsdiag::increment(_stats.probe.failed);
+ return false;
+ }
+ const uint32_t now = millis();
+ if (txPending(now) || _pong_target != 0 || (_heartbeat_enabled && _session != 0
+ && (_ping_due || mesh::bridge::rs232heartbeat::due(now, _next_ping)))) {
+ _stats.error = rsdiag::Error::TestBusy;
+ rsdiag::increment(_stats.probe.failed);
+ return false;
+ }
+ rsdiag::encodeProbe(_tx_buffer + 4, sizeof(_tx_buffer) - 4, nonce);
+ _tx_buffer[0] = 0xC0; _tx_buffer[1] = 0x3E;
+ _tx_buffer[2] = _tx_buffer[3] = 0;
+ _tx_pos = 0;
+ _tx_size = rsdiag::FrameSize;
+ _tx_is_ping = _tx_is_data = false;
+ _tx_is_probe = true;
+ _probe_timed_out = false;
+ _probe_deadline = now + frameAssemblyMs();
+ rsdiag::increment(_stats.probe.queued);
+ _stats.probe.last_tx_nonce = nonce;
+ return true;
+}
+
+void RS232Bridge::expireProbeTransmit(uint32_t now) {
+ if (!_tx_is_probe || _probe_timed_out || _tx_size == 0
+ || !mesh::bridge::rs232heartbeat::due(now, _probe_deadline)) return;
+ _probe_timed_out = true;
+ // An entirely unsent probe can be canceled. Once its header starts, retain
+ // the suffix exactly like a partially emitted heartbeat, so even legacy
+ // receivers never consume a later mesh header as this control's length/CRC.
+ // UART recovery drains only the unsent suffix, never retransmits the prefix.
+ if (_tx_pos == 0) {
+ _tx_size = 0;
+ _tx_is_probe = false;
+ }
+ rsdiag::increment(_stats.probe.failed);
+ _stats.error = rsdiag::Error::TestTimeout;
+ _debug_dirty = true;
+}
+
+void RS232Bridge::formatStatus(char* out, size_t capacity) const {
+ if (!out || capacity == 0) return;
+ const char* phase = _power.retrying() ? "retrying" : _power.suspended() ? "standby"
+ : _power.probing() ? "probing" : _initialized ? "ready" : "off";
+ snprintf(out, capacity,
+ "> %s baud=%lu pins=%d/%d src=%s rxpart=%u rxready=%u tx=%u heartbeat=%s last=%s",
+ phase, (unsigned long)_prefs->bridge_baud, (int)_rx_pin, (int)_tx_pin,
+ _prefs->bridge_pkt_src ? "rx" : "tx", (unsigned)_rx_buffer_pos,
+ _rx_packet_ready ? 1U : 0U, (unsigned)(_tx_size - _tx_pos),
+ _heartbeat_enabled ? "on" : "passive", rsdiag::errorName(_stats.error));
+ out[capacity - 1] = 0;
+}
+
+void RS232Bridge::formatStats(const char* page, char* out, size_t capacity) const {
+ if (!out || capacity == 0) return;
+ const auto& s = _stats;
+ if (!page || !page[0]) {
+ snprintf(out, capacity, "> rx=%lu mesh=%lu tx=%lu sent=%lu; pages: rx tx drops test",
+ (unsigned long)s.rx.frames, (unsigned long)s.rx.delivered,
+ (unsigned long)s.tx.queued, (unsigned long)s.tx.sent);
+ } else if (strcmp(page, "rx") == 0) {
+ snprintf(out, capacity, "> bytes=%lu frames=%lu crc=%lu length=%lu parse=%lu timeout=%lu pool=%lu",
+ (unsigned long)s.rx.bytes, (unsigned long)s.rx.frames,
+ (unsigned long)s.rx.checksum_bad, (unsigned long)s.rx.length_bad,
+ (unsigned long)s.rx.packet_bad, (unsigned long)s.rx.timeout,
+ (unsigned long)s.rx.pool_wait);
+ } else if (strcmp(page, "tx") == 0) {
+ snprintf(out, capacity, "> req=%lu queued=%lu sent=%lu bytes=%lu stall=%lu short=%lu",
+ (unsigned long)s.tx.requested, (unsigned long)s.tx.queued,
+ (unsigned long)s.tx.sent, (unsigned long)s.tx.bytes,
+ (unsigned long)s.tx.stalls, (unsigned long)s.tx.short_writes);
+ } else if (strcmp(page, "drops") == 0) {
+ snprintf(out, capacity, "> noise=%lu filter=%lu dupe=%lu busy=%lu stopped=%lu txfilter=%lu txdupe=%lu big=%lu",
+ (unsigned long)s.rx.noise, (unsigned long)s.rx.filtered,
+ (unsigned long)s.rx.duplicate, (unsigned long)s.tx.busy,
+ (unsigned long)s.tx.stopped, (unsigned long)s.tx.filtered,
+ (unsigned long)s.tx.duplicate, (unsigned long)s.tx.oversize);
+ } else if (strcmp(page, "test") == 0) {
+ snprintf(out, capacity, "> queued=%lu sent=%lu fail=%lu rx=%lu bad=%lu txid=%08lX rxid=%08lX",
+ (unsigned long)s.probe.queued, (unsigned long)s.probe.sent,
+ (unsigned long)s.probe.failed, (unsigned long)s.probe.received,
+ (unsigned long)s.probe.bad, (unsigned long)s.probe.last_tx_nonce,
+ (unsigned long)s.probe.last_rx_nonce);
+ } else snprintf(out, capacity, "Error: use get rs232.stats [rx|tx|drops|test]");
+ out[capacity - 1] = 0;
+}
+
+void RS232Bridge::emitDebug() {
+#if defined(ARDUINO)
+ if (!mesh::isUsbDebugLoggingEnabled()) { _debug_was_enabled = false; return; }
+ if (!_debug_was_enabled) _debug_dirty = true;
+ _debug_was_enabled = true;
+ const uint32_t now = millis();
+ if (!_debug_dirty || static_cast(now - _debug_last_at) < 1000) return;
+ _debug_last_at = now;
+ _debug_dirty = false;
+ char status[157];
+ formatStatus(status, sizeof(status));
+#if defined(NRF52_PLATFORM) || MESH_ESP32_USB_CONSOLE_COOPERATIVE
+ mesh::nrf52DebugPrintf("RS232: %s rx=%lu mesh=%lu crc=%lu tx=%lu sent=%lu test.rx=%08lX\n",
+ status + 2, (unsigned long)_stats.rx.frames, (unsigned long)_stats.rx.delivered,
+ (unsigned long)_stats.rx.checksum_bad, (unsigned long)_stats.tx.queued,
+ (unsigned long)_stats.tx.sent, (unsigned long)_stats.probe.last_rx_nonce);
+#else
+ char line[256];
+ const int length = snprintf(line, sizeof(line),
+ "RS232: %s rx=%lu mesh=%lu crc=%lu tx=%lu sent=%lu test.rx=%08lX\n",
+ status + 2, (unsigned long)_stats.rx.frames, (unsigned long)_stats.rx.delivered,
+ (unsigned long)_stats.rx.checksum_bad, (unsigned long)_stats.tx.queued,
+ (unsigned long)_stats.tx.sent, (unsigned long)_stats.probe.last_rx_nonce);
+ if (length > 0 && (size_t)length < sizeof(line)
+ && mesh::usbLoggingPort().availableForWrite() >= length)
+ mesh::usbLoggingPort().write(reinterpret_cast(line), length);
+#endif
+#endif
+}
+
#endif
diff --git a/src/helpers/bridges/RS232Bridge.h b/src/helpers/bridges/RS232Bridge.h
index b13642d4b..9ed7005d6 100644
--- a/src/helpers/bridges/RS232Bridge.h
+++ b/src/helpers/bridges/RS232Bridge.h
@@ -2,6 +2,7 @@
#include "helpers/bridges/BridgeBase.h"
#include "helpers/bridges/RS232Heartbeat.h"
+#include "helpers/bridges/RS232BridgeDiagnostics.h"
#include
@@ -123,6 +124,13 @@ public:
void formatHeartbeatStatus(char* out, size_t capacity, uint32_t now) const;
bool isPowerSuspended() const { return _power.suspended(); }
+ // Object-lifetime, read-only diagnostics; ordinary firmware needs no special
+ // BRIDGE_DEBUG build. Test transmission never wakes or claims the UART.
+ void formatStatus(char* out, size_t capacity) const;
+ void formatStats(const char* page, char* out, size_t capacity) const;
+ bool sendTest(uint32_t nonce);
+ void resetStats();
+
private:
/**
* RS232 Protocol Structure:
@@ -159,6 +167,7 @@ private:
// Retain the completed parser buffer while the shared pool is exhausted;
// unread bytes remain in the UART driver until this frame is delivered.
bool _rx_packet_ready = false;
+ bool _rx_pool_blocked = false;
void drainReceivedPackets();
/** Current position in the receive buffer */
@@ -172,6 +181,10 @@ private:
uint32_t _tx_wire_until = 0;
bool _tx_wire_pending = false;
bool _tx_is_ping = false;
+ bool _tx_is_probe = false;
+ bool _probe_timed_out = false;
+ bool _tx_is_data = false;
+ uint32_t _probe_deadline = 0;
uint32_t _tx_ping_challenge = 0;
mesh::bridge::RS232PowerPolicy _power;
@@ -198,6 +211,14 @@ private:
bool _legacy_fresh = false;
bool _partial_suspend_grace = false;
+ mesh::bridge::rs232diag::Stats _stats;
+ uint32_t _debug_last_at = 0;
+ bool _debug_dirty = true;
+ bool _debug_was_enabled = false;
+ void onIngressResult(IngressResult result) override;
+ void emitDebug();
+ void expireProbeTransmit(uint32_t now);
+
void resetTransportState();
void expirePartialReceive(uint32_t now);
void expireHeartbeatFreshness(uint32_t now);
diff --git a/src/helpers/bridges/RS232BridgeDiagnostics.h b/src/helpers/bridges/RS232BridgeDiagnostics.h
new file mode 100644
index 000000000..5914f32a4
--- /dev/null
+++ b/src/helpers/bridges/RS232BridgeDiagnostics.h
@@ -0,0 +1,85 @@
+#pragma once
+
+#include "RS232Heartbeat.h"
+#include
+
+namespace mesh {
+namespace bridge {
+namespace rs232diag {
+
+// Observation belongs to the bridge object, not persistent preferences. Soft
+// UART stop/start keeps counters; resetstats, object replacement or reboot does
+// not. No receive callback, heap allocation or packet-pool entry is involved.
+struct RxStats {
+ uint32_t bytes = 0, frames = 0, checksum_bad = 0, length_bad = 0;
+ uint32_t packet_bad = 0, noise = 0, timeout = 0, pool_wait = 0;
+ uint32_t filtered = 0, duplicate = 0, delivered = 0;
+};
+struct TxStats {
+ uint32_t requested = 0, queued = 0, sent = 0, bytes = 0;
+ uint32_t stalls = 0, short_writes = 0, busy = 0, stopped = 0;
+ uint32_t filtered = 0, duplicate = 0, oversize = 0;
+};
+struct ProbeStats {
+ uint32_t queued = 0, sent = 0, failed = 0, received = 0, bad = 0;
+ uint32_t last_tx_nonce = 0, last_rx_nonce = 0;
+};
+enum class Error : uint8_t { None, Pins, Driver, TestStopped, TestBusy, TestTimeout };
+struct Stats {
+ RxStats rx;
+ TxStats tx;
+ ProbeStats probe;
+ Error error = Error::None;
+};
+static_assert(sizeof(Stats) <= 128, "RS232 diagnostics must keep bounded storage");
+
+inline void increment(uint32_t& value) { if (value != UINT32_MAX) ++value; }
+inline void add(uint32_t& value, size_t amount) {
+ const uint32_t room = UINT32_MAX - value;
+ value = amount >= room ? UINT32_MAX : value + static_cast(amount);
+}
+inline const char* errorName(Error error) {
+ switch (error) {
+ case Error::None: return "none";
+ case Error::Pins: return "pins";
+ case Error::Driver: return "driver";
+ case Error::TestStopped: return "test.stopped";
+ case Error::TestBusy: return "test.busy";
+ case Error::TestTimeout: return "test.timeout";
+ }
+ return "unknown";
+}
+
+// Same fixed, zero-length control envelope as the heartbeat, with its own
+// versioned opcode. Every body byte is ASCII, so legacy parsers which reject
+// length zero cannot find a new C0 3E magic word in the body. Existing heartbeat
+// decoders reject DG1T. The probe never proves heartbeat liveness or identity.
+// Body: DG1T, nonce[8 hex], reserved[32 zeroes], Fletcher16[4 hex].
+static constexpr size_t BodySize = rs232heartbeat::BODY_SIZE;
+static constexpr size_t FrameSize = rs232heartbeat::FRAME_SIZE;
+inline bool isProbeCandidate(const uint8_t* body) {
+ return body && body[0] == 'D' && body[1] == 'G';
+}
+inline bool encodeProbe(uint8_t* body, size_t capacity, uint32_t nonce) {
+ if (!body || capacity < BodySize) return false;
+ body[0] = 'D'; body[1] = 'G'; body[2] = '1'; body[3] = 'T';
+ rs232heartbeat::writeHex(body + 4, nonce, 8);
+ memset(body + 12, '0', 32);
+ rs232heartbeat::writeHex(body + 44, rs232heartbeat::checksum(body, 44), 4);
+ return true;
+}
+inline bool decodeProbe(const uint8_t* body, size_t size, uint32_t& nonce) {
+ if (!body || size != BodySize || !isProbeCandidate(body)
+ || body[2] != '1' || body[3] != 'T') return false;
+ for (size_t i = 12; i < 44; ++i) if (body[i] != '0') return false;
+ uint64_t parsed, checksum;
+ if (!rs232heartbeat::readHex(body + 4, 8, parsed)
+ || !rs232heartbeat::readHex(body + 44, 4, checksum)
+ || checksum != rs232heartbeat::checksum(body, 44)) return false;
+ nonce = static_cast(parsed);
+ return true;
+}
+
+} // namespace rs232diag
+} // namespace bridge
+} // namespace mesh
diff --git a/test/fixtures/rs232_bridge_diagnostics.cpp b/test/fixtures/rs232_bridge_diagnostics.cpp
new file mode 100644
index 000000000..2ab12429a
--- /dev/null
+++ b/test/fixtures/rs232_bridge_diagnostics.cpp
@@ -0,0 +1,293 @@
+// Share hardware/pool/old-parser fixtures with the independent heartbeat suite.
+// All diagnostics below exercise the compiled production RS232Bridge.cpp.
+#define main heartbeat_fixture_main
+#include "rs232_heartbeat/main.cpp"
+#undef main
+
+namespace diag = mesh::bridge::rs232diag;
+
+std::vector probe(uint32_t nonce) {
+ std::vector bytes(diag::FrameSize);
+ bytes[0] = 0xC0; bytes[1] = 0x3E;
+ assert(diag::encodeProbe(bytes.data() + 4, bytes.size() - 4, nonce));
+ return bytes;
+}
+
+void probe_codec_test() {
+ assert(diag::FrameSize == 52 && sizeof(diag::Stats) <= 128);
+ for (uint32_t nonce : {0U, 1U, 0xA1B2C3D4U, UINT32_MAX}) {
+ const auto frame = probe(nonce);
+ assert(frame[2] == 0 && frame[3] == 0);
+ assert(memcmp(frame.data() + 4, "DG1T", 4) == 0);
+ for (size_t i = 4; i < frame.size(); ++i) assert(frame[i] < 0x80);
+ uint32_t parsed = 0x12345678;
+ assert(diag::decodeProbe(frame.data() + 4, 48, parsed) && parsed == nonce);
+ hb::Control heartbeat;
+ assert(!hb::decode(frame.data() + 4, heartbeat));
+ for (size_t i = 4; i < frame.size(); ++i) for (unsigned bit = 0; bit < 8; ++bit) {
+ auto bad = frame; bad[i] ^= 1U << bit;
+ parsed = 0x12345678;
+ assert(!diag::decodeProbe(bad.data() + 4, 48, parsed));
+ assert(parsed == 0x12345678);
+ }
+ for (size_t size = 0; size < 80; ++size) if (size != 48) {
+ parsed = 0x12345678;
+ assert(!diag::decodeProbe(frame.data() + 4, size, parsed));
+ assert(parsed == 0x12345678);
+ }
+ }
+ std::array untouched; untouched.fill(0x55);
+ assert(!diag::encodeProbe(untouched.data(), 47, 42));
+ for (auto value : untouched) assert(value == 0x55);
+ uint32_t nonce = 42;
+ assert(!diag::encodeProbe(nullptr, 48, 1));
+ assert(!diag::decodeProbe(nullptr, 48, nonce) && nonce == 42);
+ auto reserved = probe(42); reserved[16] = '1'; rechecksum(reserved);
+ assert(!diag::decodeProbe(reserved.data() + 4, 48, nonce));
+}
+
+void probe_isolation_test() {
+ now_ms = 100; Peer sender(A_SESSION), receiver(B_SESSION);
+ sender.driver.setHeartbeatEnabled(false);
+ receiver.driver.setHeartbeatEnabled(false);
+ sender.uart.delivered = [&](const uint8_t* data, size_t size) { receiver.uart.feed(data, size); };
+ receiver.manager.starved = true;
+ const auto last_peer = receiver.driver._power.peerAge(now_ms);
+ assert(sender.driver.sendTest(0xA1B2C3D4));
+ assert(sender.uart.tx.empty()); // Queueing is not serial or peer acceptance.
+ sender.driver.loop(); receiver.driver.loop();
+ assert(sender.uart.tx == probe(0xA1B2C3D4));
+ assert(sender.driver._stats.probe.sent == 1);
+ assert(receiver.driver._stats.probe.received == 1);
+ assert(receiver.driver._stats.probe.last_rx_nonce == 0xA1B2C3D4);
+ assert(sender.manager.allocations == 0 && receiver.manager.allocations == 0);
+ assert(sender.manager.received.empty() && receiver.manager.received.empty());
+ assert(sender.driver.seenCount() == 0 && receiver.driver.seenCount() == 0);
+ assert(receiver.uart.tx.empty() && !receiver.driver._have_ack && !receiver.driver._legacy_rx);
+ assert(receiver.driver._pong_target == 0 && !receiver.driver._power.hasPeer());
+ assert(receiver.driver._power.peerAge(now_ms) == last_peer);
+ receiver.driver.setHeartbeatEnabled(true);
+ assert(receiver.driver.servicePowerPolicy(true, 1, now_ms + 1, true) == Action::Suspend);
+ receiver.driver.setHeartbeatEnabled(false);
+ for (unsigned i = 0; i < 5; ++i) { sender.driver.loop(); receiver.driver.loop(); }
+ assert(sender.uart.tx.size() == diag::FrameSize && receiver.uart.tx.empty());
+ auto bad = probe(42); bad[49] ^= 1;
+ receiver.uart.feed(bad); receiver.driver.loop();
+ assert(receiver.driver._stats.probe.bad == 1 && receiver.manager.allocations == 0);
+ assert(receiver.driver._stats.probe.last_rx_nonce == 0xA1B2C3D4);
+}
+
+void probe_legacy_test() {
+ for (unsigned parser = 0; parser < 2; ++parser) for (size_t prefix = 0; prefix <= diag::FrameSize; ++prefix) {
+ NodePrefs prefs; HardwareSerial uart; uart.begin(115200);
+ mesh::PacketManager manager;
+ StockBridge old(&prefs, uart, &manager);
+ UpstreamStockBridge upstream(&prefs, uart, &manager);
+ BridgeBase* bridge = parser ? static_cast(&upstream) : &old;
+ const auto frame = probe(0xA1B2C3D4);
+ uart.feed(frame.data(), prefix); bridge->loop();
+ assert(manager.allocations == 0 && manager.received.empty());
+ const auto data = data_frame({5, 6, 7, 8});
+ uart.feed(data); bridge->loop();
+ // A legacy parser cannot resynchronize a truncated header (1..3 bytes),
+ // just as for any interrupted data frame. A whole probe or body prefix is
+ // ignored and does not hide the following ordinary frame.
+ if (prefix == 0 || prefix >= 6) {
+ assert(manager.received == std::vector>({{5, 6, 7, 8}}));
+ assert(manager.allocations == 1);
+ }
+ }
+}
+
+void probe_backpressure_test() {
+ // All possible partial writes retain their exact prefix. A timeout cancels
+ // only an unsent frame; a partial header keeps its unsent suffix owned until
+ // UART recovery. This protects both legacy parsers from a later mesh header.
+ for (size_t prefix = 0; prefix < diag::FrameSize; ++prefix) {
+ now_ms = 100; Peer sender(A_SESSION), receiver(B_SESSION);
+ sender.driver.setHeartbeatEnabled(false); receiver.driver.setHeartbeatEnabled(false);
+ sender.uart.write_limit = prefix;
+ assert(sender.driver.sendTest(0x12345678));
+ sender.driver.drainTransmit(now_ms);
+ assert(sender.uart.tx.size() == prefix);
+ const auto accepted = sender.uart.tx;
+ sender.uart.write_limit = 0;
+ sender.driver.loop();
+ assert(sender.uart.tx == accepted && sender.driver._stats.probe.sent == 0);
+ now_ms += sender.driver.frameAssemblyMs();
+ sender.driver.loop();
+ assert(sender.driver._stats.probe.failed == 1);
+ assert(sender.driver._tx_size == (prefix ? diag::FrameSize : 0));
+ assert(sender.driver._stats.error == diag::Error::TestTimeout);
+ if (prefix) assert(sender.driver._tx_wire_pending); // Prefix wire delay is not erased.
+ for (unsigned n = 0; n < 3; ++n) sender.driver.loop();
+ assert(sender.uart.tx == accepted && sender.driver._stats.probe.failed == 1);
+ sender.uart.write_limit = SIZE_MAX;
+ now_ms += 100;
+ if (prefix) {
+ assert(!sender.driver.sendTest(0xFEDCBA98));
+ sender.driver.loop();
+ assert(sender.uart.tx == probe(0x12345678));
+ assert(sender.driver._stats.probe.sent == 1);
+ now_ms += 100;
+ }
+ assert(sender.driver.sendTest(0xFEDCBA98)); sender.driver.loop();
+ receiver.uart.feed(sender.uart.tx); receiver.driver.loop();
+ assert(receiver.driver._stats.probe.received == (prefix ? 2U : 1U));
+ assert(receiver.driver._stats.probe.last_rx_nonce == 0xFEDCBA98);
+ for (unsigned parser = 0; parser < 2; ++parser) {
+ NodePrefs prefs; HardwareSerial uart; uart.begin(115200);
+ mesh::PacketManager manager;
+ StockBridge old(&prefs, uart, &manager);
+ UpstreamStockBridge upstream(&prefs, uart, &manager);
+ BridgeBase* bridge = parser ? static_cast(&upstream) : &old;
+ uart.feed(sender.uart.tx); bridge->loop();
+ assert(manager.allocations == 0);
+ uart.feed(data_frame({5, 6, 7, 8})); bridge->loop();
+ assert(manager.received == std::vector>({{5, 6, 7, 8}}));
+ }
+ assert(receiver.manager.allocations == 0 && !receiver.driver._power.hasPeer());
+ }
+ now_ms = 100; Peer completed(A_SESSION); completed.driver.setHeartbeatEnabled(false);
+ completed.uart.write_limit = 1;
+ assert(completed.driver.sendTest(0));
+ for (unsigned i = 0; i < 26; ++i) completed.driver.loop();
+ assert(completed.uart.tx == probe(0) && completed.driver._stats.probe.sent == 1);
+ assert(completed.driver._stats.probe.failed == 0);
+}
+
+void probe_state_test() {
+ now_ms = 100; Peer p(A_SESSION);
+ const auto before = p.driver._power.peerAge(now_ms);
+ assert(!p.driver.sendTest(1)); // Initial heartbeat must go first.
+ assert(p.driver._stats.error == diag::Error::TestBusy && p.uart.tx.empty());
+ p.driver.setHeartbeatEnabled(false);
+ assert(p.driver.sendTest(2));
+ assert(!p.driver.sendTest(3));
+ assert(p.driver._stats.probe.last_tx_nonce == 2 && p.driver._power.peerAge(now_ms) == before);
+ p.driver.end();
+ assert(p.driver._stats.probe.failed == 3); // 2 rejected + 1 canceled.
+ assert(!p.driver.sendTest(4));
+ assert(p.driver._stats.error == diag::Error::TestStopped && !p.uart.installed);
+ p.driver.begin();
+ assert(p.driver._stats.probe.queued == 1 && p.driver._stats.probe.failed == 4);
+ p.driver.setHeartbeatEnabled(true);
+ p.enter_standby(now_ms + 1);
+ assert(!p.driver.sendTest(5) && !p.uart.installed);
+ assert(p.driver._power.suspended());
+}
+
+void diagnostic_counters_test() {
+ now_ms = 100; Peer p(A_SESSION); p.driver.setHeartbeatEnabled(false);
+ p.uart.feed({0x01, 0xC0, 0x11, 0xC0, 0x3E, 0xFF, 0xFF}); p.driver.loop();
+ assert(p.driver._stats.rx.bytes == 7 && p.driver._stats.rx.noise == 2);
+ assert(p.driver._stats.rx.length_bad == 1);
+ auto bad = data_frame({4, 5, 6}); bad.back() ^= 1;
+ p.uart.feed(bad); p.driver.loop();
+ assert(p.driver._stats.rx.checksum_bad == 1 && p.driver._stats.rx.frames == 0);
+ p.uart.feed({0xC0, 0x3E, 0, 10, 1}); p.driver.loop();
+ now_ms += p.driver.frameAssemblyMs(); p.driver.loop();
+ assert(p.driver._stats.rx.timeout == 1);
+ p.manager.starved = true;
+ p.uart.feed(data_frame({8, 9, 10})); p.driver.loop();
+ for (unsigned n = 0; n < 10; ++n) p.driver.loop();
+ assert(p.driver._stats.rx.frames == 1 && p.driver._stats.rx.pool_wait == 1);
+ p.manager.starved = false; p.driver.loop();
+ assert(p.driver._stats.rx.delivered == 1 && !p.driver._rx_packet_ready);
+ mesh::Packet packet; packet.payload = {12, 13, 14};
+ p.driver.sendPacket(&packet); p.driver.sendPacket(&packet);
+ assert(p.driver._stats.tx.requested == 2 && p.driver._stats.tx.queued == 1);
+ assert(p.driver._stats.tx.sent == 1 && p.driver._stats.tx.duplicate == 1);
+ p.uart.feed(data_frame(packet.payload)); p.driver.loop();
+ assert(p.driver._stats.rx.duplicate == 1 && p.driver._stats.rx.delivered == 1);
+ packet.allowed = false; p.driver.sendPacket(&packet);
+ assert(p.driver._stats.tx.filtered == 1);
+ const auto old_rx = p.driver._stats.rx.bytes;
+ p.driver.end(); p.driver.begin();
+ assert(p.driver._stats.rx.bytes == old_rx && p.driver._stats.tx.sent == 1);
+ p.driver._outstanding_challenge = 1234; p.driver._last_ack = 12;
+ p.driver._pong_target = B_SESSION;
+ p.uart.feed({0xC0}); p.driver.loop();
+ const auto parser_pos = p.driver._rx_buffer_pos;
+ p.driver.resetStats();
+ assert(p.driver._stats.rx.bytes == 0 && p.driver._stats.tx.sent == 0);
+ assert(p.driver._outstanding_challenge == 1234 && p.driver._last_ack == 12);
+ assert(p.driver._rx_buffer_pos == parser_pos);
+}
+
+void diagnostic_bounds_test() {
+ now_ms = 100; Peer p(A_SESSION); p.driver.setHeartbeatEnabled(false);
+ for (uint32_t start : {0U, UINT32_MAX - 1, UINT32_MAX}) {
+ uint32_t counter = start;
+ diag::increment(counter);
+ assert(counter == (start == UINT32_MAX ? UINT32_MAX : start + 1));
+ diag::add(counter, SIZE_MAX); assert(counter == UINT32_MAX);
+ }
+ p.driver._stats.rx = {UINT32_MAX, UINT32_MAX, UINT32_MAX, UINT32_MAX,
+ UINT32_MAX, UINT32_MAX, UINT32_MAX, UINT32_MAX, UINT32_MAX, UINT32_MAX, UINT32_MAX};
+ p.driver._stats.tx = {UINT32_MAX, UINT32_MAX, UINT32_MAX, UINT32_MAX,
+ UINT32_MAX, UINT32_MAX, UINT32_MAX, UINT32_MAX, UINT32_MAX, UINT32_MAX, UINT32_MAX};
+ p.driver._stats.probe = {UINT32_MAX, UINT32_MAX, UINT32_MAX, UINT32_MAX,
+ UINT32_MAX, UINT32_MAX, UINT32_MAX};
+ for (const char* page : {"", "rx", "tx", "drops", "test", "invalid"}) {
+ std::array output; output.fill('!');
+ p.driver.formatStats(page, output.data(), 157);
+ assert(strlen(output.data()) < 157 && output[157] == '!');
+ if (strcmp(page, "drops") == 0) assert(strstr(output.data(), "big=4294967295"));
+ p.driver.formatStats(page, output.data(), 1); assert(output[0] == 0);
+ p.driver.formatStats(page, nullptr, 0);
+ }
+ p.prefs.bridge_baud = UINT32_MAX; p.driver._rx_pin = INT16_MIN; p.driver._tx_pin = INT16_MAX;
+ p.driver._stats.error = diag::Error::TestStopped;
+ char status[158]; memset(status, '!', sizeof(status));
+ p.driver.formatStatus(status, 157);
+ assert(strlen(status) < 157 && status[157] == '!');
+ assert(strstr(status, "last=test.stopped"));
+ p.driver.formatStatus(status, 1); assert(status[0] == 0);
+ p.driver.formatStatus(nullptr, 0);
+}
+
+void diagnostic_debug_test() {
+ now_ms = 1000; Peer p(A_SESSION); p.driver.setHeartbeatEnabled(false);
+ mesh::logging_enabled = mesh::debug_enabled = false;
+ p.driver.loop(); assert(mesh::debug_stream.writes == 0);
+ mesh::debug_enabled = true; p.driver.loop(); assert(mesh::debug_stream.writes == 0);
+ mesh::debug_enabled = false; mesh::logging_enabled = true;
+ p.driver.loop(); assert(mesh::debug_stream.writes == 0);
+ mesh::debug_enabled = true;
+ p.driver.loop(); assert(mesh::debug_stream.writes == 1);
+ const std::string text(mesh::debug_stream.bytes.begin(), mesh::debug_stream.bytes.end());
+ assert(text.find("RS232: ready baud=115200 pins=5/6") != std::string::npos);
+ assert(p.uart.tx.empty()); // Debug output never enters the bridge wire.
+ now_ms += 10000; p.driver.loop(); assert(mesh::debug_stream.writes == 1);
+ for (unsigned i = 0; i < 20; ++i) {
+ p.uart.feed({1}); p.driver.loop();
+ }
+ assert(mesh::debug_stream.writes == 2); // At most one dirty summary/second.
+ mesh::debug_stream.capacity = 0; now_ms += 1000;
+ p.uart.feed({1}); p.driver.loop();
+ const auto total = mesh::debug_stream.bytes.size();
+ now_ms += 1000; p.uart.feed({1}); p.driver.loop();
+ assert(mesh::debug_stream.bytes.size() == total);
+ mesh::debug_stream.capacity = 4096;
+ mesh::debug_enabled = false; now_ms += 1000; p.uart.feed({1}); p.driver.loop();
+ assert(mesh::debug_stream.bytes.size() == total);
+ mesh::debug_enabled = true; p.driver.loop();
+ assert(mesh::debug_stream.bytes.size() > total && p.uart.tx.empty());
+}
+
+int main(int argc, char** argv) {
+ assert(argc == 2);
+ const std::string name = argv[1];
+ if (name == "codec") probe_codec_test();
+ else if (name == "isolation") probe_isolation_test();
+ else if (name == "legacy") probe_legacy_test();
+ else if (name == "backpressure") probe_backpressure_test();
+ else if (name == "state") probe_state_test();
+ else if (name == "counters") diagnostic_counters_test();
+ else if (name == "bounds") diagnostic_bounds_test();
+ else if (name == "debug") diagnostic_debug_test();
+ else assert(false);
+ printf("%s passed\n", name.c_str());
+ return 0;
+}
diff --git a/test/fixtures/rs232_heartbeat/helpers/bridges/BridgeBase.h b/test/fixtures/rs232_heartbeat/helpers/bridges/BridgeBase.h
index ff3352a5e..38ae63a1d 100644
--- a/test/fixtures/rs232_heartbeat/helpers/bridges/BridgeBase.h
+++ b/test/fixtures/rs232_heartbeat/helpers/bridges/BridgeBase.h
@@ -2,13 +2,47 @@
#include
#include
#include
+#if defined(ARDUINO)
+#include
+#include
+#include
+#endif
#define MAX_TRANS_UNIT 255
#define PAYLOAD_TYPE_OTA 0x0c
#define BRIDGE_DEBUG_PRINTLN(...) ((void)0)
-struct NodePrefs { uint32_t bridge_baud = 115200; };
+struct NodePrefs { uint32_t bridge_baud = 115200; uint8_t bridge_pkt_src = 1; };
namespace mesh {
+#if defined(ARDUINO)
+inline bool logging_enabled = false, debug_enabled = false;
+class DebugStream : public Stream {
+public:
+ int capacity = 4096;
+ unsigned writes = 0;
+ std::vector bytes;
+ int available() override { return 0; }
+ int read() override { return -1; }
+ int availableForWrite() override { return capacity; }
+ size_t write(const uint8_t* data, size_t size) override {
+ ++writes;
+ if (size > static_cast(capacity)) return 0;
+ bytes.insert(bytes.end(), data, data + size); return size;
+ }
+};
+inline DebugStream debug_stream;
+inline bool isUsbDebugLoggingEnabled() { return logging_enabled && debug_enabled; }
+inline Stream& usbLoggingPort() { return debug_stream; }
+inline size_t nrf52DebugPrintf(const char* format, ...) {
+ if (!isUsbDebugLoggingEnabled()) return 0;
+ char record[256];
+ va_list args; va_start(args, format);
+ int size = vsnprintf(record, sizeof(record), format, args); va_end(args);
+ if (size <= 0) return 0;
+ if (size >= static_cast(sizeof(record))) size = sizeof(record) - 1;
+ return debug_stream.write(reinterpret_cast(record), size);
+}
+#endif
struct Packet {
std::vector payload{1, 2, 3};
bool allowed = true;
@@ -56,7 +90,10 @@ public:
virtual void sendPacket(mesh::Packet*) = 0;
virtual void onPacketReceived(mesh::Packet*) = 0;
bool isRunning() const { return _initialized; }
+ size_t seenCount() const { return _seen_packets.seen.size(); }
protected:
+ enum class IngressResult : uint8_t { Stopped, Filtered, Duplicate, Queued };
+ virtual void onIngressResult(IngressResult) {}
bool _initialized = false;
NodePrefs* _prefs;
mesh::PacketManager* _mgr;
@@ -74,6 +111,10 @@ protected:
return fletcher16(bytes, size) == checksum;
}
void handleReceivedPacket(mesh::Packet* packet) {
+ if (!allowsPacket(packet)) {
+ onIngressResult(IngressResult::Filtered); _mgr->free(packet); return;
+ }
+ onIngressResult(IngressResult::Queued);
_mgr->received.push_back(packet->payload); _mgr->free(packet);
}
};
diff --git a/test/fixtures/rs232_heartbeat/main.cpp b/test/fixtures/rs232_heartbeat/main.cpp
index 232eaed3b..e55dea617 100644
--- a/test/fixtures/rs232_heartbeat/main.cpp
+++ b/test/fixtures/rs232_heartbeat/main.cpp
@@ -690,4 +690,5 @@ int main(int argc, char** argv) {
else if (name == "counter-cycle") counter_cycle_test();
else assert(false);
printf("%s passed\n", name.c_str());
+ return 0;
}
diff --git a/test/test_bridge_debug_formatter.py b/test/test_bridge_debug_formatter.py
new file mode 100644
index 000000000..fe0883c89
--- /dev/null
+++ b/test/test_bridge_debug_formatter.py
@@ -0,0 +1,90 @@
+#!/usr/bin/env python3
+"""Compile and execute the production USB formatter with bridge-only flags."""
+from pathlib import Path
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+class BridgeDebugFormatterTests(unittest.TestCase):
+ def test_rs232_only_native_usb_has_bounded_runtime_gated_output(self):
+ compiler = shutil.which("g++") or shutil.which("clang++")
+ self.assertIsNotNone(compiler)
+ header = (ROOT / "src/MeshCore.h").read_text(encoding="ascii")
+ start = header.index("#if defined(ARDUINO) && \\\n (defined(NRF52_PLATFORM)")
+ formatter = header[start:header.index("#if MESH_DEBUG && ARDUINO", start)]
+ harness = r'''
+#include
+#include
+#include
+#include
+struct Stream {
+ unsigned calls = 0;
+ size_t capacity = 256;
+ std::string output;
+ size_t write(const uint8_t* bytes, size_t length) {
+ ++calls;
+ if (length > capacity) return 0;
+ output.append(reinterpret_cast(bytes), length);
+ capacity -= length;
+ return length;
+ }
+} port;
+namespace mesh {
+bool master = false, verbose = false;
+bool isUsbDebugLoggingEnabled() { return master && verbose; }
+Stream& usbLoggingPort() { return port; }
+}
+@FORMATTER@
+int main() {
+ using mesh::nrf52DebugPrintf;
+ assert(nrf52DebugPrintf("quiet") == 0 && port.calls == 0);
+ mesh::verbose = true;
+ assert(nrf52DebugPrintf("quiet") == 0 && port.calls == 0);
+ mesh::master = true;
+ mesh::verbose = false;
+ assert(nrf52DebugPrintf("quiet") == 0 && port.calls == 0);
+ mesh::verbose = true;
+ assert(nrf52DebugPrintf("RS232: ready\n") == 13);
+ assert(port.output == "RS232: ready\n");
+ port.output.clear(); port.capacity = 256;
+ const std::string large(1024, 'x');
+ assert(nrf52DebugPrintf("%s\n", large.c_str()) == 255);
+ assert(port.output.size() == 255);
+ assert(port.output.substr(251) == "...\n");
+ port.output.clear(); port.capacity = 0;
+ assert(nrf52DebugPrintf("blocked\n") == 0 && port.output.empty());
+ assert(nrf52DebugPrintf(nullptr) == 0);
+}
+'''.replace("@FORMATTER@", formatter)
+ with tempfile.TemporaryDirectory(prefix="bridge-debug-formatter-") as temporary:
+ work = Path(temporary)
+ (work / "helpers").mkdir()
+ (work / "Arduino.h").write_text("#pragma once\n", encoding="ascii")
+ (work / "helpers/NonBlockingWriteStream.h").write_text("#pragma once\n", encoding="ascii")
+ source = work / "main.cpp"
+ source.write_text(harness, encoding="ascii")
+ for name, flags in (
+ ("nrf52", ["-DNRF52_PLATFORM=1"]),
+ ("esp32", ["-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=1"]),
+ ):
+ with self.subTest(platform=name):
+ binary = work / name
+ built = subprocess.run([
+ compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
+ "-fsanitize=address,undefined", "-fno-sanitize-recover=all",
+ "-fno-pie", "-no-pie", "-DARDUINO=1", "-DWITH_RS232_BRIDGE=1",
+ "-DMESH_DEBUG=0", "-DBRIDGE_DEBUG=0", "-DPOWERSAVING_DEBUG=0",
+ *flags, "-I" + str(work), str(source), "-o", str(binary),
+ ], capture_output=True, text=True, timeout=30)
+ self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
+ checked = subprocess.run([str(binary)], capture_output=True,
+ text=True, timeout=10)
+ self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_bridge_recovery.py b/test/test_bridge_recovery.py
index 4d69fe5aa..f62ceea56 100644
--- a/test/test_bridge_recovery.py
+++ b/test/test_bridge_recovery.py
@@ -18,6 +18,7 @@ MOCKS = r'''
#include
#include
#include
+#include
#include
#define MAX_TRANS_UNIT 255
#define PAYLOAD_TYPE_OTA 0x0c
@@ -138,7 +139,7 @@ def code():
"int esp_now_deinit() { assert(espnow_active); if(failure==18)return -1; espnow_active=false; return ESP_OK; }")
base = (ROOT / "src/helpers/bridges/BridgeBase.cpp").read_text()
base_header = (ROOT / "src/helpers/bridges/BridgeBase.h").read_text()
- ingress_hook = "#if defined(WITH_ESPNOW_BRIDGE)\n"
+ ingress_hook = "#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE)\n"
ingress_hook += method(base_header, "enum class IngressResult") + ";\n"
ingress_hook += method(base_header, "virtual void onIngressResult(") + "\n#endif\n"
base_methods = "\n".join(method(base, signature) for signature in (
diff --git a/test/test_rs232_bridge_diagnostics.py b/test/test_rs232_bridge_diagnostics.py
new file mode 100644
index 000000000..fe2c833d3
--- /dev/null
+++ b/test/test_rs232_bridge_diagnostics.py
@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+"""Sanitized production UART diagnostics, probes and legacy parser tests."""
+from pathlib import Path
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+ROOT = Path(__file__).resolve().parents[1]
+FIXTURES = ROOT / "test/fixtures/rs232_heartbeat"
+
+
+class RS232BridgeDiagnosticTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ compiler = shutil.which("g++") or shutil.which("clang++")
+ if not compiler:
+ raise AssertionError("a host C++ compiler is required")
+ cls.temporary = tempfile.TemporaryDirectory(prefix="rs232-diagnostics-")
+ cls.binaries = []
+ for platform, cooperative in (("ESP32", 0), ("ESP32", 1),
+ ("NRF52_PLATFORM", 0),
+ ("RP2040_PLATFORM", 0),
+ ("STM32_PLATFORM", 0)):
+ binary = Path(cls.temporary.name) / f"diagnostics-{platform}-{cooperative}"
+ built = subprocess.run([
+ compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
+ "-fsanitize=address,undefined", "-fno-sanitize-recover=all",
+ "-fno-pie", "-no-pie", "-DARDUINO=1", f"-D{platform}=1",
+ "-DWITH_RS232_BRIDGE=1",
+ f"-DMESH_ESP32_USB_CONSOLE_COOPERATIVE={cooperative}",
+ "-I" + str(FIXTURES), "-I" + str(ROOT / "src"),
+ str(ROOT / "test/fixtures/rs232_bridge_diagnostics.cpp"),
+ str(ROOT / "src/helpers/bridges/RS232Bridge.cpp"),
+ "-o", str(binary),
+ ], capture_output=True, text=True, timeout=60)
+ if built.returncode:
+ cls.temporary.cleanup()
+ raise AssertionError(built.stdout + built.stderr)
+ cls.binaries.append(binary)
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.temporary.cleanup()
+
+ def run_case(self, name):
+ for binary in self.binaries:
+ with self.subTest(console=binary.name):
+ checked = subprocess.run([str(binary), name], capture_output=True,
+ text=True, timeout=60)
+ self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
+ self.assertIn(name + " passed", checked.stdout)
+
+ def test_probe_codec_strict_integrity_version_and_bounds(self):
+ self.run_case("codec")
+
+ def test_probe_never_allocates_echoes_forwards_or_refreshes_peer_power(self):
+ self.run_case("isolation")
+
+ def test_both_real_legacy_parsers_ignore_reserved_frames(self):
+ self.run_case("legacy")
+
+ def test_all_partial_tx_prefixes_are_bounded_and_never_retransmitted(self):
+ self.run_case("backpressure")
+
+ def test_stopped_busy_and_heartbeat_owned_transports_reject_probe(self):
+ self.run_case("state")
+
+ def test_data_errors_pool_wait_and_lifetime_reset_are_observed(self):
+ self.run_case("counters")
+
+ def test_saturating_counters_and_all_cli_page_bounds(self):
+ self.run_case("bounds")
+
+ def test_usb_debug_both_gates_change_rate_limit_and_console_backpressure(self):
+ self.run_case("debug")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_rs232_diagnostic_cli.py b/test/test_rs232_diagnostic_cli.py
new file mode 100644
index 000000000..fc06a4d92
--- /dev/null
+++ b/test/test_rs232_diagnostic_cli.py
@@ -0,0 +1,244 @@
+"""Compile actual RS232 diagnostic dispatch/forwarders with native fakes."""
+from pathlib import Path
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+from test_replay_reset_integration import extract_braced
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+class Rs232DiagnosticCliTests(unittest.TestCase):
+ def compile_run(self, source, flags=()):
+ compiler = shutil.which("g++") or shutil.which("clang++")
+ self.assertIsNotNone(compiler)
+ with tempfile.TemporaryDirectory(prefix="rs232-cli-") as directory:
+ source_path = Path(directory) / "main.cpp"
+ binary = Path(directory) / "check"
+ source_path.write_text(source, encoding="ascii")
+ subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
+ "-fsanitize=address,undefined", "-fno-omit-frame-pointer",
+ "-I", str(ROOT / "src"), *flags, str(source_path),
+ "-o", str(binary)], check=True, capture_output=True, text=True)
+ subprocess.run([str(binary)], check=True, capture_output=True, text=True)
+
+ def test_actual_dispatch_strict_local_actions_and_bounded_replies(self):
+ source = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="ascii")
+ header = (ROOT / "src/helpers/CommonCLI.h").read_text(encoding="ascii")
+ body = extract_braced(source, "bool CommonCLI::handleRs232DiagnosticCommand(")
+ defaults = "\n".join(extract_braced(header, signature) for signature in (
+ "virtual void formatRs232Transport(", "virtual void formatRs232Stats(",
+ "virtual bool sendRs232Test(", "virtual bool resetRs232Stats("))
+ self.assertIn("if (handleRs232DiagnosticCommand(sender_timestamp, command, reply)) return;", source)
+ self.compile_run(r'''
+#include
+#include
+#include
+#include
+#include
+static uint32_t now_ms = 0x1234ABCD;
+uint32_t millis() { return now_ms; }
+struct Callbacks {
+ virtual ~Callbacks() = default;
+''' + defaults + r'''
+};
+struct Fake : Callbacks {
+ int statuses = 0, stats = 0, sends = 0, resets = 0;
+ uint32_t nonce = 0;
+ size_t capacity = 0;
+ std::string page;
+ bool send_result = true, reset_result = true, long_reply = false;
+ void fill(char* reply, size_t cap, const char* value) {
+ capacity = cap;
+ const std::string text = long_reply ? std::string(300, 'x') : value;
+ snprintf(reply, cap, "%s", text.c_str());
+ }
+ void formatRs232Transport(char* r, size_t cap) override {
+ ++statuses; fill(r, cap, "status");
+ }
+ void formatRs232Stats(const char* p, char* r, size_t cap) override {
+ ++stats; page = p; fill(r, cap, "stats");
+ }
+ bool sendRs232Test(uint32_t n) override {
+ ++sends; nonce = n; return send_result;
+ }
+ bool resetRs232Stats() override { ++resets; return reset_result; }
+ int count() const { return statuses + stats + sends + resets; }
+};
+struct CommonCLI {
+ Callbacks* _callbacks;
+ bool handleRs232DiagnosticCommand(uint32_t, const char*, char*);
+};
+''' + body + r'''
+int main() {
+ Fake callbacks;
+ CommonCLI cli{&callbacks};
+ char reply[160];
+ auto command = [&](const char* text, uint32_t sender = 0) {
+ memset(reply, 'Z', sizeof(reply));
+ const bool handled = cli.handleRs232DiagnosticCommand(sender, text, reply);
+ assert(reply[157] == 'Z' && reply[158] == 'Z' && reply[159] == 'Z');
+ if (handled) assert(memchr(reply, 0, 157) != nullptr);
+ return handled;
+ };
+ assert(command("get rs232.transport", 77));
+ assert(callbacks.statuses == 1 && callbacks.capacity == 157);
+ for (const char* page : {"", "rx", "tx", "drops", "test"}) {
+ const std::string text = std::string("get rs232.stats") + (*page ? " " : "") + page;
+ assert(command(text.c_str(), 77));
+ assert(callbacks.page == page && callbacks.capacity == 157);
+ }
+ callbacks.long_reply = true;
+ assert(command("get rs232.transport"));
+ assert(strlen(reply) == 156);
+ assert(command("get rs232.stats rx"));
+ assert(strlen(reply) == 156);
+ callbacks.long_reply = false;
+ for (const char* text : {
+ "get rs232.transport extra", "get rs232.transportx", "get rs232.transport ",
+ "get rs232.statsrx", "get rs232.stats x", "get rs232.stats rx junk",
+ "get rs232.stats ", "get rs232.stats\trx", "get rs232.stats rx",
+ "rs232", "rs232 testx", "rs232 test ", "rs232 test 1234567",
+ "rs232 test 123456789", "rs232 test 0x123456", "rs232 test +1234567",
+ "rs232 test 1234567Z", "rs232 test 12345678 ", "rs232 test 12345678 x",
+ "rs232 resetstatsx", "rs232 resetstats ", "rs232 resetstats now"}) {
+ const int before = callbacks.count();
+ assert(command(text));
+ assert(strncmp(reply, "Error:", 6) == 0 && callbacks.count() == before);
+ }
+ for (const char* text : {"rs232test", "rs232x test", "get rs232.enabled", "get rs232.status", "get repeat"}) {
+ const int before = callbacks.count();
+ assert(!command(text));
+ assert(callbacks.count() == before);
+ }
+ for (uint32_t remote : {1U, UINT32_MAX}) {
+ for (const char* text : {"rs232 test", "rs232 test 12345678", "rs232 resetstats"}) {
+ const int before = callbacks.count();
+ assert(command(text, remote));
+ assert(strstr(reply, "local connection") && callbacks.count() == before);
+ }
+ }
+ assert(command("rs232 test"));
+ assert(callbacks.sends == 1 && callbacks.nonce == now_ms);
+ assert(strcmp(reply, "OK - test queued id=1234ABCD; confirm on peer") == 0);
+ assert(command("rs232 test 89abcDEF"));
+ assert(callbacks.sends == 2 && callbacks.nonce == 0x89ABCDEF);
+ assert(command("rs232 test 00000000"));
+ assert(callbacks.sends == 3 && callbacks.nonce == 0);
+ callbacks.send_result = false;
+ assert(command("rs232 test DEADBEEF"));
+ assert(callbacks.sends == 4 && strstr(reply, "not queued"));
+ assert(command("rs232 resetstats"));
+ assert(callbacks.resets == 1 && strcmp(reply, "OK - RS232 statistics reset") == 0);
+ callbacks.reset_result = false;
+ assert(command("rs232 resetstats"));
+ assert(callbacks.resets == 2 && strstr(reply, "unavailable"));
+ Callbacks unsupported;
+ cli._callbacks = &unsupported;
+ assert(command("get rs232.transport") && strstr(reply, "unavailable"));
+ assert(command("get rs232.stats") && strstr(reply, "unavailable"));
+ assert(command("rs232 test") && strstr(reply, "not queued"));
+ assert(command("rs232 resetstats") && strstr(reply, "unavailable"));
+}
+''')
+
+ def test_actual_repeater_forwarders_select_uart_and_handle_absent_transport(self):
+ repeater = (ROOT / "examples/simple_repeater/MyMesh.h").read_text(encoding="ascii")
+ signatures = ("void formatRs232Transport(", "void formatRs232Stats(",
+ "bool sendRs232Test(", "bool resetRs232Stats(")
+ forwarders = "\n".join(extract_braced(repeater, signature) for signature in signatures)
+ # Room servers own MQTT/ESP-NOW only and retain the explicit unsupported
+ # defaults rather than accidentally routing UART diagnostics to MQTT.
+ room = (ROOT / "examples/simple_room_server/MyMesh.h").read_text(encoding="ascii")
+ for signature in signatures:
+ self.assertNotIn(signature, room)
+ source = r'''
+#include
+#include
+#include
+#include
+struct Base {
+ virtual void formatRs232Transport(char*, size_t) {}
+ virtual void formatRs232Stats(const char*, char*, size_t) {}
+ virtual bool sendRs232Test(uint32_t) { return false; }
+ virtual bool resetRs232Stats() { return false; }
+};
+struct RS232Bridge {
+ int calls = 0; uint32_t nonce = 0;
+ void formatStatus(char* r, size_t cap) { ++calls; snprintf(r, cap, "status"); }
+ void formatStats(const char* page, char* r, size_t cap) {
+ ++calls; assert(strcmp(page, "rx") == 0); snprintf(r, cap, "rx");
+ }
+ bool sendTest(uint32_t n) { ++calls; nonce = n; return true; }
+ void resetStats() { ++calls; }
+};
+struct Role : Base {
+ RS232Bridge uart, mqtt_bridge, espnow_bridge;
+ RS232Bridge* bridge = &uart;
+''' + forwarders + r'''
+};
+int main() {
+ Role role; char reply[160];
+ memset(reply, 'Z', sizeof(reply));
+ role.formatRs232Transport(reply, 157);
+ role.formatRs232Stats("rx", reply, 157);
+ assert(role.sendRs232Test(0xABCD));
+ assert(role.resetRs232Stats());
+ assert(role.uart.calls == 4 && role.uart.nonce == 0xABCD);
+ assert(role.mqtt_bridge.calls == 0 && role.espnow_bridge.calls == 0);
+ role.bridge = nullptr;
+ role.formatRs232Transport(reply, 157);
+ assert(strstr(reply, "unavailable"));
+ role.formatRs232Stats("rx", reply, 157);
+ assert(strstr(reply, "unavailable"));
+ assert(!role.sendRs232Test(0xABCD) && !role.resetRs232Stats());
+ assert(role.uart.calls == 4);
+ assert(reply[157] == 'Z' && reply[158] == 'Z' && reply[159] == 'Z');
+}
+'''
+ for macros in ((), ("WITH_ESPNOW_BRIDGE",), ("WITH_MQTT_BRIDGE",),
+ ("WITH_MQTT_BRIDGE", "WITH_ESPNOW_BRIDGE")):
+ with self.subTest(macros=macros):
+ self.compile_run(source, ["-DWITH_RS232_BRIDGE=1", *["-D" + m for m in macros]])
+
+ def test_diagnostic_virtuals_are_absent_without_rs232(self):
+ header = (ROOT / "src/helpers/CommonCLI.h").read_text(encoding="ascii")
+ start = header.index("#if defined(WITH_RS232_BRIDGE)\n virtual void formatRs232Transport(")
+ end = header.index("\n#endif", start) + len("\n#endif")
+ block = header[start:end]
+ source = r'''
+#include
+#include
+#include
+#include
+#include
+struct Callbacks {
+''' + block + r'''
+};
+template struct HasDiagnostics : std::false_type {};
+template struct HasDiagnostics().formatRs232Transport(nullptr, 0)),
+ decltype(std::declval().formatRs232Stats(nullptr, nullptr, 0)),
+ decltype(std::declval().sendRs232Test(0)),
+ decltype(std::declval().resetRs232Stats())>> : std::true_type {};
+int main() {
+#if defined(WITH_RS232_BRIDGE)
+ static_assert(HasDiagnostics::value);
+ Callbacks callbacks; char reply[157];
+ callbacks.formatRs232Transport(reply, sizeof(reply));
+ assert(reply[0] == 'E');
+ assert(!callbacks.sendRs232Test(0) && !callbacks.resetRs232Stats());
+#else
+ static_assert(!HasDiagnostics::value);
+ static_assert(sizeof(Callbacks) == 1);
+#endif
+}
+'''
+ self.compile_run(source)
+ self.compile_run(source, ["-DWITH_RS232_BRIDGE=1"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_rs232_driver_lifecycle.py b/test/test_rs232_driver_lifecycle.py
index 64f6c91ed..a22f1f41b 100644
--- a/test/test_rs232_driver_lifecycle.py
+++ b/test/test_rs232_driver_lifecycle.py
@@ -13,6 +13,8 @@ ROOT = Path(__file__).resolve().parents[1]
FIXTURE = r'''
#include
#include
+#include
+namespace rsdiag = mesh::bridge::rs232diag;
#define ESP32 1
#define BRIDGE_DEBUG_PRINTLN(...) ((void)0)
struct NodePrefs { uint32_t bridge_baud = 57600; };
@@ -38,7 +40,10 @@ struct RS232Bridge : BridgeBase {
Stream* _serial;
int16_t _rx_pin, _tx_pin;
uint16_t _rx_buffer_pos = 37;
- bool _rx_packet_ready = true;
+ bool _rx_packet_ready = true, _rx_pool_blocked = false;
+ bool _tx_is_probe = false, _tx_is_data = false, _debug_dirty = false;
+ bool _probe_timed_out = false;
+ rsdiag::Stats _stats;
uint16_t _tx_size = 19, _tx_pos = 3;
bool _tx_wire_pending = true, _tx_is_ping = true;
uint32_t _tx_ping_challenge = 4, _outstanding_challenge = 5;
@@ -127,7 +132,8 @@ class RS232DriverLifecycleTests(unittest.TestCase):
source.write_text(code, encoding="ascii")
built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie",
- str(source), "-o", str(binary)], capture_output=True, text=True, timeout=60)
+ "-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=10)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
diff --git a/test/test_transport_filter_wiring.py b/test/test_transport_filter_wiring.py
index 9e9035231..1af57f506 100644
--- a/test/test_transport_filter_wiring.py
+++ b/test/test_transport_filter_wiring.py
@@ -40,6 +40,8 @@ HARNESS = r'''
#include
#include
#include
+#include
+namespace rsdiag = mesh::bridge::rs232diag;
#include
#include
#define BRIDGE_DEBUG_PRINTLN(...) ((void)0)
@@ -108,7 +110,11 @@ struct RS232Bridge : BridgeBase {
uint16_t _tx_size=0,_tx_pos=0;
uint32_t _tx_wire_until=0,_tx_ping_challenge=0;
uint32_t _outstanding_challenge=0,_outstanding_since=0;
- bool _tx_wire_pending=false,_tx_is_ping=false;
+ bool _tx_wire_pending=false,_tx_is_ping=false,_tx_is_probe=false,_tx_is_data=false,_probe_timed_out=false;
+ uint32_t _probe_deadline=0;
+ bool _debug_dirty=false;
+ rsdiag::Stats _stats;
+ void expireProbeTransmit(uint32_t);
void drainTransmit(uint32_t,bool blocking=false);
void sendPacket(mesh::Packet*) override;
};
@@ -210,7 +216,7 @@ int main() {
# Importers use this shared harness directly, so expand its production hook
# here rather than leaving fixture setup to one test's compilation path.
_base_header = (ROOT / 'src/helpers/bridges/BridgeBase.h').read_text(encoding='ascii')
-_ingress_hook = '#if defined(WITH_ESPNOW_BRIDGE)\n'
+_ingress_hook = '#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_RS232_BRIDGE)\n'
_ingress_hook += method(_base_header, 'enum class IngressResult') + ';\n'
_ingress_hook += method(_base_header, 'virtual void onIngressResult(') + '\n#endif\n'
HARNESS = HARNESS.replace('@INGRESS_HOOK@', _ingress_hook)
@@ -393,7 +399,7 @@ int main() {
methods = []
for name, signatures in {
'BridgeBase': ['void BridgeBase::handleReceivedPacket('],
- 'RS232Bridge': ['void RS232Bridge::drainTransmit(', 'void RS232Bridge::sendPacket('],
+ 'RS232Bridge': ['void RS232Bridge::expireProbeTransmit(', 'void RS232Bridge::drainTransmit(', 'void RS232Bridge::sendPacket('],
'ESPNowBridge': ['void ESPNowBridge::sendPacket('],
'MQTTBridge': ['void MQTTBridge::sendPacket(', 'void MQTTBridge::onPacketReceived('],
}.items():