Files
esp32-grub-switch/PLAN.md
adamksmith a911b9e4cb Initial port of stecman/hw-boot-selection to ESP32-S3
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.
2026-05-04 18:25:41 +00:00

12 KiB
Raw Blame History

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, OUT 0x01), 64-byte max packet (Full-Speed).
  • VID 0x26BA, PID 0x8003, 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.c is a pure STM32 concern — it gets the chip to the 48 MHz the FS USB peripheral wants.
  • main() is a busy-loop calling usbd_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 0N 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 09 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

  1. Create components/grub_boot_switch/__init__.py registering the component, requiring esp_idf framework on esp32s3 variant.
  2. Add esp_tinyusb as an IDF managed dependency via esp32.add_idf_component(...) in the Python config.
  3. Empty Component subclass, just enough for esphome compile to pass on a stub YAML.
  4. Provide example.yaml targeting an ESP32-S3 dev board with external_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

  1. 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::).
  2. Translate the BootSector[], FatSector[], generate_dir_sector(), read_block() from reference/src/usb.c into msc_disk.cpp.
    • Keep volume serial 0x55AA6922, label SWITCH, 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.
  3. Unit-test on host: write a small C++ test harness that walks LBAs 0..7, dumps to a disk.img, and verifies with mtools (mdir -i disk.img) and file 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

  1. tusb_glue.cpp implements all required tud_msc_* callbacks:
    • tud_msc_inquiry_cb — vendor/product strings (mirror SCSI strings from reference/src/usb.c:293-295).
    • tud_msc_test_unit_ready_cb — return true once setup() finished.
    • tud_msc_capacity_cb — 128 × 512.
    • tud_msc_read10_cb — delegates to MscDisk::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.
  2. usb_descriptors.c provides 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.
  3. Initialise tinyusb_driver_install() from Component::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.

  1. select platform under the component, providing a BootTargetSelect entity. Options are user-configurable in YAML (["Linux", "Windows", "Recovery"]) and map to ASCII digits 0..N-1 that get baked into the cfg file.
  2. text_sensor exposing the current digit (handy for debugging / automations that need to react to a manual override).
  3. Optional binary_sensor / GPIO input for a physical override switch. If present and active, it pre-empts the HA-selected value.
  4. 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

  1. Install the upstream GRUB hook from reference/README.md (no changes needed — that's the whole point of preserving the FS UUID).
  2. 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
    
  3. 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.