# ESP32-S3 GRUB Boot Selector — ESPHome Port Plan ## 1. How the STM32 reference works The reference firmware at [`reference/`](./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 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 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` 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): ```yaml external_components: - source: github:///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.: ```sh 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.