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

257 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
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 `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<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):
```yaml
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.:
```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.