Report the running firmware USB mode without disturbing logging

This commit is contained in:
mikecarper committed 2026-10-28 02:14:11 -07:00
1 parent 744692fb93
commit b18ba8288a
8 files changed
+278 -3

No files matched your search

+22
View File
@@ -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.
+3
View File
@@ -69,6 +69,7 @@
#include <helpers/SmallTableSort.h>
#include <helpers/UsbAsciiBinarySwitch.h>
#include <helpers/UsbLogging.h>
#include <helpers/UsbMode.h>
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID)
#include <helpers/esp32/CompanionAdminPassword.h>
#include <helpers/esp32/ManagementPassword.h>
@@ -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.
+2
View File
@@ -5,6 +5,7 @@
#include <helpers/sensors/SolarXiaoPower.h>
#include <helpers/LedSettings.h>
#include <Arduino.h>
#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
+2 -1
View File
@@ -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<const unsigned char*>(command); *p; ++p) {
if ((*p < 32 && *p != '\t') || *p >= 127) return;
}
+16
View File
@@ -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;
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#include <type_traits>
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 <typename SerialType>
inline const char* usbModeName() {
#if defined(ESP32) || defined(ESP32_PLATFORM)
if (std::is_same<SerialType, HardwareSerial>::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<SerialType, HWCDC>::value) return "hardware-cdc-jtag";
#elif defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 0
if (std::is_same<SerialType, USBCDC>::value) return "tinyusb";
#endif
#endif
#elif defined(NRF52_PLATFORM) && defined(USE_TINYUSB)
if (std::is_same<SerialType, Adafruit_USBD_CDC>::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 <typename SerialType>
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<SerialType>());
return true;
}
} // namespace mesh
+181
View File
@@ -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 <helpers/UsbMode.h>
#include <helpers/UsbLoggingClientActivity.h>
#include <cassert>
#include <cstdint>
#include <cstring>
#include <initializer_list>
@DISPATCH@
int main() {
assert(!strcmp(mesh::usbModeName<decltype(Serial)>(), 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<const char*>{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<const char*>{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<char*>(guarded + 1), capacity, Serial));
assert(guarded[0] == 0xA5 && guarded[capacity + 1] == 0xA5);
assert(guarded[capacity] == 0);
assert(!strncmp(reinterpret_cast<char*>(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()
+6 -2
View File
@@ -35,16 +35,20 @@ static void timerCases() {
mesh::configureUsbLoggingPacketStream(false);g_mock_millis=0;
assert(!mesh::isUsbPacketLoggingAlways());
for(const char* ignored:std::initializer_list<const char*>{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");