From aa5fa207b6fca919a61ed792ed73d0737ddf5375 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Sun, 23 Aug 2026 00:47:56 +0200 Subject: [PATCH] Add native USB CDC broker transport --- README.md | 66 ++- dependencies.lock | 39 +- sdkconfig.defaults | 7 + src/CMakeLists.txt | 3 + src/idf_component.yml | 1 + src/main.c | 11 +- src/usb_cdc_transport.c | 989 ++++++++++++++++++++++++++++++++++++++++ src/usb_cdc_transport.h | 82 ++++ src/usb_console.c | 184 ++++++++ src/usb_console.h | 6 + wiring.md | 15 +- 11 files changed, 1394 insertions(+), 9 deletions(-) create mode 100644 src/usb_cdc_transport.c create mode 100644 src/usb_cdc_transport.h create mode 100644 src/usb_console.c create mode 100644 src/usb_console.h diff --git a/README.md b/README.md index 016be16..466f740 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Universal wireless serial adaptor firmware for the ESP32-S3. - 8 MB octal PSRAM - Adafruit MAX3243 full-pinout RS-232 breakout, product 5988 -The firmware has completed **Phase 0 hardware characterization** and the **Phase 1 serial-core foundation**. **Phase 2** adds a transport-neutral serial-session broker with one active writer, multiple observers, bounded per-client queues, event delivery, and slow-client isolation. The MAX3243 diagnostics and persistent serial configuration remain available. Neither the UART service nor an electrical test starts automatically at boot. +The firmware has completed **Phase 0 hardware characterization**, the **Phase 1 serial-core foundation**, and the **Phase 2 transport-neutral session broker**. The current phase adds the first real broker transport: native USB CDC-ACM on the ESP32-S3 USB port. The MAX3243 diagnostics and persistent serial configuration remain available. No electrical test starts automatically; UART1 starts when requested explicitly or when a host opens native USB CDC. ## Hardware wiring @@ -59,14 +59,14 @@ pio run ## Upload and monitor -Connect the board's USB-to-UART port, then run: +Connect the board's **USB-to-UART** port for firmware upload and the UART0 development console, then run: ```sh pio run --target upload pio device monitor -b 115200 ``` -The firmware starts an interactive console on UART0 with the prompt `serial-tool>`. Type `help` to display command descriptions. +The firmware starts an interactive console on UART0 with the prompt `serial-tool>`. Type `help` to display command descriptions. This USB-to-UART device normally appears as `/dev/ttyUSB*`; it is separate from the native USB CDC serial transport described below. ### Phase 1 serial service @@ -105,7 +105,7 @@ UART1 has exclusive ownership while the service runs. Phase 0 commands will refu ### Phase 2 session broker -The broker is initialized at boot and continuously drains the serial service whenever UART1 is running. It is transport-neutral: current console clients use the same API that native USB CDC, WebSocket, and SSH transports will use later. +The broker is initialized at boot and continuously drains the serial service whenever UART1 is running. It is transport-neutral: console test clients and native USB CDC use the same API that WebSocket and SSH transports will use later. ```text broker status @@ -130,6 +130,64 @@ Exactly one client may hold the writer lease. Competing requests are denied and Connect, disconnect, writer grant, release, revoke, and denial events have a broker-global sequence number. Event queues are intentionally bounded, so future transports should reconcile sequence gaps against broker snapshots. The first and last broker connection also drive the Phase 1 `DTR=on-connect` policy. +### Native USB CDC-ACM transport + +The ESP32-S3's native USB OTG peripheral presents one CDC-ACM serial interface through the development board's connector labelled **USB**. It uses GPIO19 (`USB D-`) and GPIO20 (`USB D+`) and normally appears on Linux as `/dev/ttyACM*`. It is not the USB-to-UART bridge used for upload and logs. + +The UART0 development console provides these diagnostics and controls: + +```text +usb status +usb counters +usb clear-counters +usb request-writer +usb release-writer +``` + +Opening the CDC port with DTR asserted automatically starts UART1, connects a broker client named `usb-cdc`, and requests the writer lease. If another client already owns the lease, USB remains connected as a read-only observer; `usb status` reports its current role. Closing the port or unplugging native USB disconnects that broker client and discards transport-local pending data. The serial service itself remains running until it is stopped explicitly with `serial stop`. + +The data path is binary-transparent. UTF-8 bytes, NUL bytes, terminal escape sequences, and color sequences are passed unchanged; interpretation remains the terminal application's responsibility. USB output is bounded and nonblocking, so a host that stops reading can lose only its own observer data rather than stall UART1 or another client. + +Host line coding is accepted for baud rates 110–1000000 with 7 or 8 data bits, none/odd/even parity, and 1 or 2 stop bits. USB's 1.5 stop bits and mark/space parity are rejected. Supported settings are applied to the working UART configuration only when USB owns the writer lease and queued UART TX has drained. They are not saved to NVS automatically; use `serial save` deliberately if the setting should survive reboot. USB RTS is reported as host status only. It does not drive the physical RS-232 RTS line, which remains controlled by UART1's configured RTS/CTS flow control. + +The development VID/PID comes from Espressif's TinyUSB defaults. The USB serial-number string is derived from the ESP32-S3 station MAC so multiple adapters can be distinguished consistently. + +#### Linux loopback validation + +Keep the USB-to-UART cable connected for logs and commands, and connect a second data-capable cable to the native **USB** connector. On the host, identify the new CDC device: + +```sh +dmesg +ls -l /dev/ttyACM* +``` + +With power removed and no external RS-232 peer attached, connect only DE-9 pin 3 (`TX`) to pin 2 (`RX`), then power the board. Open the native port with a serial terminal such as: + +```sh +picocom -b 115200 /dev/ttyACM0 +``` + +Use the actual device path assigned by the host. Typed data should return through USB → broker → UART1 → MAX3243 loopback → broker → USB. On the UART0 console, verify `usb status`, `usb counters`, `broker clients`, and `serial status`. The USB client should normally be the writer and counters should increase without drops. + +For a binary check, install PySerial on the host and send all byte values: + +```python +import serial + +payload = bytes(range(256)) +with serial.Serial("/dev/ttyACM0", 115200, timeout=2) as port: + port.reset_input_buffer() + port.write(payload) + echoed = port.read(len(payload)) + +assert echoed == payload, (len(echoed), echoed.hex()) +print("256-byte binary USB/RS-232 loopback passed") +``` + +Close the terminal and check `usb status` and `broker clients`; DTR-aware applications should cause the USB broker client to disconnect. Physically unplugging the native USB cable is the definitive detach test. To test observer mode, assign a console test client as writer before opening `/dev/ttyACM0`; USB should connect as an observer, receive UART output, and discard host-originated input until ownership is granted. + +Power down and remove the DE-9 pin 3-to-2 jumper before connecting an external serial peer. + ### Phase 0 diagnostics The retained hardware-characterization commands are: diff --git a/dependencies.lock b/dependencies.lock index a39725c..3c4f4d5 100644 --- a/dependencies.lock +++ b/dependencies.lock @@ -1,4 +1,24 @@ dependencies: + espressif/esp_tinyusb: + component_hash: 9a73a76a6bc17907f6e523342ff6b1e440023e8aac2c11f4691212106ec24e66 + dependencies: + - name: idf + require: private + version: '>=5.0' + - name: espressif/tinyusb + registry_url: https://components.espressif.com + require: public + version: '>=0.17.0~2' + source: + registry_url: https://components.espressif.com/ + type: service + targets: + - esp32s2 + - esp32s3 + - esp32p4 + - esp32h4 + - esp32s31 + version: 2.2.1 espressif/led_strip: component_hash: 28621486f77229aaf81c71f5e15d6fbf36c2949cf11094e07090593e659e7639 dependencies: @@ -9,13 +29,30 @@ dependencies: registry_url: https://components.espressif.com/ type: service version: 3.0.3 + espressif/tinyusb: + component_hash: a72b7d67472914ab76309340fd50d578b31e310963d45ad0f81144bde3314752 + dependencies: + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com + type: service + targets: + - esp32s2 + - esp32s3 + - esp32p4 + - esp32h4 + - esp32s31 + version: 0.21.0~1 idf: source: type: idf version: 5.5.0 direct_dependencies: +- espressif/esp_tinyusb - espressif/led_strip - idf -manifest_hash: 2ec6b61acabeb38f1fa882ab16fa8e6ec2d49398e068a832250cb53a96ffa7d0 +manifest_hash: c088ae17e0ad9f6ac683daf40ffe6018e98fa32aea77d8f2252b0b2703e77298 target: esp32s3 version: 2.0.0 diff --git a/sdkconfig.defaults b/sdkconfig.defaults index f090aa4..f06f9ba 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -6,5 +6,12 @@ CONFIG_SPIRAM_SPEED_80M=y CONFIG_SPIRAM_BOOT_INIT=y CONFIG_SPIRAM_USE_CAPS_ALLOC=y +# Native USB OTG presents one CDC-ACM interface on the ESP32-S3 USB port. +CONFIG_TINYUSB_CDC_ENABLED=y +CONFIG_TINYUSB_CDC_COUNT=1 +CONFIG_TINYUSB_CDC_RX_BUFSIZE=1024 +CONFIG_TINYUSB_CDC_TX_BUFSIZE=1024 +CONFIG_TINYUSB_CDC_EP_BUFSIZE=512 + # Keep diagnostic and interactive-console logging concise but useful. CONFIG_LOG_DEFAULT_LEVEL_INFO=y diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a4a656a..a6ddee8 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -9,6 +9,8 @@ idf_component_register( "serial_console.c" "session_broker.c" "session_console.c" + "usb_cdc_transport.c" + "usb_console.c" INCLUDE_DIRS "." REQUIRES console @@ -16,6 +18,7 @@ idf_component_register( esp_driver_uart esp_psram esp_timer + esp_tinyusb freertos led_strip nvs_flash diff --git a/src/idf_component.yml b/src/idf_component.yml index 745f085..05bf435 100644 --- a/src/idf_component.yml +++ b/src/idf_component.yml @@ -3,3 +3,4 @@ description: ESP32 Serial Swiss Army Knife application dependencies: idf: ">=5.3.0" espressif/led_strip: "^3.0.3" + espressif/esp_tinyusb: "^2.2.1" diff --git a/src/main.c b/src/main.c index 3f5723e..2c746ab 100644 --- a/src/main.c +++ b/src/main.c @@ -11,6 +11,8 @@ #include "session_broker.h" #include "session_console.h" #include "status_led.h" +#include "usb_cdc_transport.h" +#include "usb_console.h" #define CONSOLE_BAUD_RATE 115200 #define CONSOLE_TX_GPIO 43 @@ -20,7 +22,7 @@ static const char *TAG = "firmware"; void app_main(void) { - ESP_LOGI(TAG, "ESP32-S3 Serial Swiss Army Knife Phase 2 started"); + ESP_LOGI(TAG, "ESP32-S3 Serial Swiss Army Knife native USB CDC phase started"); if (esp_psram_is_initialized()) { ESP_LOGI(TAG, "PSRAM initialized: %u bytes", (unsigned int)esp_psram_get_size()); @@ -45,9 +47,11 @@ void app_main(void) } ESP_ERROR_CHECK(serial_service_init(&serial_config)); ESP_ERROR_CHECK(session_broker_init()); + /* Native USB owns GPIO19/20; UART0 logging stays on the USB-to-UART bridge. */ + ESP_ERROR_CHECK(usb_cdc_transport_init()); ESP_LOGI( TAG, - "Using %s serial configuration; UART service remains stopped until 'serial start'", + "Using %s serial configuration; UART service starts on 'serial start' or native USB open", used_stored_config ? "stored" : "default"); esp_console_repl_config_t repl_config = ESP_CONSOLE_REPL_CONFIG_DEFAULT(); @@ -72,8 +76,9 @@ void app_main(void) ESP_ERROR_CHECK(rs232_hw_test_register_console_commands()); ESP_ERROR_CHECK(serial_console_register_commands()); ESP_ERROR_CHECK(session_console_register_commands()); + ESP_ERROR_CHECK(usb_console_register_commands()); ESP_ERROR_CHECK(esp_console_start_repl(repl)); ESP_LOGI(TAG, "Interactive test console ready at %d baud", CONSOLE_BAUD_RATE); - ESP_LOGI(TAG, "Type 'help' for commands; no serial service or test runs automatically"); + ESP_LOGI(TAG, "Type 'help' for commands; native USB starts UART1 only when its host port opens"); } diff --git a/src/usb_cdc_transport.c b/src/usb_cdc_transport.c new file mode 100644 index 0000000..c20e4ee --- /dev/null +++ b/src/usb_cdc_transport.c @@ -0,0 +1,989 @@ +#include "usb_cdc_transport.h" + +#include +#include +#include + +#include "esp_mac.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/stream_buffer.h" +#include "freertos/task.h" +#include "serial_config.h" +#include "serial_service.h" +#include "tinyusb.h" +#include "tinyusb_cdc_acm.h" +#include "tinyusb_default_config.h" + +#define USB_CDC_HOST_RX_STREAM_SIZE 4096U +#define USB_CDC_IO_CHUNK_SIZE 256U +#define USB_CDC_CONTROL_QUEUE_LENGTH 8U +#define USB_CDC_TASK_STACK_SIZE 5632U +#define USB_CDC_TASK_PRIORITY 8U +#define USB_CDC_POLL_MS 2U +#define USB_CDC_SERVICE_RETRY_MS 250U +#define USB_CDC_CONNECT_RETRY_MS 100U +#define USB_CDC_BROKER_RECONCILE_MS 100U + +#define USB_STATE_ATTACHED (1U << 0) +#define USB_STATE_DTR (1U << 1) +#define USB_STATE_RTS (1U << 2) + +typedef enum { + USB_CDC_CONTROL_REQUEST_WRITER = 0, + USB_CDC_CONTROL_RELEASE_WRITER, +} usb_cdc_control_t; + +typedef struct { + uint8_t data[USB_CDC_IO_CHUNK_SIZE]; + size_t size; + size_t offset; +} usb_cdc_pending_buffer_t; + +static StreamBufferHandle_t s_host_rx_stream; +static QueueHandle_t s_control_queue; +static atomic_uintptr_t s_transport_task; +static portMUX_TYPE s_state_lock = portMUX_INITIALIZER_UNLOCKED; + +static atomic_bool s_initialized; +static atomic_bool s_initializing; +static atomic_uint s_usb_state; +/* Changes on every effective CDC open/close boundary, even during one task poll. */ +static atomic_uint s_connection_generation; + +static session_broker_client_id_t s_broker_client_id; +static bool s_writer; +static usb_cdc_transport_line_coding_t s_line_coding; +static bool s_line_coding_pending; +static usb_cdc_transport_counters_t s_counters; + +static const char s_language_descriptor[] = {0x09, 0x04}; +static const char s_manufacturer[] = "ESP32 Serial Tools"; +/* esp_tinyusb's default UTF-16 conversion accepts at most 31 characters. */ +static const char s_product[] = "ESP32 Serial Swiss Army Knife"; +static char s_serial_number[13]; +static const char s_cdc_interface[] = "USB CDC"; +static const char *s_string_descriptors[] = { + s_language_descriptor, + s_manufacturer, + s_product, + s_serial_number, + s_cdc_interface, +}; + +static TickType_t milliseconds_to_ticks(uint32_t milliseconds) +{ + TickType_t ticks = pdMS_TO_TICKS(milliseconds); + return (milliseconds > 0U && ticks == 0U) ? 1U : ticks; +} + +static void add_counter(uint64_t *counter, uint64_t amount) +{ + taskENTER_CRITICAL(&s_state_lock); + *counter += amount; + taskEXIT_CRITICAL(&s_state_lock); +} + +static void notify_transport_task(void) +{ + TaskHandle_t task = (TaskHandle_t)atomic_load(&s_transport_task); + if (task != NULL) { + xTaskNotifyGive(task); + } +} + +static bool usb_state_is_open(unsigned int state) +{ + return (state & (USB_STATE_ATTACHED | USB_STATE_DTR)) == + (USB_STATE_ATTACHED | USB_STATE_DTR); +} + +static bool usb_host_port_open(void) +{ + return usb_state_is_open(atomic_load(&s_usb_state)); +} + +static void set_broker_state(session_broker_client_id_t client_id, bool writer) +{ + taskENTER_CRITICAL(&s_state_lock); + s_broker_client_id = client_id; + s_writer = writer; + taskEXIT_CRITICAL(&s_state_lock); +} + +static void set_writer_state(bool writer) +{ + taskENTER_CRITICAL(&s_state_lock); + s_writer = writer; + taskEXIT_CRITICAL(&s_state_lock); +} + +static void device_event_callback(tinyusb_event_t *event, void *arg) +{ + (void)arg; + + if (event == NULL) { + add_counter(&s_counters.callback_drops, 1U); + return; + } + + switch (event->id) { + case TINYUSB_EVENT_ATTACHED: + atomic_fetch_or(&s_usb_state, USB_STATE_ATTACHED); + notify_transport_task(); + break; + case TINYUSB_EVENT_DETACHED: { + /* A new attachment must receive fresh control state and line coding. */ + unsigned int old_state = atomic_exchange(&s_usb_state, 0U); + if (usb_state_is_open(old_state)) { + atomic_fetch_add(&s_connection_generation, 1U); + } + taskENTER_CRITICAL(&s_state_lock); + s_line_coding_pending = false; + taskEXIT_CRITICAL(&s_state_lock); + notify_transport_task(); + break; + } + default: + break; + } +} + +static void cdc_rx_callback(int itf, cdcacm_event_t *event) +{ + if (itf != TINYUSB_CDC_ACM_0 || event == NULL || event->type != CDC_EVENT_RX) { + add_counter(&s_counters.callback_drops, 1U); + return; + } + + uint8_t data[USB_CDC_IO_CHUNK_SIZE]; + for (;;) { + size_t received = 0U; + esp_err_t result = tinyusb_cdcacm_read(TINYUSB_CDC_ACM_0, + data, + sizeof(data), + &received); + if (result != ESP_OK) { + add_counter(&s_counters.callback_drops, 1U); + break; + } + if (received == 0U) { + break; + } + + size_t queued = 0U; + if (s_host_rx_stream != NULL) { + /* This callback is the stream's only writer and never waits. */ + queued = xStreamBufferSend(s_host_rx_stream, data, received, 0U); + } + + taskENTER_CRITICAL(&s_state_lock); + s_counters.host_rx_bytes += received; + s_counters.host_rx_stream_dropped_bytes += received - queued; + taskEXIT_CRITICAL(&s_state_lock); + } + + notify_transport_task(); +} + +static void cdc_wanted_char_callback(int itf, cdcacm_event_t *event) +{ + if (itf != TINYUSB_CDC_ACM_0 || event == NULL || + event->type != CDC_EVENT_RX_WANTED_CHAR) { + add_counter(&s_counters.callback_drops, 1U); + return; + } + + /* Wanted-character notifications are only a scheduling hint here. */ + notify_transport_task(); +} + +static void cdc_line_state_callback(int itf, cdcacm_event_t *event) +{ + if (itf != TINYUSB_CDC_ACM_0 || event == NULL || + event->type != CDC_EVENT_LINE_STATE_CHANGED) { + add_counter(&s_counters.callback_drops, 1U); + return; + } + + unsigned int old_state = atomic_load(&s_usb_state); + for (;;) { + /* A valid CDC class callback is also proof that this device is attached. */ + unsigned int new_state = (old_state | USB_STATE_ATTACHED) & + ~(USB_STATE_DTR | USB_STATE_RTS); + if (event->line_state_changed_data.dtr) { + new_state |= USB_STATE_DTR; + } + if (event->line_state_changed_data.rts) { + new_state |= USB_STATE_RTS; + } + if (atomic_compare_exchange_weak(&s_usb_state, &old_state, new_state)) { + if (usb_state_is_open(old_state) != usb_state_is_open(new_state)) { + atomic_fetch_add(&s_connection_generation, 1U); + } + break; + } + } + + if (!event->line_state_changed_data.dtr) { + /* Do not apply a closed host session's deferred line coding after reopen. */ + taskENTER_CRITICAL(&s_state_lock); + s_line_coding_pending = false; + taskEXIT_CRITICAL(&s_state_lock); + } + + notify_transport_task(); +} + +static void cdc_line_coding_callback(int itf, cdcacm_event_t *event) +{ + if (itf != TINYUSB_CDC_ACM_0 || event == NULL || + event->type != CDC_EVENT_LINE_CODING_CHANGED || + event->line_coding_changed_data.p_line_coding == NULL) { + add_counter(&s_counters.callback_drops, 1U); + return; + } + + /* The TinyUSB value is packed and callback-owned, so copy it immediately. */ + cdc_line_coding_t coding; + memcpy(&coding, + event->line_coding_changed_data.p_line_coding, + sizeof(coding)); + + taskENTER_CRITICAL(&s_state_lock); + if (s_line_coding_pending) { + /* Preserve the latest complete setting and account for the superseded one. */ + ++s_counters.callback_drops; + } + s_line_coding = (usb_cdc_transport_line_coding_t) { + .baud_rate = coding.bit_rate, + .stop_bits = coding.stop_bits, + .parity = coding.parity, + .data_bits = coding.data_bits, + }; + s_line_coding_pending = true; + taskEXIT_CRITICAL(&s_state_lock); + + notify_transport_task(); +} + +static bool take_pending_line_coding(usb_cdc_transport_line_coding_t *coding) +{ + bool pending; + + taskENTER_CRITICAL(&s_state_lock); + pending = s_line_coding_pending; + if (pending) { + *coding = s_line_coding; + s_line_coding_pending = false; + } + taskEXIT_CRITICAL(&s_state_lock); + return pending; +} + +static bool serial_configs_equal(const serial_config_t *left, + const serial_config_t *right) +{ + return left->version == right->version && + left->baud_rate == right->baud_rate && + left->data_bits == right->data_bits && + left->parity == right->parity && + left->stop_bits == right->stop_bits && + left->flow_control == right->flow_control && + left->dtr_behavior == right->dtr_behavior && + left->rts_threshold == right->rts_threshold; +} + +static bool map_line_coding(const usb_cdc_transport_line_coding_t *coding, + serial_config_t *config) +{ + if (coding->baud_rate < SERIAL_CONFIG_MIN_BAUD_RATE || + coding->baud_rate > SERIAL_CONFIG_MAX_BAUD_RATE) { + return false; + } + config->baud_rate = coding->baud_rate; + + switch (coding->data_bits) { + case 7U: + config->data_bits = SERIAL_CONFIG_DATA_BITS_7; + break; + case 8U: + config->data_bits = SERIAL_CONFIG_DATA_BITS_8; + break; + default: + return false; + } + + switch (coding->parity) { + case CDC_LINE_CODING_PARITY_NONE: + config->parity = SERIAL_CONFIG_PARITY_NONE; + break; + case CDC_LINE_CODING_PARITY_ODD: + config->parity = SERIAL_CONFIG_PARITY_ODD; + break; + case CDC_LINE_CODING_PARITY_EVEN: + config->parity = SERIAL_CONFIG_PARITY_EVEN; + break; + default: + /* Mark and space parity are intentionally not representable by UART policy. */ + return false; + } + + switch (coding->stop_bits) { + case CDC_LINE_CODING_STOP_BITS_1: + config->stop_bits = SERIAL_CONFIG_STOP_BITS_1; + break; + case CDC_LINE_CODING_STOP_BITS_2: + config->stop_bits = SERIAL_CONFIG_STOP_BITS_2; + break; + default: + /* This also rejects USB's 1.5-stop-bit encoding. */ + return false; + } + + return true; +} + +static void apply_pending_line_coding(bool writer) +{ + if (!writer || !serial_service_is_running()) { + return; + } + + /* Restarting UART1 discards queued TX, so defer framing changes until idle. */ + if (serial_service_tx_pending() > 0U) { + return; + } + + usb_cdc_transport_line_coding_t coding; + if (!take_pending_line_coding(&coding)) { + return; + } + + serial_config_t current; + if (serial_service_get_config(¤t) != ESP_OK) { + add_counter(&s_counters.line_coding_failed, 1U); + return; + } + + serial_config_t desired = current; + if (!map_line_coding(&coding, &desired)) { + add_counter(&s_counters.line_coding_rejected, 1U); + return; + } + + /* Flow control, DTR policy, and RTS threshold remain from current RAM state. */ + if (serial_configs_equal(¤t, &desired)) { + return; + } + + if (serial_service_apply_config(&desired) == ESP_OK) { + add_counter(&s_counters.line_coding_applied, 1U); + } else { + add_counter(&s_counters.line_coding_failed, 1U); + } +} + +static bool writer_event_type(session_broker_event_type_t type) +{ + return type == SESSION_BROKER_EVENT_WRITER_GRANTED || + type == SESSION_BROKER_EVENT_WRITER_RELEASED || + type == SESSION_BROKER_EVENT_WRITER_REVOKED || + type == SESSION_BROKER_EVENT_WRITER_DENIED; +} + +static esp_err_t reconcile_broker_state(session_broker_client_id_t client_id, + bool *writer) +{ + session_broker_client_snapshot_t snapshot; + esp_err_t result = session_broker_get_client_snapshot(client_id, &snapshot); + if (result == ESP_OK) { + *writer = snapshot.is_writer; + set_writer_state(*writer); + } + return result; +} + +static esp_err_t drain_broker_events(session_broker_client_id_t client_id, + bool *writer) +{ + session_broker_event_t event; + for (;;) { + esp_err_t result = session_broker_pop_event(client_id, &event); + if (result == ESP_ERR_TIMEOUT) { + return ESP_OK; + } + if (result != ESP_OK) { + return result; + } + + /* writer_id is ownership after this event, including forced changes. */ + *writer = event.writer_id == client_id; + set_writer_state(*writer); + + if (!writer_event_type(event.type)) { + continue; + } + + taskENTER_CRITICAL(&s_state_lock); + ++s_counters.writer_events; + if (event.type == SESSION_BROKER_EVENT_WRITER_GRANTED && + event.client_id == client_id) { + ++s_counters.writer_grants; + } else if (event.type == SESSION_BROKER_EVENT_WRITER_DENIED && + event.client_id == client_id) { + ++s_counters.writer_denials; + } else if (event.type == SESSION_BROKER_EVENT_WRITER_REVOKED && + event.client_id == client_id) { + ++s_counters.writer_revocations; + } + taskEXIT_CRITICAL(&s_state_lock); + } +} + +static void discard_host_input(usb_cdc_pending_buffer_t *pending) +{ + uint64_t discarded = pending->size - pending->offset; + pending->size = 0U; + pending->offset = 0U; + + uint8_t data[USB_CDC_IO_CHUNK_SIZE]; + size_t drain_budget = USB_CDC_HOST_RX_STREAM_SIZE; + while (drain_budget > 0U) { + size_t request = drain_budget < sizeof(data) ? drain_budget : sizeof(data); + size_t received = xStreamBufferReceive(s_host_rx_stream, data, request, 0U); + if (received == 0U) { + break; + } + discarded += received; + drain_budget -= received; + } + + if (discarded > 0U) { + add_counter(&s_counters.broker_rejected_bytes, discarded); + } +} + +static void discard_usb_pending(usb_cdc_pending_buffer_t *pending) +{ + size_t discarded = pending->size - pending->offset; + pending->size = 0U; + pending->offset = 0U; + if (discarded > 0U) { + add_counter(&s_counters.usb_tx_dropped_bytes, discarded); + } +} + +static esp_err_t move_host_data_to_broker(session_broker_client_id_t client_id, + usb_cdc_pending_buffer_t *pending) +{ + if (pending->offset == pending->size) { + pending->size = xStreamBufferReceive(s_host_rx_stream, + pending->data, + sizeof(pending->data), + 0U); + pending->offset = 0U; + } + if (pending->size == 0U) { + return ESP_OK; + } + + size_t accepted = 0U; + esp_err_t result = session_broker_write(client_id, + pending->data + pending->offset, + pending->size - pending->offset, + &accepted); + size_t remaining = pending->size - pending->offset; + if (accepted > remaining) { + accepted = remaining; + } + pending->offset += accepted; + if (accepted > 0U) { + add_counter(&s_counters.broker_accepted_bytes, accepted); + } + if (pending->offset == pending->size) { + pending->size = 0U; + pending->offset = 0U; + } + + /* Timeout, zero acceptance, and partial acceptance retain exact byte order. */ + return result; +} + +static esp_err_t move_broker_data_to_usb(session_broker_client_id_t client_id, + usb_cdc_pending_buffer_t *pending) +{ + if (pending->offset == pending->size) { + size_t received = 0U; + esp_err_t result = session_broker_read(client_id, + pending->data, + sizeof(pending->data), + &received); + if (result != ESP_OK) { + return result; + } + pending->size = received; + pending->offset = 0U; + if (received > 0U) { + add_counter(&s_counters.broker_to_usb_bytes, received); + } + } + + if (pending->offset < pending->size) { + size_t remaining = pending->size - pending->offset; + size_t queued = tinyusb_cdcacm_write_queue(TINYUSB_CDC_ACM_0, + pending->data + pending->offset, + remaining); + if (queued > remaining) { + queued = remaining; + } + pending->offset += queued; + if (queued > 0U) { + add_counter(&s_counters.usb_tx_queued_bytes, queued); + } + if (pending->offset == pending->size) { + pending->size = 0U; + pending->offset = 0U; + } + } + + /* Keep endpoint servicing nonblocking even when the host stops reading. */ + (void)tinyusb_cdcacm_write_flush(TINYUSB_CDC_ACM_0, 0U); + return ESP_OK; +} + +static void discard_control_requests(void) +{ + usb_cdc_control_t control; + uint64_t discarded = 0U; + while (xQueueReceive(s_control_queue, &control, 0U) == pdTRUE) { + ++discarded; + } + if (discarded > 0U) { + add_counter(&s_counters.control_drops, discarded); + } +} + +static esp_err_t process_control_requests(session_broker_client_id_t client_id, + bool *writer) +{ + usb_cdc_control_t control; + while (xQueueReceive(s_control_queue, &control, 0U) == pdTRUE) { + esp_err_t result; + if (control == USB_CDC_CONTROL_REQUEST_WRITER) { + result = session_broker_request_writer(client_id); + /* A competing writer is an observed denial, not a lost control. */ + if (result != ESP_OK && result != ESP_ERR_INVALID_STATE) { + add_counter(&s_counters.control_drops, 1U); + } + } else { + result = session_broker_release_writer(client_id); + /* Releasing while already an observer is an idempotent no-op. */ + if (result != ESP_OK && result != ESP_ERR_INVALID_STATE) { + add_counter(&s_counters.control_drops, 1U); + } + } + + if (result == ESP_ERR_NOT_FOUND) { + return result; + } + esp_err_t event_result = drain_broker_events(client_id, writer); + if (event_result != ESP_OK) { + return event_result; + } + } + return ESP_OK; +} + +static bool retry_due(TickType_t now, + TickType_t interval, + TickType_t *last_attempt, + bool *attempted) +{ + if (!*attempted || (TickType_t)(now - *last_attempt) >= interval) { + *last_attempt = now; + *attempted = true; + return true; + } + return false; +} + +static void mark_client_disconnected(session_broker_client_id_t *client_id, + bool *writer) +{ + *client_id = SESSION_BROKER_NO_CLIENT; + *writer = false; + set_broker_state(SESSION_BROKER_NO_CLIENT, false); + add_counter(&s_counters.disconnections, 1U); +} + +static void transport_task(void *context) +{ + (void)context; + + session_broker_client_id_t client_id = SESSION_BROKER_NO_CLIENT; + bool writer = false; + unsigned int observed_generation = atomic_load(&s_connection_generation); + TickType_t last_service_attempt = 0U; + TickType_t last_connect_attempt = 0U; + TickType_t last_reconcile = 0U; + bool service_attempted = false; + bool connect_attempted = false; + bool reconciled = false; + usb_cdc_pending_buffer_t host_pending = {0}; + usb_cdc_pending_buffer_t usb_pending = {0}; + const TickType_t poll_ticks = milliseconds_to_ticks(USB_CDC_POLL_MS); + const TickType_t service_retry_ticks = + milliseconds_to_ticks(USB_CDC_SERVICE_RETRY_MS); + const TickType_t connect_retry_ticks = + milliseconds_to_ticks(USB_CDC_CONNECT_RETRY_MS); + const TickType_t reconcile_ticks = + milliseconds_to_ticks(USB_CDC_BROKER_RECONCILE_MS); + + for (;;) { + unsigned int generation = atomic_load(&s_connection_generation); + if (generation != observed_generation) { + /* Never let pending data or a broker identity cross a CDC session. */ + service_attempted = false; + connect_attempted = false; + reconciled = false; + discard_control_requests(); + discard_host_input(&host_pending); + discard_usb_pending(&usb_pending); + + if (client_id != SESSION_BROKER_NO_CLIENT) { + esp_err_t result = session_broker_disconnect(client_id); + if (result == ESP_OK || result == ESP_ERR_NOT_FOUND) { + mark_client_disconnected(&client_id, &writer); + } + if (client_id != SESSION_BROKER_NO_CLIENT) { + /* Leave the generation unmatched so cleanup is retried. */ + (void)ulTaskNotifyTake(pdTRUE, poll_ticks); + continue; + } + } + observed_generation = generation; + } + + bool open = usb_host_port_open(); + if (!open) { + discard_control_requests(); + discard_host_input(&host_pending); + discard_usb_pending(&usb_pending); + + if (client_id != SESSION_BROKER_NO_CLIENT) { + esp_err_t event_result = drain_broker_events(client_id, &writer); + if (event_result == ESP_ERR_NOT_FOUND) { + mark_client_disconnected(&client_id, &writer); + } else { + esp_err_t result = session_broker_disconnect(client_id); + if (result == ESP_OK || result == ESP_ERR_NOT_FOUND) { + mark_client_disconnected(&client_id, &writer); + } + /* Other failures leave the broker transaction intact for retry. */ + } + } + + (void)ulTaskNotifyTake(pdTRUE, poll_ticks); + continue; + } + + TickType_t now = xTaskGetTickCount(); + if (!serial_service_is_running() && + retry_due(now, + service_retry_ticks, + &last_service_attempt, + &service_attempted)) { + esp_err_t result = serial_service_start(); + if (result != ESP_OK && !serial_service_is_running()) { + add_counter(&s_counters.service_start_failures, 1U); + } + } + + if (client_id == SESSION_BROKER_NO_CLIENT && + serial_service_is_running() && + retry_due(now, + connect_retry_ticks, + &last_connect_attempt, + &connect_attempted)) { + session_broker_client_id_t new_client_id = SESSION_BROKER_NO_CLIENT; + esp_err_t result = session_broker_connect(SESSION_BROKER_CLIENT_USB, + "usb-cdc", + &new_client_id); + if (result == ESP_OK) { + client_id = new_client_id; + writer = false; + set_broker_state(client_id, false); + add_counter(&s_counters.connections, 1U); + + /* Initial ownership is opportunistic; denial leaves an observer. */ + (void)session_broker_request_writer(client_id); + esp_err_t state_result = drain_broker_events(client_id, &writer); + if (state_result == ESP_OK) { + state_result = reconcile_broker_state(client_id, &writer); + reconciled = state_result == ESP_OK; + last_reconcile = now; + } + if (state_result == ESP_ERR_NOT_FOUND) { + mark_client_disconnected(&client_id, &writer); + } + } + } + + if (client_id == SESSION_BROKER_NO_CLIENT) { + discard_control_requests(); + discard_host_input(&host_pending); + discard_usb_pending(&usb_pending); + (void)ulTaskNotifyTake(pdTRUE, poll_ticks); + continue; + } + + esp_err_t result = drain_broker_events(client_id, &writer); + if (result == ESP_OK) { + result = process_control_requests(client_id, &writer); + } + now = xTaskGetTickCount(); + if (result == ESP_OK && + retry_due(now, reconcile_ticks, &last_reconcile, &reconciled)) { + /* Event queues are bounded; the snapshot is authoritative after gaps. */ + result = reconcile_broker_state(client_id, &writer); + } + if (result == ESP_ERR_NOT_FOUND) { + discard_host_input(&host_pending); + discard_usb_pending(&usb_pending); + mark_client_disconnected(&client_id, &writer); + (void)ulTaskNotifyTake(pdTRUE, poll_ticks); + continue; + } + + /* Re-check after broker calls that may have waited for another task. */ + if (atomic_load(&s_connection_generation) != observed_generation) { + continue; + } + + apply_pending_line_coding(writer); + + if (atomic_load(&s_connection_generation) != observed_generation) { + continue; + } + + if (writer) { + result = move_host_data_to_broker(client_id, &host_pending); + if (result == ESP_ERR_INVALID_STATE) { + /* Ownership may have changed after an event-queue overflow. */ + result = reconcile_broker_state(client_id, &writer); + } + if (result == ESP_ERR_NOT_FOUND) { + discard_host_input(&host_pending); + discard_usb_pending(&usb_pending); + mark_client_disconnected(&client_id, &writer); + (void)ulTaskNotifyTake(pdTRUE, poll_ticks); + continue; + } + } else { + /* Observers receive UART data but may not retain host-originated input. */ + discard_host_input(&host_pending); + } + + result = move_broker_data_to_usb(client_id, &usb_pending); + if (result == ESP_ERR_NOT_FOUND) { + discard_host_input(&host_pending); + discard_usb_pending(&usb_pending); + mark_client_disconnected(&client_id, &writer); + } + + (void)ulTaskNotifyTake(pdTRUE, poll_ticks); + } +} + +static void reset_uninitialized_state(void) +{ + atomic_store(&s_usb_state, 0U); + atomic_store(&s_connection_generation, 0U); + atomic_store(&s_transport_task, (uintptr_t)NULL); + + taskENTER_CRITICAL(&s_state_lock); + s_broker_client_id = SESSION_BROKER_NO_CLIENT; + s_writer = false; + s_line_coding = (usb_cdc_transport_line_coding_t) { + .baud_rate = 115200U, + .stop_bits = USB_CDC_TRANSPORT_STOP_BITS_1, + .parity = USB_CDC_TRANSPORT_PARITY_NONE, + .data_bits = 8U, + }; + s_line_coding_pending = false; + memset(&s_counters, 0, sizeof(s_counters)); + taskEXIT_CRITICAL(&s_state_lock); +} + +static void cleanup_init_allocations(bool cdc_initialized, bool driver_installed) +{ + if (cdc_initialized) { + (void)tinyusb_cdcacm_deinit(TINYUSB_CDC_ACM_0); + } + if (driver_installed) { + (void)tinyusb_driver_uninstall(); + } + if (s_control_queue != NULL) { + vQueueDelete(s_control_queue); + s_control_queue = NULL; + } + if (s_host_rx_stream != NULL) { + vStreamBufferDelete(s_host_rx_stream); + s_host_rx_stream = NULL; + } + reset_uninitialized_state(); +} + +esp_err_t usb_cdc_transport_init(void) +{ + bool expected = false; + if (atomic_load(&s_initialized) || + !atomic_compare_exchange_strong(&s_initializing, &expected, true)) { + return ESP_ERR_INVALID_STATE; + } + + reset_uninitialized_state(); + + uint8_t mac[6]; + esp_err_t result = esp_read_mac(mac, ESP_MAC_WIFI_STA); + if (result != ESP_OK) { + atomic_store(&s_initializing, false); + return result; + } + (void)snprintf(s_serial_number, + sizeof(s_serial_number), + "%02X%02X%02X%02X%02X%02X", + mac[0], + mac[1], + mac[2], + mac[3], + mac[4], + mac[5]); + + s_host_rx_stream = xStreamBufferCreate(USB_CDC_HOST_RX_STREAM_SIZE, 1U); + if (s_host_rx_stream == NULL) { + atomic_store(&s_initializing, false); + return ESP_ERR_NO_MEM; + } + + s_control_queue = xQueueCreate(USB_CDC_CONTROL_QUEUE_LENGTH, + sizeof(usb_cdc_control_t)); + if (s_control_queue == NULL) { + cleanup_init_allocations(false, false); + atomic_store(&s_initializing, false); + return ESP_ERR_NO_MEM; + } + + /* ESP32-S3's default full-speed internal PHY is fixed to GPIO19/20. */ + tinyusb_config_t usb_config = TINYUSB_DEFAULT_CONFIG(device_event_callback); + usb_config.descriptor.string = s_string_descriptors; + usb_config.descriptor.string_count = + (int)(sizeof(s_string_descriptors) / sizeof(s_string_descriptors[0])); + /* NULL device/config descriptor fields deliberately select class defaults. */ + + result = tinyusb_driver_install(&usb_config); + if (result != ESP_OK) { + cleanup_init_allocations(false, false); + atomic_store(&s_initializing, false); + return result; + } + + const tinyusb_config_cdcacm_t cdc_config = { + .cdc_port = TINYUSB_CDC_ACM_0, + .callback_rx = cdc_rx_callback, + .callback_rx_wanted_char = cdc_wanted_char_callback, + .callback_line_state_changed = cdc_line_state_callback, + .callback_line_coding_changed = cdc_line_coding_callback, + }; + result = tinyusb_cdcacm_init(&cdc_config); + if (result != ESP_OK) { + cleanup_init_allocations(false, true); + atomic_store(&s_initializing, false); + return result; + } + + TaskHandle_t task = NULL; + if (xTaskCreate(transport_task, + "usb_cdc_transport", + USB_CDC_TASK_STACK_SIZE, + NULL, + USB_CDC_TASK_PRIORITY, + &task) != pdPASS) { + cleanup_init_allocations(true, true); + atomic_store(&s_initializing, false); + return ESP_ERR_NO_MEM; + } + + atomic_store(&s_transport_task, (uintptr_t)task); + atomic_store(&s_initialized, true); + atomic_store(&s_initializing, false); + notify_transport_task(); + return ESP_OK; +} + +esp_err_t usb_cdc_transport_get_snapshot(usb_cdc_transport_snapshot_t *snapshot) +{ + if (snapshot == NULL) { + return ESP_ERR_INVALID_ARG; + } + + unsigned int usb_state = atomic_load(&s_usb_state); + snapshot->initialized = atomic_load(&s_initialized); + snapshot->attached = (usb_state & USB_STATE_ATTACHED) != 0U; + snapshot->dtr = (usb_state & USB_STATE_DTR) != 0U; + snapshot->rts = (usb_state & USB_STATE_RTS) != 0U; + + taskENTER_CRITICAL(&s_state_lock); + snapshot->broker_client_id = s_broker_client_id; + snapshot->writer = s_writer; + snapshot->line_coding = s_line_coding; + snapshot->counters = s_counters; + taskEXIT_CRITICAL(&s_state_lock); + return ESP_OK; +} + +static esp_err_t enqueue_control_request(usb_cdc_control_t control) +{ + if (!atomic_load(&s_initialized)) { + return ESP_ERR_INVALID_STATE; + } + + taskENTER_CRITICAL(&s_state_lock); + bool connected = s_broker_client_id != SESSION_BROKER_NO_CLIENT; + taskEXIT_CRITICAL(&s_state_lock); + if (!connected) { + add_counter(&s_counters.control_drops, 1U); + return ESP_ERR_INVALID_STATE; + } + + if (xQueueSend(s_control_queue, &control, 0U) != pdTRUE) { + add_counter(&s_counters.control_drops, 1U); + return ESP_ERR_TIMEOUT; + } + + notify_transport_task(); + return ESP_OK; +} + +esp_err_t usb_cdc_transport_request_writer(void) +{ + return enqueue_control_request(USB_CDC_CONTROL_REQUEST_WRITER); +} + +esp_err_t usb_cdc_transport_release_writer(void) +{ + return enqueue_control_request(USB_CDC_CONTROL_RELEASE_WRITER); +} + +esp_err_t usb_cdc_transport_clear_counters(void) +{ + if (!atomic_load(&s_initialized)) { + return ESP_ERR_INVALID_STATE; + } + + taskENTER_CRITICAL(&s_state_lock); + memset(&s_counters, 0, sizeof(s_counters)); + taskEXIT_CRITICAL(&s_state_lock); + return ESP_OK; +} diff --git a/src/usb_cdc_transport.h b/src/usb_cdc_transport.h new file mode 100644 index 0000000..408cff0 --- /dev/null +++ b/src/usb_cdc_transport.h @@ -0,0 +1,82 @@ +#pragma once + +#include +#include + +#include "esp_err.h" +#include "session_broker.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* USB CDC line-coding values are the numeric encodings defined by USB PSTN. */ +#define USB_CDC_TRANSPORT_STOP_BITS_1 0U +#define USB_CDC_TRANSPORT_STOP_BITS_1_5 1U +#define USB_CDC_TRANSPORT_STOP_BITS_2 2U + +#define USB_CDC_TRANSPORT_PARITY_NONE 0U +#define USB_CDC_TRANSPORT_PARITY_ODD 1U +#define USB_CDC_TRANSPORT_PARITY_EVEN 2U +#define USB_CDC_TRANSPORT_PARITY_MARK 3U +#define USB_CDC_TRANSPORT_PARITY_SPACE 4U + +typedef struct { + uint32_t baud_rate; + uint8_t stop_bits; + uint8_t parity; + uint8_t data_bits; +} usb_cdc_transport_line_coding_t; + +typedef struct { + uint64_t host_rx_bytes; + uint64_t host_rx_stream_dropped_bytes; + uint64_t broker_accepted_bytes; + uint64_t broker_rejected_bytes; + uint64_t broker_to_usb_bytes; + /* Bytes admitted to TinyUSB's TX FIFO, not a host-delivery acknowledgement. */ + uint64_t usb_tx_queued_bytes; + uint64_t usb_tx_dropped_bytes; + uint64_t connections; + uint64_t disconnections; + uint64_t writer_grants; + uint64_t writer_denials; + uint64_t writer_revocations; + uint64_t writer_events; + uint64_t service_start_failures; + uint64_t line_coding_applied; + uint64_t line_coding_rejected; + uint64_t line_coding_failed; + uint64_t callback_drops; + uint64_t control_drops; +} usb_cdc_transport_counters_t; + +typedef struct { + bool initialized; + bool attached; + bool dtr; + bool rts; + session_broker_client_id_t broker_client_id; + bool writer; + usb_cdc_transport_line_coding_t line_coding; + usb_cdc_transport_counters_t counters; +} usb_cdc_transport_snapshot_t; + +/* + * Installs the native ESP32-S3 USB-OTG device on GPIO19/20 as CDC-ACM 0. + * UART0 console/logging configuration is not changed. + */ +esp_err_t usb_cdc_transport_init(void); + +esp_err_t usb_cdc_transport_get_snapshot(usb_cdc_transport_snapshot_t *snapshot); + +/* Requests are asynchronous and execute in the transport task. */ +esp_err_t usb_cdc_transport_request_writer(void); +esp_err_t usb_cdc_transport_release_writer(void); + +/* Clearing counters does not alter connection, ownership, or queued data. */ +esp_err_t usb_cdc_transport_clear_counters(void); + +#ifdef __cplusplus +} +#endif diff --git a/src/usb_console.c b/src/usb_console.c new file mode 100644 index 0000000..ffd4b6a --- /dev/null +++ b/src/usb_console.c @@ -0,0 +1,184 @@ +#include "usb_console.h" + +#include +#include +#include + +#include "esp_console.h" +#include "esp_err.h" +#include "session_broker.h" +#include "usb_cdc_transport.h" + +static const char *yes_no(bool value) +{ + return value ? "yes" : "no"; +} + +static const char *stop_bits_name(uint8_t stop_bits) +{ + switch (stop_bits) { + case USB_CDC_TRANSPORT_STOP_BITS_1: + return "1"; + case USB_CDC_TRANSPORT_STOP_BITS_1_5: + return "1.5"; + case USB_CDC_TRANSPORT_STOP_BITS_2: + return "2"; + default: + return "unknown"; + } +} + +static const char *parity_name(uint8_t parity) +{ + switch (parity) { + case USB_CDC_TRANSPORT_PARITY_NONE: + return "none"; + case USB_CDC_TRANSPORT_PARITY_ODD: + return "odd"; + case USB_CDC_TRANSPORT_PARITY_EVEN: + return "even"; + case USB_CDC_TRANSPORT_PARITY_MARK: + return "mark"; + case USB_CDC_TRANSPORT_PARITY_SPACE: + return "space"; + default: + return "unknown"; + } +} + +static int show_status(void) +{ + usb_cdc_transport_snapshot_t snapshot; + esp_err_t err = usb_cdc_transport_get_snapshot(&snapshot); + if (err != ESP_OK) { + printf("Could not read USB CDC status: %s\n", esp_err_to_name(err)); + return 1; + } + + bool host_open = snapshot.attached && snapshot.dtr; + printf("USB CDC: initialized=%s attached=%s host-open=%s DTR=%s RTS=%s\n", + yes_no(snapshot.initialized), + yes_no(snapshot.attached), + yes_no(host_open), + yes_no(snapshot.dtr), + yes_no(snapshot.rts)); + + if (snapshot.broker_client_id == SESSION_BROKER_NO_CLIENT) { + printf("Broker: disconnected\n"); + } else { + printf("Broker: client=%" PRIu32 " role=%s\n", + snapshot.broker_client_id, + snapshot.writer ? "writer" : "observer"); + } + + printf("Last host line coding: baud=%" PRIu32 " data=%u parity=%s stop=%s\n", + snapshot.line_coding.baud_rate, + (unsigned int)snapshot.line_coding.data_bits, + parity_name(snapshot.line_coding.parity), + stop_bits_name(snapshot.line_coding.stop_bits)); + printf("USB line coding changes RAM only; use 'serial save' to persist it.\n"); + return 0; +} + +static int show_counters(void) +{ + usb_cdc_transport_snapshot_t snapshot; + esp_err_t err = usb_cdc_transport_get_snapshot(&snapshot); + if (err != ESP_OK) { + printf("Could not read USB CDC counters: %s\n", esp_err_to_name(err)); + return 1; + } + + const usb_cdc_transport_counters_t *counter = &snapshot.counters; + printf("Host RX: received=%" PRIu64 " stream-dropped=%" PRIu64 + " broker-accepted=%" PRIu64 " discarded=%" PRIu64 "\n", + counter->host_rx_bytes, + counter->host_rx_stream_dropped_bytes, + counter->broker_accepted_bytes, + counter->broker_rejected_bytes); + printf("Host TX: broker-read=%" PRIu64 " USB-queued=%" PRIu64 + " local-dropped=%" PRIu64 "\n", + counter->broker_to_usb_bytes, + counter->usb_tx_queued_bytes, + counter->usb_tx_dropped_bytes); + printf("Sessions: connect=%" PRIu64 " disconnect=%" PRIu64 + " grants=%" PRIu64 " denials=%" PRIu64 + " revocations=%" PRIu64 " writer-events=%" PRIu64 "\n", + counter->connections, + counter->disconnections, + counter->writer_grants, + counter->writer_denials, + counter->writer_revocations, + counter->writer_events); + printf("Line coding: applied=%" PRIu64 " rejected=%" PRIu64 + " failed=%" PRIu64 " service-start-failures=%" PRIu64 "\n", + counter->line_coding_applied, + counter->line_coding_rejected, + counter->line_coding_failed, + counter->service_start_failures); + printf("Control/callback drops: control=%" PRIu64 " callback=%" PRIu64 "\n", + counter->control_drops, + counter->callback_drops); + return 0; +} + +static int queue_writer_request(bool request) +{ + esp_err_t err = request ? usb_cdc_transport_request_writer() + : usb_cdc_transport_release_writer(); + if (err != ESP_OK) { + printf("Could not queue USB writer %s: %s\n", + request ? "request" : "release", + esp_err_to_name(err)); + return 1; + } + + printf("USB writer %s queued; use 'usb status' to observe the result.\n", + request ? "request" : "release"); + return 0; +} + +static void print_usage(void) +{ + printf("Usage: usb status|counters|clear-counters|request-writer|release-writer\n"); +} + +static int command_usb(int argc, char **argv) +{ + if (argc == 1 || (argc == 2 && strcmp(argv[1], "status") == 0)) { + return show_status(); + } + if (argc == 2 && strcmp(argv[1], "counters") == 0) { + return show_counters(); + } + if (argc == 2 && strcmp(argv[1], "clear-counters") == 0) { + esp_err_t err = usb_cdc_transport_clear_counters(); + if (err != ESP_OK) { + printf("Could not clear USB CDC counters: %s\n", esp_err_to_name(err)); + return 1; + } + printf("USB CDC counters cleared.\n"); + return 0; + } + if (argc == 2 && strcmp(argv[1], "request-writer") == 0) { + return queue_writer_request(true); + } + if (argc == 2 && strcmp(argv[1], "release-writer") == 0) { + return queue_writer_request(false); + } + + print_usage(); + return 1; +} + +esp_err_t usb_console_register_commands(void) +{ + const esp_console_cmd_t command = { + .command = "usb", + .help = "Inspect native USB CDC and manage its broker writer request", + .hint = NULL, + .func = &command_usb, + .argtable = NULL, + }; + return esp_console_cmd_register(&command); +} diff --git a/src/usb_console.h b/src/usb_console.h new file mode 100644 index 0000000..0f19146 --- /dev/null +++ b/src/usb_console.h @@ -0,0 +1,6 @@ +#pragma once + +#include "esp_err.h" + +/* Register UART0 diagnostics and writer controls for native USB CDC-ACM. */ +esp_err_t usb_console_register_commands(void); diff --git a/wiring.md b/wiring.md index 3ca92d7..2028236 100644 --- a/wiring.md +++ b/wiring.md @@ -59,7 +59,7 @@ GPIO9 ────────────> OFF UART1 is used for the external RS-232 data path. GPIO17 and GPIO18 are the ESP32-S3's conventional `U1TXD` and `U1RXD` pins. GPIO15 and GPIO16 are physically adjacent to them on header J1 and are routed to UART1 RTS and CTS through the ESP32-S3 GPIO matrix. -The GPIO matrix means GPIO15 and GPIO16 can serve UART1 even though their fixed-function labels mention UART0 RTS and CTS. UART0 logging remains on GPIO43 and GPIO44 through the board's USB-to-UART bridge. GPIO19 and GPIO20 remain free for future native USB CDC-ACM support. +The GPIO matrix means GPIO15 and GPIO16 can serve UART1 even though their fixed-function labels mention UART0 RTS and CTS. UART0 logging remains on GPIO43 and GPIO44 through the board's USB-to-UART bridge. GPIO19 and GPIO20 are reserved for the current native USB CDC-ACM transport. RTS/CTS flow control will be configurable. When enabled, the UART peripheral can handle it in hardware so backpressure does not depend on application-task scheduling. When disabled, firmware must place the output signals in compatible idle states. @@ -88,6 +88,19 @@ The breakout's `OFF` pin is connected to the MAX3243 active-low `!FORCEOFF` inpu GPIO8 and GPIO9 are not adjacent on the official J1 header. GPIO3 and GPIO46 lie between them and are boot-strapping pins, so follow the printed GPIO labels instead of counting header positions. +### Development and native USB connectors + +This hardware profile uses both USB controllers for different purposes: + +| DevKit connector | ESP32-S3 path | Typical Linux device | Purpose | +|---|---|---|---| +| `USB-to-UART` | UART0 on GPIO43/44 through the board's bridge chip | `/dev/ttyUSB*` | Firmware upload, logs, and `serial-tool>` development console | +| `USB` | Native USB OTG, GPIO19 `D-` and GPIO20 `D+` | `/dev/ttyACM*` | CDC-ACM client connected to the serial-session broker | + +GPIO19 and GPIO20 are connected to the native USB connector and must not be assigned to the MAX3243, display, buttons, or other peripherals while USB CDC is enabled. Both connectors may be attached to the development host during testing so UART0 logs remain available while native USB carries broker data. Compatible DevKit clones can differ in connector labels and power-path design; verify the board schematic before assuming that two attached cables cannot back-power one another. + +CDC DTR indicates that the host application has opened the native serial port. It controls the lifetime of the `usb-cdc` broker client but is not directly forwarded to the physical DE-9 DTR output. Physical DTR follows the firmware's `serial` configuration. Likewise, host CDC RTS is status information only; GPIO15/DE-9 RTS remains dedicated to UART1 receive flow control when `flow=rts-cts` is enabled. + ## Phase 0 loopback tests The hardware-characterization firmware never starts a test automatically. Wire exactly one configuration below while the board is powered down, inspect the connections, power it again, and then invoke the corresponding console command.