Add runtime RS232 bridge diagnostics and wired link probes

This commit is contained in:
mikecarper committed 2026-10-22 11:51:56 -07:00
1 parent e7b863494c
commit 281f1a5199
22 files changed
+1468 -24

No files matched your search

+3
View File
@@ -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: |
+99 -2
View File
@@ -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
+157
View File
@@ -0,0 +1,157 @@
<!-- meshcore-hosted-doc-link:start -->
<p class="meshcore-hosted-doc-link"><a href="https://mikecarper.github.io/MeshCore/rs232_bridge_setup/">View this page on MeshCore Docs</a>.</p>
<!-- meshcore-hosted-doc-link:end -->
# 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.
+18
View File
@@ -1441,6 +1441,24 @@ public:
static_cast<unsigned>(_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();
+1
View File
@@ -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
+1 -1
View File
@@ -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 <Arduino.h>
#include <atomic>
#include <stdarg.h>
+74
View File
@@ -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<uint8_t>(c - '0');
else if (c >= 'a' && c <= 'f') digit = static_cast<uint8_t>(c - 'a' + 10);
else if (c >= 'A' && c <= 'F') digit = static_cast<uint8_t>(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<unsigned long>(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
+14
View File
@@ -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
+4 -4
View File
@@ -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
}
+1 -1
View File
@@ -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) {}
+221 -9
View File
@@ -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<uint32_t>(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<size_t>(_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<size_t>(available)) amount = static_cast<size_t>(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<uint16_t>(_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<uint32_t>((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<uint8_t>(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<uint16_t>(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<uint32_t>(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<const uint8_t*>(line), length);
#endif
#endif
}
#endif
+21
View File
@@ -2,6 +2,7 @@
#include "helpers/bridges/BridgeBase.h"
#include "helpers/bridges/RS232Heartbeat.h"
#include "helpers/bridges/RS232BridgeDiagnostics.h"
#include <Stream.h>
@@ -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);
@@ -0,0 +1,85 @@
#pragma once
#include "RS232Heartbeat.h"
#include <string.h>
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<uint32_t>(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<uint32_t>(parsed);
return true;
}
} // namespace rs232diag
} // namespace bridge
} // namespace mesh
+293
View File
@@ -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<uint8_t> probe(uint32_t nonce) {
std::vector<uint8_t> 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<uint8_t, 60> 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<BridgeBase*>(&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<std::vector<uint8_t>>({{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<BridgeBase*>(&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<std::vector<uint8_t>>({{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<char, 200> 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;
}
+42 -1
View File
@@ -2,13 +2,47 @@
#include <cstddef>
#include <cstdint>
#include <vector>
#if defined(ARDUINO)
#include <Stream.h>
#include <cstdarg>
#include <cstdio>
#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<uint8_t> 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<size_t>(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<int>(sizeof(record))) size = sizeof(record) - 1;
return debug_stream.write(reinterpret_cast<const uint8_t*>(record), size);
}
#endif
struct Packet {
std::vector<uint8_t> 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);
}
};
+1
View File
@@ -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;
}
+90
View File
@@ -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 <cassert>
#include <cstdint>
#include <cstring>
#include <string>
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<const char*>(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()
+2 -1
View File
@@ -18,6 +18,7 @@ MOCKS = r'''
#include <cstdarg>
#include <string>
#include <helpers/bridges/RS232Heartbeat.h>
#include <helpers/bridges/RS232BridgeDiagnostics.h>
#include <helpers/bridges/ESPNowBridgeDiagnostics.h>
#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 (
+80
View File
@@ -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()
+244
View File
@@ -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 <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
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 <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
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 <cassert>
#include <cstdint>
#include <cstdio>
#include <type_traits>
#include <utility>
struct Callbacks {
''' + block + r'''
};
template <typename T, typename = void> struct HasDiagnostics : std::false_type {};
template <typename T> struct HasDiagnostics<T, std::void_t<
decltype(std::declval<T>().formatRs232Transport(nullptr, 0)),
decltype(std::declval<T>().formatRs232Stats(nullptr, nullptr, 0)),
decltype(std::declval<T>().sendRs232Test(0)),
decltype(std::declval<T>().resetRs232Stats())>> : std::true_type {};
int main() {
#if defined(WITH_RS232_BRIDGE)
static_assert(HasDiagnostics<Callbacks>::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<Callbacks>::value);
static_assert(sizeof(Callbacks) == 1);
#endif
}
'''
self.compile_run(source)
self.compile_run(source, ["-DWITH_RS232_BRIDGE=1"])
if __name__ == "__main__":
unittest.main()
+8 -2
View File
@@ -13,6 +13,8 @@ ROOT = Path(__file__).resolve().parents[1]
FIXTURE = r'''
#include <cassert>
#include <cstdint>
#include <helpers/bridges/RS232BridgeDiagnostics.h>
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)
+9 -3
View File
@@ -40,6 +40,8 @@ HARNESS = r'''
#include <cassert>
#include <vector>
#include <helpers/bridges/RS232Heartbeat.h>
#include <helpers/bridges/RS232BridgeDiagnostics.h>
namespace rsdiag = mesh::bridge::rs232diag;
#include <helpers/bridges/ESPNowBridgeDiagnostics.h>
#include <helpers/bridges/ESPNowBridgeFormat.h>
#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():