diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 0c70c4fc..a19173ca 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -266,6 +266,8 @@ jobs: python3 -B test/test_esp32_tinyusb_nonblocking.py python3 -B test/test_esp32_usb_session_fix.py python3 -B test/test_hwcdc_tx_backport.py -v + python3 -B test/test_esp32_hwcdc_recipes.py -v + python3 -B test/test_hwcdc_role_transport.py -v python3 -B test/test_hwcdc_write_capacity.py -v python3 -B test/test_esp32_file_buffer_fix.py -v python3 -B test/test_companion_usb_logging_reply.py diff --git a/boards/heltec_e213.json b/boards/heltec_e213.json index 81efd8f5..9a5111a0 100644 --- a/boards/heltec_e213.json +++ b/boards/heltec_e213.json @@ -8,7 +8,7 @@ "core": "esp32", "extra_flags": [ "-DBOARD_HAS_PSRAM", - "-DARDUINO_USB_MODE=0", + "-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=1", "-DARDUINO_RUNNING_CORE=1", "-DARDUINO_EVENT_RUNNING_CORE=1" @@ -34,8 +34,8 @@ "flash_size": "16MB", "maximum_ram_size": 8388608, "maximum_size": 16777216, - "use_1200bps_touch": true, - "wait_for_upload_port": true, + "use_1200bps_touch": false, + "wait_for_upload_port": false, "require_upload_port": true, "speed": 921600 }, diff --git a/boards/heltec_e290.json b/boards/heltec_e290.json index 07577557..5caa1a6e 100644 --- a/boards/heltec_e290.json +++ b/boards/heltec_e290.json @@ -8,7 +8,7 @@ "core": "esp32", "extra_flags": [ "-DBOARD_HAS_PSRAM", - "-DARDUINO_USB_MODE=0", + "-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=1", "-DARDUINO_RUNNING_CORE=1", "-DARDUINO_EVENT_RUNNING_CORE=1" @@ -34,8 +34,8 @@ "flash_size": "16MB", "maximum_ram_size": 8388608, "maximum_size": 16777216, - "use_1200bps_touch": true, - "wait_for_upload_port": true, + "use_1200bps_touch": false, + "wait_for_upload_port": false, "require_upload_port": true, "speed": 921600 }, diff --git a/boards/heltec_t190.json b/boards/heltec_t190.json index 6aa674fc..ba2116df 100644 --- a/boards/heltec_t190.json +++ b/boards/heltec_t190.json @@ -8,7 +8,7 @@ "core": "esp32", "extra_flags": [ "-DBOARD_HAS_PSRAM", - "-DARDUINO_USB_MODE=0", + "-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=1", "-DARDUINO_RUNNING_CORE=1", "-DARDUINO_EVENT_RUNNING_CORE=1" @@ -34,8 +34,8 @@ "flash_size": "16MB", "maximum_ram_size": 8388608, "maximum_size": 16777216, - "use_1200bps_touch": true, - "wait_for_upload_port": true, + "use_1200bps_touch": false, + "wait_for_upload_port": false, "require_upload_port": true, "speed": 921600 }, diff --git a/boards/heltec_tracker_v1_1.json b/boards/heltec_tracker_v1_1.json index a9ec5851..d481c538 100644 --- a/boards/heltec_tracker_v1_1.json +++ b/boards/heltec_tracker_v1_1.json @@ -7,7 +7,7 @@ "core": "esp32", "extra_flags": [ "-DARDUINO_USB_CDC_ON_BOOT=1", - "-DARDUINO_USB_MODE=0", + "-DARDUINO_USB_MODE=1", "-DARDUINO_RUNNING_CORE=1", "-DARDUINO_EVENT_RUNNING_CORE=1" ], @@ -30,8 +30,8 @@ "flash_size": "8MB", "maximum_ram_size": 327680, "maximum_size": 8388608, - "use_1200bps_touch": true, - "wait_for_upload_port": true, + "use_1200bps_touch": false, + "wait_for_upload_port": false, "require_upload_port": true, "speed": 921600 }, diff --git a/boards/heltec_tracker_v2.json b/boards/heltec_tracker_v2.json index 62b569e0..bbada478 100644 --- a/boards/heltec_tracker_v2.json +++ b/boards/heltec_tracker_v2.json @@ -7,7 +7,7 @@ "core": "esp32", "extra_flags": [ "-DARDUINO_USB_CDC_ON_BOOT=1", - "-DARDUINO_USB_MODE=0", + "-DARDUINO_USB_MODE=1", "-DARDUINO_RUNNING_CORE=1", "-DARDUINO_EVENT_RUNNING_CORE=1" ], @@ -30,8 +30,8 @@ "flash_size": "8MB", "maximum_ram_size": 327680, "maximum_size": 8388608, - "use_1200bps_touch": true, - "wait_for_upload_port": true, + "use_1200bps_touch": false, + "wait_for_upload_port": false, "require_upload_port": true, "speed": 921600 }, diff --git a/boards/station-g2.json b/boards/station-g2.json index 8ea6ebc6..be1207bf 100644 --- a/boards/station-g2.json +++ b/boards/station-g2.json @@ -8,7 +8,7 @@ "extra_flags": [ "-DBOARD_HAS_PSRAM", "-DARDUINO_USB_CDC_ON_BOOT=1", - "-DARDUINO_USB_MODE=0", + "-DARDUINO_USB_MODE=1", "-DARDUINO_RUNNING_CORE=1", "-DARDUINO_EVENT_RUNNING_CORE=0" ], @@ -33,8 +33,8 @@ "flash_size": "16MB", "maximum_ram_size": 327680, "maximum_size": 16777216, - "use_1200bps_touch": true, - "wait_for_upload_port": true, + "use_1200bps_touch": false, + "wait_for_upload_port": false, "require_upload_port": true, "speed": 921600 }, diff --git a/boards/station-g3-esp32.json b/boards/station-g3-esp32.json index abfe5ece..9b74f203 100644 --- a/boards/station-g3-esp32.json +++ b/boards/station-g3-esp32.json @@ -8,7 +8,7 @@ "extra_flags": [ "-DBOARD_HAS_PSRAM", "-DARDUINO_USB_CDC_ON_BOOT=1", - "-DARDUINO_USB_MODE=0", + "-DARDUINO_USB_MODE=1", "-DARDUINO_RUNNING_CORE=1", "-DARDUINO_EVENT_RUNNING_CORE=0" ], @@ -33,8 +33,8 @@ "flash_size": "16MB", "maximum_ram_size": 327680, "maximum_size": 16777216, - "use_1200bps_touch": true, - "wait_for_upload_port": true, + "use_1200bps_touch": false, + "wait_for_upload_port": false, "require_upload_port": true, "speed": 921600 }, diff --git a/boards/t_beam_1w.json b/boards/t_beam_1w.json index 09df1ff5..b88982c7 100644 --- a/boards/t_beam_1w.json +++ b/boards/t_beam_1w.json @@ -10,7 +10,7 @@ "-DBOARD_HAS_PSRAM", "-DLILYGO_TBEAM_1W", "-DARDUINO_USB_CDC_ON_BOOT=1", - "-DARDUINO_USB_MODE=0", + "-DARDUINO_USB_MODE=1", "-DARDUINO_RUNNING_CORE=1", "-DARDUINO_EVENT_RUNNING_CORE=1" ], @@ -40,6 +40,8 @@ ], "name": "LilyGo TBeam-1W", "upload": { + "use_1200bps_touch": false, + "wait_for_upload_port": false, "flash_size": "16MB", "maximum_ram_size": 327680, "maximum_size": 16777216, diff --git a/docs/test-results/usb_serial_backpressure.md b/docs/test-results/usb_serial_backpressure.md index e7af43ed..3ddafe7d 100644 --- a/docs/test-results/usb_serial_backpressure.md +++ b/docs/test-results/usb_serial_backpressure.md @@ -1,5 +1,10 @@ # Native USB backpressure and radio liveness +The native USB defaults for eight ESP32-S3 board families now use hardware +USB Serial/JTAG (HWCDC). The TinyUSB behavior below remains available to +explicitly selected TinyUSB builds. UART and non-ESP32 transports retain their +existing backend. + ESP32-S2/S3 builds using native TinyUSB CDC (`ARDUINO_USB_MODE=0` and CDC-on-boot) must not wait for a computer to read USB output. In the bundled Arduino-ESP32 2.0.17 core, `USBCDC::write()` can wait indefinitely for transmit @@ -159,3 +164,78 @@ describe those earlier binaries, not the current merged source. The two custom qualification builds used the lab profile `909.5 MHz / 500 kHz / SF5 / CR5`. No board was flashed, and no physical disconnect/reconnect or hardware endpoint recovery was claimed by these checks. + +## ESP32 HWCDC migration and upstream findings (2026-10-04) + +The board defaults change 78 native-TinyUSB recipes across Heltec E213, E290, +T190, Wireless Tracker V1.1 and V2, Station G2 and G3, and T-Beam 1W. Two G2 +recipes already used HWCDC. All 80 recipes in these families select +`ARDUINO_USB_MODE=1` and `ARDUINO_USB_CDC_ON_BOOT=1`; upload touch and waiting +for a replacement upload port are explicitly disabled. Their framework stays +on PlatformIO Espressif32 6.11.0 / Arduino-ESP32 2.0.17. Existing ESP32 UART +recipes and all non-ESP32 recipes are unchanged. + +HWCDC output uses the existing bounded facade, early RX/TX allocation, shared +writer exclusion, and fresh capacity check inside that exclusion. The pinned +SDK receives a build-local TX suffix/interrupt backport from upstream +[Arduino-ESP32 #12606](https://github.com/espressif/arduino-esp32/pull/12606). +KISS now prepares buffers before starting the USB driver and uses this facade. +Its debug/logging gates stay closed so diagnostics cannot split a binary frame. +A USB bus reset drops partial host commands and old queued frame suffixes while +preserving radio transmission state and settings. Radio work continues while +USB cleanup retries. + +On S3, the pinned HWCDC initialization also restores RTC PHY ownership to the +hardware Serial/JTAG peripheral. A software restart after running TinyUSB can +otherwise retain ownership by the OTG controller, including an OTA update to an +HWCDC image. When that retained state is detected, initialization first briefly +detaches the old OTG pads so the host can enumerate the changed device. Ordinary +HWCDC startup does not incur that extra interval. + +HWCDC does not expose the same DTR session boundary as TinyUSB. Closing a host +port while leaving USB connected is not proof of a new firmware session; a +partial command may survive that close. Bus reset/unplug cleanup and protocol +client leases must be tested separately. Hosts should rediscover the port after +the backend change: the USB product and `/dev/serial/by-id` path can change. + +Upstream reports checked for this transition: + +| Report | Documented behavior | Relevance and mitigation | +| --- | --- | --- | +| [ESP-IDF #9826](https://github.com/espressif/esp-idf/issues/9826) | S3 retains TinyUSB PHY ownership across software/watchdog restart, preventing USB Serial/JTAG operation. | Pinned HWCDC initialization now explicitly restores the official S3 hardware PHY route and detaches the former OTG connection first. Executed initialization tests seed the retained RTC state; real OTA transition remains a separate hardware check. | +| [Meshtastic #10955](https://github.com/meshtastic/firmware/issues/10955) | Tracker V2 watchdog reboot after a serial reader closes; raw log writes block while the USB bus remains alive. Reproduced on Arduino 2.x and 3.x. | Applies to the same hardware/backend combination. Our bounded writer and diagnostic admission prevent waiting for a host to drain output; synthetic negative controls exercise unsafe raw writes. | +| [Meshtastic #10975](https://github.com/meshtastic/firmware/issues/10975) | Tracker V2 binary synchronization corrupted by interleaved diagnostics and abandoned short-write suffixes. | Companion/KISS tests retain suffixes across short writes; KISS disables diagnostics before board initialization. | +| [Arduino-ESP32 #12782](https://github.com/espressif/arduino-esp32/issues/12782) | Open report of HWCDC failing to reconnect after about two minutes idle on C3/C6/C61/S3, latest master / IDF 5.5.4. | Reported SDK differs from our pinned Arduino 2.0.17 / IDF 4.4. Synthetic tests cover idle lease expiry and reply admission, not physical enumeration on that SDK. | +| [ESP-IDF #18996](https://github.com/espressif/esp-idf/issues/18996) | Open report of S3 USB Serial/JTAG failing after repeated software resets from macOS, on IDF 6.0.2; includes XIAO S3. | Not qualified by Linux port reopen tests and not reproduced on our pinned SDK. CPU reset and host-port reopen are distinct cases. | +| [ESP-IDF #13287](https://github.com/espressif/esp-idf/issues/13287) | S3 may remain in ROM after upload when download mode was entered with physical BOOT/RESET. | Our older esptool does not offer the newer `watchdog-reset` option. Press RESET after a manual BOOT-mode upload if the application does not start. | +| [Meshtastic #4206](https://github.com/meshtastic/firmware/issues/4206) | Station G2 native USB fails to resume around light sleep. | Keep USB active while a host is present. Espressif documents USB Serial/JTAG sleep limitations; a port disappearing during sleep does not establish a firmware crash. | + +Espressif's [USB Serial/JTAG guide](https://docs.espressif.com/projects/esp-idf/en/stable/esp32s3/api-guides/usb-serial-jtag-console.html) +describes the fixed CDC/JTAG peripheral and its light/deep sleep limitations. +This migration uses only its serial role; it does not provide TinyUSB composite +interfaces such as MSC, HID, or a second CDC. + +Additional regression commands (only one PlatformIO process at a time): + +```sh +python3 -B test/test_esp32_hwcdc_recipes.py -v +python3 -B test/test_hwcdc_tx_backport.py -v +python3 -B test/test_hwcdc_write_capacity.py -v +python3 -B test/test_hwcdc_role_transport.py -v +pio test -e native_kiss_modem +``` + +The recipe check resolves all 821 tracked environments using PlatformIO's real +inheritance and SCons flag/unflag processing. It checks all 500 ESP32 selectors, +including all 78 migrated recipes, and the 321 non-ESP32 recipes. The transport +test compiles production Companion framing, KISS output methods, setup/loop, +and the bounded facade with the patched SDK against simulated USB registers and +RTOS queues under ASan/UBSan. It covers withheld readers, stale capacity, +partial frames, queue limits, session reset, idle leases, and cleanup retries. +The role fixture injects a confirmed session boundary; separate SDK tests +execute the bus-reset callback and the S3 retained-PHY initialization. The +setup/loop fixture uses stub transport functions to check ordering and continued +radio service rather than real peripheral recovery. +Native KISS tests also exercise the full modem's radio lifecycle during reset. +These tests do not establish physical USB timing or host compatibility for +boards that were not connected to the lab. diff --git a/examples/kiss_modem/KissModem.cpp b/examples/kiss_modem/KissModem.cpp index ec309e7b..9436284c 100644 --- a/examples/kiss_modem/KissModem.cpp +++ b/examples/kiss_modem/KissModem.cpp @@ -77,6 +77,13 @@ void KissModem::begin() { resetOutputQueue(); } +void KissModem::resetHostSession() { + _rx_len = 0; + _rx_escaped = false; + _rx_active = false; + resetOutputQueue(); +} + void KissModem::resetOutputQueue() { _tx_frame_head = 0; _tx_frame_tail = 0; diff --git a/examples/kiss_modem/KissModem.h b/examples/kiss_modem/KissModem.h index 88f534d4..9537c0eb 100644 --- a/examples/kiss_modem/KissModem.h +++ b/examples/kiss_modem/KissModem.h @@ -240,6 +240,9 @@ public: mesh::Radio& radio, mesh::MainBoard& board, SensorManager& sensors); void begin(); + // Drop partial host input and queued frame suffixes at a USB bus reset. + // Radio transmissions and profile settings belong to the radio lifecycle. + void resetHostSession(); void loop(); void setRadioCallback(SetRadioCallback cb) { _setRadioCallback = cb; } diff --git a/examples/kiss_modem/main.cpp b/examples/kiss_modem/main.cpp index 0e30bfb8..d965bf8d 100644 --- a/examples/kiss_modem/main.cpp +++ b/examples/kiss_modem/main.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include "KissModem.h" #if defined(ESP32_PLATFORM) @@ -27,14 +28,21 @@ #define NOISE_FLOOR_CALIB_INTERVAL_MS 2000 #define AGC_RESET_INTERVAL_MS 30000 -#define USB_TX_TIMEOUT_MS 50 -#define USB_TX_BUFFER_SIZE 1024 + +#if MESH_ESP32_HWCDC_SESSION_GUARD && !(defined(KISS_UART_RX) && defined(KISS_UART_TX)) + #define KISS_HWCDC_TRANSPORT 1 +#else + #define KISS_HWCDC_TRANSPORT 0 +#endif StdRNG rng; mesh::LocalIdentity identity; KissModem* modem; static uint32_t next_noise_floor_calib_ms = 0; static uint32_t next_agc_reset_ms = 0; +#if KISS_HWCDC_TRANSPORT +static bool usb_host_reset_pending = false; +#endif void halt() { while (1) ; @@ -136,6 +144,11 @@ void onGetStats(uint32_t* rx, uint32_t* tx, uint32_t* errors) { } void setup() { +#if KISS_HWCDC_TRANSPORT + // KISS owns the entire serial byte stream, including gaps between chunks. + mesh::setUsbLoggingEnabled(false); + mesh::setUsbDebugEnabled(false); +#endif board.begin(); int radioinit_attempts = 0; @@ -176,15 +189,22 @@ void setup() { #endif modem = new KissModem(Serial1, identity, rng, radio_driver, board, sensors); #else +#if KISS_HWCDC_TRANSPORT + // Allocate the USB queues before begin() enables the live driver ISR. + mesh::prepareUsbLoggingPort(); +#endif Serial.begin(115200); +#if KISS_HWCDC_TRANSPORT + mesh::beginUsbLoggingPort(); +#endif uint32_t start = millis(); while (!Serial && millis() - start < 3000) delay(10); delay(100); -#if defined(ESP32) && ARDUINO_USB_MODE && ARDUINO_USB_CDC_ON_BOOT - Serial.setTxTimeoutMs(USB_TX_TIMEOUT_MS); - Serial.setTxBufferSize(USB_TX_BUFFER_SIZE); -#endif +#if KISS_HWCDC_TRANSPORT + modem = new KissModem(mesh::usbCompanionPort(), identity, rng, radio_driver, board, sensors); +#else modem = new KissModem(Serial, identity, rng, radio_driver, board, sensors); +#endif #endif modem->setRadioCallback(onSetRadio); @@ -197,6 +217,18 @@ void setup() { } void loop() { +#if KISS_HWCDC_TRANSPORT + mesh::serviceUsbLoggingPort(); + if (mesh::takeUsbTerminalSessionReset()) { + modem->resetHostSession(); + usb_host_reset_pending = true; + } + if (usb_host_reset_pending && mesh::tryCompleteUsbTerminalSessionReset()) { + usb_host_reset_pending = false; + } + // The guarded stream blocks host I/O during cleanup. Continue the modem + // loop so an in-flight RF transmission can finish while USB is quarantined. +#endif #if defined(NRF52_PLATFORM) board.feedWatchdog(); #endif diff --git a/scripts/esp32_usb_session_fix.py b/scripts/esp32_usb_session_fix.py index a028cc14..cc1cdd3a 100644 --- a/scripts/esp32_usb_session_fix.py +++ b/scripts/esp32_usb_session_fix.py @@ -106,7 +106,44 @@ def replace_source(build_env, node): # HWCDC fixes are deliberately limited to the installed Arduino2.0.17 C3/S3 # native Serial/JTAG driver. Other versions retain their own framework driver. PINNED_HWCDC_SHA256 = "d0a8ca606c2729c8522a041113285dbf27033c22a5a6af8649a7305ffe84c449" -PATCHED_HWCDC_SHA256 = "5107db35dc3c855326dff0a9b93b279ee9bda60b5cc1ebcdce77a9028e3577b4" +PATCHED_HWCDC_SHA256 = "7429655b063022e42c677dc79ecf0aab78acf33bba21a8cdb822435400c39719" +HWCDC_S3_PHY_GUARD = "#if CONFIG_IDF_TARGET_ESP32S3 && ARDUINO_USB_MODE && ARDUINO_USB_CDC_ON_BOOT\n" +HWCDC_S3_PHY_HELPERS = HWCDC_S3_PHY_GUARD + r'''#include "soc/usb_wrap_struct.h" +#include "soc/rtc_cntl_struct.h" + +// Exact ESP-IDF 4.4.7 usb_phy_ll.h bodies, renamed locally. The complete HAL +// header has unrelated volatile struct copies that cannot compile as C++. +static inline void mesh_hwcdc_int_jtag_enable(usb_serial_jtag_dev_t *hw) +{ + // USB_Serial_JTAG use internal PHY + hw->conf0.phy_sel = 0; + // Disable software control USB D+ D- pullup pulldown (Device FS: dp_pullup = 1) + hw->conf0.pad_pull_override = 0; + // Enable USB D+ pullup + hw->conf0.dp_pullup = 1; + // Enable USB pad function + hw->conf0.usb_pad_enable = 1; + // phy_sel is controlled by the following register value + RTCCNTL.usb_conf.sw_hw_usb_phy_sel = 1; + // phy_sel=sw_usb_phy_sel=0, USB_Serial_JTAG is connected with internal PHY + RTCCNTL.usb_conf.sw_usb_phy_sel = 0; +} + +static inline void mesh_hwcdc_usb_wrap_pad_enable(usb_wrap_dev_t *hw, bool pad_en) +{ + hw->otg_conf.pad_enable = pad_en; +} +#endif +''' +HWCDC_S3_PHY_CONFIG = r""" // TinyUSB's RTC PHY selection survives a software restart (IDF #9826). + // An OTA upgrade must release its old pullup before claiming the shared + // PHY, so the host discards the previous USB device's descriptors. + if (RTCCNTL.usb_conf.sw_hw_usb_phy_sel && RTCCNTL.usb_conf.sw_usb_phy_sel) { + mesh_hwcdc_usb_wrap_pad_enable(&USB_WRAP, false); + delay(20); + } + mesh_hwcdc_int_jtag_enable(&USB_SERIAL_JTAG); +""" HWCDC_TX_SUPPORT = r""" // MeshCore pinned HWCDC TX suffix/interrupt backport (upstream #12606). static uint8_t mesh_hwcdc_tx_stash[64] = {0}; @@ -262,6 +299,13 @@ def patched_hwcdc_source(source): # Perform exact-shape transforms only after the complete input hash passes. source = source.replace('#include "esp_freertos_hooks.h"\n', '#include "esp_freertos_hooks.h"\n#include \n', 1) + source = source.replace('#include "hal/usb_serial_jtag_ll.h"\n', + '#include "hal/usb_serial_jtag_ll.h"\n' + HWCDC_S3_PHY_HELPERS, 1) + begin = source.index('void HWCDC::begin(unsigned long baud)') + phy_begin = source.index(' // Configure PHY\n', begin) + phy_end = source.index(' usb_serial_jtag_ll_disable_intr_mask(USB_SERIAL_JTAG_LL_INTR_MASK);', phy_begin) + source = (source[:phy_begin] + HWCDC_S3_PHY_GUARD + HWCDC_S3_PHY_CONFIG + + '#else\n' + source[phy_begin:phy_end] + '#endif\n' + source[phy_end:]) source = source.replace('usb_serial_jtag_ll_ena_intr_mask(USB_SERIAL_JTAG_INTR_SERIAL_IN_EMPTY);', 'mesh_hwcdc_enable_tx_intr();') begin = source.index(' if (usbjtag_intr_status & USB_SERIAL_JTAG_INTR_SERIAL_IN_EMPTY) {') diff --git a/test/fixtures/hwcdc_kiss_setup.cpp b/test/fixtures/hwcdc_kiss_setup.cpp new file mode 100644 index 00000000..2d80175c --- /dev/null +++ b/test/fixtures/hwcdc_kiss_setup.cpp @@ -0,0 +1,169 @@ +// The runner inserts the production KISS setup and backend selection. +#include +#include +#include +#include +#include + +static std::vector calls; +static bool usb_logging = true, usb_debug = true; +static bool serial_live = false; +static bool reset_event = false, cleanup_ready = false; +struct TestSerial : Stream { + int rx = -1, tx = -1; + unsigned baud = 0; + explicit operator bool() const { return true; } + void begin(unsigned speed) { + baud = speed; + if (this == primary) { serial_live = true; calls.push_back("serial.begin"); } + else calls.push_back("uart.begin"); + } + void setPins(int receive, int transmit) { rx = receive; tx = transmit; } + static TestSerial* primary; +} Serial, Serial1; +TestSerial* TestSerial::primary = &Serial; +static Stream guarded_port; +namespace mesh { +void setUsbLoggingEnabled(bool value) { usb_logging = value; calls.push_back("logging.gate"); } +void setUsbDebugEnabled(bool value) { usb_debug = value; calls.push_back("debug.gate"); } +void prepareUsbLoggingPort() { + assert(!serial_live && "HWCDC ring resize must precede enabling the ISR"); + calls.push_back("prepare"); +} +void beginUsbLoggingPort() { + assert(serial_live); + calls.push_back("begin.logging"); +} +Stream& usbCompanionPort() { return guarded_port; } +void serviceUsbLoggingPort() { calls.push_back("usb.service"); } +bool takeUsbTerminalSessionReset() { + calls.push_back("usb.boundary"); + const bool event = reset_event; + reset_event = false; + return event; +} +bool tryCompleteUsbTerminalSessionReset() { + calls.push_back("usb.cleanup"); + return cleanup_ready; +} +} +struct Board { + void begin() { +#if EXPECT_HWCDC + assert(!usb_logging && !usb_debug); +#endif + calls.push_back("board.begin"); + } + void reboot() { assert(false); } + void onBootComplete() { calls.push_back("boot.complete"); } + void loop() { calls.push_back("board.loop"); } +} board; +struct Radio { + void begin() { calls.push_back("radio.begin"); } + unsigned getRngSeed() const { return 0; } + void resetAGC() { calls.push_back("radio.agc"); } + int recvRaw(uint8_t*, size_t) { calls.push_back("radio.recv"); return 0; } + uint8_t receiveProfile() const { return 0; } + float getLastSNR() const { return 0; } + float getLastRSSI() const { return 0; } + void onReceiveProcessed() {} + void triggerNoiseFloorCalibrate(unsigned) { calls.push_back("radio.noise"); } + void loop() { calls.push_back("radio.loop"); } +} radio_driver; +struct Rng { void begin(unsigned) {} } rng; +static int identity; +struct Sensors { + void begin() {} + void loop() { calls.push_back("sensors.loop"); } +} sensors; +static bool radio_init() { return true; } +static void loadOrCreateIdentity() {} +static void onSetRadio() {} +static void onSetTxPower() {} +static void onGetCurrentRssi() {} +static void onGetStats() {} +class KissModem { +public: + Stream* port; + KissModem(Stream& stream, int&, Rng&, Radio&, Board&, Sensors&) : port(&stream) { + calls.push_back("modem.construct"); + } + template void setRadioCallback(T) {} + template void setTxPowerCallback(T) {} + template void setGetCurrentRssiCallback(T) {} + template void setGetStatsCallback(T) {} + void begin() { calls.push_back("modem.begin"); } + void loop() { calls.push_back("modem.loop"); } + void resetHostSession() { calls.push_back("modem.reset"); } + bool isActuallyTransmitting() const { return true; } + bool isHostOutputBackedUp() const { return false; } + bool isTxBusy() const { return true; } + void onPacketReceived(int8_t, int8_t, const uint8_t*, int, uint8_t) {} +}; +static KissModem* modem = nullptr; +#define MESH_DEBUG_PRINTLN(...) ((void)0) +@TRANSPORT_SELECTION@ +@LOOP_STATE@ +@SETUP@ +@LOOP@ + +int main() { + setup(); + assert(modem != nullptr); +#if EXPECT_HWCDC + assert(modem->port == &guarded_port); + const std::vector expected = { + "logging.gate", "debug.gate", "board.begin", "radio.begin", "prepare", + "serial.begin", "begin.logging", "modem.construct", "modem.begin", "boot.complete"}; + assert(calls == expected); +#elif EXPECT_UART + assert(modem->port == &Serial1); + assert(Serial1.rx == KISS_UART_RX && Serial1.tx == KISS_UART_TX); + assert(Serial1.baud == 115200 && !serial_live); + const std::vector expected = { + "board.begin", "radio.begin", "uart.begin", "modem.construct", "modem.begin", "boot.complete"}; + assert(calls == expected); +#else + assert(modem->port == &Serial); + const std::vector expected = { + "board.begin", "radio.begin", "serial.begin", "modem.construct", "modem.begin", "boot.complete"}; + assert(calls == expected); +#endif + // An in-flight radio send and the outer sketch services must continue while + // native USB queue purging retries. The boundary is consumed only once. + calls.clear(); + loop(); +#if EXPECT_HWCDC + assert(!usb_host_reset_pending); + const std::vector normal = { + "usb.service", "usb.boundary", "modem.loop", "sensors.loop", "board.loop", "radio.loop"}; + assert(calls == normal); + reset_event = true; + calls.clear(); + loop(); + assert(usb_host_reset_pending); + const std::vector boundary = { + "usb.service", "usb.boundary", "modem.reset", "usb.cleanup", + "modem.loop", "sensors.loop", "board.loop", "radio.loop"}; + assert(calls == boundary); + calls.clear(); + loop(); + assert(usb_host_reset_pending); + const std::vector retry = { + "usb.service", "usb.boundary", "usb.cleanup", "modem.loop", + "sensors.loop", "board.loop", "radio.loop"}; + assert(calls == retry); + cleanup_ready = true; + calls.clear(); + loop(); + assert(!usb_host_reset_pending && calls == retry); + calls.clear(); + loop(); + assert(calls == normal); +#else + const std::vector normal = { + "modem.loop", "sensors.loop", "board.loop", "radio.loop"}; + assert(calls == normal); +#endif + delete modem; +} diff --git a/test/fixtures/hwcdc_role_transport.cpp b/test/fixtures/hwcdc_role_transport.cpp new file mode 100644 index 00000000..d095f1c3 --- /dev/null +++ b/test/fixtures/hwcdc_role_transport.cpp @@ -0,0 +1,253 @@ +// This file is appended to the actual HWCDC facade/SDK host harness. +#include + +@KISS_CONSTANTS@ +class KissModem { + Stream& _serial; +@KISS_FIELDS@ +@KISS_DECLARATIONS@ +public: + explicit KissModem(Stream& serial) : _serial(serial) { resetOutputQueue(); } + bool queue(uint8_t type, const std::vector& bytes) { + return queueFrame(type, bytes.data(), bytes.size()); + } + bool service() { return tryFlushFrames(); } + bool busy() { return _tx_frame_count != 0 || _tx_busy_error_pending; } + void serviceBusy() { queuePendingBusyError(); } +}; +@KISS_METHODS@ + +@COMPANION_LEASE_DEFAULTS@ +static_assert(USB_FRAME_REPLY_GRACE_MS == 2000, "default frame grace"); +static_assert(USB_CLIENT_IDLE_TIMEOUT == 600000, "default idle lease"); +static_assert(USB_HOST_LOSS_EDGE_MS == 100, "default host edge"); +static_assert(USB_HOST_LOSS_GRACE_MS == 2000, "default host grace"); +static mesh::UsbHostPresenceDebouncer usb_hwcdc_host_presence; +static ArduinoSerialInterface usb_serial_interface; +struct Board { + bool host = true; + bool isUsbHostConnected() const { return host; } +} board; +struct Mesh { + bool finite = false; + bool hasFiniteDelayedReplyForRoute(const ArduinoSerialInterface*) const { + return finite; + } +} the_mesh; + +// Independent protocol expectations; no production encoder is used here. +static std::vector companionFrame(const std::vector& payload) { + std::vector result = {'>', uint8_t(payload.size()), uint8_t(payload.size() >> 8)}; + result.insert(result.end(), payload.begin(), payload.end()); + return result; +} +static std::vector kissFrame(uint8_t type, const std::vector& payload) { + std::vector result = {0xC0}; + auto encode = [&](uint8_t byte) { + if (byte == 0xC0) result.insert(result.end(), {0xDB, 0xDC}); + else if (byte == 0xDB) result.insert(result.end(), {0xDB, 0xDD}); + else result.push_back(byte); + }; + encode(type); + for (uint8_t byte : payload) encode(byte); + result.push_back(0xC0); + return result; +} +static void append(std::vector& dest, const std::vector& bytes) { + dest.insert(dest.end(), bytes.begin(), bytes.end()); +} + +// Consume ring capacity after the protocol's outer availableForWrite sample. +// This represents another serial producer winning before the write. It is a +// one-shot disturbance, and must never make the guarded SDK wait for space. +class StaleCapacityStream : public Stream { + Stream& _port; + bool _consume_once = true; +public: + explicit StaleCapacityStream(Stream& port) : _port(port) {} + int availableForWrite() override { return _port.availableForWrite(); } + size_t write(const uint8_t* data, size_t size) override { + if (_consume_once) { + _consume_once = false; + ring_free = ring_free > 2 ? 2 : ring_free; + } + return _port.write(data, size); + } +}; + +static void setupCompanion(bool activity_check = false) { + usb_serial_interface.begin(@ROLE_STREAM@); + usb_serial_interface.enableFlowControl(true); + usb_serial_interface.enable(); + if (activity_check) { +@COMPANION_CONNECTION@ + } +} +static void drainCompanion(size_t capacity = 7) { + for (unsigned pass = 0; pass < 1000 && usb_serial_interface.hasPendingIO(); ++pass) { + ring_free = capacity; + usb_serial_interface.loop(); + } + assert(!usb_serial_interface.hasPendingIO()); +} +static void drainKiss(KissModem& modem, size_t capacity = 7) { + for (unsigned pass = 0; pass < 1000 && modem.busy(); ++pass) { + ring_free = capacity; + modem.service(); + modem.serviceBusy(); + } + assert(!modem.busy()); +} + +int main(int argc, char** argv) { + assert(argc == 2); + const std::string name = argv[1]; + mesh::serialLogBegin(); + assert(tx_timeout_ms == 5); + if (name == "companion_stall" || name == "companion_short") { + setupCompanion(); + std::vector payload(MAX_FRAME_SIZE); + payload[0] = 0x05; // Required protocol response, rather than a best-effort push. + for (size_t i = 1; i < payload.size(); ++i) payload[i] = uint8_t(i); + const std::vector tail = {0x00, 0xC0, 0xDB}; + ring_free = name == "companion_short" ? 5 : 0; + if (name == "companion_short") reject_send = true; + assert(usb_serial_interface.writeFrame(payload.data(), payload.size()) == payload.size()); + assert(usb_serial_interface.writeFrame(tail.data(), tail.size()) == tail.size()); + for (unsigned poll = 0; poll < 100; ++poll) usb_serial_interface.loop(); + assert(admitted.empty() && usb_serial_interface.hasPendingIO()); + reject_send = false; + drainCompanion(); + auto expected = companionFrame(payload); + append(expected, companionFrame(tail)); + assert(admitted == expected && zero_sends == 0 && g_mock_millis == 0); + } else if (name == "companion_stale") { + StaleCapacityStream stale(@ROLE_STREAM@); + usb_serial_interface.begin(stale); + usb_serial_interface.enableFlowControl(true); + usb_serial_interface.enable(); + ring_free = 10; + const std::vector payload = {0x05, 1, 2, 3, 4, 5, 6, 7}; + assert(usb_serial_interface.writeFrame(payload.data(), payload.size()) == payload.size()); + assert(admitted.size() == 2 && g_mock_millis == 0 && zero_sends == 0); + drainCompanion(); + assert(admitted == companionFrame(payload)); + } else if (name == "companion_session") { + setupCompanion(); + host_input = {'<', 3, 0, 0x01}; + uint8_t received[MAX_FRAME_SIZE] = {}; + assert(usb_serial_interface.checkRecvFrame(received) == 0); + assert(usb_serial_interface.isReadBusy()); + ring_free = 0; + const uint8_t stale[] = {0x05, 0xAA}; + assert(usb_serial_interface.writeFrame(stale, sizeof(stale)) == sizeof(stale)); + // A confirmed BUS_RESET owner closes access before purging protocol state. + access_allowed = false; + usb_serial_interface.resetSessionState(); + host_input.clear(); + assert(!usb_serial_interface.hasPendingIO()); + ring_free = 32; + assert(mesh::guarded.write(stale, sizeof(stale)) == 0 && admitted.empty()); + access_allowed = true; + host_input = {'<', 1, 0, 0x02}; + assert(usb_serial_interface.checkRecvFrame(received) == 1 && received[0] == 0x02); + const uint8_t fresh[] = {0x00, 0xBB}; + assert(usb_serial_interface.writeFrame(fresh, sizeof(fresh)) == sizeof(fresh)); + assert(admitted == companionFrame({0x00, 0xBB})); + } else if (name == "companion_activity_lease") { + setupCompanion(true); + board.host = false; + connected = false; // The SDK activity heuristic is not DTR proof. + assert(!usb_serial_interface.isConnected()); + g_mock_millis = 100; + host_input = {'<', 1, 0, 0x01}; + uint8_t received[MAX_FRAME_SIZE] = {}; + assert(usb_serial_interface.checkRecvFrame(received) == 1 && received[0] == 0x01); + assert(usb_serial_interface.isConnected()); + ring_free = 0; + const uint8_t response[] = {0x00, 0x11, 0x22}; + assert(usb_serial_interface.writeFrame(response, sizeof(response)) == sizeof(response)); + g_mock_millis += 5; // Transient SOF/activity gap must not discard a queued reply. + drainCompanion(2); + assert(admitted == companionFrame({0x00, 0x11, 0x22})); + board.host = true; + assert(usb_serial_interface.isConnected()); + g_mock_millis = 100 + 120000; + assert(usb_serial_interface.isConnected()); + ring_free = 2; + assert(usb_serial_interface.writeFrame(response, sizeof(response)) == sizeof(response)); + drainCompanion(2); + auto after_idle = companionFrame({0x00, 0x11, 0x22}); + append(after_idle, companionFrame({0x00, 0x11, 0x22})); + assert(admitted == after_idle && zero_sends == 0); + g_mock_millis = 100 + USB_CLIENT_IDLE_TIMEOUT; + assert(!usb_serial_interface.isConnected()); // An idle cable cannot claim USB forever. + the_mesh.finite = true; + assert(usb_serial_interface.isConnected()); + the_mesh.finite = false; + assert(usb_serial_interface.isConnected()); // One bounded final-frame drain lease. + g_mock_millis += USB_FRAME_REPLY_GRACE_MS; + assert(!usb_serial_interface.isConnected()); + usb_serial_interface.resetSessionState(); + usb_hwcdc_host_presence.reset(); + assert(!usb_serial_interface.isConnected()); + host_input = {'<', 1, 0, 0x02}; + assert(usb_serial_interface.checkRecvFrame(received) == 1 && received[0] == 0x02); + assert(usb_serial_interface.isConnected()); + ring_free = 2; + assert(usb_serial_interface.writeFrame(response, sizeof(response)) == sizeof(response)); + drainCompanion(2); + append(after_idle, companionFrame({0x00, 0x11, 0x22})); + assert(admitted == after_idle && zero_sends == 0); + } else if (name == "kiss_stall" || name == "kiss_short") { + KissModem modem(@ROLE_STREAM@); + ring_free = name == "kiss_short" ? 7 : 0; + reject_send = name == "kiss_short"; + const std::vector payload = {0xC0, 0xDB, 1, 2, 3, 4}; + assert(modem.queue(0, payload)); + for (unsigned poll = 0; poll < 100; ++poll) modem.service(); + assert(admitted.empty() && modem.busy()); + reject_send = false; + drainKiss(modem, 2); + assert(admitted == kissFrame(0, payload) && zero_sends == 0 && g_mock_millis == 0); + } else if (name == "kiss_escaped_max") { + KissModem modem(@ROLE_STREAM@); + std::vector payload(KISS_MAX_ESCAPABLE_BYTES); + for (size_t i = 0; i < payload.size(); ++i) payload[i] = i % 2 ? 0xDB : 0xC0; + // Both the type and each payload byte expand to two bytes. The actual + // encoder must reject one more byte without retaining an invalid frame. + assert(!modem.queue(0xC0, payload)); + assert(!modem.busy() && admitted.empty()); + payload.pop_back(); + assert(modem.queue(0xC0, payload)); + assert(modem.busy() && admitted.empty()); + drainKiss(modem, 3); + const auto expected = kissFrame(0xC0, payload); + assert(expected.size() == KISS_MAX_ENCODED_FRAME_SIZE); + assert(admitted == expected && g_mock_millis == 0 && zero_sends == 0); + } else if (name == "kiss_stale") { + StaleCapacityStream stale(@ROLE_STREAM@); + KissModem modem(stale); + ring_free = 10; + const std::vector payload = {0xC0, 0xDB, 1, 2, 3, 4}; + assert(modem.queue(0, payload)); + assert(admitted.size() == 2 && g_mock_millis == 0 && zero_sends == 0); + drainKiss(modem); + assert(admitted == kissFrame(0, payload)); + } else if (name == "kiss_busy") { + KissModem modem(@ROLE_STREAM@); + const std::vector first = {0xC0, 1}, second = {0xDB, 2}, discarded = {3}; + assert(modem.queue(0, first)); + assert(modem.queue(0, second)); + assert(!modem.queue(0, discarded)); + assert(admitted.empty() && modem.busy()); + drainKiss(modem, 1); + auto expected = kissFrame(0, first); + append(expected, kissFrame(0, second)); + append(expected, kissFrame(0x06, {0xF1, 0x07})); + assert(admitted == expected && g_mock_millis == 0 && zero_sends == 0); + } else { + assert(false && "unknown transport fixture case"); + } + return 0; +} diff --git a/test/fixtures/hwcdc_tx_backport/README.txt b/test/fixtures/hwcdc_tx_backport/README.txt index 183030f2..67da01b0 100644 --- a/test/fixtures/hwcdc_tx_backport/README.txt +++ b/test/fixtures/hwcdc_tx_backport/README.txt @@ -8,3 +8,11 @@ The full source is necessary to verify the exact input fingerprint and all producer/ISR/reset replacement sites without a PlatformIO package dependency. Underlying correction: upstream commit2ecad233178bfbba9c584ffd9f67e941d84adbb7, https://github.com/espressif/arduino-esp32/pull/12606. + +usb_phy_ll.h is the exact upstream ESP-IDF v4.4.7 ESP32-S3 HAL header: +https://github.com/espressif/esp-idf/blob/v4.4.7/components/hal/esp32s3/include/hal/usb_phy_ll.h +SHA256: 07f869755299e96fc73fb913bb0b9fed2ade580f12a22c058bbb07247f5c959c +It preserves its upstream copyright and Apache-2.0 notice. The executed +HWCDC::begin regression uses these official HAL bodies to test retained RTC +PHY ownership after a TinyUSB-to-HWCDC software restart, including a host +disconnect before the controller changes. It does not simulate enumeration. diff --git a/test/fixtures/hwcdc_tx_backport/usb_phy_ll.h b/test/fixtures/hwcdc_tx_backport/usb_phy_ll.h new file mode 100644 index 00000000..133daf30 --- /dev/null +++ b/test/fixtures/hwcdc_tx_backport/usb_phy_ll.h @@ -0,0 +1,157 @@ +/* + * SPDX-FileCopyrightText: 2015-2023 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#pragma once + +#include +#include "esp_attr.h" +#include "soc/soc.h" +#include "soc/system_struct.h" +#include "soc/usb_wrap_struct.h" +#include "soc/rtc_cntl_struct.h" +#include "soc/usb_serial_jtag_struct.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Configures the internal PHY for USB_OTG + * + * @param hw Start address of the USB Wrap registers + */ +static inline void usb_phy_ll_int_otg_enable(usb_wrap_dev_t *hw) +{ + // USB_OTG use internal PHY + hw->otg_conf.phy_sel = 0; + // phy_sel is controlled by the following register value + RTCCNTL.usb_conf.sw_hw_usb_phy_sel = 1; + // phy_sel=sw_usb_phy_sel=1, USB_OTG is connected with internal PHY + RTCCNTL.usb_conf.sw_usb_phy_sel = 1; +} + +/** + * @brief Configures the external PHY for USB_OTG + * + * @param hw Start address of the USB Wrap registers + */ +static inline void usb_phy_ll_ext_otg_enable(usb_wrap_dev_t *hw) +{ + // USB_OTG use external PHY + hw->otg_conf.phy_sel = 1; + // phy_sel is controlled by the following register value + RTCCNTL.usb_conf.sw_hw_usb_phy_sel = 1; + // phy_sel=sw_usb_phy_sel=0, USB_OTG is connected with external PHY through GPIO Matrix + RTCCNTL.usb_conf.sw_usb_phy_sel = 0; +} + +/** + * @brief Configures the internal PHY for USB_Serial_JTAG + * + * @param hw Start address of the USB Serial_JTAG registers + */ +static inline void usb_phy_ll_int_jtag_enable(usb_serial_jtag_dev_t *hw) +{ + // USB_Serial_JTAG use internal PHY + hw->conf0.phy_sel = 0; + // Disable software control USB D+ D- pullup pulldown (Device FS: dp_pullup = 1) + hw->conf0.pad_pull_override = 0; + // Enable USB D+ pullup + hw->conf0.dp_pullup = 1; + // Enable USB pad function + hw->conf0.usb_pad_enable = 1; + // phy_sel is controlled by the following register value + RTCCNTL.usb_conf.sw_hw_usb_phy_sel = 1; + // phy_sel=sw_usb_phy_sel=0, USB_Serial_JTAG is connected with internal PHY + RTCCNTL.usb_conf.sw_usb_phy_sel = 0; +} + +/** + * @brief Configures the external PHY for USB_Serial_JTAG + * + * @param hw Start address of the USB Serial_JTAG registers + */ +static inline void usb_phy_ll_ext_jtag_enable(usb_serial_jtag_dev_t *hw) +{ + // USB_Serial_JTAG use external PHY + hw->conf0.phy_sel = 1; + // phy_sel is controlled by the following register value + RTCCNTL.usb_conf.sw_hw_usb_phy_sel = 1; + // phy_sel=sw_usb_phy_sel=1, USB_Serial_JTAG is connected with external PHY + RTCCNTL.usb_conf.sw_usb_phy_sel = 1; +} + +/** + * @brief Configures port loads for the internal PHY + * + * @param hw Start address of the USB Wrap registers + * @param dp_pu D+ pullup load + * @param dp_pd D+ pulldown load + * @param dm_pu D- pullup load + * @param dm_pd D- pulldown load + */ +static inline void usb_phy_ll_int_load_conf(usb_wrap_dev_t *hw, bool dp_pu, bool dp_pd, bool dm_pu, bool dm_pd) +{ + usb_wrap_otg_conf_reg_t conf = hw->otg_conf; + conf.pad_pull_override = 1; + conf.dp_pullup = dp_pu; + conf.dp_pulldown = dp_pd; + conf.dm_pullup = dm_pu; + conf.dm_pulldown = dm_pd; + hw->otg_conf = conf; +} + +/** + * @brief Enable the internal PHY control to D+/D- pad + * @param hw Start address of the USB Wrap registers + * @param pad_en Enable the PHY control to D+/D- pad + */ +static inline void usb_phy_ll_usb_wrap_pad_enable(usb_wrap_dev_t *hw, bool pad_en) +{ + hw->otg_conf.pad_enable = pad_en; +} + +/** + * @brief Enable the internal PHY's test mode + * + * @param hw Start address of the USB Wrap registers + * @param en Whether to enable the internal PHY's test mode + */ +static inline void usb_phy_ll_int_enable_test_mode(usb_wrap_dev_t *hw, bool en) +{ + if (en) { + // Clear USB_WRAP_TEST_CONF_REG + hw->test_conf.val = 0; + // Set USB test pad oen + hw->test_conf.test_usb_wrap_oe = 1; + // Enable USB test mode + hw->test_conf.test_enable = 1; + } else { + hw->test_conf.test_enable = 0; + } +} + +/** + * Enable the bus clock for USB Wrap module + * @param clk_en True if enable the clock of USB Wrap module + */ +FORCE_INLINE_ATTR void usb_phy_ll_usb_wrap_enable_bus_clock(bool clk_en) +{ + SYSTEM.perip_clk_en0.usb_clk_en = clk_en; +} + +/** + * @brief Reset the USB Wrap module + */ +FORCE_INLINE_ATTR void usb_phy_ll_usb_wrap_reset_register(void) +{ + SYSTEM.perip_rst_en0.usb_rst = 1; + SYSTEM.perip_rst_en0.usb_rst = 0; +} + +#ifdef __cplusplus +} +#endif diff --git a/test/test_esp32_hwcdc_recipes.py b/test/test_esp32_hwcdc_recipes.py new file mode 100644 index 00000000..0bdbabcd --- /dev/null +++ b/test/test_esp32_hwcdc_recipes.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +"""Resolve ESP32 USB recipes with PlatformIO without starting a build. + +The board manifests select Serial's SDK class and the build-local SDK patch. +Exercise both selectors after PlatformIO's real flag/unflag processing. This +also checks UART and non-ESP32 recipes, whose transports must not migrate. +""" + +from collections import Counter +import json +import os +from pathlib import Path +import re +import runpy +import sys +import unittest + +try: + import SCons.Script +except ImportError: + core = Path(os.environ.get("PLATFORMIO_CORE_DIR", Path.home() / ".platformio")) + for path in sorted((core / "packages" / "tool-scons").glob("scons-local-*"), reverse=True): + sys.path.insert(0, str(path)) + import SCons.Script + +from platformio.builder.tools.piobuild import ParseFlagsExtended, ProcessFlags, ProcessUnFlags +from platformio.project.config import ProjectConfig + + +ROOT = Path(__file__).resolve().parents[1] +CORE = Path(os.environ.get("PLATFORMIO_CORE_DIR", Path.home() / ".platformio")) +USB_MODE = "ARDUINO_USB_MODE" +USB_CDC = "ARDUINO_USB_CDC_ON_BOOT" +USB_MACROS = (USB_MODE, USB_CDC) + +# These are the eight native-USB families that used TinyUSB before migration. +# G2 already used HWCDC for its observer repeater and KISS roles. +MIGRATED_BOARD_COUNTS = { + "heltec_e213": 8, + "heltec_e290": 9, + "heltec_t190": 9, + "heltec_tracker_v1_1": 2, + "heltec_tracker_v2": 15, + "station-g2": 12, + "station-g3-esp32": 14, + "t_beam_1w": 11, +} +PREVIOUS_G2_HWCDC = {"Station_G2_repeater_observer_mqtt", "Station_G2_kiss_modem"} +LEGACY_DIAGNOSTIC_CDC_OVERRIDES = { + "heltec_v4_partition_migrator_test_usb_diagnostic", + "xiao_s3_partition_migrator_test_usb_diagnostic", +} + +# Pin the unaffected family/transport counts rather than copying a machine's +# environment inventory. Mixed families have explicit role rules below. +UNCHANGED_COUNTS = { + ("ESP32-S3-WROOM-1-N4", "uart"): 21, + ("ebyte_eora-s3", "hwcdc"): 8, + ("esp32-c3-devkitm-1", "uart"): 19, + ("esp32-c6-devkitm-1", "hwcdc"): 12, + ("esp32-c6-devkitm-1", "uart"): 4, + ("esp32-s3-devkitc-1", "hwcdc"): 22, + ("esp32-s3-devkitc-1", "uart"): 55, + ("esp32-s3-zero", "hwcdc"): 17, + ("esp32dev", "classic_uart"): 5, + ("esp32doit-devkit-v1", "classic_uart"): 19, + ("esp32s3box", "hwcdc"): 7, + ("heltec-rc32", "hwcdc"): 22, + ("heltec_v4", "hwcdc"): 45, + ("heltec_v4_migrator", "hwcdc"): 1, + ("heltec_v4_migrator", "uart"): 4, + ("heltec_v4_r8", "hwcdc"): 25, + ("heltec_wifi_lora_32_V2", "classic_uart"): 9, + ("meshnology_w12", "hwcdc"): 12, + ("seeed_xiao_esp32c3", "hwcdc"): 7, + ("seeed_xiao_esp32s3", "hwcdc"): 26, + ("seeed_xiao_esp32s3_migrator", "hwcdc"): 1, + ("seeed_xiao_esp32s3_migrator", "uart"): 3, + ("t-deck", "hwcdc"): 4, + ("t3_s3_v1_x", "hwcdc"): 19, + ("t_beams3_supreme", "hwcdc"): 9, + ("thinknode_m7", "uart"): 10, + ("thinknode_m9", "uart"): 6, + ("ttgo-lora32-v1", "classic_uart"): 12, + ("ttgo-t-beam", "classic_uart"): 16, +} + +# Native-only CI does not install the ESP32 platform's standard manifests. +# Use installed pinned manifests when present, and retain their 6.11.0 USB +# defaults for a test requiring no downloads. Project-owned manifests, +# including every migrated board, are always read from the checkout. +EXTERNAL_BOARD_DEFAULTS = { + "esp32-c3-devkitm-1": ("esp32c3", []), + "esp32-c6-devkitm-1": ("esp32c6", []), + "esp32-s3-devkitc-1": ("esp32s3", ["-DARDUINO_USB_MODE=1"]), + "esp32dev": ("esp32", []), + "esp32doit-devkit-v1": ("esp32", []), + "esp32s3box": ("esp32s3", ["-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=1"]), + "heltec_wifi_lora_32_V2": ("esp32", []), + "seeed_xiao_esp32c3": ("esp32c3", ["-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=1"]), + "seeed_xiao_esp32s3": ("esp32s3", ["-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=1"]), + "ttgo-lora32-v1": ("esp32", []), + "ttgo-t-beam": ("esp32", []), +} + + +def flag_environment(board_flags=(), build_flags=(), build_unflags=()): + env = SCons.Script.Environment(tools=["gcc"]) + for function in (ParseFlagsExtended, ProcessFlags, ProcessUnFlags): + env.AddMethod(function) + # Match ProcessProgramDeps: board, user, then build_unflags. + env.ProcessFlags(list(board_flags)) + env.ProcessFlags(list(build_flags)) + env.ProcessUnFlags(list(build_unflags)) + return env + + +def usb_values(env, *, legacy_diagnostic_cdc_override=False): + values = {name: [] for name in USB_MACROS} + for definition in env.get("CPPDEFINES", []): + if isinstance(definition, (tuple, list)): + name, value = definition[:2] + else: + name, value = definition, "1" + if name in values: + values[name].append(str(value)) + for name, history in values.items(): + definitions = set(history) + permitted_override = legacy_diagnostic_cdc_override and name == USB_CDC + if (len(definitions) > 1 and not permitted_override) or definitions - {"0", "1"}: + raise ValueError(f"ambiguous USB selector {name}: {sorted(definitions)}") + # ProcessFlags moves -U options to the end of the compiler command. + # The SDK patch selector reads CPPDEFINES, so such an override could + # compile a different class from the one whose source was patched. + if re.search(r"-U\s*" + re.escape(name) + r"\b", str(env.get("_CPPDEFFLAGS", ""))): + raise ValueError(f"USB selector undefinition bypasses SDK selection: {name}") + result = {name: history[-1] if history else "0" for name, history in values.items()} + if result[USB_CDC] == "1" and not values[USB_MODE]: + raise ValueError("native CDC requires an explicit USB backend for SDK selection") + return result + + +def bool_option(value): + if isinstance(value, bool): + return value + if str(value).lower() not in ("true", "false", "1", "0", "yes", "no"): + raise ValueError(f"invalid upload boolean: {value}") + return str(value).lower() in ("true", "1", "yes") + + +def expected_unchanged_transport(board, name): + choices = {kind for family, kind in UNCHANGED_COUNTS if family == board} + if len(choices) == 1: + return next(iter(choices)) + if board == "esp32-s3-devkitc-1": + return "hwcdc" if name.startswith(("Heltec_Wireless_Tracker_", "RAK_3112_")) else "uart" + if board == "esp32-c6-devkitm-1": + if name.startswith("M5Stack_Unit_C6L_"): + return "hwcdc" if name == "M5Stack_Unit_C6L_companion_radio_usb" else "uart" + return "hwcdc" + if board in ("heltec_v4_migrator", "seeed_xiao_esp32s3_migrator"): + return "hwcdc" if name.endswith("_test_usb_diagnostic") else "uart" + raise AssertionError(f"unreviewed ESP32 board: {board}") + + +class Esp32HwcdcRecipeTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Load tracked recipes explicitly. A developer's platformio.local.ini + # must not create extra matrix rows or change this regression contract. + cls.config = ProjectConfig(str(ROOT / "platformio.ini"), parse_extra=False) + for path in sorted((ROOT / "variants").glob("*/platformio.ini")): + cls.config.read(str(path), parse_extra=False) + cls.sdk = runpy.run_path(str(ROOT / "scripts/esp32_usb_session_fix.py")) + cls.rows = {} + for section in cls.config.sections(): + if not section.startswith("env:"): + continue + build_flags = cls.config.get(section, "build_flags", []) + board = cls.config.get(section, "board", "") + manifest_path = ROOT / "boards" / f"{board}.json" + manifest = json.loads(manifest_path.read_text()) if manifest_path.is_file() else {} + esp32 = any(re.search(r"\bESP32_PLATFORM\b", flag) for flag in build_flags) + if manifest: + mcu = manifest["build"].get("mcu", "") + board_flags = manifest["build"].get("extra_flags", []) + elif esp32: + mcu, board_flags = EXTERNAL_BOARD_DEFAULTS[board] + pinned_path = CORE / "platforms" / "espressif32@6.11.0" / "boards" / f"{board}.json" + if pinned_path.is_file(): + pinned = json.loads(pinned_path.read_text()) + mcu = pinned["build"]["mcu"] + board_flags = pinned["build"].get("extra_flags", []) + else: + mcu, board_flags = "", [] + if isinstance(board_flags, str): + board_flags = [board_flags] + env = flag_environment(board_flags, build_flags, + cls.config.get(section, "build_unflags", [])) + try: + values = usb_values(env, legacy_diagnostic_cdc_override=( + section[4:] in LEGACY_DIAGNOSTIC_CDC_OVERRIDES)) + except ValueError as error: + raise ValueError(f"{section}: {error}") from error + if not esp32: + kind = "non_esp32" + elif mcu == "esp32": + kind = "classic_uart" + elif values[USB_CDC] == "0": + kind = "uart" + else: + kind = "hwcdc" if values[USB_MODE] == "1" else "tinyusb" + cls.rows[section[4:]] = { + "board": board, "mcu": mcu, "env": env, "values": values, + "esp32": esp32, "kind": kind, "manifest": manifest, + } + + def test_all_78_migrated_roles_select_hwcdc_and_its_sdk_patch(self): + counts = Counter() + migrated = set() + for name, row in self.rows.items(): + if row["board"] not in MIGRATED_BOARD_COUNTS: + continue + with self.subTest(environment=name): + counts[row["board"]] += 1 + self.assertEqual(row["mcu"], "esp32s3") + self.assertEqual(row["values"], {USB_MODE: "1", USB_CDC: "1"}) + self.assertEqual(row["kind"], "hwcdc") + self.assertTrue(self.sdk["hwcdc_primary_enabled"](row["env"])) + self.assertFalse(self.sdk["native_primary_enabled"](row["env"])) + self.assertEqual(self.config.get(f"env:{name}", "platform"), + "platformio/espressif32@6.11.0") + self.assertFalse(self.config.get(f"env:{name}", "platform_packages", [])) + self.assertIn("pre:scripts/esp32_usb_session_fix.py", + self.config.get(f"env:{name}", "extra_scripts", [])) + if name not in PREVIOUS_G2_HWCDC: + migrated.add(name) + self.assertEqual(counts, MIGRATED_BOARD_COUNTS) + self.assertTrue(PREVIOUS_G2_HWCDC <= self.rows.keys()) + self.assertEqual(len(migrated), 78) + + def test_migrated_uploads_use_hardware_reset_without_touch_or_port_wait(self): + for name, row in self.rows.items(): + if row["board"] not in MIGRATED_BOARD_COUNTS: + continue + with self.subTest(environment=name): + upload = row["manifest"]["upload"] + for option in ("use_1200bps_touch", "wait_for_upload_port"): + self.assertIn(option, upload, "native HWCDC upload defaults must be explicit") + value = self.config.get(f"env:{name}", f"board_upload.{option}", upload[option]) + self.assertFalse(bool_option(value), option) + before_reset = self.config.get(f"env:{name}", "board_upload.before_reset", + upload.get("before_reset", "default_reset")) + self.assertIn(before_reset, ("default_reset", "usb_reset")) + self.assertIn(["0x303A", "0x1001"], row["manifest"]["build"]["hwids"]) + + def test_no_native_tinyusb_esp32_role_remains_and_sdk_selectors_agree(self): + counts = Counter() + for name, row in self.rows.items(): + if not row["esp32"]: + continue + with self.subTest(environment=name): + counts[row["kind"]] += 1 + self.assertNotEqual(row["kind"], "tinyusb") + self.assertEqual(self.sdk["hwcdc_primary_enabled"](row["env"]), + row["kind"] == "hwcdc") + self.assertFalse(self.sdk["native_primary_enabled"](row["env"])) + self.assertEqual(counts, {"hwcdc": 317, "uart": 122, "classic_uart": 61}) + + def test_uart_existing_hwcdc_and_newer_mcu_recipes_keep_their_transports(self): + counts = Counter() + for name, row in self.rows.items(): + if not row["esp32"] or row["board"] in MIGRATED_BOARD_COUNTS: + continue + with self.subTest(environment=name): + expected = expected_unchanged_transport(row["board"], name) + self.assertEqual(row["kind"], expected) + counts[row["board"], row["kind"]] += 1 + self.assertEqual(counts, UNCHANGED_COUNTS) + + def test_non_esp32_recipes_do_not_acquire_esp32_usb_selectors(self): + count = 0 + for name, row in self.rows.items(): + if row["esp32"]: + continue + with self.subTest(environment=name): + count += 1 + self.assertEqual(row["values"], {USB_MODE: "0", USB_CDC: "0"}) + self.assertFalse(self.sdk["hwcdc_primary_enabled"](row["env"])) + self.assertFalse(self.sdk["native_primary_enabled"](row["env"])) + self.assertEqual(count, 321) + + def test_only_existing_partition_diagnostics_keep_their_cdc_override(self): + # These two pre-existing diagnostic recipes intentionally select CDC1 + # over a CDC0 recovery board. Keep their compiler/SDK last-definition + # behavior while rejecting conflicting selectors in normal images. + for name in LEGACY_DIAGNOSTIC_CDC_OVERRIDES: + with self.subTest(environment=name): + row = self.rows[name] + with self.assertRaises(ValueError): + usb_values(row["env"]) + self.assertEqual(row["kind"], "hwcdc") + self.assertEqual(row["values"], {USB_MODE: "1", USB_CDC: "1"}) + + def test_real_unflags_remove_the_old_selector_and_identical_duplicates_are_safe(self): + env = flag_environment(["-DARDUINO_USB_MODE=0", "-DARDUINO_USB_CDC_ON_BOOT=1"], + ["-D ARDUINO_USB_MODE=1", "-DARDUINO_USB_MODE=1"], + ["-DARDUINO_USB_MODE=0"]) + self.assertEqual(usb_values(env), {USB_MODE: "1", USB_CDC: "1"}) + self.assertTrue(self.sdk["hwcdc_primary_enabled"](env)) + self.assertFalse(self.sdk["native_primary_enabled"](env)) + env = flag_environment(["-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=0"]) + self.assertEqual(usb_values(env), {USB_MODE: "1", USB_CDC: "0"}) + self.assertFalse(self.sdk["hwcdc_primary_enabled"](env)) + + def test_conflicting_missing_and_undefined_backend_flags_are_rejected(self): + cases = ( + (["-DARDUINO_USB_MODE=0", "-DARDUINO_USB_CDC_ON_BOOT=1"], + ["-DARDUINO_USB_MODE=1"], []), + (["-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=1"], + ["-DARDUINO_USB_CDC_ON_BOOT=0"], []), + (["-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=1"], + ["-UARDUINO_USB_MODE"], []), + (["-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=1"], + [], ["-DARDUINO_USB_MODE"]), + (["-DARDUINO_USB_MODE=2", "-DARDUINO_USB_CDC_ON_BOOT=1"], [], []), + ) + for board_flags, flags, unflags in cases: + with self.subTest(board=board_flags, flags=flags, unflags=unflags): + with self.assertRaises(ValueError): + usb_values(flag_environment(board_flags, flags, unflags)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_hwcdc_role_transport.py b/test/test_hwcdc_role_transport.py new file mode 100644 index 00000000..3bf15178 --- /dev/null +++ b/test/test_hwcdc_role_transport.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Run Companion and KISS output over the actual bounded HWCDC facade/SDK. + +The USB registers and RTOS ring are synthetic. The frame parser/queue, KISS +encoder/output queue, HWCDC facade, and patched SDK write are production code. +""" +from pathlib import Path +import os +import re +import shutil +import subprocess +import tempfile +import unittest + +import test_hwcdc_write_capacity as capacity +from test_hwcdc_tx_backport import body + + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "test/fixtures/hwcdc_role_transport.cpp" +SETUP_FIXTURE = ROOT / "test/fixtures/hwcdc_kiss_setup.cpp" + + +class HwcdcRoleTransportTest(unittest.TestCase): + def harness(self, raw=False): + source = capacity.HwcdcWriteCapacityTest().harness() + source = source[:source.index("int main(int argc, char** argv)")] + source = source.replace("#include ", + "#include \n#include ") + source = source.replace("static size_t ring_free = 0;", + "static std::deque host_input;\n" + "static size_t ring_free = 0;") + source = source.replace("int available() override { return 0; }", + "int available() override { return host_input.size(); }") + source = source.replace("int read() override { return -1; }", """ + int read() override { + if (host_input.empty()) return -1; + const int byte = host_input.front(); host_input.pop_front(); return byte; + }""") + source = source.replace("int peek() override { return -1; }", + "int peek() override { return host_input.empty() ? -1 : host_input.front(); }") + + # Compile the production output methods verbatim, with only their + # unrelated radio/cryptography owners omitted from this host boundary. + header = (ROOT / "examples/kiss_modem/KissModem.h").read_text() + kiss = (ROOT / "examples/kiss_modem/KissModem.cpp").read_text() + constants = "\n".join(line for line in header.splitlines() + if line.startswith("#define KISS_") + or line.startswith("#define HW_ERR_TX_BUSY") + or line.startswith("#define HW_RESP_ERROR")) + fields = header[header.index(" uint8_t _tx_frame_buf["): + header.index(" static uint16_t appendEscapedByte")] + # The output-only shell retains the exact queue fields and declarations + # from the production class; its constructor just selects the stream. + names = ("appendEscapedByte", "encodeFrame", "resetOutputQueue", + "popTxFrame", "tryFlushFrames", "queueFrame", + "queueHardwareFrame", "queuePendingBusyError") + declarations = "\n".join( + line for line in header.splitlines() + if any(re.search(r"\b" + name + r"\(", line) for name in names)) + # body() starts at the supplied signature; retain each return type. + methods = "\n".join( + kiss[kiss.rfind("\n", 0, kiss.index("KissModem::" + name + "(")) + 1: + kiss.index("KissModem::" + name + "(")] + + body(kiss, "KissModem::" + name + "(") + for name in names) + main = (ROOT / "examples/companion_radio/main.cpp").read_text() + connection = body(main[main.index(" // The ESP32 USB-Serial-JTAG peripheral"):], + "usb_serial_interface.setConnectedCheck([]()") + ");" + fixture = FIXTURE.read_text().replace("@KISS_CONSTANTS@", constants) + fixture = fixture.replace("@KISS_FIELDS@", fields) + fixture = fixture.replace("@KISS_DECLARATIONS@", declarations) + fixture = fixture.replace("@KISS_METHODS@", methods) + fixture = fixture.replace("@COMPANION_CONNECTION@", connection) + lease_defaults = [] + for name in ("USB_FRAME_REPLY_GRACE_MS", "USB_CLIENT_IDLE_TIMEOUT", + "USB_HOST_LOSS_EDGE_MS", "USB_HOST_LOSS_GRACE_MS"): + definition = re.search(r"^\s*#define " + name + r"\s+(.+)$", + main, re.MULTILINE) + self.assertIsNotNone(definition, name) + lease_defaults.append("#define " + name + " " + definition[1]) + fixture = fixture.replace("@COMPANION_LEASE_DEFAULTS@", "\n".join(lease_defaults)) + fixture = fixture.replace("@ROLE_STREAM@", "Serial" if raw else "mesh::guarded") + return source + fixture + + def compile(self, directory, raw=False): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if compiler is None: + self.skipTest("a host C++17 compiler is required") + source = Path(directory) / "transport.cpp" + source.write_text(self.harness(raw), encoding="ascii") + binary = Path(directory) / "transport.exe" + sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] if os.name != "nt" else []) + result = subprocess.run([ + compiler, "-std=c++17", "-Wall", "-Wextra", "-Wno-unused-parameter", + "-pthread", *sanitizers, "-DESP32_PLATFORM", "-DESP32=1", + "-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=1", + "-I" + str(ROOT / "test/mocks"), "-I" + str(ROOT / "src"), + str(source), str(ROOT / "src/helpers/ArduinoSerialInterface.cpp"), + "-o", str(binary) + ], capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return binary + + def test_actual_hwcdc_companion_and_kiss_queues(self): + with tempfile.TemporaryDirectory(prefix="hwcdc-role-transport-") as directory: + binary = self.compile(directory) + for case in ("companion_stall", "companion_short", "companion_stale", + "companion_session", "companion_activity_lease", + "kiss_stall", "kiss_escaped_max", "kiss_stale", + "kiss_short", "kiss_busy"): + with self.subTest(case=case): + result = subprocess.run([str(binary), case], capture_output=True, + text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_raw_hwcdc_negative_control_waits_on_stale_capacity(self): + with tempfile.TemporaryDirectory(prefix="hwcdc-raw-role-") as directory: + binary = self.compile(directory, raw=True) + for case in ("companion_stale", "kiss_stale"): + with self.subTest(case=case): + result = subprocess.run([str(binary), case], capture_output=True, + text=True, timeout=10) + self.assertNotEqual(result.returncode, 0) + + def test_actual_kiss_setup_selects_guard_and_prepares_before_driver(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if compiler is None: + self.skipTest("a host C++17 compiler is required") + main = (ROOT / "examples/kiss_modem/main.cpp").read_text() + marker = "#if MESH_ESP32_HWCDC_SESSION_GUARD &&" + start = main.index(marker) + end = main.index("#endif", start) + len("#endif") + setup = body(main, "void setup()") + loop = body(main, "void loop()") + source = SETUP_FIXTURE.read_text().replace("@TRANSPORT_SELECTION@", main[start:end]) + source = source.replace("@SETUP@", setup) + state = "\n".join(re.findall( + r"^#define (?:NOISE_FLOOR_CALIB_INTERVAL_MS|AGC_RESET_INTERVAL_MS).*|" + r"^static uint32_t next_(?:noise_floor_calib|agc_reset)_ms.*", main, re.MULTILINE)) + reset = re.search(r"^static bool usb_host_reset_pending.*", main, re.MULTILINE) + self.assertIsNotNone(reset) + state += "\n#if KISS_HWCDC_TRANSPORT\n" + reset[0] + "\n#endif" + source = source.replace("@LOOP_STATE@", state).replace("@LOOP@", loop) + profiles = { + "hwcdc": ["ARDUINO_USB_MODE=1", "ARDUINO_USB_CDC_ON_BOOT=1", "EXPECT_HWCDC=1"], + "uart-hwcdc": ["ARDUINO_USB_MODE=1", "ARDUINO_USB_CDC_ON_BOOT=1", + "KISS_UART_RX=43", "KISS_UART_TX=44", "EXPECT_UART=1"], + "tinyusb": ["ARDUINO_USB_MODE=0", "ARDUINO_USB_CDC_ON_BOOT=1"], + "classic-uart": ["ARDUINO_USB_MODE=1", "ARDUINO_USB_CDC_ON_BOOT=0"], + } + sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] if os.name != "nt" else []) + with tempfile.TemporaryDirectory(prefix="hwcdc-kiss-setup-") as directory: + cpp = Path(directory) / "setup.cpp" + cpp.write_text(source, encoding="ascii") + for name, flags in profiles.items(): + with self.subTest(profile=name): + binary = Path(directory) / name + compiled = subprocess.run([ + compiler, "-std=c++17", *sanitizers, "-DESP32=1", "-DARDUINO=10800", + "-DESP32_PLATFORM", *["-D" + flag for flag in flags], + "-I" + str(ROOT / "test/mocks"), "-I" + str(ROOT / "src"), + str(cpp), "-o", str(binary) + ], capture_output=True, text=True, timeout=60) + self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) + executed = subprocess.run([str(binary)], capture_output=True, + text=True, timeout=10) + self.assertEqual(executed.returncode, 0, executed.stdout + executed.stderr) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/test/test_hwcdc_tx_backport.py b/test/test_hwcdc_tx_backport.py index eaf484b3..ed136c33 100644 --- a/test/test_hwcdc_tx_backport.py +++ b/test/test_hwcdc_tx_backport.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import importlib.util +import hashlib from pathlib import Path import subprocess import tempfile @@ -8,7 +9,10 @@ import unittest ROOT=Path(__file__).resolve().parents[1] CORE=ROOT/'test/fixtures/hwcdc_tx_backport' spec=importlib.util.spec_from_file_location('usb_fix',ROOT/'scripts/esp32_usb_session_fix.py') -module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) +module=importlib.util.module_from_spec(spec) +# Exercise the current transform after rapid edits without timestamp-based +# bytecode caches retaining an earlier same-size fingerprint literal. +exec(compile(Path(spec.origin).read_text(),spec.origin,'exec'),module.__dict__) RAW=(CORE/'HWCDC.cpp').read_text() PATCHED=module.patched_hwcdc_source(RAW) @@ -201,6 +205,112 @@ class BackportTest(unittest.TestCase): body(source,'bool HWCDC::isCDC_Connected()')).replace('@ISR@',body(source,'static void hw_cdc_isr_handler(')) def test_actual_isr_suffix_wakeup_pending_quarantine_and_reset(self): self.compile_run(self.harness()) + def phy_harness(self, source=PATCHED, *, s3=1, mode=1, cdc=1): + hal=(CORE/'usb_phy_ll.h').read_text() + self.assertEqual(hashlib.sha256(hal.encode()).hexdigest(), + '07f869755299e96fc73fb913bb0b9fed2ade580f12a22c058bbb07247f5c959c') + helper_names=[('mesh_hwcdc_int_jtag_enable','usb_phy_ll_int_jtag_enable'), + ('mesh_hwcdc_usb_wrap_pad_enable','usb_phy_ll_usb_wrap_pad_enable')] + hal_functions=[] + for local,official in helper_names: + helper=body(source,'static inline void '+local+'(') + self.assertEqual(helper.replace(local,official,1), + body(hal,'static inline void '+official+'(')) + hal_functions.append(helper) + hal_functions='\n'.join(hal_functions) + self.assertNotIn('#include "hal/usb_phy_ll.h"',source) + harness=r''' +#include +#include +#include +@CONFIG@ +#define USB_SERIAL_JTAG_LL_INTR_MASK 7 +#define USB_SERIAL_JTAG_INTR_SERIAL_IN_EMPTY 1 +#define USB_SERIAL_JTAG_INTR_SERIAL_OUT_RECV_PKT 2 +#define USB_SERIAL_JTAG_INTR_BUS_RESET 4 +#define ETS_USB_SERIAL_JTAG_INTR_SOURCE 1 +#define ESP_OK 0 +#define log_e(...) ((void)0) +#define isr_log_e(...) ((void)0) +struct usb_serial_jtag_dev_t { + struct {unsigned phy_sel=1,pad_pull_override=1,dp_pullup=0,usb_pad_enable=0;} conf0; +} USB_SERIAL_JTAG; +struct usb_wrap_dev_t {struct {unsigned pad_enable=1;} otg_conf;} USB_WRAP; +struct {struct {unsigned sw_hw_usb_phy_sel=1,sw_usb_phy_sel=1;} usb_conf;} RTCCNTL; +static unsigned delays=0,interrupt_mask=0,allocations=0; +static void delay(unsigned ms){ + // The old TinyUSB owner must be detached before any PHY ownership change. + assert(ms==20&&!USB_WRAP.otg_conf.pad_enable); + assert(RTCCNTL.usb_conf.sw_hw_usb_phy_sel==1&&RTCCNTL.usb_conf.sw_usb_phy_sel==1); + ++delays; +} +#if CONFIG_IDF_TARGET_ESP32S3 && ARDUINO_USB_MODE && ARDUINO_USB_CDC_ON_BOOT +@HAL@ +#endif +static int handle_token; +static void* tx_lock=&handle_token; +static void* rx_queue=&handle_token; +static void* tx_ring_buf=&handle_token; +static void* intr_handle=nullptr; +static void* xSemaphoreCreateMutex(){return &handle_token;} +static void usb_serial_jtag_ll_disable_intr_mask(unsigned){interrupt_mask=0;} +static void usb_serial_jtag_ll_ena_intr_mask(unsigned mask){interrupt_mask=mask;} +static void hw_cdc_isr_handler(void*){} +static int esp_intr_alloc(int,int,void(*)(void*),void*,void** out){ + ++allocations;*out=&handle_token;return ESP_OK; +} +struct HWCDC { + bool setRxBufferSize(size_t){return true;} + bool setTxBufferSize(size_t){return true;} + void end(){} + void begin(unsigned long baud); +}; +@BEGIN@ +int main(){ + HWCDC serial; + // Simulate RTC USB registers retained across esp_restart from TinyUSB. + serial.begin(115200); +#if CONFIG_IDF_TARGET_ESP32S3 && ARDUINO_USB_MODE && ARDUINO_USB_CDC_ON_BOOT + assert(RTCCNTL.usb_conf.sw_hw_usb_phy_sel==1&&RTCCNTL.usb_conf.sw_usb_phy_sel==0); + assert(delays==1&&!USB_WRAP.otg_conf.pad_enable); +#else + // C3, UART-backed Serial and native TinyUSB do not change RTC ownership. + assert(RTCCNTL.usb_conf.sw_hw_usb_phy_sel==1&&RTCCNTL.usb_conf.sw_usb_phy_sel==1); + assert(delays==0&&USB_WRAP.otg_conf.pad_enable); +#endif + assert(USB_SERIAL_JTAG.conf0.phy_sel==0&&!USB_SERIAL_JTAG.conf0.pad_pull_override); + assert(USB_SERIAL_JTAG.conf0.dp_pullup==1&&USB_SERIAL_JTAG.conf0.usb_pad_enable==1); + assert(interrupt_mask==7&&allocations==1); + unsigned prior_delays=delays; + serial.begin(115200);assert(delays==prior_delays&&allocations==1); + // Normal cold/continued HWCDC startup needs no forced disconnect delay. + RTCCNTL.usb_conf.sw_hw_usb_phy_sel=0;RTCCNTL.usb_conf.sw_usb_phy_sel=0; + serial.begin(115200);assert(delays==prior_delays); +} +''' + config='\n'.join('#define '+name+' '+str(value) for name,value in [ + ('CONFIG_IDF_TARGET_ESP32S3',s3),('ARDUINO_USB_MODE',mode),('ARDUINO_USB_CDC_ON_BOOT',cdc)]) + # Suppress unused functions only for branches whose complete HAL is + # intentionally unavailable; the real source's begin() still executes. + harness=harness.replace('static void delay(unsigned ms)', + 'static void __attribute__((unused)) delay(unsigned ms)') + return harness.replace('@CONFIG@',config).replace('@HAL@',hal_functions).replace( + '@BEGIN@',body(source,'void HWCDC::begin(unsigned long baud)')) + def test_s3_begin_reclaims_retained_tinyusb_phy_after_host_disconnect(self): + self.compile_run(self.phy_harness()) + def test_s3_legacy_begin_retains_otg_ownership_negative_control(self): + # Keep verified production helpers while executing only legacy begin. + harness=self.phy_harness().replace(body(PATCHED,'void HWCDC::begin(unsigned long baud)'), + body(RAW,'void HWCDC::begin(unsigned long baud)')) + self.compile_run(harness,expect_success=False) + def test_s3_handoff_requires_disconnecting_the_old_controller_negative_control(self): + source=PATCHED.replace(' mesh_hwcdc_usb_wrap_pad_enable(&USB_WRAP, false);\n','',1) + self.assertNotEqual(source,PATCHED) + self.compile_run(self.phy_harness(source),expect_success=False) + def test_phy_handoff_is_excluded_from_c3_uart_and_tinyusb(self): + for s3,mode,cdc in [(0,1,1),(1,1,0),(1,0,1)]: + with self.subTest(s3=s3,mode=mode,cdc=cdc): + self.compile_run(self.phy_harness(s3=s3,mode=mode,cdc=cdc)) def test_ignored_fifo_count_negative_control(self): source=PATCHED.replace('sent_size = usb_serial_jtag_ll_write_txfifo(queued_buff, queued_size);', 'usb_serial_jtag_ll_write_txfifo(queued_buff, queued_size);sent_size = queued_size;',1) diff --git a/test/test_kiss_modem/test_tx_backpressure.cpp b/test/test_kiss_modem/test_tx_backpressure.cpp index 0c1c64ce..00489133 100644 --- a/test/test_kiss_modem/test_tx_backpressure.cpp +++ b/test/test_kiss_modem/test_tx_backpressure.cpp @@ -335,6 +335,90 @@ TEST_F(KissModemFixture, RadioTxCompletionAdvancesWhileHostOutputIsBackedUp) { EXPECT_FALSE(modem.isTxBusy()); } +TEST_F(KissModemFixture, HostSessionResetPreservesAnActiveRadioTransmission) { + radio.setSendComplete(false); + serial.pushRx(dataFrame({0x42})); + advanceToTxSending(); + ASSERT_TRUE(modem.isActuallyTransmitting()); + ASSERT_EQ(radio.startSendCount(), 1); + ASSERT_EQ(radio.sendFinishedCount(), 0); + + serial.setBlockWrites(true); + serial.pushRx(hardwareFrame(HW_CMD_PING)); + modem.loop(); + ASSERT_TRUE(modem.isHostOutputBackedUp()); + + modem.resetHostSession(); + EXPECT_TRUE(modem.isActuallyTransmitting()); + EXPECT_TRUE(modem.isTxBusy()); + EXPECT_FALSE(modem.isHostOutputBackedUp()); + EXPECT_EQ(radio.startSendCount(), 1); + EXPECT_EQ(radio.sendFinishedCount(), 0); + serial.setBlockWrites(false); + modem.loop(); + EXPECT_TRUE(modem.isActuallyTransmitting()); + EXPECT_TRUE(serial.writesSnapshot().empty()); + + radio.setSendComplete(true); + modem.loop(); + modem.loop(); + EXPECT_EQ(radio.sendFinishedCount(), 1); + EXPECT_EQ(radio.startSendCount(), 1); + EXPECT_FALSE(modem.isTxBusy()); + const std::vector expected = { + KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_TX_DONE, 0x01, KISS_FEND}; + EXPECT_EQ(serial.writesSnapshot(), expected); +} + +TEST_F(KissModemFixture, HostSessionResetReleasesAnAlreadyCompletedRadioResult) { + serial.pushRx(dataFrame({0x42})); + advanceToTxSending(); + ASSERT_TRUE(modem.isActuallyTransmitting()); + serial.setBlockWrites(true); + const uint8_t packet[] = {0x11}; + // Occupy both output slots so the completed RF result waits outside them. + modem.onPacketReceived(TEST_SNR, TEST_RSSI, packet, sizeof(packet)); + modem.loop(); + modem.loop(); + ASSERT_EQ(radio.sendFinishedCount(), 1); + ASSERT_TRUE(modem.isTxBusy()); + ASSERT_TRUE(modem.isHostOutputBackedUp()); + + modem.resetHostSession(); + serial.setBlockWrites(false); + modem.loop(); + EXPECT_FALSE(modem.isTxBusy()); + EXPECT_FALSE(modem.isHostOutputBackedUp()); + EXPECT_TRUE(serial.writesSnapshot().empty()); + EXPECT_EQ(radio.sendFinishedCount(), 1); + EXPECT_EQ(radio.startSendCount(), 1); + + sendHardware(HW_CMD_PING); + const std::vector expected = { + KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP(HW_CMD_PING), KISS_FEND}; + EXPECT_EQ(serial.writesSnapshot(), expected); +} + +TEST_F(KissModemFixture, HostSessionResetDropsPartialInputAndQueuedOutput) { + serial.setBlockWrites(true); + sendHardware(HW_CMD_PING); + serial.pushRx({KISS_FEND, KISS_CMD_TXDELAY}); + modem.loop(); + ASSERT_TRUE(modem.isHostOutputBackedUp()); + + modem.resetHostSession(); + EXPECT_FALSE(modem.isHostOutputBackedUp()); + serial.setBlockWrites(false); + // The tail of the abandoned setter must not replace the 50-unit TX delay. + serial.pushRx({100, KISS_FEND}); + modem.loop(); + EXPECT_TRUE(serial.writesSnapshot().empty()); + serial.pushRx(dataFrame({0x42})); + advanceToTxSending(); + EXPECT_EQ(radio.startSendCount(), 1); + EXPECT_TRUE(modem.isActuallyTransmitting()); +} + TEST_F(KissModemFixture, QueueFullReportsBusyWithoutDroppingQueuedFrames) { static constexpr uint8_t TEST_PACKET_ONE[] = {0x11, 0x12}; static constexpr uint8_t TEST_PACKET_TWO[] = {0x21, 0x22}; diff --git a/variants/station_g2/platformio.ini b/variants/station_g2/platformio.ini index 81b8b7ad..5ae3d97e 100644 --- a/variants/station_g2/platformio.ini +++ b/variants/station_g2/platformio.ini @@ -349,19 +349,12 @@ build_flags = ${Station_G2.build_flags} -UENABLE_OTA -D DISABLE_LORA_OTA=1 - -D ARDUINO_USB_MODE=1 build_src_filter = ${Station_G2.build_src_filter} +<../examples/kiss_modem/> [env:Station_G2_repeater_observer_mqtt] extends = Station_G2 -; The Full repeater needs a serial console, so use the S3's fixed hardware -; CDC/JTAG controller instead of TinyUSB's DTR-driven session quarantine. -; Companion and the other G2 roles retain their existing USB backend. -build_unflags = - ${Station_G2.build_unflags} - -DARDUINO_USB_MODE=0 -board_upload.use_1200bps_touch = false +; All native USB roles inherit hardware CDC/JTAG from the board manifest. board_build.partitions = default_16MB.csv ; standard 16MB partition table extra_scripts = ${esp32_base.extra_scripts} @@ -370,7 +363,6 @@ board_ssl_cert_source = adafruit-full board_build.embed_files = src/certs/x509_crt_bundle.bin build_flags = ${Station_G2.build_flags} - -D ARDUINO_USB_MODE=1 ; The base observer disables LoRa OTA; build.sh promotes it to FULL and restores OTA. -UENABLE_OTA -D DISABLE_LORA_OTA=1