ESPHome external_component that presents a synthetic 64 KB FAT12 disk over the ESP32-S3's native USB-OTG, containing a one-byte switch_position file and a switch_position_grub.cfg snippet. The disk's volume serial is fixed at 0x55AA6922 to stay drop-in compatible with the upstream /etc/grub.d hook. Components: - fat12 / msc_disk: portable FAT12 sector synthesiser ported from reference/src/fat.c and the boot-sector / FAT / dir-entry generation in reference/src/usb.c. - usb_descriptors / tusb_glue: TinyUSB MSC class with our own callbacks delegating to MscDisk::read_lba (linker --allow-multiple-definition picks ours over esp_tinyusb's storage-backed defaults). - grub_boot_switch: top-level Component, NVS persistence on target changes, exposes targets table to sub-platforms. - select: HA-facing select platform reading parent's targets map. Validated end-to-end on an ESP32-S3-DevKitC-1: USB enumerates as 26ba:8003, /dev/sdb mounts FAT12, mtype reads back the live target, and POST /select/boot_target/set?option=Windows updates both files on the next host read.
12 KiB
ESP32-S3 GRUB Boot Selector — ESPHome Port Plan
1. How the STM32 reference works
The reference firmware at reference/ (cloned from
stecman/hw-boot-selection) is ~600 lines of C on top of libopencm3.
It boils down to four small pieces:
1a. USB enumeration
- Single USB device, single interface, USB MSC class, BBB transport, SCSI subclass
(
reference/src/usb.c:61-93). - Two bulk endpoints (IN
0x81, OUT0x01), 64-byte max packet (Full-Speed). - VID
0x26BA, PID0x8003, manufacturer "Stecman", product "Boot Switch".
1b. Virtual FAT12 disk
A 64 KB block device (128 × 512 B sectors) that exists entirely in code —
no flash, no RAM image. Layout (reference/src/usb.c:104-114):
| LBA | Contents | Source |
|---|---|---|
| 0 | FAT12 boot sector with volume serial 55AA6922, label SWITCH |
BootSector[] constant |
| 1 | FAT (first copy) | FatSector[] constant — 8 bytes, rest zero |
| 2 | FAT (second copy) | same |
| 3 | Root directory | generated each read by generate_dir_sector() |
| 4 | File: switch_position (1 byte: '0' or '1') |
readSwitch() |
| 5 | File: switch_position_grub.cfg (set os_hw_switch=N\n) |
readGrubConfig() |
| 6+ | Zeroes | default in read_block() |
Sector contents are synthesised on every read — the GPIO is sampled inside
the read callback (reference/src/usb.c:159-174), so each fresh GRUB read
reflects the current switch position. This is also why the device is
unreliable to mount in a running OS: the OS caches reads.
The FAT serial number 55AA-6922 is hardcoded so the GRUB hook (README.md:53)
can find it with search --no-floppy --fs-uuid --set hdswitch 55AA-6922
regardless of what drive letter the BIOS assigns.
1c. Long-filename generation
fat.c writes VFAT LFN entries before each 8.3 short name so users see
switch_position_grub.cfg instead of SWITCH~1.CFG. This is portable C —
copies cleanly to any platform.
1d. Hardware/clock plumbing
- One GPIO (PA6, internal pull-up) for the toggle switch.
- Clock setup in
clock.cis a pure STM32 concern — it gets the chip to the 48 MHz the FS USB peripheral wants. main()is a busy-loop callingusbd_poll()(reference/src/main.c:57-62).
1e. The host side
A drop-in script at /etc/grub.d/01_bootswitch reads the virtual .cfg
file at GRUB-load time and uses os_hw_switch to override default=.
We want this to keep working unchanged — i.e., same FS UUID, same
filename, same set os_hw_switch=N content — so an existing user's GRUB
config doesn't need to change when they swap the STM32 for an ESP32.
2. Target architecture on ESP32-S3
The ESP32-S3 has a native USB-OTG controller (USB 1.1 FS). On Espressif's
side this is exposed via TinyUSB, packaged as the esp_tinyusb IDF
managed component. TinyUSB ships a ready MSC class whose callback API maps
1:1 onto the STM32 code:
| STM32 (libopencm3) | TinyUSB |
|---|---|
read_block(lba, buf) |
tud_msc_read10_cb(lun, lba, offset, buf, bufsize) |
write_block(lba, buf) |
tud_msc_write10_cb(...) |
usb_msc_init(... SECTOR_COUNT, ...) |
tud_msc_capacity_cb(lun, &block_count, &block_size) |
usbd_poll() |
runs in TinyUSB's own task (no manual polling) |
So the FAT12 emulation layer ports almost verbatim — only the USB glue
changes. The component must be built under the ESP-IDF framework
(not Arduino-ESP32) for esp_tinyusb integration to be clean.
2a. Wiring (no external switch needed)
Original uses a physical switch on PA6. We replace that with state held in the ESPHome component, driven by Home Assistant via the native API. A physical switch can still be supported as an optional input that overrides or seeds the state.
2b. N-way instead of binary
The original is 1-bit ('0' / '1'). We'll generalise to a small integer
0–N so HA can pick from multiple GRUB entries (Linux / Windows / recovery /
memtest etc.). The on-disk file stays a single ASCII digit so the existing
GRUB hook keeps parsing correctly — just allow 0–9 instead of 0/1.
3. Repo layout to build
esp32-grub-switch/
├── PLAN.md (this file)
├── reference/ (untouched STM32 source — read-only)
├── components/
│ └── grub_boot_switch/ (ESPHome external_component)
│ ├── __init__.py (Python config schema)
│ ├── grub_boot_switch.h (Component class)
│ ├── grub_boot_switch.cpp (setup/loop, state, HA glue)
│ ├── fat12.h / fat12.cpp (port of fat.c — pure logic)
│ ├── msc_disk.h / msc_disk.cpp (LBA → sector synthesiser)
│ ├── tusb_glue.cpp (TinyUSB callbacks → component)
│ ├── usb_descriptors.c (VID/PID/strings)
│ └── select/ (sub-component: HA-facing select entity)
│ ├── __init__.py
│ ├── boot_target_select.h
│ └── boot_target_select.cpp
└── example.yaml (reference ESPHome config)
The split mirrors how official ESPHome components organise sub-platforms
(e.g. sensor/, switch/ under a hub component).
4. Implementation phases
Phase 1 — Skeleton & framework selection
- Create
components/grub_boot_switch/__init__.pyregistering the component, requiringesp_idfframework onesp32s3variant. - Add
esp_tinyusbas an IDF managed dependency viaesp32.add_idf_component(...)in the Python config. - Empty
Componentsubclass, just enough foresphome compileto pass on a stub YAML. - Provide
example.yamltargeting an ESP32-S3 dev board withexternal_components:pointing at this repo.
Done when: esphome compile example.yaml produces a binary that boots
and logs "grub_boot_switch: setup".
Phase 2 — Port the FAT12 emulator
- Translate
reference/src/fat.{c,h}→fat12.{cpp,h}. Pure data manipulation, no platform dependencies — copy and rename to the project's namespace (esphome::grub_boot_switch::). - Translate the
BootSector[],FatSector[],generate_dir_sector(),read_block()fromreference/src/usb.cintomsc_disk.cpp.- Keep volume serial
0x55AA6922, labelSWITCH, and the same two filenames so existing GRUB hooks are unaffected. - Generalise the read callback so file content comes from a
std::function<void(uint8_t* out)>table populated by the component.
- Keep volume serial
- Unit-test on host: write a small C++ test harness that walks LBAs 0..7,
dumps to a
disk.img, and verifies withmtools(mdir -i disk.img) andfile disk.img. This catches FAT bugs before flashing.
Done when: disk.img produced on host mounts as FAT12 in Linux and
contains the two files with correct content.
Phase 3 — Wire up TinyUSB MSC
tusb_glue.cppimplements all requiredtud_msc_*callbacks:tud_msc_inquiry_cb— vendor/product strings (mirror SCSI strings fromreference/src/usb.c:293-295).tud_msc_test_unit_ready_cb— return true oncesetup()finished.tud_msc_capacity_cb— 128 × 512.tud_msc_read10_cb— delegates toMscDisk::read_lba().tud_msc_write10_cb— no-op (return success, ignore data) so the OS doesn't error if it tries to write FAT metadata back.tud_msc_scsi_cb— return -1 for unsupported.
usb_descriptors.cprovides device + config + string descriptors. Keep VID/PID identical to the STM32 (0x26BA/0x8003) so any host-side udev rules continue to match — change the serial-number string to include the ESP32's MAC for uniqueness.- Initialise
tinyusb_driver_install()fromComponent::setup(). The stack runs its own FreeRTOS task;Component::loop()stays empty.
Done when: plugging the ESP32 into a Linux box gives
lsblk a 64 KB block device, mount -t vfat succeeds, and both files
read back correctly.
Phase 4 — Home Assistant control surface
ESPHome already has the right primitives — we expose state via standard sub-components, no custom HA integration needed.
selectplatform under the component, providing aBootTargetSelectentity. Options are user-configurable in YAML (["Linux", "Windows", "Recovery"]) and map to ASCII digits0..N-1that get baked into the cfg file.text_sensorexposing the current digit (handy for debugging / automations that need to react to a manual override).- Optional
binary_sensor/ GPIO input for a physical override switch. If present and active, it pre-empts the HA-selected value. - Persist the last-selected target to NVS so a power cycle of the ESP32 doesn't silently change which OS will boot next.
YAML sketch (matches ESPHome conventions):
external_components:
- source: github://<you>/esp32-grub-switch
components: [grub_boot_switch]
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
grub_boot_switch:
id: bootsw
# volume_serial defaults to 0x55AA6922 to match upstream GRUB hook
select:
- platform: grub_boot_switch
name: "Boot Target"
options:
- Linux
- Windows
- Recovery
initial_option: Linux
restore_value: true
Done when: changing the HA select dropdown causes a fresh GRUB read
on the host to see the new digit. Verify with
sudo dd if=/dev/sdX bs=512 skip=5 count=1 status=none | xxd after each
change (skip caching by re-plugging or re-running with iflag=direct).
Phase 5 — End-to-end with a real GRUB
- Install the upstream GRUB hook from
reference/README.md(no changes needed — that's the whole point of preserving the FS UUID). - Extend it to handle 0..N targets, e.g.:
if [ "${hdswitch}" ] ; then source ($hdswitch)/switch_position_grub.cfg case "${os_hw_switch}" in 0) set default="0" ;; # Linux 1) set default="2" ;; # Windows 2) set default="1" ;; # Recovery esac fi - Run
update-grub, reboot, confirm the chosen entry boots. Toggle from HA, reboot again, confirm the other entry boots.
Done when: flipping the HA select and rebooting reliably picks the right OS, both fresh-cold and warm-restart.
5. Risks & open questions
| Risk | Mitigation |
|---|---|
ESP-IDF version pinning — esp_tinyusb API has shifted between IDF 4.4 / 5.x. |
Pin framework.version in YAML; document the tested version in the README. |
| ESP32-S3 USB-OTG vs UART USB CDC: the same USB port can't be both at once. | Document that flashing has to happen via the secondary UART/JTAG USB port (most S3 dev boards have two USB-C connectors for this exact reason) or in download-mode. |
| Some BIOSes won't enumerate USB devices that present too slowly. | Move TinyUSB init to the very top of setup() so enumeration starts before WiFi/HA bring-up. |
| Host-side caching makes the device unreliable to use from a running Linux. | Document: HA → ESP32 is the live channel; the disk is only for GRUB. (Same caveat as the original — see reference/README.md "Reading from an operating system".) |
| Volume-serial change accidentally breaks an existing user's GRUB hook. | Keep 0x55AA6922 as the default in YAML; expose it as configurable for users who want isolation. |
| Power loss between HA setting a value and the user rebooting — ESP32 forgets. | NVS persistence (Phase 4 step 4). |
6. Suggested first concrete step
Phase 1 + a thin slice of Phase 2: stand up the external_component
skeleton, port fat.c verbatim, and add a host-side test that builds a
disk.img and verifies it mounts. That's a self-contained chunk that
de-risks the FAT logic before any ESP32 hardware is involved, and from
there everything else is plumbing.