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.
This commit is contained in:
2026-05-04 18:25:41 +00:00
commit a911b9e4cb
19 changed files with 1497 additions and 0 deletions

256
PLAN.md Normal file
View File

@@ -0,0 +1,256 @@
# 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.