Skip to main content

dmx_interface/hardware/
usb.rs

1//! USB-CDC-ACM device with esptool auto-reset support.
2//!
3//! The Lolin S2 Mini has no USB-UART bridge, so esptool cannot toggle
4//! GPIO0/EN via physical DTR/RTS pins. Instead, the firmware exposes the
5//! native USB-OTG port as a CDC-ACM device and interprets the CDC
6//! control-line state the way the classic devkit reset sequence does:
7//!
8//! | DTR | RTS | Meaning            | Action                          |
9//! |-----|-----|--------------------|---------------------------------|
10//! | 1   | 0   | IO0 low            | set `FORCE_DOWNLOAD_BOOT`       |
11//! | 0   | 1   | EN low (reset)     | software reset                  |
12//! | 0   | 0   | idle               | clear `FORCE_DOWNLOAD_BOOT`     |
13//! | 1   | 1   | transient          | no-op                           |
14//!
15//! esptool's `--before usb-reset` walk is `(0,0) -> (1,0) -> (0,1) ->
16//! (0,0)`: the `(1,0)` state arms `FORCE_DOWNLOAD_BOOT`, then `(0,1)`
17//! resets into the ROM bootloader.
18//!
19//! The flag lives in the RTC domain and survives software resets. The ROM
20//! bootloader does **not** clear it on entry — after a flash the chip would
21//! stay stuck in the bootloader forever (until power-on). `inv flash`
22//! therefore clears the flag itself after flashing (esptool `write-reg
23//! 0x3F408128 0 0x1` through the stub) and then resets via the RTC watchdog,
24//! which boots the application.
25//!
26//! The same task also drains the log ring from [`crate::logging`] over the
27//! CDC data endpoint, so `inv monitor` shows the firmware log output.
28
29use embassy_futures::{
30    join::join,
31    select::{Either, select},
32};
33use embassy_time::{Duration, Instant, Timer};
34use embassy_usb::{
35    Builder,
36    class::cdc_acm::{CdcAcmClass, State},
37};
38use esp_hal::{
39    otg_fs::{
40        Usb,
41        asynch::{Config, Driver},
42    },
43    peripherals::{GPIO19, GPIO20, USB0},
44};
45use static_cell::StaticCell;
46
47use crate::logging;
48
49/// Espressif USB vendor ID (same as the ROM bootloader).
50const USB_VID: u16 = 0x303A;
51
52/// Espressif generic CDC-ACM product ID.
53const USB_PID: u16 = 0x3001;
54
55/// Line-poll interval of the DTR/RTS state machine.
56const POLL_INTERVAL: Duration = Duration::from_millis(10);
57
58/// Log chunk size handed to the CDC endpoint (matches `max_packet_size`).
59const LOG_CHUNK: usize = 64;
60
61/// The S2 peripheral types are invariant in their lifetime, which pins the
62/// USB driver to `'static`; the embassy-usb buffers therefore must be
63/// `'static` too. They are initialized exactly once at task start.
64static EP_OUT_BUFFER: StaticCell<[u8; 1024]> = StaticCell::new();
65/// Bulk OUT endpoint buffer for the CDC data endpoint.
66static CONFIG_DESCRIPTOR: StaticCell<[u8; 256]> = StaticCell::new();
67/// Buffer for the generated USB configuration descriptor.
68static BOS_DESCRIPTOR: StaticCell<[u8; 256]> = StaticCell::new();
69/// Buffer for the generated USB BOS descriptor.
70static CONTROL_BUF: StaticCell<[u8; 64]> = StaticCell::new();
71/// Scratch buffer for USB control transfers.
72static CDC_STATE: StaticCell<State<'static>> = StaticCell::new();
73
74/// Runs the USB-CDC-ACM device and the esptool reset protocol.
75///
76/// The DP/DM pins are fixed by the S2 hardware (GPIO20/GPIO19).
77#[embassy_executor::task]
78pub async fn task(usb0: USB0<'static>, dp: GPIO20<'static>, dm: GPIO19<'static>) -> ! {
79    let usb = Usb::new(usb0, dp, dm);
80
81    let driver = Driver::new(usb, EP_OUT_BUFFER.init([0u8; 1024]), Config::default());
82
83    let mut config = embassy_usb::Config::new(USB_VID, USB_PID);
84    config.manufacturer = Some("dmx-interface");
85    config.product = Some("DMX-Interface USB-CDC");
86    config.device_class = 0xEF;
87    config.device_sub_class = 0x02;
88    config.device_protocol = 0x01;
89    config.composite_with_iads = true;
90
91    let mut builder = Builder::new(
92        driver,
93        config,
94        CONFIG_DESCRIPTOR.init([0u8; 256]),
95        BOS_DESCRIPTOR.init([0u8; 256]),
96        &mut [],
97        CONTROL_BUF.init([0u8; 64]),
98    );
99    let mut class = CdcAcmClass::new(&mut builder, CDC_STATE.init(State::new()), 64);
100    let mut device = builder.build();
101
102    log::info!("USB-CDC ready");
103    join(device.run(), control_and_log(&mut class)).await;
104    unreachable!()
105}
106
107/// Runs the DTR/RTS state machine and drains the log ring into the host.
108///
109/// Both roles live in one task because the line poll needs `&class` while
110/// the log flush needs `&mut class` — a single `&mut` serves both (`dtr()`
111/// and `rts()` take `&self`).
112///
113/// The future is a `select` of two branches:
114///
115/// * a deadline-based 10 ms timer that re-checks the control lines (the
116///   deadline is only re-armed when the timer actually fired, so log traffic
117///   can never starve the esptool reset walk), and
118/// * a log flush that copies one chunk out of the ring and awaits
119///   `write_packet`. Bytes are only committed after the driver accepted the
120///   packet, so a cancelled flush (timer branch won) loses nothing and
121///   duplicates nothing.
122///
123/// The flush has no connection gate: before the host has configured us the
124/// endpoint is disabled and the write errors out right away (the flush then
125/// waits a poll interval instead of spinning, so the timer branch keeps
126/// running), and an idle ring waits 10 ms between polls instead of spinning.
127async fn control_and_log(class: &mut CdcAcmClass<'static, Driver<'static>>) -> ! {
128    let mut prev = (class.dtr(), class.rts());
129    let mut deadline = Instant::now().saturating_add(POLL_INTERVAL);
130    loop {
131        let outcome = select(Timer::at(deadline), flush_logs(class)).await;
132        match outcome {
133            Either::First(()) => {
134                deadline = Instant::now().saturating_add(POLL_INTERVAL);
135                let now = (class.dtr(), class.rts());
136                if now == prev {
137                    continue;
138                }
139                prev = now;
140                match now {
141                    (false, false) => set_force_download(false),
142                    (true, false) => set_force_download(true),
143                    (false, true) => enter_bootloader(),
144                    (true, true) => {}
145                }
146            }
147            Either::Second(()) => {}
148        }
149    }
150}
151
152/// Copies pending log lines from the ring into the CDC endpoint until the
153/// ring is empty or the endpoint errors out. See [`control_and_log`] for the
154/// cancellation-safety argument.
155async fn flush_logs(class: &mut CdcAcmClass<'static, Driver<'static>>) {
156    loop {
157        let mut chunk = [0u8; LOG_CHUNK];
158        let n = logging::cdc_peek(&mut chunk);
159        if n == 0 {
160            Timer::after(POLL_INTERVAL).await;
161            return;
162        }
163        let (data, _) = chunk.split_at(n);
164        if class.write_packet(data).await.is_err() {
165            // The endpoint is disabled (host has not configured us yet) —
166            // returning immediately would let `select` complete over and
167            // over without ever yielding, starving `device.run()` and
168            // stalling enumeration. Wait instead of spinning.
169            Timer::after(POLL_INTERVAL).await;
170            return;
171        }
172        logging::cdc_commit(n);
173    }
174}
175
176/// Set or clear the ROM `FORCE_DOWNLOAD_BOOT` flag in `RTC_CNTL_OPTION1`.
177///
178/// The register lives in the RTC domain and survives a software reset, so
179/// the ROM bootloader sees it on the next boot.
180fn set_force_download(armed: bool) {
181    // SAFETY: RTC registers are always memory-mapped; `steal()` only bypasses
182    // singleton ownership of the peripheral and `modify` leaves every other
183    // bit of `RTC_CNTL_OPTION1` untouched.
184    let rtc_cntl = unsafe { esp32s2::RTC_CNTL::steal() };
185    rtc_cntl
186        .options1()
187        .modify(|_, w| w.force_download_boot().bit(armed));
188}
189
190/// Resets into the ROM USB-CDC download mode (bootloader).
191///
192/// `(0,1)` is a plain reset: the boot target is decided by the
193/// `FORCE_DOWNLOAD_BOOT` flag, which the `(1,0)` state arms. esptool's
194/// `--before usb-reset` walk therefore lands in the bootloader. The flag is
195/// only cleared again by `inv flash` after flashing (esptool `write-reg`)
196/// — the ROM bootloader does not clear it itself.
197fn enter_bootloader() -> ! {
198    esp_hal::system::software_reset()
199}