Fix USB logging ownership, persistence, and reconnect safety

This commit is contained in:
mikecarper committed 2026-10-03 14:45:57 -07:00
1 parent c1a533572a
commit 91d533bfff
32 files changed
+1543 -140

No files matched your search

+7 -4
View File
@@ -2607,7 +2607,7 @@ void MyMesh::begin(bool has_display, bool radio_available,
}
#if MESH_USB_LOGGING_AVAILABLE
const bool usb_logging_enabled = _prefs.usb_logging_enabled != 0;
mesh::setUsbLoggingEnabled(usb_logging_enabled);
applyUsbLoggingState(usb_logging_enabled);
if (!mesh::saveUsbLoggingBootPreference(usb_logging_enabled)) {
MESH_DEBUG_PRINTLN("Unable to save next-boot USB logging interface state");
}
@@ -3259,7 +3259,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
snprintf(reply, reply_size, "Error: use set usb.logging <on|off> [reboot]");
} else {
_prefs.usb_logging_enabled = enabled ? 1 : 0;
mesh::setUsbLoggingEnabled(enabled);
applyUsbLoggingState(enabled);
if (!savePrefs()) {
snprintf(reply, reply_size, "Error: USB logging changed for this boot but save failed");
} else if (!mesh::saveUsbLoggingBootPreference(enabled)) {
@@ -3777,11 +3777,14 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
_mqtt_enabled = wifi;
if (!wifi) stopMQTT();
_prefs.usb_logging_enabled = usb ? 1 : 0;
mesh::setUsbLoggingEnabled(usb);
applyUsbLoggingState(usb);
if (!savePrefs() || !mesh::saveUsbLoggingBootPreference(usb)) {
snprintf(reply, reply_size, "Error: logging output changed but USB setting could not be saved");
} else {
snprintf(reply, reply_size, "OK - logging.output %s (saved)", command + 19);
// USB ownership changes can clear the terminal's input buffer while
// this command is executing. Use parsed values, not that borrowed text.
snprintf(reply, reply_size, "OK - logging.output %s (saved)",
mesh::cli::loggingOutputName(usb, wifi));
}
}
return true;
+1
View File
@@ -301,6 +301,7 @@ public:
// setup/status commands here; Full builds add TempRadio and OTA commands.
bool handleLocalControlCommand(const char* command, char* reply,
size_t reply_size);
void applyUsbLoggingState(bool enabled);
bool handleTxRoutingCommand(const char* command, char* reply, size_t reply_size);
int getRecentlyHeard(AdvertPath dest[], int max_num);
+110 -59
View File
@@ -624,6 +624,8 @@ static size_t usb_terminal_line_len = 0;
static bool usb_terminal_discard_line = false;
static bool usb_host_session_connected = false;
static bool usb_logging_terminal_mode = false;
static bool usb_logging_network_parked = false;
static bool usb_protocol_initialized = false;
static mesh::UsbBinaryStartupProbe usb_binary_startup_probe;
static mesh::UsbAsciiSessionDefault usb_ascii_session_default;
#if COMPANION_FEATURE_USB_MOTA_SOURCE
@@ -772,7 +774,10 @@ static void leaveUsbTerminalMode(bool acknowledge) {
mesh::discardUsbTerminalOutput();
}
the_mesh.exitTerminalMode();
usb_serial_interface.setPassthroughMode(false);
// A network handoff can leave the USB CLI while diagnostics remain enabled.
// Do not briefly reopen the framed transport on that same logging stream.
usb_serial_interface.setPassthroughMode(
!mesh::hasDedicatedUsbLoggingPort() && mesh::isUsbLoggingEnabled());
clearUsbTerminalLine();
usb_terminal_discard_line = false;
usb_logging_terminal_mode = false;
@@ -1011,69 +1016,16 @@ static void beginUsbDefaultSession() {
serviceUsbAsciiSessionDefault();
}
static void serviceUsbLoggingOwnership(bool logging_enabled);
static void serviceUsbTerminal() {
serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled());
if (usb_logging_network_parked) return;
#if COMPANION_FEATURE_USB_MOTA_SOURCE
if (usb_mota_mode) {
#if MESH_USB_LOGGING_AVAILABLE
// A single-TTY logger and framed USB mOTA cannot share the primary USB
// stream. A remote logging change can arrive while mOTA owns USB, so stop
// the transfer before its next binary exchange and return to ASCII.
if (!mesh::hasDedicatedUsbLoggingPort()
&& mesh::isUsbLoggingEnabled()) {
leaveUsbMotaMode(false);
enterUsbLoggingTerminalMode();
usbTerminalOutput().print(
"\r\nUSB mOTA stopped: USB logging owns this port\r\n> ");
return;
}
#endif
serviceUsbMota();
return;
}
#endif
// A saved logging-on preference makes the one available TTY behave like a
// logging repeater: plaintext diagnostics plus an input-capable CLI. Put the
// Companion interface into passthrough before it can mix framed traffic with
// logs. An active TCP terminal owns the role CLI, so logging must not reclaim
// it. Turning logging off retains the ordinary ASCII terminal.
#if MESH_USB_LOGGING_AVAILABLE
const mesh::UsbLoggingTerminalAction logging_action =
mesh::selectUsbLoggingTerminalAction(
mesh::hasDedicatedUsbLoggingPort(), mesh::isUsbLoggingEnabled(),
the_mesh.isTerminalMode(), usb_logging_terminal_mode,
true,
#if COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG)
isNetworkTerminalActive()
#else
false
#endif
);
switch (logging_action) {
case mesh::UsbLoggingTerminalAction::CLAIM_USB:
if (!the_mesh.isTerminalMode()) {
enterUsbLoggingTerminalMode();
return;
}
usb_logging_terminal_mode = true;
break;
case mesh::UsbLoggingTerminalAction::RETURN_TO_BINARY:
leaveUsbTerminalMode(true);
return;
case mesh::UsbLoggingTerminalAction::KEEP_ASCII:
// Logging may also be disabled over BLE/WiFi. Stop treating this session
// as the logging terminal, but keep the ordinary ASCII terminal active;
// do not silently change the USB protocol underneath an idle host. A
// remote mode change also cancels any partially typed USB command before
// drawing a fresh prompt.
usb_logging_terminal_mode = false;
clearUsbTerminalLine();
usb_terminal_discard_line = false;
usbTerminalOutput().print(
"\r\nUSB logging off; ASCII terminal active\r\n> ");
break;
case mesh::UsbLoggingTerminalAction::NO_ACTION:
break;
}
#endif
if (!the_mesh.isTerminalMode()) {
const mesh::UsbBinaryStartupProbe::Result probe_result =
@@ -1200,6 +1152,91 @@ static void serviceUsbTerminal() {
}
}
static void serviceUsbLoggingOwnership(bool logging_enabled) {
// A saved logging-on preference makes the one available TTY behave like a
// logging repeater: plaintext diagnostics plus an input-capable CLI. Put the
// Companion interface into passthrough before it can mix framed traffic with
// logs. An active TCP terminal owns the role CLI, so logging must not reclaim
// it. Turning logging off retains the ordinary ASCII terminal.
#if MESH_USB_LOGGING_AVAILABLE
#if COMPANION_FEATURE_USB_MOTA_SOURCE
if (usb_mota_mode) {
if (mesh::hasDedicatedUsbLoggingPort() || !logging_enabled) return;
// Run before framed dispatch, not just the later terminal-input service.
leaveUsbMotaMode(false);
}
#endif
const mesh::UsbLoggingTerminalAction logging_action =
mesh::selectUsbLoggingTerminalAction(
mesh::hasDedicatedUsbLoggingPort(), logging_enabled,
the_mesh.isTerminalMode(), usb_logging_terminal_mode,
true,
#if COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG)
isNetworkTerminalActive()
#else
false
#endif
);
if (usb_logging_network_parked
&& logging_action != mesh::UsbLoggingTerminalAction::PARK_USB_FOR_NETWORK) {
// Bytes typed while USB was log-only belong to neither the new ASCII CLI
// nor a new Binary client. Bound the discard to the existing RX snapshot.
Stream& input = mesh::usbCompanionPort();
int pending = input.available();
while (pending-- > 0) input.read();
usb_logging_network_parked = false;
usb_serial_interface.setPassthroughMode(the_mesh.isTerminalMode());
}
switch (logging_action) {
case mesh::UsbLoggingTerminalAction::PARK_USB_FOR_NETWORK:
if (!usb_logging_network_parked
|| !usb_serial_interface.isPassthroughMode()) {
usb_binary_startup_probe.cancel();
cancelUsbSerialOperations();
mesh::discardUsbTerminalOutput();
usb_serial_interface.setPassthroughMode(true);
clearUsbTerminalLine();
usb_terminal_discard_line = false;
}
usb_logging_network_parked = true;
return; // Do not move the role CLI away from TCP/browser.
case mesh::UsbLoggingTerminalAction::CLAIM_USB:
usb_logging_network_parked = false;
if (!the_mesh.isTerminalMode()) {
enterUsbLoggingTerminalMode();
return;
}
usb_logging_terminal_mode = true;
break;
case mesh::UsbLoggingTerminalAction::RETURN_TO_BINARY:
leaveUsbTerminalMode(true);
return;
case mesh::UsbLoggingTerminalAction::KEEP_ASCII:
// Logging may also be disabled over BLE/WiFi. Stop treating this session
// as the logging terminal, but keep the ordinary ASCII terminal active;
// do not silently change the USB protocol underneath an idle host. A
// remote mode change also cancels any partially typed USB command before
// drawing a fresh prompt.
usb_logging_terminal_mode = false;
clearUsbTerminalLine();
usb_terminal_discard_line = false;
usbTerminalOutput().print(
"\r\nUSB logging off; ASCII terminal active\r\n> ");
break;
case mesh::UsbLoggingTerminalAction::NO_ACTION:
break;
}
#endif
}
void MyMesh::applyUsbLoggingState(bool enabled) {
// Park the framed transport before opening the diagnostic gate, including
// TCP/browser commands which execute outside the mesh dispatcher.
if (enabled && usb_protocol_initialized) serviceUsbLoggingOwnership(true);
mesh::setUsbLoggingEnabled(enabled);
if (usb_protocol_initialized) serviceUsbLoggingOwnership(enabled);
}
static void expireUsbBinaryStartupProbeBeforeDispatch() {
const uint32_t now = millis();
if (!usb_binary_startup_probe.hasTimedOut(now)) return;
@@ -1216,6 +1253,12 @@ static void expireUsbBinaryStartupProbeBeforeDispatch() {
}
#endif
#if !defined(ENABLE_USB_INTERFACE)
void MyMesh::applyUsbLoggingState(bool enabled) {
mesh::setUsbLoggingEnabled(enabled);
}
#endif
#if defined(ENABLE_USB_INTERFACE) && (COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG))
static bool isNetworkTerminalActive() {
return the_mesh.isAnyNetworkTerminalMode();
@@ -1237,7 +1280,10 @@ bool MyMesh::beginStreamTerminal(Stream& output) {
hasObservableActiveUsbTerminalClient(), input_idle,
usb_serial_interface.getCompletedFrameCount())) return false;
if (ascii_selected) leaveUsbTerminalMode(false);
if (enterNetworkTerminalMode(output)) return true;
if (enterNetworkTerminalMode(output)) {
serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled());
return true;
}
if (browser_usb_handoff.shouldRestoreAscii(
usb_serial_interface.getCompletedFrameCount())) enterUsbTerminalMode();
return false;
@@ -1937,6 +1983,9 @@ void halt() {
"ERROR: another client currently owns the Full Companion terminal\r\n");
ota_console_client.stop();
}
#if defined(ENABLE_USB_INTERFACE)
serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled());
#endif
#else
ota_console_client.print("Local CLI and OTA console - type `version` or `ota status`\r\n> ");
#endif
@@ -3248,6 +3297,7 @@ void setup() {
usb_serial_interface.setConnectedCheck([]() { return (bool)Serial; });
#endif
interface_manager.addInterface(InterfaceType::USB, &usb_serial_interface);
usb_protocol_initialized = true;
// Select ASCII before any framed dispatch, including non-Full USB builds.
// BLE/WiFi/Ethernet keep their existing Binary Companion transports.
beginUsbDefaultSession();
@@ -3317,6 +3367,7 @@ void loop() {
#if defined(ENABLE_USB_INTERFACE)
serviceUsbTerminalHostSessionReset();
serviceUsbAsciiSessionDefault();
serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled()); // Before framed dispatch.
#endif
// Identify nRF52 CDC 1 when a terminal opens it. Doing this on the connection
// edge avoids losing the marker before the host has opened the port.
+67 -12
View File
@@ -5092,9 +5092,34 @@ void MyMesh::dumpLogFile() {
#endif
}
void MyMesh::printAclSerial() {
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
if (hasPendingSerialOutput()) {
mesh::usbConsolePort().printf("Err - USB output busy\r\n");
return;
}
// Reuse the bounded file-output buffer. Command admission prevents overlapping
// USB jobs, and session reset cancels the retained row before a new host reads.
serial_acl_next = 0;
serial_acl_count = acl.getNumClients();
serial_acl_header = true;
serial_log_pending_size = 0;
#else
mesh::usbConsolePort().printf("ACL:\r\n");
for (int i = 0; i < acl.getNumClients(); i++) {
auto c = acl.getClientByIdx(i);
if (c->permissions == 0) continue;
char public_key[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(public_key, c->id.pub_key, PUB_KEY_SIZE);
mesh::usbConsolePort().printf("%02X %s\n", c->permissions, public_key);
}
#endif
}
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
bool MyMesh::hasPendingSerialOutput() const {
return serial_log_active || serial_log_eof_pending || serial_recent_next >= 0;
return serial_log_active || serial_log_eof_pending || serial_recent_next >= 0
|| serial_acl_next >= 0;
}
void MyMesh::cancelPendingSerialOutput() {
@@ -5104,6 +5129,9 @@ void MyMesh::cancelPendingSerialOutput() {
serial_log_skip_line = false;
serial_log_remaining = 0;
serial_log_pending_size = 0;
serial_acl_next = -1;
serial_acl_count = 0;
serial_acl_header = false;
serial_recent_next = -1;
serial_recent_count = 0;
serial_recent_header = false;
@@ -5114,6 +5142,43 @@ void MyMesh::cancelPendingSerialOutput() {
void MyMesh::servicePendingSerialOutput() {
Stream& console = mesh::usbConsolePort();
if (serial_acl_next >= 0) {
if (serial_log_pending_size == 0) {
if (serial_acl_header) {
static const char header[] = "ACL:\r\n";
memcpy(serial_log_pending, header, sizeof(header) - 1);
serial_log_pending_size = sizeof(header) - 1;
serial_acl_header = false;
} else {
// At most one row per pass; deleted entries can be skipped without
// allocating a table-sized response or waiting for USB FIFO space.
while (serial_acl_next < serial_acl_count
&& serial_acl_next < acl.getNumClients()) {
auto* client = acl.getClientByIdx(serial_acl_next++);
if (!client->permissions) continue;
char key[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(key, client->id.pub_key, PUB_KEY_SIZE);
serial_log_pending_size = snprintf(serial_log_pending,
sizeof(serial_log_pending), "%02X %s\n", client->permissions, key);
break;
}
if (serial_log_pending_size == 0) {
serial_acl_next = -1;
return;
}
}
}
if (console.availableForWrite() < static_cast<int>(serial_log_pending_size)) return;
size_t written = console.write(
reinterpret_cast<const uint8_t*>(serial_log_pending), serial_log_pending_size);
if (written > serial_log_pending_size) written = serial_log_pending_size;
serial_log_pending_size -= written;
if (written > 0 && serial_log_pending_size > 0) {
memmove(serial_log_pending, serial_log_pending + written, serial_log_pending_size);
}
return;
}
if (serial_recent_next >= 0) {
char record[64];
int length = 0;
@@ -12093,17 +12158,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
return;
}
#endif
mesh::usbConsolePort().printf("ACL:\r\n");
for (int i = 0; i < acl.getNumClients(); i++) {
auto c = acl.getClientByIdx(i);
if (c->permissions == 0) continue; // skip deleted (or guest) entries
// Admit each line together so concurrent USB diagnostics cannot split
// a public key or insert text between its permission prefix and value.
char public_key[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(public_key, c->id.pub_key, PUB_KEY_SIZE);
mesh::usbConsolePort().printf("%02X %s\n", c->permissions, public_key);
}
printAclSerial();
reply[0] = 0;
} else if (handleClientPathCommand(sender, command, reply)) {
return;
+4
View File
@@ -322,6 +322,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
bool serial_log_active = false;
bool serial_log_eof_pending = false;
bool serial_log_skip_line = false;
int serial_acl_next = -1;
int serial_acl_count = 0;
bool serial_acl_header = false;
int serial_recent_next = -1;
int serial_recent_count = 0;
bool serial_recent_header = false;
@@ -1073,6 +1076,7 @@ public:
}
void dumpLogFile() override;
void printAclSerial();
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
// Large local-only replies advance between radio service passes.
bool hasPendingSerialOutput() const;
+65 -12
View File
@@ -1669,9 +1669,33 @@ void MyMesh::dumpLogFile() {
#endif
}
void MyMesh::printAclSerial() {
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
if (hasPendingSerialOutput()) {
mesh::usbConsolePort().printf("Err - USB output busy\r\n");
return;
}
// Reuse the bounded file-output buffer. Command admission prevents overlapping
// USB jobs, and session reset cancels the retained row before a new host reads.
serial_acl_next = 0;
serial_acl_count = acl.getNumClients();
serial_acl_header = true;
serial_log_pending_size = 0;
#else
mesh::usbConsolePort().printf("ACL:\r\n");
for (int i = 0; i < acl.getNumClients(); i++) {
auto c = acl.getClientByIdx(i);
if (c->permissions == 0) continue;
char public_key[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(public_key, c->id.pub_key, PUB_KEY_SIZE);
mesh::usbConsolePort().printf("%02X %s\n", c->permissions, public_key);
}
#endif
}
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
bool MyMesh::hasPendingSerialOutput() const {
return serial_log_active || serial_log_eof_pending;
return serial_log_active || serial_log_eof_pending || serial_acl_next >= 0;
}
void MyMesh::cancelPendingSerialOutput() {
@@ -1681,10 +1705,49 @@ void MyMesh::cancelPendingSerialOutput() {
serial_log_skip_line = false;
serial_log_remaining = 0;
serial_log_pending_size = 0;
serial_acl_next = -1;
serial_acl_count = 0;
serial_acl_header = false;
}
void MyMesh::servicePendingSerialOutput() {
Stream& console = mesh::usbConsolePort();
if (serial_acl_next >= 0) {
if (serial_log_pending_size == 0) {
if (serial_acl_header) {
static const char header[] = "ACL:\r\n";
memcpy(serial_log_pending, header, sizeof(header) - 1);
serial_log_pending_size = sizeof(header) - 1;
serial_acl_header = false;
} else {
// At most one row per pass; deleted entries can be skipped without
// allocating a table-sized response or waiting for USB FIFO space.
while (serial_acl_next < serial_acl_count
&& serial_acl_next < acl.getNumClients()) {
auto* client = acl.getClientByIdx(serial_acl_next++);
if (!client->permissions) continue;
char key[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(key, client->id.pub_key, PUB_KEY_SIZE);
serial_log_pending_size = snprintf(serial_log_pending,
sizeof(serial_log_pending), "%02X %s\n", client->permissions, key);
break;
}
if (serial_log_pending_size == 0) {
serial_acl_next = -1;
return;
}
}
}
if (console.availableForWrite() < static_cast<int>(serial_log_pending_size)) return;
size_t written = console.write(
reinterpret_cast<const uint8_t*>(serial_log_pending), serial_log_pending_size);
if (written > serial_log_pending_size) written = serial_log_pending_size;
serial_log_pending_size -= written;
if (written > 0 && serial_log_pending_size > 0) {
memmove(serial_log_pending, serial_log_pending + written, serial_log_pending_size);
}
return;
}
if (!serial_log_active) {
// CommonCLI's synchronous EOF is suppressed until the queued dump ends.
static const char eof[] = " -> EOF\r\n";
@@ -2440,17 +2503,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
return;
}
#endif
mesh::usbConsolePort().printf("ACL:\r\n");
for (int i = 0; i < acl.getNumClients(); i++) {
auto c = acl.getClientByIdx(i);
if (c->permissions == 0) continue; // skip deleted (or guest) entries
// Admit each line together so concurrent USB diagnostics cannot split
// a public key or insert text between its permission prefix and value.
char public_key[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(public_key, c->id.pub_key, PUB_KEY_SIZE);
mesh::usbConsolePort().printf("%02X %s\n", c->permissions, public_key);
}
printAclSerial();
reply[0] = 0;
#if defined(WITH_MQTT_NEIGHBORS)
} else if (memcmp(command, "discover.neighbors", 18) == 0) {
+4
View File
@@ -185,6 +185,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks,
bool serial_log_active = false;
bool serial_log_eof_pending = false;
bool serial_log_skip_line = false;
int serial_acl_next = -1;
int serial_acl_count = 0;
bool serial_acl_header = false;
#endif
uint32_t last_millis;
uint64_t uptime_millis;
@@ -488,6 +491,7 @@ public:
}
void dumpLogFile() override;
void printAclSerial();
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
// Large local-only replies advance between radio service passes.
bool hasPendingSerialOutput() const;