Switching USB controller at boot on ESP32-S3

I'm building a digital synthesizer based on the ESP32-S3 MCU (using the ESP-IDF framework) and have been meaning to start a dev blog for a while. I couldn't figure out a good first post so I figured why not just start somewhere - let's talk about USB!

I've been pondering connectivity on my synth for a while. It's got hardware MIDI, but I also figured I'd take advantage of the fact that the ESP32-S3 has native USB to add USB-MIDI support. The downside of this is that I'm also using this USB port for programming & debugging (and plan to ship the device with the ability for users to upload firmware updates). By default the ESP32-S3 USB port exposes a CDC-ACM device that allows you to upload & debug without a USB-UART device. It's very handy - if you're designing your own board, all you need to do is run two data pins (and power & ground) from the internal PHY pins to a USB connector for direct upload (including automatic reset, etc). I don't want to lose this functionality, but I want to add a default mode where my synth appears as a USB-MIDI device. What to do?

The answer turns out is pretty straightward. If you follow the USB stack docs, or better yet the USB-MIDI example which I've been adapting, you'll see that there's a process in your app_main function to initialize & install the TinyUSB driver (tinyusb_driver_install). Calling this switches the internal USB PHY port from the CDC-ACM serial controller to the USB-OTG controller, which is run by the TinyUSB stack. So, that's handy - that means that at runtime it's possible to switch the functionality of the USB PHY.

My first attempt was then to just conditionalize calling tinyusb_driver_install - if you boot up holding the "debug" button, skip installing the TinyUSB driver, and huzzah - we have the USB Serial/JTAG controller connected to the PHY, if you boot without holding the button, we install the driver. This works, but unfortunately since the default behaviour in the ROM is to connect the serial/JTAG controller to the PHY, computers will first enumerate the device as a serial device, then again as a USB-MIDI device. This is not great for M* Macs which pop up a dialog confirming that the user wants to connect a USB device (that'll happen twice).

The solution is pretty well documented in the Technical Reference Manual. The important bit:

After the user program is running, it can modify the initial configuration by setting registers. Specifically, RTC_CNTL_SW_HW_USB_PHY_SEL can be used to have software override the effect of EFUSE_USB_PHY_SEL: if this bit is set, the USB PHY selection logic will use the value of the RTC_CNTL_SW_USB_PHY_SEL bit in place of that of EFUSE_USB_PHY_SEL.

So, sure enough, by first burning the USB_PHY_SEL eFuse to have the ROM connect the internal USB PHY to the USB-OTG controller, we can then either boot normally and call tinyusb_driver_install to run as a USB-MIDI device (with no startup enumeration as a serial device), or we can decide at startup to boot into USB Serial/JTAG controller mode by running the following:

#include "soc/rtc_cntl_reg.h"

...

uint32_t new_val = REG_READ(RTC_CNTL_USB_CONF_REG) | RTC_CNTL_SW_HW_USB_PHY_SEL;
REG_WRITE(RTC_CNTL_USB_CONF_REG, new_val);

I'm planning on persisting the mode via an NVS variable so I can easily stay in debugging mode. A future improvement would be to allow a user to toggle between the two modes at runtime, not just at boot - it seems this has been added to the arduino-esp32 package here so it seems possible but requires some fiddling/restarting of peripherals, none of which is necessary for my use case. Additionally, when I ship a final product, I probably won't expose the low level USB Serial/JTAG controller in "upload" mode and might implement something based on DFU mode for the actual user update process.

Hope someone else finds this useful!