diff --git a/src/SerialConsole.cpp b/src/SerialConsole.cpp index fb40bf8910..0cbb09870b 100644 --- a/src/SerialConsole.cpp +++ b/src/SerialConsole.cpp @@ -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); } diff --git a/src/SerialConsole.h b/src/SerialConsole.h index 466d6afa96..1a06c1428d 100644 --- a/src/SerialConsole.h +++ b/src/SerialConsole.h @@ -63,6 +63,9 @@ class SerialConsole : public StreamAPI, public RedirectablePrint, private concur /// port, so a dead host can't stall the main loop and trip the task watchdog. void setHostDraining(bool draining); + /// Write one byte of console text, restarting a latched HWCDC TX path where it can + size_t writeText(uint8_t c); + #if defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT StreamFrameWriter frameWriter; #endif