Migrate ESP32 native USB roles to bounded hardware CDC

Switch eight S3 board defaults and 78 remaining TinyUSB recipes to hardware Serial/JTAG while preserving UART and other platforms. Restore retained S3 PHY ownership after a TinyUSB OTA restart and prepare KISS queues before enabling the driver.

Add real recipe resolution, Companion/KISS transport sanitizer coverage, and RF lifecycle reset regressions to CI. Native tests pass 1765/1765; SDK, transport, recipe and USB watchdog checks pass. Board and role cross-compilation and physical OTA qualification continue.
This commit is contained in:
mikecarper committed 2026-10-04 23:36:54 -07:00
1 parent 4939de4c61
commit 835c7a8e6e
23 files changed
+1491 -39

No files matched your search

+2
View File
@@ -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
+3 -3
View File
@@ -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
},
+3 -3
View File
@@ -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
},
+3 -3
View File
@@ -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
},
+3 -3
View File
@@ -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
},
+3 -3
View File
@@ -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
},
+3 -3
View File
@@ -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
},
+3 -3
View File
@@ -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
},
+3 -1
View File
@@ -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,
@@ -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.
+7
View File
@@ -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;
+3
View File
@@ -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; }
+38 -6
View File
@@ -3,6 +3,7 @@
#include <helpers/ArduinoHelpers.h>
#include <helpers/IdentityGeneration.h>
#include <helpers/IdentityStore.h>
#include <helpers/UsbLogging.h>
#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
+45 -1
View File
@@ -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 <string.h>\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) {')
+169
View File
@@ -0,0 +1,169 @@
// The runner inserts the production KISS setup and backend selection.
#include <Arduino.h>
#include <helpers/UsbLogging.h>
#include <cassert>
#include <string>
#include <vector>
static std::vector<std::string> 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 <typename T> void setRadioCallback(T) {}
template <typename T> void setTxPowerCallback(T) {}
template <typename T> void setGetCurrentRssiCallback(T) {}
template <typename T> 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<std::string> 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<std::string> expected = {
"board.begin", "radio.begin", "uart.begin", "modem.construct", "modem.begin", "boot.complete"};
assert(calls == expected);
#else
assert(modem->port == &Serial);
const std::vector<std::string> 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<std::string> 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<std::string> 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<std::string> 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<std::string> normal = {
"modem.loop", "sensors.loop", "board.loop", "radio.loop"};
assert(calls == normal);
#endif
delete modem;
}
+253
View File
@@ -0,0 +1,253 @@
// This file is appended to the actual HWCDC facade/SDK host harness.
#include <helpers/ArduinoSerialInterface.h>
@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<uint8_t>& 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<uint8_t> companionFrame(const std::vector<uint8_t>& payload) {
std::vector<uint8_t> result = {'>', uint8_t(payload.size()), uint8_t(payload.size() >> 8)};
result.insert(result.end(), payload.begin(), payload.end());
return result;
}
static std::vector<uint8_t> kissFrame(uint8_t type, const std::vector<uint8_t>& payload) {
std::vector<uint8_t> 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<uint8_t>& dest, const std::vector<uint8_t>& 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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;
}
+8
View File
@@ -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.
+157
View File
@@ -0,0 +1,157 @@
/*
* SPDX-FileCopyrightText: 2015-2023 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#include <stdbool.h>
#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
+335
View File
@@ -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()
+174
View File
@@ -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 <functional>",
"#include <functional>\n#include <deque>")
source = source.replace("static size_t ring_free = 0;",
"static std::deque<uint8_t> 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)
+111 -1
View File
@@ -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 <cassert>
#include <cstdint>
#include <cstddef>
@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)
@@ -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<uint8_t> 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<uint8_t> 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};
+1 -9
View File
@@ -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