Save valid busy RX, fast-switch and Companion tuple changes once, then apply the latest accepted intent at a safe radio gap. Preserve checked-save rollback, runtime RXEN intent, TempRadio ownership and exact remote reply delivery. Repair failed preamble compensation without hiding uncertain saves. Retry WebConfig, MQTT and NimBLE startup failures automatically. Preserve explicit off choices, OTA ownership and the latest WiFi credentials through cooperative shutdown. Add regression coverage and update command guides. Validation: focused host transaction, rollback, ownership and lifecycle suites; Full RAK4631, Heltec V4 R8 TFT and RC32 packages; representative room, sensor, LR1110, LR2021, SX1276, STM32WL and ESP-NOW builds. Linked RAK crypto and OTA workspace checks pass; its complete OTA image uses 712644/712704 bytes.
835 lines
38 KiB
Python
835 lines
38 KiB
Python
#!/usr/bin/env python3
|
|
"""Integration guards and native tests for nRF52 nonblocking USB diagnostics."""
|
|
|
|
from pathlib import Path
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
from test_replay_reset_integration import extract_braced
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
class Nrf52UsbLoggingContractTest(unittest.TestCase):
|
|
def test_dedicated_cdc_name_is_installed_while_descriptor_is_built(self):
|
|
source = (ROOT / "src/helpers/UsbLogging.cpp").read_text()
|
|
|
|
override = source.index("class DedicatedUsbLoggingCdc")
|
|
descriptor = source.index("getInterfaceDescriptor(", override)
|
|
custom_name = source.index(
|
|
"setStringDescriptor(dedicated_usb_logging_descriptor)", descriptor
|
|
)
|
|
core_builder = source.index(
|
|
"Adafruit_USBD_CDC::getInterfaceDescriptor(", custom_name
|
|
)
|
|
begin = source.index("dedicated_usb_logging_port.begin(115200)")
|
|
|
|
self.assertLess(descriptor, custom_name)
|
|
self.assertLess(custom_name, core_builder)
|
|
self.assertLess(core_builder, begin)
|
|
self.assertIn('"MeshCore Logging"', source)
|
|
# A post-begin setter is too late: the installed Adafruit core has
|
|
# already copied the CDC interface descriptor by then.
|
|
self.assertNotIn(
|
|
'dedicated_usb_logging_port.setStringDescriptor("MeshCore Logging")',
|
|
source,
|
|
)
|
|
|
|
def test_connection_marker_retries_one_whole_record_without_waiting(self):
|
|
source = (ROOT / "src/helpers/UsbLogging.cpp").read_text()
|
|
|
|
service = source.index("void serviceDedicatedUsbLoggingFromUsbTask()")
|
|
connection = source.index("tud_cdc_n_connected(1)", service)
|
|
capacity = source.index(
|
|
"single_attempt_dedicated_usb_logging_port.availableForWrite()",
|
|
connection,
|
|
)
|
|
write = source.index(
|
|
"single_attempt_dedicated_usb_logging_port.write(", capacity
|
|
)
|
|
progress = source.index(
|
|
"detail::nextUsbLoggingIdentityOffset(", write
|
|
)
|
|
queued_drain = source.index(
|
|
"usb_task_dedicated_usb_logging_port.drainOne();", progress
|
|
)
|
|
|
|
self.assertLess(connection, capacity)
|
|
self.assertLess(capacity, write)
|
|
self.assertLess(write, progress)
|
|
self.assertLess(progress, queued_drain)
|
|
self.assertIn("if (should_service)", source)
|
|
self.assertIn('"MeshCore USB logging port\\r\\n"\n', source)
|
|
self.assertIn(
|
|
'"USB CDC 1; interface 02; Linux stable suffix: -if02\\r\\n"',
|
|
source,
|
|
)
|
|
self.assertRegex(
|
|
source,
|
|
r"dedicated_usb_logging_identity_size\s*"
|
|
r"<= CFG_TUD_CDC_TX_BUFSIZE",
|
|
)
|
|
self.assertNotIn('port.println("MeshCore USB logging port")', source)
|
|
|
|
def test_partial_marker_write_restarts_from_actual_usb_task_result(self):
|
|
source = (ROOT / "src/helpers/UsbLogging.cpp").read_text()
|
|
header = (ROOT / "src/helpers/UsbLogging.h").read_text()
|
|
|
|
service = source.index("void serviceDedicatedUsbLoggingFromUsbTask()")
|
|
write = source.index(
|
|
"single_attempt_dedicated_usb_logging_port.write(", service
|
|
)
|
|
progress = source.index(
|
|
"detail::nextUsbLoggingIdentityOffset(", write
|
|
)
|
|
|
|
self.assertLess(write, progress)
|
|
self.assertIn(
|
|
"return written == requested ? current_offset + written : 0;",
|
|
header,
|
|
)
|
|
self.assertNotIn(
|
|
"dedicated_usb_logging_identity_offset += accepted;", source
|
|
)
|
|
self.assertNotIn(
|
|
"nonblocking_dedicated_usb_logging_port.write(\n"
|
|
" reinterpret_cast<const uint8_t*>(dedicated_usb_logging_identity",
|
|
source,
|
|
)
|
|
|
|
def test_all_nrf52_logging_ports_return_the_nonblocking_facade(self):
|
|
source = (ROOT / "src/helpers/UsbLogging.cpp").read_text()
|
|
|
|
self.assertIn(
|
|
"nonblocking_dedicated_usb_logging_port(\n"
|
|
" usb_task_dedicated_usb_logging_port)",
|
|
source,
|
|
)
|
|
self.assertIn(
|
|
"nonblocking_primary_usb_logging_port(\n"
|
|
" nonblocking_primary_usb_companion_port)",
|
|
source,
|
|
)
|
|
self.assertIn("return nonblocking_dedicated_usb_logging_port;", source)
|
|
self.assertIn("return nonblocking_primary_usb_logging_port;", source)
|
|
# ESP32 and other platforms retain their original Serial path.
|
|
self.assertIn("#else\n return Serial;\n #endif", source)
|
|
|
|
def test_facade_uses_a_zero_wait_gate_around_capacity_and_write(self):
|
|
source = (ROOT / "src/helpers/NonBlockingWriteStream.h").read_text()
|
|
|
|
facade = source.index("class WholeRecordNonBlockingStream")
|
|
enter = source.index("_writer_busy.test_and_set", facade)
|
|
capacity = source.index("_delegate.availableForWrite()", enter)
|
|
write = source.index("_delegate.write(data, size)", capacity)
|
|
release = source.index("_writer_busy.clear", write)
|
|
self.assertLess(enter, capacity)
|
|
self.assertLess(capacity, write)
|
|
self.assertLess(write, release)
|
|
self.assertIn("size > MAX_WRITE_SIZE", source)
|
|
|
|
def test_primary_cdc_uses_one_direct_tinyusb_write(self):
|
|
source = (ROOT / "src/helpers/UsbLogging.cpp").read_text()
|
|
main = (ROOT / "examples/companion_radio/main.cpp").read_text()
|
|
|
|
helper = extract_braced(source, "static size_t writeTinyUsbCdcOnce(")
|
|
self.assertEqual(helper.count("tud_cdc_n_write(instance, data, attempt)"), 1)
|
|
self.assertNotIn("Adafruit_USBD_CDC::write", helper)
|
|
self.assertIn("tud_cdc_n_connected(instance)", helper)
|
|
self.assertIn("tud_cdc_n_write_available(instance)", helper)
|
|
self.assertIn("nonblocking_primary_usb_companion_port(", source)
|
|
self.assertIn("single_attempt_dedicated_usb_logging_port(", source)
|
|
self.assertIn(
|
|
"usb_serial_interface.begin(mesh::usbCompanionPort(),", main
|
|
)
|
|
|
|
def test_primary_empty_rx_query_never_yields_and_preserves_session_gates(self):
|
|
source = (ROOT / "src/helpers/UsbLogging.cpp").read_text(encoding="ascii")
|
|
facade = extract_braced(source, "class Nrf52PrimaryUsbStream") + ";"
|
|
access = extract_braced(source, "static bool canAccessPrimaryUsbSession(")
|
|
self.assertIn("static Nrf52PrimaryUsbStream nonblocking_primary_usb_companion_port(", source)
|
|
self.assertIn("using Nrf52PrimaryUsbStream = SingleAttemptNonBlockingStream;", source)
|
|
fixture = r'''
|
|
#include <atomic>
|
|
#include <cassert>
|
|
#include <functional>
|
|
#include <helpers/NonBlockingWriteStream.h>
|
|
static bool mounted = true;
|
|
static unsigned queried[2] = {}, delegate_queries = 0, delegate_yields = 0;
|
|
static unsigned rx[2] = {};
|
|
static std::function<void()> during_query, during_read;
|
|
static bool tud_mounted() { return mounted; }
|
|
static uint32_t tud_cdc_n_available(uint8_t instance) {
|
|
assert(instance < 2);
|
|
++queried[instance];
|
|
const uint32_t count = rx[instance];
|
|
if (during_query) {
|
|
auto callback = std::move(during_query);
|
|
during_query = nullptr;
|
|
callback();
|
|
}
|
|
return count;
|
|
}
|
|
struct Delegate : Stream {
|
|
int available() override {
|
|
++delegate_queries;
|
|
if (rx[0] == 0) ++delegate_yields;
|
|
return static_cast<int>(rx[0]);
|
|
}
|
|
int read() override {
|
|
const bool had_byte = rx[0] != 0;
|
|
if (had_byte) --rx[0];
|
|
if (during_read) {
|
|
auto callback = std::move(during_read);
|
|
during_read = nullptr;
|
|
callback();
|
|
}
|
|
return had_byte ? 42 : -1;
|
|
}
|
|
int peek() override { return rx[0] ? 42 : -1; }
|
|
};
|
|
namespace mesh {
|
|
static std::atomic<uint32_t> primary_usb_reset_generation{0};
|
|
static std::atomic<uint32_t> primary_usb_allowed_generation{0};
|
|
static std::atomic<bool> primary_usb_terminal_discard_pending{false};
|
|
static std::atomic<bool> nrf52_usb_reenumeration_pending{false};
|
|
static std::atomic<bool> primary_usb_line_state_dtr{true};
|
|
static std::atomic<uint32_t> primary_usb_rx_generation{UINT32_MAX};
|
|
static uint32_t primaryUsbSessionGeneration() { return primary_usb_reset_generation.load(); }
|
|
@ACCESS@
|
|
@FACADE@
|
|
}
|
|
int main() {
|
|
Delegate delegate;
|
|
mesh::Nrf52PrimaryUsbStream port(delegate, nullptr, nullptr,
|
|
mesh::canAccessPrimaryUsbSession);
|
|
static_assert(sizeof(port) == sizeof(mesh::SingleAttemptNonBlockingStream),
|
|
"Primary RX polling must not add per-stream RAM");
|
|
for (unsigned i = 0; i < 1000; ++i) assert(port.available() == 0);
|
|
assert(delegate_queries == 0 && delegate_yields == 0);
|
|
assert(queried[0] == 1000 && queried[1] == 0);
|
|
rx[1] = 17;
|
|
assert(port.available() == 0 && queried[1] == 0); // Never borrow CDC1 input.
|
|
rx[0] = 3;
|
|
assert(port.available() == 3 && port.peek() == 42);
|
|
assert(port.read() == 42 && rx[0] == 2); // Keep the inherited read/peek route.
|
|
during_query = [] { ++mesh::primary_usb_reset_generation; };
|
|
assert(port.available() == 0); // Closed during the count query.
|
|
const unsigned closed_queries = queried[0];
|
|
assert(port.available() == 0 && queried[0] == closed_queries);
|
|
mesh::primary_usb_allowed_generation = mesh::primaryUsbSessionGeneration();
|
|
mesh::primary_usb_line_state_dtr = false;
|
|
mesh::primary_usb_rx_generation = UINT32_MAX;
|
|
rx[0] = 0;
|
|
assert(port.available() == 0);
|
|
assert(mesh::primary_usb_rx_generation == UINT32_MAX); // Empty RX is not proof.
|
|
rx[0] = 2;
|
|
assert(port.available() == 2); // MeshCLI may send with DTR low.
|
|
assert(mesh::primary_usb_rx_generation == mesh::primaryUsbSessionGeneration());
|
|
assert(port.read() == 42 && port.available() == 1);
|
|
during_read = [] { ++mesh::primary_usb_reset_generation; };
|
|
assert(port.read() == -1); // Inherited read drops an old-epoch byte.
|
|
assert(port.available() == 0);
|
|
mesh::primary_usb_allowed_generation = mesh::primaryUsbSessionGeneration();
|
|
mesh::primary_usb_line_state_dtr = true;
|
|
for (auto* gate : {&mesh::primary_usb_terminal_discard_pending,
|
|
&mesh::nrf52_usb_reenumeration_pending}) {
|
|
*gate = true;
|
|
const unsigned before = queried[0];
|
|
assert(port.available() == 0 && queried[0] == before);
|
|
*gate = false;
|
|
}
|
|
mounted = false;
|
|
assert(port.available() == 0);
|
|
assert(delegate_queries == 0 && delegate_yields == 0 && queried[1] == 0);
|
|
}
|
|
'''.replace("@ACCESS@", access).replace("@FACADE@", facade)
|
|
controls = {
|
|
"production": fixture,
|
|
"delegated-empty-query": fixture.replace(
|
|
"static_cast<int>(tud_cdc_n_available(0))", "SingleAttemptNonBlockingStream::available()"),
|
|
"missing-post-query-gate": fixture.replace(
|
|
"return canAccessPrimaryUsbSession(nullptr) ? count : 0;", "return count;"),
|
|
"wrong-cdc-index": fixture.replace(
|
|
"static_cast<int>(tud_cdc_n_available(0))", "static_cast<int>(tud_cdc_n_available(1))"),
|
|
}
|
|
compiler = shutil.which(os.environ.get("CXX", "g++"))
|
|
self.assertIsNotNone(compiler, "a host C++17 compiler is required")
|
|
with tempfile.TemporaryDirectory(prefix="meshcore-nrf52-empty-rx-") as directory:
|
|
for label, code in controls.items():
|
|
with self.subTest(control=label):
|
|
cpp, binary = Path(directory) / f"{label}.cpp", Path(directory) / label
|
|
cpp.write_text(code, encoding="ascii")
|
|
built = subprocess.run([
|
|
compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
|
"-Wno-unused-parameter", "-Wno-sign-compare",
|
|
*([] if os.name == "nt" else ["-fsanitize=address,undefined",
|
|
"-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]),
|
|
"-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"),
|
|
str(cpp), "-o", str(binary)], capture_output=True, text=True, timeout=30)
|
|
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
|
|
ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
|
|
self.assertEqual(ran.returncode == 0, label == "production", ran.stdout + ran.stderr)
|
|
|
|
def test_dedicated_logging_is_drained_only_from_tinyusb_task(self):
|
|
source = (ROOT / "src/helpers/UsbLogging.cpp").read_text()
|
|
stream = (ROOT / "src/helpers/NonBlockingWriteStream.h").read_text()
|
|
|
|
self.assertIn(
|
|
"TaskOwnedWriteStream<> usb_task_dedicated_usb_logging_port(",
|
|
source,
|
|
)
|
|
override = (
|
|
ROOT / "src/helpers/UsbLoggingLineStateOverride.cpp"
|
|
).read_text()
|
|
self.assertNotIn('extern "C" void tud_sof_cb(', source)
|
|
self.assertIn('extern "C" void tud_sof_cb(', override)
|
|
self.assertIn("meshTinyUsbStartOfFrame(frame_count);", override)
|
|
self.assertIn(
|
|
'extern "C" void meshTinyUsbStartOfFrame(', source
|
|
)
|
|
self.assertIn(
|
|
'extern "C" void TinyUSB_Device_FlushCDC(void)', override
|
|
)
|
|
self.assertIn(
|
|
'extern "C" uint32_t tud_cdc_n_write_flush(uint8_t itf);',
|
|
override,
|
|
)
|
|
flush_override = override[override.index(
|
|
'extern "C" void TinyUSB_Device_FlushCDC(void)'
|
|
):override.index('extern "C" void tud_mount_cb(void)')]
|
|
self.assertIn("tud_cdc_n_write_flush(0)", flush_override)
|
|
self.assertNotIn("tud_cdc_n_write_flush(1)", flush_override)
|
|
self.assertGreaterEqual(source.count("tud_cdc_n_write_flush(1)"), 3)
|
|
self.assertIn("serviceDedicatedUsbLoggingFromUsbTask();", source)
|
|
self.assertIn(
|
|
"usb_task_dedicated_usb_logging_port.drainOne();",
|
|
source,
|
|
)
|
|
self.assertIn(
|
|
"single_attempt_dedicated_usb_logging_port.write(",
|
|
source,
|
|
)
|
|
self.assertIn("tud_cdc_n_connected(1)", source)
|
|
self.assertIn("dedicated_usb_logging_reset_generation", source)
|
|
self.assertIn("tud_sof_cb_enable(true);", source)
|
|
self.assertIn("tud_sof_cb_enable(false);", source)
|
|
self.assertIn(
|
|
"tud_sof_cb_enable(dtr && isUsbLoggingEnabled());", source
|
|
)
|
|
self.assertIn(
|
|
"usb_task_dedicated_usb_logging_port.discardPending();",
|
|
source,
|
|
)
|
|
main_loop_service = source[
|
|
source.index("void serviceUsbLoggingPort()") :
|
|
source.index("Stream& usbLoggingPort()")
|
|
]
|
|
self.assertNotIn("discardPending()", main_loop_service)
|
|
producer = stream[
|
|
stream.index("class TaskOwnedWriteStream") :
|
|
stream.index("class WholeRecordNonBlockingStream")
|
|
]
|
|
write = producer[
|
|
producer.index("size_t write(const uint8_t* data") :
|
|
producer.index("size_t drainOne()")
|
|
]
|
|
self.assertNotIn("_delegate.write", write)
|
|
self.assertIn("_delegate.write(record.data, record.size)", producer)
|
|
|
|
def test_exact_cdc_line_state_hook_preserves_cdc0_touch(self):
|
|
source = (ROOT / "src/helpers/UsbLogging.cpp").read_text()
|
|
override = (
|
|
ROOT / "src/helpers/UsbLoggingLineStateOverride.cpp"
|
|
).read_text()
|
|
|
|
# Including TinyUSB's declaration here would make our definition weak
|
|
# like the framework default instead of overriding it.
|
|
self.assertNotIn("#include <Arduino.h>", override)
|
|
self.assertNotIn("#include <Adafruit_TinyUSB.h>", override)
|
|
self.assertNotIn("#include <tusb.h>", override)
|
|
self.assertNotIn("#include <class/cdc/cdc_device.h>", override)
|
|
self.assertIn("#include <class/cdc/cdc.h>", override)
|
|
guard = "#if defined(ARDUINO) && defined(NRF52_PLATFORM)"
|
|
self.assertLess(override.index(guard), override.index(
|
|
"#include <class/cdc/cdc.h>"
|
|
))
|
|
self.assertIn(
|
|
'extern "C" void tud_cdc_line_state_cb(', override
|
|
)
|
|
self.assertIn(
|
|
"meshTinyUsbCdcLineStateChanged(instance, dtr, rts);", override
|
|
)
|
|
self.assertIn(
|
|
'extern "C" void tud_cdc_line_coding_cb(', override
|
|
)
|
|
self.assertIn(
|
|
"cdc_line_coding_t const* line_coding", override
|
|
)
|
|
self.assertIn(
|
|
"meshTinyUsbCdcLineCodingChanged(instance);", override
|
|
)
|
|
self.assertIn('extern "C" void tud_mount_cb(void)', override)
|
|
self.assertIn('extern "C" void tud_umount_cb(void)', override)
|
|
self.assertEqual(
|
|
override.count("meshTinyUsbDeviceSessionBoundary();"), 3
|
|
)
|
|
|
|
bridge = source[source.index(
|
|
'extern "C" void meshTinyUsbCdcLineStateChanged('
|
|
):]
|
|
self.assertIn("if (instance == 1)", bridge)
|
|
self.assertIn("handleDedicatedUsbLoggingLineState(dtr);", bridge)
|
|
self.assertIn("clearNrf52UsbTxForSession(1);", source)
|
|
self.assertIn("endPrimaryUsbHostSession(true);", bridge)
|
|
self.assertIn("if (instance == 0 && !dtr)", bridge)
|
|
self.assertIn("tud_cdc_get_line_coding(&coding);", bridge)
|
|
self.assertIn("if (coding.bit_rate == 1200)", bridge)
|
|
self.assertIn("TinyUSB_Port_EnterDFU();", bridge)
|
|
self.assertIn("const bool was_open =", bridge)
|
|
self.assertIn("primary_usb_line_state_dtr.load(", bridge)
|
|
cdc0_close = bridge[
|
|
bridge.index("if (instance == 0 && !dtr)") :
|
|
bridge.index(
|
|
'extern "C" void meshTinyUsbCdcLineCodingChanged('
|
|
)
|
|
]
|
|
self.assertLess(
|
|
cdc0_close.index("endPrimaryUsbHostSession(true)"),
|
|
cdc0_close.index("tud_cdc_get_line_coding(&coding)"),
|
|
)
|
|
cdc0_open = cdc0_close[cdc0_close.index(
|
|
"} else if (instance == 0)"
|
|
):]
|
|
self.assertIn("const bool was_open =", cdc0_open)
|
|
self.assertIn("if (!was_open)", cdc0_open)
|
|
self.assertIn("tud_cdc_n_read_flush(0);", cdc0_open)
|
|
self.assertIn("primary_usb_line_state_dtr.store(", cdc0_open)
|
|
self.assertLess(
|
|
cdc0_open.index("tud_cdc_n_read_flush(0);"),
|
|
cdc0_open.index("primary_usb_line_state_dtr.store("),
|
|
)
|
|
boundary = source[source.index(
|
|
'extern "C" void meshTinyUsbDeviceSessionBoundary()'
|
|
):source.index(
|
|
'extern "C" void meshTinyUsbCdcLineCodingChanged('
|
|
)]
|
|
self.assertIn("endPrimaryUsbHostSession(false);", boundary)
|
|
self.assertIn("handleDedicatedUsbLoggingLineState(false);", boundary)
|
|
|
|
session_end = source[source.index(
|
|
"static void endPrimaryUsbHostSession("
|
|
):source.index("\n}\n#endif", source.index(
|
|
"static void endPrimaryUsbHostSession("
|
|
))]
|
|
self.assertIn("primary_usb_line_state_dtr.exchange(", session_end)
|
|
self.assertIn("if (!previous && !received", session_end)
|
|
self.assertIn("primary_usb_rx_generation.load(", session_end)
|
|
self.assertLess(
|
|
session_end.index("primary_usb_reset_generation.fetch_add("),
|
|
session_end.index("tud_cdc_n_read_flush(0)"),
|
|
)
|
|
self.assertLess(
|
|
session_end.index("tud_cdc_n_read_flush(0)"),
|
|
session_end.index("clearNrf52UsbTxForSession(0)"),
|
|
)
|
|
self.assertIn(
|
|
"dedicated_usb_logging_line_state_dtr.exchange(", source
|
|
)
|
|
coding_bridge = source[source.index(
|
|
'extern "C" void meshTinyUsbCdcLineCodingChanged('
|
|
):]
|
|
self.assertIn("if (instance == 0)", coding_bridge)
|
|
self.assertIn("endPrimaryUsbHostSession(true);", coding_bridge)
|
|
self.assertIn("tud_cdc_n_connected(0)", coding_bridge)
|
|
self.assertIn("if (instance == 1)", coding_bridge)
|
|
self.assertIn("handleDedicatedUsbLoggingLineCoding();", coding_bridge)
|
|
self.assertIn("if (tud_cdc_n_connected(1))", source)
|
|
self.assertIn("restartDedicatedUsbLoggingHostSession();", source)
|
|
self.assertIn("dedicated_usb_logging_host_settle_sofs = 50", source)
|
|
self.assertIn("dedicated_usb_logging_quiet_sofs > 0", source)
|
|
self.assertIn("--dedicated_usb_logging_quiet_sofs;", source)
|
|
|
|
restart = source[
|
|
source.index("static void restartDedicatedUsbLoggingHostSession()"):
|
|
source.index("static void handleDedicatedUsbLoggingLineState")
|
|
]
|
|
restart_gate = restart.index(
|
|
"dedicated_usb_logging_port_connected.store(false"
|
|
)
|
|
restart_fifo = restart.index("clearNrf52UsbTxForSession(1)")
|
|
restart_state = restart.index("resetDedicatedUsbLoggingUsbTaskState()")
|
|
restart_quiet = restart.index("dedicated_usb_logging_quiet_sofs =")
|
|
restart_generation = restart.index(
|
|
"dedicated_usb_logging_reset_generation.fetch_add("
|
|
)
|
|
self.assertLess(restart_gate, restart_fifo)
|
|
self.assertLess(restart_fifo, restart_state)
|
|
self.assertLess(restart_state, restart_quiet)
|
|
self.assertLess(restart_quiet, restart_generation)
|
|
|
|
owner_service = source[
|
|
source.index("void serviceDedicatedUsbLoggingFromUsbTask()"):
|
|
source.index("#elif defined(NRF52_PLATFORM)")
|
|
]
|
|
quiet_start = owner_service.index(
|
|
"if (dedicated_usb_logging_quiet_sofs > 0)"
|
|
)
|
|
positive_gate = owner_service.index(
|
|
"dedicated_usb_logging_port_connected.store(\n"
|
|
" true",
|
|
quiet_start,
|
|
)
|
|
marker_write = owner_service.index(
|
|
"single_attempt_dedicated_usb_logging_port.write(",
|
|
positive_gate,
|
|
)
|
|
queued_drain = owner_service.index(
|
|
"usb_task_dedicated_usb_logging_port.drainOne();",
|
|
marker_write,
|
|
)
|
|
self.assertLess(quiet_start, positive_gate)
|
|
self.assertLess(positive_gate, marker_write)
|
|
self.assertLess(marker_write, queued_drain)
|
|
|
|
quiet_block = owner_service[quiet_start:positive_gate]
|
|
quiet_fifo = quiet_block.index("clearNrf52UsbTxForSession(1)")
|
|
quiet_state = quiet_block.index(
|
|
"resetDedicatedUsbLoggingUsbTaskState()"
|
|
)
|
|
quiet_decrement = quiet_block.index(
|
|
"--dedicated_usb_logging_quiet_sofs;"
|
|
)
|
|
quiet_return = quiet_block.index("return;", quiet_decrement)
|
|
self.assertLess(quiet_fifo, quiet_state)
|
|
self.assertLess(quiet_state, quiet_decrement)
|
|
self.assertLess(quiet_decrement, quiet_return)
|
|
|
|
positive_publish_sites = list(re.finditer(
|
|
r"dedicated_usb_logging_port_connected\.store\(\s*true",
|
|
source,
|
|
))
|
|
self.assertEqual(1, len(positive_publish_sites))
|
|
owner_start = source.index(
|
|
"void serviceDedicatedUsbLoggingFromUsbTask()"
|
|
)
|
|
owner_end = source.index("#elif defined(NRF52_PLATFORM)")
|
|
self.assertGreater(positive_publish_sites[0].start(), owner_start)
|
|
self.assertLess(positive_publish_sites[0].start(), owner_end)
|
|
|
|
main_loop_service = source[
|
|
source.index("void serviceUsbLoggingPort()"):
|
|
source.index("Stream& usbLoggingPort()")
|
|
]
|
|
positive_publish = (
|
|
"dedicated_usb_logging_port_connected.store(\n"
|
|
" true"
|
|
)
|
|
self.assertNotIn(positive_publish, main_loop_service)
|
|
|
|
self.assertIn(
|
|
'#error "MESH_DUAL_CDC_LOGGING requires ENABLE_USB_INTERFACE"',
|
|
source,
|
|
)
|
|
|
|
def test_nrf52_ascii_terminal_uses_the_buffered_nonblocking_primary_port(self):
|
|
main = (ROOT / "examples/companion_radio/main.cpp").read_text()
|
|
mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
|
|
usb = (ROOT / "src/helpers/UsbLogging.cpp").read_text()
|
|
stream = (ROOT / "src/helpers/NonBlockingWriteStream.h").read_text()
|
|
|
|
self.assertIn("static Stream& usbTerminalOutput()", main)
|
|
self.assertIn("return mesh::usbTerminalPort();", main)
|
|
self.assertNotRegex(main, r"Serial\.(?:print|println|printf|write)\(")
|
|
self.assertNotRegex(mesh, r"Serial\.(?:print|println|printf|write)\(")
|
|
self.assertIn(
|
|
"_terminal_output = &mesh::usbTerminalPort();",
|
|
mesh,
|
|
)
|
|
self.assertIn(
|
|
"if (_terminal_output == &mesh::usbTerminalPort())",
|
|
mesh,
|
|
)
|
|
self.assertIn("BufferedNonBlockingWriteStream<4096>", usb)
|
|
self.assertIn("class BufferedNonBlockingWriteStream", stream)
|
|
self.assertIn("mesh::serviceUsbTerminalPort();", main)
|
|
self.assertIn("drainUsbTerminalOutputBeforeProtocolSwitch();", main)
|
|
self.assertIn("mesh::discardUsbTerminalOutput();", main)
|
|
self.assertIn("size > CAPACITY - _count", stream)
|
|
self.assertIn("data[PRINTF_SCRATCH_SIZE - 1] == '\\0'", stream)
|
|
|
|
def test_nrf52_serial_mota_uses_one_attempt_and_never_flushes(self):
|
|
context = (ROOT / "src/helpers/ota/OtaContext.h").read_text()
|
|
stream = (ROOT / "src/helpers/NonBlockingWriteStream.h").read_text()
|
|
usb = (ROOT / "src/helpers/UsbLogging.cpp").read_text()
|
|
|
|
self.assertIn(
|
|
"#define OTA_FOLDER_SERIAL_STREAM ::mesh::usbMotaPort()",
|
|
context,
|
|
)
|
|
self.assertIn(
|
|
"#define OTA_FOLDER_SERIAL_WRITE_POLICY MotaStreamWritePolicy::NoFlush",
|
|
context,
|
|
)
|
|
self.assertIn(
|
|
"static SerialMotaSource src(OTA_FOLDER_SERIAL_STREAM,\n"
|
|
" OTA_FOLDER_SERIAL_WRITE_POLICY, 600);",
|
|
context,
|
|
)
|
|
facade = stream[
|
|
stream.index("class SingleAttemptNonBlockingStream") :
|
|
stream.index("class BufferedNonBlockingWriteStream")
|
|
]
|
|
self.assertIn("void flush() override {}", facade)
|
|
self.assertNotIn("_delegate.flush()", facade)
|
|
self.assertIn(
|
|
"AtomicWholeRecordNonBlockingStream<11>\n"
|
|
" nonblocking_primary_usb_mota_port(",
|
|
usb,
|
|
)
|
|
self.assertIn("Stream& usbMotaPort()", usb)
|
|
self.assertIn("return nonblocking_primary_usb_mota_port;", usb)
|
|
atomic = stream[
|
|
stream.index("class AtomicWholeRecordNonBlockingStream") :
|
|
stream.index("class BufferedNonBlockingWriteStream")
|
|
]
|
|
self.assertIn("_delegate.writeWholeRecord(data, size)", atomic)
|
|
inner = stream[
|
|
stream.index("size_t writeWholeRecord(") :
|
|
stream.index("bool tryRunExclusive(")
|
|
]
|
|
self.assertLess(
|
|
inner.index("_writer_busy.test_and_set"),
|
|
inner.index("_delegate.availableForWrite()"),
|
|
)
|
|
self.assertLess(
|
|
inner.index("_delegate.availableForWrite()"),
|
|
inner.index("_try_write(_context, data, size)"),
|
|
)
|
|
|
|
def test_cdc0_close_resets_the_complete_application_session_before_dispatch(self):
|
|
source = (ROOT / "src/helpers/UsbLogging.cpp").read_text()
|
|
header = (ROOT / "src/helpers/UsbLogging.h").read_text()
|
|
main = (ROOT / "examples/companion_radio/main.cpp").read_text()
|
|
|
|
self.assertIn("bool takeUsbTerminalSessionReset();", header)
|
|
self.assertIn("bool tryCompleteUsbTerminalSessionReset();", header)
|
|
self.assertIn("bool takeUsbTerminalSessionReset()", source)
|
|
self.assertIn("bool tryCompleteUsbTerminalSessionReset()", source)
|
|
reset_helper = main[
|
|
main.index("static void resetUsbTerminalHostSession(") :
|
|
main.index("static void serviceUsbTerminalHostSessionReset()")
|
|
]
|
|
reset_service = main[
|
|
main.index("static void serviceUsbTerminalHostSessionReset()") :
|
|
main.index("static void serviceUsbTerminal()")
|
|
]
|
|
self.assertIn("mesh::takeUsbTerminalSessionReset()", reset_service)
|
|
self.assertIn("mesh::tryCompleteUsbTerminalSessionReset()", reset_service)
|
|
self.assertIn("resetUsbTerminalHostSession();", reset_service)
|
|
self.assertLess(
|
|
reset_service.index("resetUsbTerminalHostSession();"),
|
|
reset_service.index("mesh::tryCompleteUsbTerminalSessionReset()"),
|
|
)
|
|
self.assertIn("leaveUsbMotaMode(false);", reset_helper)
|
|
self.assertIn("leaveUsbTerminalMode(false);", reset_helper)
|
|
self.assertIn("usb_serial_interface.setPassthroughMode(false);", reset_helper)
|
|
self.assertIn("clearUsbTerminalLine();", reset_helper)
|
|
self.assertIn("usb_binary_startup_probe.cancel();", reset_helper)
|
|
self.assertIn("usb_ascii_session_default.request(", reset_helper)
|
|
self.assertIn("usb_terminal_host_reset_completion_pending", reset_service)
|
|
self.assertIn("cancelUsbSerialOperations();", reset_helper)
|
|
usb_cancel = main[
|
|
main.index("static void cancelUsbSerialOperations()"):
|
|
main.index("static void enterUsbTerminalMode(")
|
|
]
|
|
route_cancel = usb_cancel.index(
|
|
"the_mesh.cancelSerialResponseStream(&usb_serial_interface)"
|
|
)
|
|
delayed_cancel = usb_cancel.index(
|
|
"the_mesh.cancelSerialOperationsForRoute(&usb_serial_interface)"
|
|
)
|
|
route_forget = usb_cancel.index(
|
|
"interface_manager.forgetReplyRouteForDisconnected("
|
|
)
|
|
self.assertLess(route_cancel, delayed_cancel)
|
|
self.assertLess(delayed_cancel, route_forget)
|
|
self.assertIn("the_mesh.resetUsbHostSessionInput();", reset_helper)
|
|
self.assertNotRegex(main, r"Serial\.(?:available|read|peek)\(")
|
|
mesh_source = (
|
|
ROOT / "examples/companion_radio/MyMesh.cpp"
|
|
).read_text()
|
|
self.assertNotRegex(
|
|
mesh_source, r"Serial\.(?:available|read|peek)\("
|
|
)
|
|
session_operations = mesh_source[
|
|
mesh_source.index("void MyMesh::cancelSerialOperationsForRoute("):
|
|
mesh_source.index("void MyMesh::handleCmdFrame(")
|
|
]
|
|
self.assertIn(
|
|
"_delayed_replies.request.route == route", session_operations
|
|
)
|
|
self.assertNotIn("cancelPendingRadioParamApply", session_operations)
|
|
self.assertNotIn("saved_radio_apply_pending = false", session_operations)
|
|
self.assertIn("if (_cli_rescue)", session_operations)
|
|
self.assertIn(
|
|
"memset(cli_command, 0, sizeof(cli_command));",
|
|
session_operations,
|
|
)
|
|
|
|
# Every delayed Binary mesh response uses a captured requester. A
|
|
# missing/disconnected route fails closed instead of broadcasting.
|
|
delayed_source = (ROOT / "src/helpers/CompanionDelayedReplies.cpp").read_text()
|
|
begin = extract_braced(mesh_source, "bool MyMesh::beginPendingRequest(")
|
|
self.assertIn("_serial->captureReplyRoute()", begin)
|
|
self.assertIn("_delayed_replies.reserveRequest(kind, contact.id.pub_key,", begin)
|
|
for command, kind in (("LOGIN", "Login"), ("ANON_REQ", "Binary"),
|
|
("STATUS_REQ", "Status"), ("PATH_DISCOVERY_REQ", "Discovery"),
|
|
("TELEMETRY_REQ", "Telemetry"), ("BINARY_REQ", "Binary")):
|
|
branch = extract_braced(mesh_source, "} else if (cmd_frame[0] == CMD_SEND_" + command)
|
|
self.assertIn(f"beginPendingRequest(mesh::CompanionDelayedReplies::{kind}, *recipient)", branch)
|
|
self.assertIn("finishPendingRequest(result, tag, est_timeout);", branch)
|
|
self.assertGreaterEqual(
|
|
mesh_source.count("writePendingSerialFrame(out_frame, i, now)"), 5
|
|
)
|
|
pending_writer = mesh_source[
|
|
mesh_source.index("size_t MyMesh::writePendingSerialFrame("):
|
|
mesh_source.index("void MyMesh::writeDisabledFrame(")
|
|
]
|
|
self.assertIn("_delayed_replies.storeRequest(frame, len, now)", pending_writer)
|
|
self.assertIn("_delayed_replies.serviceRequest(_serial, now);", pending_writer)
|
|
reserve = extract_braced(delayed_source, "bool CompanionDelayedReplies::reserveRequest(")
|
|
self.assertIn("(!terminal && route == nullptr)", reserve)
|
|
service = extract_braced(delayed_source, "bool CompanionDelayedReplies::service(")
|
|
self.assertIn("serial->isReplyRouteAvailable(reply.route)", service)
|
|
self.assertIn("serial->writeFrameToRoute(reply.route,", service)
|
|
self.assertNotRegex(service, r"serial->writeFrame\(")
|
|
self.assertNotIn("_serial->writeFrame(out_frame, i);", pending_writer)
|
|
# Settings now acknowledge a durable target immediately. A host
|
|
# reconnect must not discard it or route an unrelated late reply.
|
|
radio_setter = extract_braced(mesh_source, 'if (cmd_frame[0] == CMD_SET_RADIO_PARAMS)')
|
|
self.assertIn("applyAndSaveRadioParams(", radio_setter)
|
|
self.assertNotIn("captureReplyRoute", radio_setter)
|
|
self.assertNotIn("command_radio_reply_route", mesh_source)
|
|
|
|
completion = source[
|
|
source.index("static void completePrimaryUsbSessionReset(") :
|
|
source.index("bool tryCompleteUsbTerminalSessionReset()")
|
|
]
|
|
# The owner-task close/line-coding callback purges old RX immediately.
|
|
# A second purge after the settle gate would erase a new host's
|
|
# immediately sent APP_START (meshcli does not delay after open).
|
|
self.assertNotIn("tud_cdc_n_read_flush(0);", completion)
|
|
self.assertIn("clearNrf52UsbTxForSession(0);", completion)
|
|
self.assertIn("primary_usb_allowed_generation.store(", completion)
|
|
self.assertIn(".tryRunExclusive(", source)
|
|
self.assertIn("primary_usb_reset_settle_until.store(", source)
|
|
self.assertIn("primary_usb_session_settle_millis = 8", source)
|
|
self.assertIn(
|
|
"(int32_t)(millis() - settle_until) < 0", source
|
|
)
|
|
access = source[
|
|
source.index("static bool canAccessPrimaryUsbSession(") :
|
|
source.index("#endif", source.index(
|
|
"static bool canAccessPrimaryUsbSession("
|
|
))
|
|
]
|
|
self.assertIn("primary_usb_line_state_dtr.load(", access)
|
|
self.assertIn("primary_usb_allowed_generation.load(", access)
|
|
self.assertIn("primaryUsbSessionGeneration() == generation", access)
|
|
|
|
loop = main[main.index("void loop() {"):]
|
|
self.assertLess(
|
|
loop.index("serviceUsbTerminalHostSessionReset();"),
|
|
loop.index("the_mesh.loop();"),
|
|
)
|
|
|
|
def test_delayed_companion_work_keeps_exact_transport_ownership(self):
|
|
source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
|
|
header = (ROOT / "examples/companion_radio/MyMesh.h").read_text()
|
|
|
|
self.assertIn("BaseSerialInterface* reply_route;", header)
|
|
self.assertIn(
|
|
"entry.reply_route = _serial->captureReplyRoute();", source
|
|
)
|
|
self.assertIn("expected_ack_table[i].reply_route == route", source)
|
|
ack_expiry = source[
|
|
source.index("void MyMesh::expireExpectedAcks()") :
|
|
source.index("MyMesh::AckTableEntry* MyMesh::findPendingTextMessage(")
|
|
]
|
|
self.assertIn(
|
|
"_serial->writeFrameToRoute(entry.reply_route, confirmation, 9) == 9",
|
|
ack_expiry,
|
|
)
|
|
self.assertIn("entry.reply_route = NULL;", ack_expiry)
|
|
self.assertNotIn("clearExpectedAck(entry);", ack_expiry)
|
|
route_cancel = source[
|
|
source.index("void MyMesh::cancelSerialOperationsForRoute(") :
|
|
source.index("void MyMesh::resetUsbHostSessionInput()")
|
|
]
|
|
self.assertIn(
|
|
"expected_ack_table[i].reply_route = NULL;", route_cancel
|
|
)
|
|
self.assertNotIn(
|
|
"clearExpectedAck(expected_ack_table[i]);", route_cancel
|
|
)
|
|
|
|
self.assertIn("const bool starts_long_lived_request", source)
|
|
self.assertIn(
|
|
"starts_long_lived_request && hasPendingReqs()", source
|
|
)
|
|
self.assertEqual(source.count("finishPendingRequest(result, tag, est_timeout);"), 6)
|
|
delayed = (ROOT / "src/helpers/CompanionDelayedReplies.cpp").read_text()
|
|
arm = extract_braced(delayed, "void CompanionDelayedReplies::arm(")
|
|
# Exact timeout windows and delayed SENT admission are exercised by
|
|
# test_companion_delayed_reply_delivery.py against real callbacks.
|
|
self.assertIn("radioBudget(reply)", arm)
|
|
self.assertIn("reply.sent_deadline = now + DELIVERY_GRACE_MS;", arm)
|
|
self.assertIn("servicePendingSerialReply();", source)
|
|
self.assertIn(
|
|
"another Companion request is still pending", source
|
|
)
|
|
|
|
trace = extract_braced(source, "} else if (cmd_frame[0] == CMD_SEND_TRACE_PATH")
|
|
self.assertIn("_delayed_replies.reserveBinaryTrace(tag, auth,", trace)
|
|
self.assertIn("_serial->captureReplyRoute(), _ms->getMillis())", trace)
|
|
self.assertIn("_delayed_replies.armBinaryTrace(est_timeout, _ms->getMillis());", trace)
|
|
self.assertIn("_delayed_replies.serviceBinaryTrace(_serial, now);", source)
|
|
self.assertIn("_delayed_replies.trace.route == route", source)
|
|
|
|
self.assertIn("sign_data_reply_route != signing_route", source)
|
|
self.assertIn("sign_data_reply_route == route", source)
|
|
self.assertIn("SIGN_SESSION_TIMEOUT_MILLIS = 120000", source)
|
|
self.assertIn("serviceSigningSession();", source)
|
|
|
|
app_start = source[
|
|
source.index("cmd_frame[0] == CMD_APP_START"):
|
|
source.index("cmd_frame[0] == CMD_GET_CONTACTS")
|
|
]
|
|
self.assertIn("cancelSerialOperationsForRoute(app_route);", app_start)
|
|
self.assertNotIn("cancelPendingRadioParamApply();", app_start)
|
|
serial_service = source[
|
|
source.index("void MyMesh::checkSerialInterface()"):
|
|
source.index("void MyMesh::loop()")
|
|
]
|
|
self.assertNotIn("cancelPendingRadioParamApply();", serial_service)
|
|
|
|
def test_meshcore_debug_macros_use_the_bounded_formatter_only_on_nrf52(self):
|
|
source = (ROOT / "src/MeshCore.h").read_text()
|
|
|
|
self.assertIn("#if defined(NRF52_PLATFORM)", source)
|
|
self.assertIn('mesh::nrf52DebugPrintf("DEBUG: " F', source)
|
|
self.assertIn(
|
|
'mesh::usbLoggingPort().printf("DEBUG: " F',
|
|
source,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|