#!/usr/bin/env python3 """Keep ESP32 native-USB role output away from blocking Arduino writes.""" from pathlib import Path import re import unittest ROOT = Path(__file__).resolve().parents[1] ROLES = ("simple_repeater", "simple_room_server") def source(relative: str) -> str: return (ROOT / relative).read_text() class Esp32TinyUsbRoleHygieneTest(unittest.TestCase): def test_console_helper_preserves_other_platform_semantics(self): text = source("src/helpers/UsbLogging.cpp") self.assertRegex( text, r"Stream& usbConsolePort\(\)\s*\{\s*" r"#if MESH_ESP32_TINYUSB_NONBLOCKING\s+" r"return buffered_esp32_tinyusb_terminal_port;\s+" r"#elif MESH_ESP32_HWCDC_SESSION_GUARD\s+" r"return guarded_esp32_hwcdc_port;\s+" r"#elif MESH_NRF52_USB_CONSOLE_COOPERATIVE\s+" r"return buffered_primary_usb_terminal_port;\s+" r"#else\s+return Serial;\s+#endif", ) def test_role_output_never_bypasses_the_usb_facade(self): # A capacity check outside Serial.write() cannot make Arduino's # native USBCDC writer bounded. Cover direct writes and Print/Stream # references, including printHex's less obvious indirect writes. for role in ROLES: for filename in ("main.cpp", "MyMesh.cpp", "UITask.cpp"): relative = f"examples/{role}/{filename}" with self.subTest(path=relative): text = source(relative) self.assertNotRegex( text, r"\bSerial\s*\.\s*(?:print|println|printf|write|flush)\s*\(", ) self.assertNotRegex(text, r"\bprintHex\s*\(\s*Serial\b") self.assertNotRegex( text, r"\b(?:Print|Stream)\s*&\s*\w+\s*=\s*Serial\b", ) self.assertIn("mesh::usbConsolePort()", text) def test_functional_replies_do_not_use_the_logging_gate(self): for role in ROLES: with self.subTest(role=role): text = source(f"examples/{role}/MyMesh.cpp") self.assertIn('mesh::usbConsolePort().printf("ACL:\\r\\n");', text) self.assertIn( 'mesh::usbConsolePort().printf("%02X %s\\n", ' "c->permissions, public_key);", text, ) self.assertIn( 'mesh::usbConsolePort().printf("OTA: starting update\\r\\n");', text, ) self.assertIn('mesh::usbConsolePort().printf("%s\\r\\n", wc_reply);', text) start = text.index("void MyMesh::dumpLogFile()") end = text.index("bool MyMesh::setTxPower(", start) dump = text[start:end] self.assertIn("mesh::usbConsolePort().print((char)c);", dump) self.assertNotIn("isUsbLoggingEnabled", dump) self.assertNotIn("usbLoggingPort", dump) repeater = source("examples/simple_repeater/MyMesh.cpp") self.assertIn( 'mesh::usbConsolePort().printf("Recent repeaters (%d):\\n", count);', repeater, ) self.assertIn('mesh::usbConsolePort().printf("%s\\r\\n", record);', repeater) def test_unconditional_display_diagnostics_keep_their_visibility(self): for role in ROLES: with self.subTest(role=role): text = source(f"examples/{role}/UITask.cpp") self.assertIn("#include ", text) self.assertIn("// Logged unconditionally:", text) self.assertIn( 'mesh::usbConsolePort().printf("Display: flip %s\\n",', text, ) self.assertIn( 'mesh::usbConsolePort().printf("Display: %s -> %s\\n",', text, ) self.assertIn( 'mesh::usbConsolePort().printf("Powering Off\\r\\n");', text ) def test_both_roles_initialize_and_service_queued_terminal_output(self): for role in ROLES: with self.subTest(role=role): text = source(f"examples/{role}/main.cpp") setup = text[text.index("void setup()") : text.index("void loop()")] loop = text[text.index("void loop()") :] self.assertLess( setup.index("mesh::prepareUsbLoggingPort();"), setup.index("Serial.begin(115200);"), ) self.assertIn("mesh::beginUsbLoggingPort();", setup) self.assertIn("mesh::serviceUsbTerminalPort();", loop) def test_sensor_uses_the_same_guarded_console_lifecycle(self): main = source("examples/simple_sensor/main.cpp") sensor = source("examples/simple_sensor/SensorMesh.cpp") setup = main[main.index("void setup()") : main.index("void loop()")] loop = main[main.index("void loop()") :] self.assertLess( setup.index("mesh::prepareUsbLoggingPort();"), setup.index("Serial.begin(115200);"), ) self.assertIn("mesh::beginUsbLoggingPort();", setup) self.assertIn("mesh::takeUsbTerminalSessionReset()", loop) self.assertIn("mesh::tryCompleteUsbTerminalSessionReset()", loop) self.assertIn("mesh::canAcceptUsbConsoleCommand()", loop) for text in (main, sensor): self.assertNotRegex( text, r"\bSerial\s*\.\s*(?:print|println|printf|write|flush|available|read|peek)\s*\(", ) self.assertNotRegex(text, r"\bprintHex\s*\(\s*Serial\b") self.assertIn("mesh::usbConsolePort()", main) self.assertIn("mesh::usbConsolePort()", sensor) def test_hwcdc_guard_is_not_limited_to_companion_builds(self): header = source("src/helpers/UsbLogging.h") start = header.index("#if defined(ESP32) && defined(ARDUINO_USB_MODE)", header.index("MESH_ESP32_HWCDC_SESSION_GUARD") - 300) end = header.index("#if MESH_ESP32_TINYUSB_NONBLOCKING", start) guard = header[start:end] self.assertIn("ARDUINO_USB_MODE == 1", guard) self.assertNotIn("ENABLE_USB_INTERFACE", guard) def test_sleep_keeps_raw_flush_only_for_other_esp32_transports(self): text = source("src/helpers/ESP32Board.cpp") shutdown = text[text.index("void ESP32Board::shutdownPeripherals(") : text.index("void ESP32Board::powerOff(")] sleep = text[text.index("void ESP32Board::enterDeepSleep(") :] self.assertIn("shutdownPeripherals();", sleep) guard = re.search( r"#if MESH_ESP32_USB_CONSOLE_COOPERATIVE\s+" r"mesh::serviceUsbTerminalPort\(\);\s+" r"#else\s+Serial\.flush\(\);\s+#endif", shutdown, ) self.assertIsNotNone(guard) self.assertEqual(shutdown.count("Serial.flush();"), 1) self.assertNotIn("Serial.flush();", sleep) def test_large_file_dumps_are_bounded_and_cancelable(self): for role in ROLES: with self.subTest(role=role): text = source(f"examples/{role}/MyMesh.cpp") header = source(f"examples/{role}/MyMesh.h") self.assertIn("char serial_log_pending[640];", header) self.assertIn("serial_log_active ? serial_log_dump.size() : 0", text) start = text.index("void MyMesh::servicePendingSerialOutput()") end = text.index("bool MyMesh::setTxPower(", start) service = text[start:end] self.assertIn("serial_log_pending_size < sizeof(serial_log_pending)", service) self.assertIn("--serial_log_remaining;", service) self.assertIn("serial_log_pending_size -= written;", service) self.assertIn("memmove(serial_log_pending", service) self.assertNotIn("delay(", service) self.assertNotIn("flush(", service) cancel = text[ text.index("void MyMesh::cancelPendingSerialOutput()") : start ] self.assertIn("serial_log_dump.close();", cancel) self.assertIn("serial_log_pending_size = 0;", cancel) loop = text[text.index("void MyMesh::loop()") :] self.assertLess( loop.index("mesh::Mesh::loop();"), loop.index("servicePendingSerialOutput();"), ) def test_recent_list_advances_only_after_retained_row_finishes(self): text = source("examples/simple_repeater/MyMesh.cpp") start = text.index("void MyMesh::servicePendingSerialOutput()") start = text.index("if (serial_recent_next >= 0)", start) service = text[start : text.index("if (!serial_log_active)", start)] self.assertIn("char record[64];", service) self.assertIn("serial_recent_next < serial_recent_count", service) self.assertIn("getNextRecentRepeaterBySortKey", service) self.assertNotIn("getRecentRepeaterBySortedIdx", service) self.assertNotRegex(service, r"\b(?:while|for)\s*\(") self.assertLess( service.index("if (serial_log_pending_size != 0) return;"), service.index("++serial_recent_next"), ) self.assertIn("if (serial_log_pending_size == 0)", service) self.assertIn("memcpy(serial_log_pending, record, length);", service) self.assertIn("write_pending();", service) def test_functional_reserve_fits_default_full_acl_plus_command_echo(self): logging = source("src/helpers/UsbLogging.cpp") reserve = int(re.search( r"esp32_tinyusb_functional_reserve\s*=\s*(\d+)", logging ).group(1)) acl = source("src/helpers/ClientACL.h") clients = int(re.search(r"#define MAX_CLIENTS\s+(\d+)", acl).group(1)) acl_bytes = len("ACL:\r\n") + clients * (2 + 1 + 64 + 1) self.assertGreaterEqual(reserve, acl_bytes + len("get acl\r\n") + 160) def test_file_eof_and_malformed_line_marker_are_deferred(self): for role in ROLES: with self.subTest(role=role): text = source(f"examples/{role}/MyMesh.cpp") self.assertIn('strcmp(reply, " EOF") == 0) reply[0] = 0;', text) self.assertIn('static const char eof[] = " -> EOF\\r\\n";', text) self.assertIn("if (!serial_log_active) {", text) self.assertIn("serial_log_eof_pending = false;", text) self.assertIn("while (budget-- > 0 && serial_log_remaining > 0)", text) self.assertIn("[USB log line omitted: exceeds 640 bytes]", text) self.assertIn("if (serial_log_skip_line) return;", text) if __name__ == "__main__": unittest.main()