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}