From b18ba8288a484589667422af04eb554bb52ebe74 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 02:14:11 -0700 Subject: [PATCH] Report the running firmware USB mode without disturbing logging --- docs/cli_commands.md | 22 +++ examples/companion_radio/MyMesh.cpp | 3 + src/helpers/CommonCLI.cpp | 2 + src/helpers/UsbLogging.cpp | 3 +- src/helpers/UsbLoggingClientActivity.h | 16 +++ src/helpers/UsbMode.h | 46 +++++++ test/test_usb_mode.py | 181 +++++++++++++++++++++++++ test/test_usb_packet_logging_quiet.py | 8 +- 8 files changed, 278 insertions(+), 3 deletions(-) create mode 100644 src/helpers/UsbMode.h create mode 100644 test/test_usb_mode.py diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 6094581f3..f48c2d6c3 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1631,6 +1631,28 @@ For other roles, use the separately named `-ota-` artifact when required. A LoRa OTA, and the expanded FULL feature set. A `-full-logging-ota-` artifact is emitted only when that hardware/role has no matching WiFi MQTT environment. +### Report the USB connection mode + +`get usb.mode` reports the primary serial backend compiled into the running +application, checking its actual primary `Serial` type as well as the USB +build flags. It is available on Repeaters, Room Servers, Sensors and Full +Companions, including the Companion protocol-14 CLI command. It is read-only: +no reset, settings write, logging toggle or packet-capture pause is performed. + +The exact reply is one of: + +- `usb.mode hardware-cdc-jtag`: ESP32 hardware USB Serial/JTAG, with CDC on boot. +- `usb.mode tinyusb`: ESP32 TinyUSB CDC on boot, or nRF52 using TinyUSB. +- `usb.mode uart`: ESP32 primary `Serial` is UART, not native USB CDC. +- `usb.mode unknown`: this build does not provide a proven backend mapping. + +The result describes the application, not a connected adapter, host COM-port +name, optional second logging interface or current ROM/DFU bootloader. There +is no `set usb.mode`; changing the backend requires another firmware build. +Older firmware may reject this command. A flasher should retain its manual +connection option when the report is absent, unknown or conflicts with the +selected USB device; it must not guess from the board name or shared USB ID. + ### Control live USB logging **Search terms:** serial logging, USB debug log, enable logging, disable logging, debug output. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 2ff0c856e..6c6449eda 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -69,6 +69,7 @@ #include #include #include +#include #if defined(ESP32_PLATFORM) && defined(WIFI_SSID) #include #include @@ -11132,6 +11133,8 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, while (*command == ' ' || *command == '\t') command++; } + if (mesh::handleUsbModeCommand(command, reply, reply_capacity, Serial)) return true; + #if defined(ESP32_PLATFORM) // Catch remote requests before the direct-transport router below. That // router is also used by USB/BLE/browser commands without a timestamp. diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index cb84f8e54..d989c8da3 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -5,6 +5,7 @@ #include #include #include +#include "UsbMode.h" #include "CarrierTools.h" #include "PacketFileLoggingConfig.h" #include "CommonCLI.h" @@ -3553,6 +3554,7 @@ bool CommonCLI::handleUDPBridgeCommand(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 (mesh::handleUsbModeCommand(command, reply, 160, Serial)) return; #if MESH_ENABLE_HOST_KISS if (mesh::hostkiss::handleCommand(command, reply, 157, sender_timestamp == 0)) return; #endif diff --git a/src/helpers/UsbLogging.cpp b/src/helpers/UsbLogging.cpp index f03c2486b..6d7e15c90 100644 --- a/src/helpers/UsbLogging.cpp +++ b/src/helpers/UsbLogging.cpp @@ -1303,7 +1303,8 @@ void noteUsbConsoleCommand(const char* command) { if (command == nullptr || isUsbPacketLoggingAlways() || hasDedicatedUsbLoggingPort() || isUsbLoggingPacketStream()) return; while (*command == ' ' || *command == '\t' || *command == '\r' || *command == '\n') ++command; - if (*command == 0 || isUsbLoggingStatsCommand(command)) return; + if (*command == 0 || isUsbLoggingStatsCommand(command) + || isUsbModeDetectionCommand(command)) return; for (const unsigned char* p = reinterpret_cast(command); *p; ++p) { if ((*p < 32 && *p != '\t') || *p >= 127) return; } diff --git a/src/helpers/UsbLoggingClientActivity.h b/src/helpers/UsbLoggingClientActivity.h index bc03f838c..2fe6d2fb1 100644 --- a/src/helpers/UsbLoggingClientActivity.h +++ b/src/helpers/UsbLoggingClientActivity.h @@ -11,6 +11,22 @@ namespace mesh { // not identify a particular application or prove MQTT/disk health. constexpr uint32_t USB_LOGGING_CLIENT_LEASE_MS = 15UL * 60UL * 1000UL; +// Firmware detection is passive even before the role strips an optional +// two-byte Companion request prefix or normalizes an uppercase GET verb. +// Unlike stats polling, this query must not establish a logging-reader lease. +inline bool isUsbModeDetectionCommand(const char* command) { + if (!command) return false; + while (*command == ' ' || *command == '\t') ++command; + if (command[0] && command[1] && command[2] == '|' && command[3]) { + command += 3; + while (*command == ' ' || *command == '\t') ++command; + } + return (command[0] == 'g' || command[0] == 'G') + && (command[1] == 'e' || command[1] == 'E') + && (command[2] == 't' || command[2] == 'T') + && strcmp(command + 3, " usb.mode") == 0; +} + inline bool isUsbLoggingStatsCommand(const char* command) { if (!command) return false; while (*command == ' ' || *command == '\t') ++command; diff --git a/src/helpers/UsbMode.h b/src/helpers/UsbMode.h new file mode 100644 index 000000000..2a5c2fe31 --- /dev/null +++ b/src/helpers/UsbMode.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include + +namespace mesh { + +// Describe the primary Serial backend selected by this application build, +// not its board name, host port name, USB VID/PID or optional logging CDC. +// Include after Arduino.h. The caller supplies decltype(Serial), after any +// board-specific alias is visible, so a custom primary UART cannot inherit a +// native-USB claim merely because the unused USB peripheral has those flags. +template +inline const char* usbModeName() { +#if defined(ESP32) || defined(ESP32_PLATFORM) + if (std::is_same::value) return "uart"; + #if defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT + #if defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 + if (std::is_same::value) return "hardware-cdc-jtag"; + #elif defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 0 + if (std::is_same::value) return "tinyusb"; + #endif + #endif +#elif defined(NRF52_PLATFORM) && defined(USE_TINYUSB) + if (std::is_same::value) return "tinyusb"; +#endif + return "unknown"; +} + +inline bool isUsbModeCommand(const char* command) { + return command != nullptr && strcmp(command, "get usb.mode") == 0; +} + +// Read-only, bounded and independent of diagnostic/logging build flags. +// No setter: changing a USB backend requires installing another application. +template +inline bool handleUsbModeCommand(const char* command, char* reply, + size_t capacity, const SerialType&) { + if (!isUsbModeCommand(command) || reply == nullptr || capacity == 0) return false; + snprintf(reply, capacity, "usb.mode %s", usbModeName()); + return true; +} + +} // namespace mesh diff --git a/test/test_usb_mode.py b/test/test_usb_mode.py new file mode 100644 index 000000000..60ce40840 --- /dev/null +++ b/test/test_usb_mode.py @@ -0,0 +1,181 @@ +"""Execute the read-only USB backend report with actual compiled flag branches.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +from test_radio_receive_contract import method + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +// Minimal concrete types stand in for each SDK's actual primary Serial type. +class HardwareSerial {}; +class HWCDC {}; +class USBCDC {}; +class Adafruit_USBD_CDC {}; +class CustomSerial {}; +PRIMARY_SERIAL Serial; +#include +#include +#include +#include +#include +#include + +@DISPATCH@ + +int main() { + assert(!strcmp(mesh::usbModeName(), EXPECTED_MODE)); + const char expected[] = "usb.mode " EXPECTED_MODE; + char reply[160] = {}; + assert(mesh::handleUsbModeCommand("get usb.mode", reply, sizeof(reply), Serial)); + assert(!strcmp(reply, expected)); + assert(strlen(reply) < 32); + // This detection query neither pauses capture nor grants a logging-client + // watchdog lease. Stats polling remains a separate explicit mechanism. + assert(!mesh::isUsbLoggingStatsCommand("get usb.mode")); + for (const char* raw : {"get usb.mode", "GET usb.mode", "GeT usb.mode", + "xy|get usb.mode", "xy| \tget usb.mode", " \txy|GET usb.mode"}) { + assert(mesh::isUsbModeDetectionCommand(raw)); + assert(!mesh::isUsbLoggingStatsCommand(raw)); + } + for (const char* raw : std::initializer_list{nullptr, "", "x", "xy", "xy|", + "x|get usb.mode", "xyz|get usb.mode", "get usb.mode ", "get usb.mode extra", + "xy|set usb.mode tinyusb", "xy|get usb.modex", "get USB.MODE"}) + assert(!mesh::isUsbModeDetectionCommand(raw)); + for (const char* invalid : std::initializer_list{nullptr, "", "get usb.modex", "get usb.mode extra", + "get usb.mode\n", "get usb.mode\t", "get USB.MODE", "usb.mode", + "set usb.mode", "set usb.mode tinyusb", "set usb.mode hardware-cdc-jtag"}) { + strcpy(reply, "unchanged"); + assert(!mesh::handleUsbModeCommand(invalid, reply, sizeof(reply), Serial)); + assert(!strcmp(reply, "unchanged")); + } + assert(!mesh::handleUsbModeCommand("get usb.mode", nullptr, sizeof(reply), Serial)); + strcpy(reply, "unchanged"); + assert(!mesh::handleUsbModeCommand("get usb.mode", reply, 0, Serial)); + assert(!strcmp(reply, "unchanged")); + for (size_t capacity = 1; capacity <= sizeof(expected); ++capacity) { + unsigned char guarded[160]; + memset(guarded, 0xA5, sizeof(guarded)); + assert(mesh::handleUsbModeCommand("get usb.mode", + reinterpret_cast(guarded + 1), capacity, Serial)); + assert(guarded[0] == 0xA5 && guarded[capacity + 1] == 0xA5); + assert(guarded[capacity] == 0); + assert(!strncmp(reinterpret_cast(guarded + 1), expected, capacity - 1)); + } + @DISPATCH_CASES@ +} +''' + + +class UsbModeTests(unittest.TestCase): + def compile_and_run(self, directory, name, flags, expected, dispatch="", cases="", primary=None): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + self.skipTest("a host C++17 compiler is required") + source = Path(directory) / (name + ".cpp") + source.write_text(HARNESS.replace("@DISPATCH@", dispatch) + .replace("@DISPATCH_CASES@", cases), encoding="ascii") + binary = source.with_suffix(".exe") + if primary is None: + primary = {"hardware-cdc-jtag": "HWCDC", "tinyusb": "USBCDC", + "uart": "HardwareSerial"}.get(expected, "CustomSerial") + if "NRF52_PLATFORM" in flags and expected == "tinyusb": + primary = "Adafruit_USBD_CDC" + built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + *([] if os.name == "nt" else ["-fsanitize=address,undefined", + "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]), + *["-D" + flag for flag in flags], '-DEXPECTED_MODE="' + expected + '"', + "-DPRIMARY_SERIAL=" + primary, + "-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) + ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + + def test_actual_backend_flags_and_bounded_read_only_command(self): + cases = ( + (["ESP32", "ARDUINO_USB_MODE=1", "ARDUINO_USB_CDC_ON_BOOT=1"], "hardware-cdc-jtag"), + (["ESP32", "ARDUINO_USB_MODE=0", "ARDUINO_USB_CDC_ON_BOOT=1"], "tinyusb"), + (["ESP32", "ARDUINO_USB_MODE=1", "ARDUINO_USB_CDC_ON_BOOT=0"], "uart"), + (["ESP32", "ARDUINO_USB_MODE=0", "ARDUINO_USB_CDC_ON_BOOT=0"], "uart"), + (["ESP32", "ARDUINO_USB_MODE=1"], "uart"), + (["ESP32"], "uart"), + (["ESP32", "ARDUINO_USB_CDC_ON_BOOT=1"], "unknown"), + (["ESP32", "ARDUINO_USB_MODE=7", "ARDUINO_USB_CDC_ON_BOOT=1"], "unknown"), + (["ESP32_PLATFORM", "ARDUINO_USB_MODE=1", "ARDUINO_USB_CDC_ON_BOOT=1"], "hardware-cdc-jtag"), + (["NRF52_PLATFORM", "USE_TINYUSB", "MESH_DUAL_CDC_LOGGING"], "tinyusb"), + (["NRF52_PLATFORM"], "unknown"), + (["RP2040_PLATFORM"], "unknown"), + ([], "unknown"), + ) + with tempfile.TemporaryDirectory(prefix="meshcore-usb-mode-") as directory: + for index, (flags, expected) in enumerate(cases): + with self.subTest(flags=flags): + self.compile_and_run(directory, "mode" + str(index), flags, expected) + # Exact flags alone are insufficient when a board redirects Serial. + for index, (flags, primary, expected) in enumerate(( + (["ESP32", "ARDUINO_USB_MODE=1", "ARDUINO_USB_CDC_ON_BOOT=1"], "HardwareSerial", "uart"), + (["ESP32", "ARDUINO_USB_MODE=1", "ARDUINO_USB_CDC_ON_BOOT=1"], "CustomSerial", "unknown"), + (["ESP32", "ARDUINO_USB_MODE=0", "ARDUINO_USB_CDC_ON_BOOT=1"], "HWCDC", "unknown"), + (["NRF52_PLATFORM", "USE_TINYUSB"], "CustomSerial", "unknown"), + )): + with self.subTest(primary_override=primary, flags=flags): + self.compile_and_run(directory, "alias" + str(index), flags, expected, primary=primary) + + def test_actual_common_and_companion_dispatch_including_v14_request_prefix(self): + common = method((ROOT / "src/helpers/CommonCLI.cpp").read_text(), + "void CommonCLI::handleCommand(") + companion = method((ROOT / "examples/companion_radio/MyMesh.cpp").read_text(), + "bool MyMesh::handleCommand(") + call = "if (mesh::handleUsbModeCommand(command, reply, reply_capacity, Serial)) return true;" + self.assertIn(call, companion) + # Execute the real early dispatch bodies, before unrelated board/radio + # services. The framed V14 command path invokes this same handleCommand. + common = common.split("#if MESH_ENABLE_HOST_KISS", 1)[0] + "\n}" + companion = companion[:companion.index(call) + len(call)] + "\nreturn false;\n}" + # The omitted unrelated dispatch branches normally use this argument. + common = common.replace("{", "{ (void)sender_timestamp;", 1) + companion = companion.replace("{", "{ (void)sender_timestamp;", 1) + dispatch = r''' +struct PrefsSaveReplyGuard { + PrefsSaveReplyGuard(unsigned&, char*) {} +}; +namespace mesh { namespace cli { void normalizeCommandVerb(char*) {} } } +struct CommonCLI { + unsigned _prefs_save_failures = 0; + void handleCommand(uint32_t, char*, char*); +}; +struct MyMesh { bool handleCommand(const char*, uint32_t, char*); }; +''' + cases = r''' + CommonCLI common; + MyMesh companion; + char command[] = "get usb.mode"; + common.handleCommand(0, command, reply); + assert(!strcmp(reply, expected) && common._prefs_save_failures == 0); + assert(companion.handleCommand(command, 0, reply)); + assert(!strcmp(reply, expected)); + assert(companion.handleCommand("xy|get usb.mode", 0, reply)); + assert(!strncmp(reply, "xy|", 3) && !strcmp(reply + 3, expected)); + assert(!companion.handleCommand("set usb.mode tinyusb", 0, reply)); +''' + with tempfile.TemporaryDirectory(prefix="meshcore-usb-mode-dispatch-") as directory: + self.compile_and_run(directory, "dispatch", ["ESP32", "ARDUINO_USB_MODE=1", + "ARDUINO_USB_CDC_ON_BOOT=1"], "hardware-cdc-jtag", dispatch + common + companion, cases) + full = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + frame = full[full.index("mesh::companion::isRunCliFrame"):] + self.assertIn("handleCommand(text, 0, reply_buf)", frame) + for role, implementation in (("simple_repeater", "MyMesh.cpp"), + ("simple_room_server", "MyMesh.cpp"), + ("simple_sensor", "SensorMesh.cpp")): + source = (ROOT / "examples" / role / implementation).read_text() + self.assertTrue("_cli.handleCommand(sender_timestamp, command, reply)" in source, + role + " must route to the shared CommonCLI") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_usb_packet_logging_quiet.py b/test/test_usb_packet_logging_quiet.py index 9b60fb494..b6e8fc3df 100644 --- a/test/test_usb_packet_logging_quiet.py +++ b/test/test_usb_packet_logging_quiet.py @@ -35,16 +35,20 @@ static void timerCases() { mesh::configureUsbLoggingPacketStream(false);g_mock_millis=0; assert(!mesh::isUsbPacketLoggingAlways()); for(const char* ignored:std::initializer_list{nullptr,""," \t\r\n","stats-core"," \tstats-radio\t ","stats-packets", + "get usb.mode", "GET usb.mode", "GeT usb.mode", "xy|get usb.mode", "xy|\t get usb.mode", " \txy|GeT usb.mode", "\x01" "get name","get\x7f","\xff" "get name","get name\n","stats-core\r","get\rname"}) { mesh::noteUsbConsoleCommand(ignored);assert(mesh::isUsbPacketLoggingAllowed()); } - for(const char* human:{"get name","stats-core extra","stats-radiox","stats-packets 1"}) { + for(const char* human:{"get name","stats-core extra","stats-radiox","stats-packets 1", + "get usb.mode ", "get usb.modex", "get usb.mode extra", "xy|get usb.mode extra", + "x|get usb.mode", "xyz|get usb.mode", "xy|set usb.mode tinyusb", "get USB.MODE"}) { mesh::noteUsbConsoleCommand(human);assert(!mesh::isUsbPacketLoggingAllowed()); mesh::resumeUsbPacketLogging(); } mesh::noteUsbConsoleCommand("get name"); assert(mesh::isUsbLoggingEnabled()&&!mesh::isUsbPacketLoggingAllowed()); - g_mock_millis=299999;assert(!mesh::isUsbPacketLoggingAllowed()); + g_mock_millis=299999;mesh::noteUsbConsoleCommand("xy|GET usb.mode"); + assert(!mesh::isUsbPacketLoggingAllowed()); ++g_mock_millis;assert(mesh::isUsbPacketLoggingAllowed()); g_mock_millis+=0x80000000U;assert(mesh::isUsbPacketLoggingAllowed()); // Expiry latches. g_mock_millis=100;mesh::noteUsbConsoleCommand("get radio");