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 @@ + + + + +# 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():