SerialConsole: restart a latched HWCDC TX path on ESP32-S3 (#12076)

* SerialConsole: restart a latched HWCDC TX path on ESP32-S3

HWCDC's ISR can clear the IN_EMPTY interrupt without moving data, after
which nothing raises it again: the TX ring stays full, the IN FIFO empty,
and the console refuses every write, for seconds up to a replug. Once a
text write has found that state for 20 ms, write the byte straight into
the FIFO. The host reads it, the interrupt is raised, and HWCDC drains
its ring again. Text path only, so no protobuf frame is touched.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* SerialConsole: kick only where serial output is enabled

The FIFO kick sent a byte even when the serial-output setting suppresses
console text. Apply the same check RedirectablePrint::write() uses.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jonathan BennettandClaude Opus 5.5 authored and GitHub committed 2026-10-06 16:23:52 +00:00
1 parent b38eb43762
commit 33222732d9
2 files changed
+61 -1

No files matched your search

+58 -1
View File
@@ -24,6 +24,11 @@
#include "HWCDC.h"
#endif
#if defined(IS_USB_HWCDC) && defined(CONFIG_IDF_TARGET_ESP32S3)
#include "soc/usb_serial_jtag_struct.h"
#define HWCDC_TX_KICK
#endif
#ifdef RP2040_SLOW_CLOCK
#define Port Serial2
#else
@@ -48,6 +53,45 @@ SerialConsole *console;
static bool s_serialLinkUp = false;
#endif
#ifdef HWCDC_TX_KICK
namespace
{
// HWCDC's TX interrupt can end up enabled but never raised again: its ISR clears it without moving data when it finds
// the IN FIFO not writable. The ring then stays full, the FIFO empty, and every write is refused until the cable is
// replugged. A byte written straight into the FIFO is a packet the host reads, which raises the interrupt again.
constexpr uint32_t TX_LATCH_KICK_MS = 20;
uint32_t txLatchedSinceMs = 0;
/// The TX ring is full, yet the IN FIFO is free and its interrupt enabled but not raised: nothing will drain the ring
bool txLatched()
{
return Port.availableForWrite() == 0 && USB_SERIAL_JTAG.ep1_conf.serial_in_ep_data_free &&
USB_SERIAL_JTAG.int_ena.serial_in_empty_int_ena && !USB_SERIAL_JTAG.int_raw.serial_in_empty_int_raw;
}
/// Send c through the FIFO once the TX path has been latched for TX_LATCH_KICK_MS; false if c is left to the ring. The
/// byte reaches the host ahead of the ring's older bytes; left to the ring, it would have been dropped.
bool kickLatchedTx(uint8_t c)
{
if (!txLatched()) {
txLatchedSinceMs = 0;
return false;
}
const uint32_t now = millis();
if (!txLatchedSinceMs) {
txLatchedSinceMs = now ? now : 1;
return false;
}
if (now - txLatchedSinceMs < TX_LATCH_KICK_MS)
return false;
txLatchedSinceMs = 0;
USB_SERIAL_JTAG.ep1.rdwr_byte = c;
USB_SERIAL_JTAG.ep1_conf.wr_done = 1;
return true;
}
} // namespace
#endif
/// Create the shared serial console once and register receive wakeups.
void consoleInit()
{
@@ -163,7 +207,20 @@ size_t SerialConsole::write(uint8_t c)
return 1;
if (c == '\n')
RedirectablePrint::write('\r');
writeText('\r');
return writeText(c);
}
/// Write one byte of console text, restarting a latched HWCDC TX path where it can
size_t SerialConsole::writeText(uint8_t c)
{
#ifdef HWCDC_TX_KICK
// Only on the text path, where a byte sent ahead of the ring cannot land inside a protobuf frame, and only where
// RedirectablePrint::write() would send it at all
const bool serialEnabled = config.has_security ? config.security.serial_enabled : config.device.serial_enabled;
if ((!config.has_lora || serialEnabled) && kickLatchedTx(c))
return 1;
#endif
return RedirectablePrint::write(c);
}