From a911b9e4cbdd50a0fb7cb02795c2ebc61c12ec57 Mon Sep 17 00:00:00 2001 From: adamksmith Date: Mon, 4 May 2026 18:25:41 +0000 Subject: [PATCH] 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. --- .gitignore | 26 ++ LICENSE | 26 ++ PLAN.md | 256 ++++++++++++++++++ components/grub_boot_switch/__init__.py | 102 +++++++ components/grub_boot_switch/fat12.cpp | 80 ++++++ components/grub_boot_switch/fat12.h | 52 ++++ .../grub_boot_switch/grub_boot_switch.cpp | 116 ++++++++ .../grub_boot_switch/grub_boot_switch.h | 82 ++++++ components/grub_boot_switch/msc_disk.cpp | 157 +++++++++++ components/grub_boot_switch/msc_disk.h | 73 +++++ .../grub_boot_switch/select/__init__.py | 51 ++++ .../select/boot_target_select.cpp | 44 +++ .../select/boot_target_select.h | 24 ++ components/grub_boot_switch/tusb_glue.cpp | 120 ++++++++ .../grub_boot_switch/usb_descriptors.cpp | 110 ++++++++ components/grub_boot_switch/usb_descriptors.h | 31 +++ example.yaml | 36 +++ test/Makefile | 52 ++++ test/test_disk.cpp | 59 ++++ 19 files changed, 1497 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 PLAN.md create mode 100644 components/grub_boot_switch/__init__.py create mode 100644 components/grub_boot_switch/fat12.cpp create mode 100644 components/grub_boot_switch/fat12.h create mode 100644 components/grub_boot_switch/grub_boot_switch.cpp create mode 100644 components/grub_boot_switch/grub_boot_switch.h create mode 100644 components/grub_boot_switch/msc_disk.cpp create mode 100644 components/grub_boot_switch/msc_disk.h create mode 100644 components/grub_boot_switch/select/__init__.py create mode 100644 components/grub_boot_switch/select/boot_target_select.cpp create mode 100644 components/grub_boot_switch/select/boot_target_select.h create mode 100644 components/grub_boot_switch/tusb_glue.cpp create mode 100644 components/grub_boot_switch/usb_descriptors.cpp create mode 100644 components/grub_boot_switch/usb_descriptors.h create mode 100644 example.yaml create mode 100644 test/Makefile create mode 100644 test/test_disk.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57cdcce --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# ESPHome build cache +/.esphome/ + +# Device YAML + secrets live in the user's HA config repo, not here +/ctn1-usco-wks-boot-selector.yaml +/secrets.yaml + +# Upstream reference firmware — separate repo, clone manually for context: +# git clone https://github.com/stecman/hw-boot-selection.git reference +/reference/ + +# Host-side test outputs +/test/test_disk +/test/disk.img +/test/sweep_*.img +/test/*.img + +# Python / editor / OS +__pycache__/ +*.pyc +*.pyo +.DS_Store +*.swp +.vscode/ +.idea/ +.claude/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fafd804 --- /dev/null +++ b/LICENSE @@ -0,0 +1,26 @@ +MIT License + +Copyright (c) 2021 Stephen Holdaway + (FAT12 sector synthesis, GRUB hook design — ported from + https://github.com/stecman/hw-boot-selection) + +Copyright (c) 2026 Adam K Smith + (ESP32-S3 port, ESPHome external_component, TinyUSB MSC integration) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..e6bd3be --- /dev/null +++ b/PLAN.md @@ -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 +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. diff --git a/components/grub_boot_switch/__init__.py b/components/grub_boot_switch/__init__.py new file mode 100644 index 0000000..28ea321 --- /dev/null +++ b/components/grub_boot_switch/__init__.py @@ -0,0 +1,102 @@ +"""ESPHome external_component: GRUB boot switch over USB MSC. + +Presents a synthetic 64 KB FAT12 disk to the attached host containing two +virtual files: + + switch_position - one ASCII digit (0-9) reflecting the current + boot target. + switch_position_grub.cfg - GRUB snippet `set os_hw_switch=N\\n` consumed + by the user's /etc/grub.d/01_bootswitch hook. + +The volume serial defaults to 0x55AA6922 to stay drop-in compatible with +the upstream stecman/hw-boot-selection firmware. +""" + +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.components import esp32 + +DEPENDENCIES = ["esp32"] +CODEOWNERS = ["@adamksmith"] +MULTI_CONF = False + +grub_boot_switch_ns = cg.esphome_ns.namespace("grub_boot_switch") +GrubBootSwitch = grub_boot_switch_ns.class_("GrubBootSwitch", cg.Component) + +CONF_VOLUME_SERIAL = "volume_serial" +CONF_VOLUME_LABEL = "volume_label" +CONF_USB_VID = "usb_vid" +CONF_USB_PID = "usb_pid" +CONF_USB_MANUFACTURER = "usb_manufacturer" +CONF_USB_PRODUCT = "usb_product" +CONF_INITIAL_TARGET = "initial_target" +CONF_TARGETS = "targets" +CONF_GRUB_BOOT_SWITCH_ID = "grub_boot_switch_id" + + +def _validate_label(value): + value = cv.string_strict(value) + if len(value) > 11: + raise cv.Invalid("volume_label must be 11 characters or fewer") + return value + + +# targets: maps a digit (0-9) the firmware writes into switch_position to a +# human-readable label. GRUB scripts react to the digit; the labels are only +# used by the select sub-platform exposed to Home Assistant. +TARGETS_SCHEMA = cv.Schema({cv.int_range(min=0, max=9): cv.string_strict}) + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(GrubBootSwitch), + cv.Optional(CONF_VOLUME_SERIAL, default=0x55AA6922): cv.hex_uint32_t, + cv.Optional(CONF_VOLUME_LABEL, default="SWITCH"): _validate_label, + cv.Optional(CONF_USB_VID, default=0x26BA): cv.hex_uint16_t, + cv.Optional(CONF_USB_PID, default=0x8003): cv.hex_uint16_t, + cv.Optional(CONF_USB_MANUFACTURER, default="Stecman"): cv.string, + cv.Optional(CONF_USB_PRODUCT, default="Boot Switch"): cv.string, + cv.Optional(CONF_INITIAL_TARGET, default=0): cv.int_range(min=0, max=9), + cv.Optional(CONF_TARGETS, default={}): TARGETS_SCHEMA, + } + ).extend(cv.COMPONENT_SCHEMA), + cv.only_with_esp_idf, + cv.only_on_esp32, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + cg.add(var.set_volume_serial(config[CONF_VOLUME_SERIAL])) + cg.add(var.set_volume_label(config[CONF_VOLUME_LABEL])) + cg.add(var.set_usb_ids(config[CONF_USB_VID], config[CONF_USB_PID])) + cg.add(var.set_usb_strings(config[CONF_USB_MANUFACTURER], config[CONF_USB_PRODUCT])) + cg.add(var.set_initial_target(config[CONF_INITIAL_TARGET])) + + for digit, label in sorted(config[CONF_TARGETS].items()): + cg.add(var.add_target(digit, label)) + + # esp_tinyusb is the supported MSC stack on ESP32-S3 USB-OTG. + esp32.add_idf_component( + name="espressif/esp_tinyusb", + ref="~1.4.0", + ) + + # Move IDF console off USB-Serial-JTAG so TinyUSB can claim the peripheral. + esp32.add_idf_sdkconfig_option("CONFIG_ESP_CONSOLE_UART_DEFAULT", True) + esp32.add_idf_sdkconfig_option("CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG", False) + + # esp_tinyusb's tusb_msc_storage.c provides default tud_msc_* callbacks as + # strong symbols. We provide our own callback-driven implementations, so + # we tell the linker to allow multiple definitions and rely on link order + # (main component first → our symbols win) to pick ours. + esp32.add_idf_sdkconfig_option("CONFIG_TINYUSB_MSC_ENABLED", True) + esp32.add_idf_sdkconfig_option("CONFIG_TINYUSB_CDC_ENABLED", False) + + cg.add_build_flag("-DCFG_TUSB_MCU=OPT_MCU_ESP32S3") + cg.add_build_flag("-Wl,--allow-multiple-definition") diff --git a/components/grub_boot_switch/fat12.cpp b/components/grub_boot_switch/fat12.cpp new file mode 100644 index 0000000..b295d3d --- /dev/null +++ b/components/grub_boot_switch/fat12.cpp @@ -0,0 +1,80 @@ +#include "fat12.h" + +#include + +namespace esphome { +namespace grub_boot_switch { + +static inline uint8_t fat_checksum(const char *name) { + uint8_t s = name[0]; + s = (s << 7) + (s >> 1) + name[1]; + s = (s << 7) + (s >> 1) + name[2]; + s = (s << 7) + (s >> 1) + name[3]; + s = (s << 7) + (s >> 1) + name[4]; + s = (s << 7) + (s >> 1) + name[5]; + s = (s << 7) + (s >> 1) + name[6]; + s = (s << 7) + (s >> 1) + name[7]; + s = (s << 7) + (s >> 1) + name[8]; + s = (s << 7) + (s >> 1) + name[9]; + s = (s << 7) + (s >> 1) + name[10]; + return s; +} + +static uint8_t write_utf16_chars(const char *name, uint8_t *index, uint8_t length, uint8_t write_count, + uint8_t *output) { + for (uint8_t i = write_count; i != 0; --i) { + if (*index <= length) { + *output++ = name[*index]; + *output++ = 0x00; + ++(*index); + } else { + *output++ = 0xFF; + *output++ = 0xFF; + } + } + return write_count * 2; +} + +uint8_t fat_write_lfn(const char *name, const FatDirEntry *direntry, uint8_t *output) { + constexpr uint8_t kCharsPerEntry = 13; + + const size_t length = strlen(name); + const uint8_t num_entries = (length + (kCharsPerEntry - 1)) / kCharsPerEntry; + const uint8_t checksum = fat_checksum(reinterpret_cast(direntry)); + + for (uint8_t ordinal = num_entries; ordinal != 0; --ordinal) { + uint8_t index = (ordinal - 1) * kCharsPerEntry; + + *output = ordinal | (kFat_LastLFN * (ordinal == num_entries)); + ++output; + + output += write_utf16_chars(name, &index, length, 5, output); + + output[0] = kFatAttr_LongFileName; + output[1] = 0x00; + output[2] = checksum; + output += 3; + + output += write_utf16_chars(name, &index, length, 6, output); + + output[0] = 0x00; + output[1] = 0x00; + output += 2; + + output += write_utf16_chars(name, &index, length, 2, output); + } + + return sizeof(FatDirEntry) * num_entries; +} + +uint8_t fat_write_dir(const FatDirEntry *direntry, uint8_t *output) { + memcpy(output, direntry, sizeof(FatDirEntry)); + return sizeof(FatDirEntry); +} + +uint16_t fat_date(uint16_t year, uint8_t month, uint8_t day) { + return ((year - 1980) << 9) | (month << 5) | day; +} + +} // namespace grub_boot_switch +} // namespace esphome diff --git a/components/grub_boot_switch/fat12.h b/components/grub_boot_switch/fat12.h new file mode 100644 index 0000000..5ae821f --- /dev/null +++ b/components/grub_boot_switch/fat12.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +namespace esphome { +namespace grub_boot_switch { + +#define WBVAL(x) ((x) & 0xFF), (((x) >> 8) & 0xFF) +#define QBVAL(x) ((x) & 0xFF), (((x) >> 8) & 0xFF), (((x) >> 16) & 0xFF), (((x) >> 24) & 0xFF) + +constexpr uint8_t FAT_MEDIA_FIXED_DISK = 0xF8; +constexpr uint8_t FAT_NAME_LEN = 8; +constexpr uint8_t FAT_EXT_LEN = 3; + +enum FatFileAttributes : uint8_t { + kFatAttr_ReadOnly = 0x01, + kFatAttr_Hidden = 0x02, + kFatAttr_System = 0x04, + kFatAttr_VolumeLabel = 0x08, + kFatAttr_Subdirectory = 0x10, + kFatAttr_Archive = 0x20, + kFatAttr_LongFileName = 0x0F, +}; + +enum FatOrdinalFlags : uint8_t { + kFat_LastLFN = 0x40, + kFat_DeletedLFN = 0x80, +}; + +struct FatDirEntry { + char name[FAT_NAME_LEN]; + char ext[FAT_EXT_LEN]; + uint8_t attrs; + uint8_t type; + uint8_t ctime_ms; + uint16_t ctime; + uint16_t cdate; + uint16_t adate; + uint16_t ea_index; + uint16_t mtime; + uint16_t mdate; + uint16_t start; + uint32_t size; +} __attribute__((packed)); + +uint8_t fat_write_lfn(const char *name, const FatDirEntry *direntry, uint8_t *output); +uint8_t fat_write_dir(const FatDirEntry *direntry, uint8_t *output); +uint16_t fat_date(uint16_t year, uint8_t month, uint8_t day); + +} // namespace grub_boot_switch +} // namespace esphome diff --git a/components/grub_boot_switch/grub_boot_switch.cpp b/components/grub_boot_switch/grub_boot_switch.cpp new file mode 100644 index 0000000..4be9847 --- /dev/null +++ b/components/grub_boot_switch/grub_boot_switch.cpp @@ -0,0 +1,116 @@ +#include "grub_boot_switch.h" +#include "usb_descriptors.h" + +#include "tinyusb.h" + +namespace esphome { +namespace grub_boot_switch { + +static const char *const TAG = "grub_boot_switch"; + +extern GrubBootSwitch *g_active_component; // defined in tusb_glue.cpp + +static UsbDescriptorBundle s_usb_descriptors; + +std::string GrubBootSwitch::label_for_digit(uint8_t digit) const { + for (const auto &t : targets_) { + if (t.first == digit) + return t.second; + } + return std::string(); +} + +bool GrubBootSwitch::digit_for_label(const std::string &label, uint8_t *out) const { + for (const auto &t : targets_) { + if (t.second == label) { + if (out) + *out = t.first; + return true; + } + } + return false; +} + +void GrubBootSwitch::set_target(uint8_t t) { + if (current_target_.load() == t) + return; + current_target_.store(t); + pref_.save(&t); + for (auto &cb : callbacks_) + cb(t); +} + +void GrubBootSwitch::install_virtual_files_() { + // File 1: bare ASCII digit, one byte. Mirrors readSwitch() in the reference. + disk_.add_file("switch_position", "SWITCH~1", " ", 1, + [this](uint8_t *out) { out[0] = '0' + (current_target_.load() % 10); }); + + // File 2: GRUB snippet. Length and final-byte position match the reference's + // grubConfigStr ("set os_hw_switch=N\n") so existing /etc/grub.d hooks parse + // it identically. + static const char kPrefix[] = "set os_hw_switch="; + static const size_t kPrefixLen = sizeof(kPrefix) - 1; + static const size_t kFileLen = kPrefixLen + 2; // digit + newline + disk_.add_file("switch_position_grub.cfg", "SWITCH~1", "CFG", kFileLen, + [this](uint8_t *out) { + memcpy(out, kPrefix, kPrefixLen); + out[kPrefixLen] = '0' + (current_target_.load() % 10); + out[kPrefixLen + 1] = '\n'; + }); +} + +void GrubBootSwitch::start_usb_() { + g_active_component = this; + + usb_descriptors_build(&s_usb_descriptors, usb_vid_, usb_pid_, + usb_manufacturer_.c_str(), usb_product_.c_str(), nullptr); + + tinyusb_config_t cfg = {}; + cfg.device_descriptor = + reinterpret_cast(s_usb_descriptors.device); + cfg.string_descriptor = s_usb_descriptors.strings; + cfg.string_descriptor_count = + sizeof(s_usb_descriptors.strings) / sizeof(s_usb_descriptors.strings[0]); + cfg.external_phy = false; + cfg.configuration_descriptor = s_usb_descriptors.config; + cfg.self_powered = false; + + esp_err_t err = tinyusb_driver_install(&cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "tinyusb_driver_install failed: %d", err); + this->mark_failed(); + return; + } + ESP_LOGI(TAG, "TinyUSB MSC installed (VID=%04X PID=%04X serial=%s)", usb_vid_, + usb_pid_, s_usb_descriptors.serial_buf); +} + +void GrubBootSwitch::setup() { + ESP_LOGCONFIG(TAG, "Setting up GRUB boot switch"); + + // NVS-backed persistence: hash is "grub_boot_switch::target" + a fixed key + // so updates stay tied to this component instance. + pref_ = global_preferences->make_preference(0x47425354U /* "GBST" */, true); + uint8_t saved = current_target_.load(); + if (pref_.load(&saved)) { + current_target_.store(saved); + ESP_LOGCONFIG(TAG, "Restored target from NVS: %u", saved); + } + + install_virtual_files_(); + start_usb_(); +} + +void GrubBootSwitch::dump_config() { + ESP_LOGCONFIG(TAG, "GRUB boot switch:"); + ESP_LOGCONFIG(TAG, " USB VID/PID: %04X:%04X", usb_vid_, usb_pid_); + ESP_LOGCONFIG(TAG, " Current target: %u", current_target_.load()); + if (!targets_.empty()) { + ESP_LOGCONFIG(TAG, " Targets:"); + for (const auto &t : targets_) + ESP_LOGCONFIG(TAG, " %u: %s", t.first, t.second.c_str()); + } +} + +} // namespace grub_boot_switch +} // namespace esphome diff --git a/components/grub_boot_switch/grub_boot_switch.h b/components/grub_boot_switch/grub_boot_switch.h new file mode 100644 index 0000000..7ee5350 --- /dev/null +++ b/components/grub_boot_switch/grub_boot_switch.h @@ -0,0 +1,82 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/log.h" +#include "esphome/core/preferences.h" + +#include "msc_disk.h" + +#include +#include +#include +#include +#include + +namespace esphome { +namespace grub_boot_switch { + +class GrubBootSwitch : public Component { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + void set_volume_serial(uint32_t s) { disk_.set_volume_serial(s); } + void set_volume_label(const std::string &l) { disk_.set_volume_label(l.c_str()); } + void set_usb_ids(uint16_t vid, uint16_t pid) { + usb_vid_ = vid; + usb_pid_ = pid; + } + void set_usb_strings(const std::string &manuf, const std::string &product) { + usb_manufacturer_ = manuf; + usb_product_ = product; + } + void set_initial_target(uint8_t t) { current_target_ = t; } + + // Target table: digit (0-9) → human-readable label. Populated from YAML + // before setup() runs. Order matters only for select-platform option lists. + void add_target(uint8_t digit, const std::string &label) { + targets_.emplace_back(digit, label); + } + const std::vector> &get_targets() const { return targets_; } + + // Look up the label for a digit. Returns empty string if no match. + std::string label_for_digit(uint8_t digit) const; + // Look up the digit for a label. Returns false on miss; *out unchanged. + bool digit_for_label(const std::string &label, uint8_t *out) const; + + // Public API used by automations + sub-components. set_target() persists + // to NVS and notifies registered callbacks. + uint8_t get_target() const { return current_target_.load(); } + void set_target(uint8_t t); + + // Registered by the select sub-platform so it can publish state changes + // when set_target() is called from REST/automation/etc. + void add_on_target_change(std::function &&cb) { + callbacks_.emplace_back(std::move(cb)); + } + + // Called by the TinyUSB MSC glue layer. + int read_lba(uint32_t lba, uint8_t *out) { return disk_.read_lba(lba, out); } + uint32_t sector_count() const { return disk_.sector_count(); } + uint32_t sector_size() const { return disk_.sector_size(); } + + protected: + void install_virtual_files_(); + void start_usb_(); + + MscDisk disk_; + std::atomic current_target_{0}; + std::vector> targets_; + std::vector> callbacks_; + + ESPPreferenceObject pref_; + + uint16_t usb_vid_ = 0x26BA; + uint16_t usb_pid_ = 0x8003; + std::string usb_manufacturer_; + std::string usb_product_; +}; + +} // namespace grub_boot_switch +} // namespace esphome diff --git a/components/grub_boot_switch/msc_disk.cpp b/components/grub_boot_switch/msc_disk.cpp new file mode 100644 index 0000000..3c66cec --- /dev/null +++ b/components/grub_boot_switch/msc_disk.cpp @@ -0,0 +1,157 @@ +#include "msc_disk.h" + +#include + +namespace esphome { +namespace grub_boot_switch { + +MscDisk::MscDisk() { + file_date_ = fat_date(2026, 1, 1); +} + +void MscDisk::set_volume_label(const char *label) { + // Pad to 11 chars with spaces, like a FAT volume label. + memset(volume_label_, ' ', sizeof(volume_label_)); + for (size_t i = 0; i < sizeof(volume_label_) && label[i] != '\0'; ++i) { + volume_label_[i] = label[i]; + } +} + +void MscDisk::set_file_date(uint16_t year, uint8_t month, uint8_t day) { + file_date_ = fat_date(year, month, day); +} + +void MscDisk::add_file(const std::string &long_name, const std::string &short_name, + const std::string &short_ext, uint32_t size, FileReadCallback content_cb) { + VirtualFile vf{}; + vf.long_name = long_name; + vf.read = std::move(content_cb); + + memset(&vf.dir, 0, sizeof(vf.dir)); + memset(vf.dir.name, ' ', FAT_NAME_LEN); + memset(vf.dir.ext, ' ', FAT_EXT_LEN); + for (size_t i = 0; i < FAT_NAME_LEN && i < short_name.size(); ++i) { + vf.dir.name[i] = short_name[i]; + } + for (size_t i = 0; i < FAT_EXT_LEN && i < short_ext.size(); ++i) { + vf.dir.ext[i] = short_ext[i]; + } + vf.dir.size = size; + files_.push_back(std::move(vf)); +} + +void MscDisk::write_boot_sector(uint8_t *out) { + // Mirrors BootSector[] from reference/src/usb.c:126, plus a configurable + // volume serial / label. + static const uint8_t header[] = { + 0xEB, 0x3C, 0x90, + 'm', 'k', 'f', 's', '.', 'f', 'a', 't', + WBVAL(SECTOR_SIZE), + SECTORS_PER_CLUSTER, + WBVAL(RESERVED_SECTORS), + FAT_COPIES, + WBVAL(ROOT_ENTRIES), + WBVAL(SECTOR_COUNT), + FAT_MEDIA_FIXED_DISK, + WBVAL(1), // sectors per FAT + WBVAL(32), // sectors per track + WBVAL(64), // heads + QBVAL(0), // hidden sectors + QBVAL(0), // large sector count + 0x00, // drive number + 0x00, // reserved + 0x29, // extended boot signature + }; + memcpy(out, header, sizeof(header)); + + uint8_t *p = out + sizeof(header); + *p++ = volume_serial_ & 0xFF; + *p++ = (volume_serial_ >> 8) & 0xFF; + *p++ = (volume_serial_ >> 16) & 0xFF; + *p++ = (volume_serial_ >> 24) & 0xFF; + + memcpy(p, volume_label_, sizeof(volume_label_)); + p += sizeof(volume_label_); + + static const char fs_type[8] = {'F', 'A', 'T', '1', '2', ' ', ' ', ' '}; + memcpy(p, fs_type, sizeof(fs_type)); + + // Bootable partition signature + out[SECTOR_SIZE - 2] = 0x55; + out[SECTOR_SIZE - 1] = 0xAA; +} + +void MscDisk::write_fat_sector(uint8_t *out) { + // First two FAT entries are the media descriptor + EOC marker. + // Then one EOC entry per file (each file occupies a single cluster). + out[0] = 0xF8; + out[1] = 0xFF; + out[2] = 0xFF; + + // Cluster N → 12-bit entry. Files start at FILEDATA_START_CLUSTER. + // For single-cluster files we just write 0xFFF (EOC) at each entry. + uint32_t entry = FILEDATA_START_CLUSTER; + for (size_t i = 0; i < files_.size(); ++i, ++entry) { + const uint32_t bit_offset = entry * 12; + const uint32_t byte_offset = bit_offset / 8; + if (byte_offset + 1 >= SECTOR_SIZE) { + break; + } + if (entry & 1) { + // Odd: high nibble of byte N + all of byte N+1 + out[byte_offset] |= 0xF0; + out[byte_offset + 1] = 0xFF; + } else { + // Even: all of byte N + low nibble of byte N+1 + out[byte_offset] = 0xFF; + out[byte_offset + 1] |= 0x0F; + } + } +} + +void MscDisk::write_dir_sector(uint8_t *out) { + uint8_t *p = out; + for (size_t i = 0; i < files_.size(); ++i) { + VirtualFile &vf = files_[i]; + vf.dir.start = FILEDATA_START_CLUSTER + i; + vf.dir.cdate = file_date_; + vf.dir.mdate = file_date_; + vf.dir.adate = file_date_; + + p += fat_write_lfn(vf.long_name.c_str(), &vf.dir, p); + p += fat_write_dir(&vf.dir, p); + } +} + +int MscDisk::read_lba(uint32_t lba, uint8_t *out) { + memset(out, 0, SECTOR_SIZE); + + switch (lba) { + case 0: + write_boot_sector(out); + return 0; + case 1: + case 2: + write_fat_sector(out); + return 0; + case 3: + write_dir_sector(out); + return 0; + default: { + if (lba < FILEDATA_START_SECTOR) { + return 0; // gap between root dir and data region + } + const uint32_t file_index = (lba - FILEDATA_START_SECTOR) / SECTORS_PER_CLUSTER; + if (file_index >= files_.size()) { + return 0; // beyond any file + } + if (files_[file_index].read) { + files_[file_index].read(out); + } + return 0; + } + } +} + +} // namespace grub_boot_switch +} // namespace esphome diff --git a/components/grub_boot_switch/msc_disk.h b/components/grub_boot_switch/msc_disk.h new file mode 100644 index 0000000..a32f45f --- /dev/null +++ b/components/grub_boot_switch/msc_disk.h @@ -0,0 +1,73 @@ +#pragma once + +#include "fat12.h" + +#include +#include +#include +#include +#include + +namespace esphome { +namespace grub_boot_switch { + +constexpr uint32_t SECTOR_COUNT = 128; +constexpr uint32_t SECTOR_SIZE = 512; +constexpr uint32_t SECTORS_PER_CLUSTER = 1; +constexpr uint32_t RESERVED_SECTORS = 1; +constexpr uint32_t FAT_COPIES = 2; +constexpr uint32_t ROOT_ENTRIES = 512; +constexpr uint32_t ROOT_ENTRY_LENGTH = 32; +constexpr uint32_t FILEDATA_START_CLUSTER = 3; +constexpr uint32_t DATA_REGION_SECTOR = + RESERVED_SECTORS + FAT_COPIES + (ROOT_ENTRIES * ROOT_ENTRY_LENGTH) / SECTOR_SIZE; +constexpr uint32_t FILEDATA_START_SECTOR = + DATA_REGION_SECTOR + (FILEDATA_START_CLUSTER - 2) * SECTORS_PER_CLUSTER; + +using FileReadCallback = std::function; + +struct VirtualFile { + std::string long_name; + FatDirEntry dir; + FileReadCallback read; +}; + +class MscDisk { + public: + MscDisk(); + + // Volume serial baked into the FAT boot sector. + // Default 0x55AA6922 matches the upstream stecman/hw-boot-selection firmware + // so existing GRUB hooks (search --fs-uuid 55AA-6922) keep working unchanged. + void set_volume_serial(uint32_t serial) { volume_serial_ = serial; } + void set_volume_label(const char *label); // 11 chars, space-padded + + // Date stamped on every directory entry. GRUB 2.06 rejects 0-valued dates. + void set_file_date(uint16_t year, uint8_t month, uint8_t day); + + // Register a file. Short name must be 8.3 with space padding handled by caller + // (use add_file() helper below for typical cases). size is the file's logical + // size in bytes; content_cb is called once per host read of the file's sector. + void add_file(const std::string &long_name, const std::string &short_name, + const std::string &short_ext, uint32_t size, FileReadCallback content_cb); + + // Synthesise a single 512-byte sector for LBA `lba` into `out`. + // out is zeroed first. Returns 0 on success. + int read_lba(uint32_t lba, uint8_t *out); + + uint32_t sector_count() const { return SECTOR_COUNT; } + uint32_t sector_size() const { return SECTOR_SIZE; } + + protected: + void write_boot_sector(uint8_t *out); + void write_fat_sector(uint8_t *out); + void write_dir_sector(uint8_t *out); + + uint32_t volume_serial_ = 0x55AA6922u; + char volume_label_[11] = {'S', 'W', 'I', 'T', 'C', 'H', ' ', ' ', ' ', ' ', ' '}; + uint16_t file_date_ = 0; + std::vector files_; +}; + +} // namespace grub_boot_switch +} // namespace esphome diff --git a/components/grub_boot_switch/select/__init__.py b/components/grub_boot_switch/select/__init__.py new file mode 100644 index 0000000..5b63532 --- /dev/null +++ b/components/grub_boot_switch/select/__init__.py @@ -0,0 +1,51 @@ +"""Select sub-platform for grub_boot_switch. + +Exposes the parent's targets table as a Home Assistant select entity. The +options list is derived at code-gen time from the parent's `targets:` map, so +adding/removing entries there automatically updates the dropdown. +""" + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import select +from esphome.const import CONF_ID + +from .. import ( + CONF_GRUB_BOOT_SWITCH_ID, + CONF_TARGETS, + GrubBootSwitch, + grub_boot_switch_ns, +) + +DEPENDENCIES = ["grub_boot_switch"] + +BootTargetSelect = grub_boot_switch_ns.class_( + "BootTargetSelect", select.Select, cg.Component +) + +CONFIG_SCHEMA = select.select_schema(BootTargetSelect).extend( + { + cv.GenerateID(CONF_GRUB_BOOT_SWITCH_ID): cv.use_id(GrubBootSwitch), + } +) + + +async def to_code(config): + parent = await cg.get_variable(config[CONF_GRUB_BOOT_SWITCH_ID]) + + # Reach back into the *full* validated config so we can pull the parent's + # targets map. CORE.config holds the post-validation tree. + from esphome.core import CORE + + parent_cfg = CORE.config.get("grub_boot_switch", {}) + targets = parent_cfg.get(CONF_TARGETS, {}) + if not targets: + raise cv.Invalid( + "select platform requires the grub_boot_switch component to define `targets:`" + ) + + options = [label for _digit, label in sorted(targets.items())] + + var = await select.new_select(config, options=options) + await cg.register_component(var, config) + cg.add(var.set_parent(parent)) diff --git a/components/grub_boot_switch/select/boot_target_select.cpp b/components/grub_boot_switch/select/boot_target_select.cpp new file mode 100644 index 0000000..5f7838b --- /dev/null +++ b/components/grub_boot_switch/select/boot_target_select.cpp @@ -0,0 +1,44 @@ +#include "boot_target_select.h" + +#include "esphome/core/log.h" + +namespace esphome { +namespace grub_boot_switch { + +static const char *const TAG = "grub_boot_switch.select"; + +void BootTargetSelect::setup() { + // Publish the current state from the parent so HA reflects whatever was + // restored from NVS / set_initial_target. + if (parent_ != nullptr) { + auto label = parent_->label_for_digit(parent_->get_target()); + if (!label.empty()) + this->publish_state(label); + + // Mirror future external set_target() calls (REST/automation/MQTT) into + // the select's reported state. + parent_->add_on_target_change([this](uint8_t digit) { + auto l = this->parent_->label_for_digit(digit); + if (!l.empty()) + this->publish_state(l); + }); + } +} + +void BootTargetSelect::control(const std::string &value) { + if (parent_ == nullptr) + return; + uint8_t digit = 0; + if (!parent_->digit_for_label(value, &digit)) { + ESP_LOGW(TAG, "control: unknown option '%s'", value.c_str()); + return; + } + parent_->set_target(digit); + // The parent will fire add_on_target_change → publish_state, but if the + // target was already at this digit no callback fires; publish anyway so + // HA's optimistic state stays in sync. + this->publish_state(value); +} + +} // namespace grub_boot_switch +} // namespace esphome diff --git a/components/grub_boot_switch/select/boot_target_select.h b/components/grub_boot_switch/select/boot_target_select.h new file mode 100644 index 0000000..06d3901 --- /dev/null +++ b/components/grub_boot_switch/select/boot_target_select.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/select/select.h" + +#include "../grub_boot_switch.h" + +namespace esphome { +namespace grub_boot_switch { + +class BootTargetSelect : public select::Select, public Component { + public: + void set_parent(GrubBootSwitch *parent) { parent_ = parent; } + + void setup() override; + + protected: + void control(const std::string &value) override; + + GrubBootSwitch *parent_{nullptr}; +}; + +} // namespace grub_boot_switch +} // namespace esphome diff --git a/components/grub_boot_switch/tusb_glue.cpp b/components/grub_boot_switch/tusb_glue.cpp new file mode 100644 index 0000000..7c0cf76 --- /dev/null +++ b/components/grub_boot_switch/tusb_glue.cpp @@ -0,0 +1,120 @@ +// TinyUSB MSC class callback implementations. +// +// These are weak symbols inside TinyUSB; defining them here overrides the +// stubs in esp_tinyusb's MSC wrapper so we control the SCSI / block-device +// surface entirely from our component, with no on-device storage. + +#include "grub_boot_switch.h" + +#include "tusb.h" +#include "class/msc/msc_device.h" + +#include + +namespace esphome { +namespace grub_boot_switch { + +// Set by GrubBootSwitch::start_usb_() before tinyusb_driver_install() runs. +// Single-instance assumption (MULTI_CONF=False in __init__.py). +GrubBootSwitch *g_active_component = nullptr; + +} // namespace grub_boot_switch +} // namespace esphome + +using esphome::grub_boot_switch::g_active_component; + +extern "C" { + +// SCSI Inquiry: vendor / product / revision strings. Mirrors the SCSI strings +// from reference/src/usb.c so the host sees the same identity as the STM32 +// firmware. +void tud_msc_inquiry_cb(uint8_t lun, uint8_t vendor_id[8], uint8_t product_id[16], + uint8_t product_rev[4]) { + (void) lun; + static const char kVendor[] = "STECMAN "; + static const char kProduct[] = "BOOT SWITCH "; + static const char kRev[] = "V1 "; + memcpy(vendor_id, kVendor, 8); + memcpy(product_id, kProduct, 16); + memcpy(product_rev, kRev, 4); +} + +// Unit ready: report ready once the component finished setup and we have a +// disk to serve. +bool tud_msc_test_unit_ready_cb(uint8_t lun) { + (void) lun; + return g_active_component != nullptr; +} + +// Capacity in blocks. +void tud_msc_capacity_cb(uint8_t lun, uint32_t *block_count, uint16_t *block_size) { + (void) lun; + if (g_active_component != nullptr) { + *block_count = g_active_component->sector_count(); + *block_size = static_cast(g_active_component->sector_size()); + } else { + *block_count = 0; + *block_size = 512; + } +} + +// Start/stop unit: we have no media to load/eject, just acknowledge. +bool tud_msc_start_stop_cb(uint8_t lun, uint8_t power_condition, bool start, bool load_eject) { + (void) lun; + (void) power_condition; + (void) start; + (void) load_eject; + return true; +} + +// Read10: TinyUSB asks for `bufsize` bytes starting at (lba, offset). Our +// disk is sector-granular (offset is always 0 for typical hosts), so we +// generate the full sector and copy out the requested slice. +int32_t tud_msc_read10_cb(uint8_t lun, uint32_t lba, uint32_t offset, void *buffer, + uint32_t bufsize) { + (void) lun; + if (g_active_component == nullptr) + return -1; + if (lba >= g_active_component->sector_count()) + return -1; + + uint8_t sector[512]; + g_active_component->read_lba(lba, sector); + + uint32_t to_copy = bufsize; + if (offset + to_copy > sizeof(sector)) + to_copy = sizeof(sector) - offset; + memcpy(buffer, sector + offset, to_copy); + return static_cast(to_copy); +} + +// Write10: silently accept and discard. Hosts (especially Windows) sometimes +// scribble FAT metadata back; failing the writes makes them retry forever. +int32_t tud_msc_write10_cb(uint8_t lun, uint32_t lba, uint32_t offset, uint8_t *buffer, + uint32_t bufsize) { + (void) lun; + (void) lba; + (void) offset; + (void) buffer; + return static_cast(bufsize); +} + +// Report writability — false → host treats us as read-only media (the GRUB +// use case is read-only by design). +bool tud_msc_is_writable_cb(uint8_t lun) { + (void) lun; + return false; +} + +// SCSI passthrough for unsupported commands. Returning -1 + sense data +// lets TinyUSB STALL the appropriate endpoint. +int32_t tud_msc_scsi_cb(uint8_t lun, uint8_t const scsi_cmd[16], void *buffer, + uint16_t bufsize) { + (void) lun; + (void) scsi_cmd; + (void) buffer; + (void) bufsize; + return -1; +} + +} // extern "C" diff --git a/components/grub_boot_switch/usb_descriptors.cpp b/components/grub_boot_switch/usb_descriptors.cpp new file mode 100644 index 0000000..cefd9a8 --- /dev/null +++ b/components/grub_boot_switch/usb_descriptors.cpp @@ -0,0 +1,110 @@ +#include "usb_descriptors.h" + +#include +#include + +#include "esp_mac.h" + +namespace esphome { +namespace grub_boot_switch { + +// USB constants we need without dragging in from this header +constexpr uint8_t USB_DESC_DEVICE = 0x01; +constexpr uint8_t USB_DESC_CONFIGURATION = 0x02; +constexpr uint8_t USB_DESC_INTERFACE = 0x04; +constexpr uint8_t USB_DESC_ENDPOINT = 0x05; + +constexpr uint8_t USB_CLASS_MSC = 0x08; +constexpr uint8_t USB_MSC_SUBCLASS_SCSI = 0x06; +constexpr uint8_t USB_MSC_PROTOCOL_BBB = 0x50; + +constexpr uint8_t EP_BULK = 0x02; + +// Mirrors the layout in reference/src/usb.c so the host sees the same shape +// as the original STM32 firmware. +constexpr uint8_t MSC_EP_IN = 0x81; +constexpr uint8_t MSC_EP_OUT = 0x01; +constexpr uint16_t MSC_EP_SIZE = 64; // Full-speed bulk + +static void put16le(uint8_t *p, uint16_t v) { + p[0] = v & 0xFF; + p[1] = (v >> 8) & 0xFF; +} + +void usb_descriptors_build(UsbDescriptorBundle *b, uint16_t vid, uint16_t pid, + const char *manufacturer, const char *product, + const char *serial_override) { + // ---- Device descriptor ---- + uint8_t *d = b->device; + d[0] = 18; // bLength + d[1] = USB_DESC_DEVICE; // bDescriptorType + put16le(d + 2, 0x0200); // bcdUSB + d[4] = 0x00; // bDeviceClass (defined in interface) + d[5] = 0x00; // bDeviceSubClass + d[6] = 0x00; // bDeviceProtocol + d[7] = 64; // bMaxPacketSize0 + put16le(d + 8, vid); + put16le(d + 10, pid); + put16le(d + 12, 0x0200); // bcdDevice + d[14] = 1; // iManufacturer (string index) + d[15] = 2; // iProduct + d[16] = 3; // iSerialNumber + d[17] = 1; // bNumConfigurations + + // ---- Configuration descriptor (config + interface + 2 endpoints) ---- + uint8_t *c = b->config; + // Configuration + c[0] = 9; + c[1] = USB_DESC_CONFIGURATION; + put16le(c + 2, 32); // wTotalLength + c[4] = 1; // bNumInterfaces + c[5] = 1; // bConfigurationValue + c[6] = 0; // iConfiguration + c[7] = 0x80; // bmAttributes (bus powered) + c[8] = 50; // bMaxPower (100mA) + // Interface + c[9] = 9; + c[10] = USB_DESC_INTERFACE; + c[11] = 0; // bInterfaceNumber + c[12] = 0; // bAlternateSetting + c[13] = 2; // bNumEndpoints + c[14] = USB_CLASS_MSC; + c[15] = USB_MSC_SUBCLASS_SCSI; + c[16] = USB_MSC_PROTOCOL_BBB; + c[17] = 0; // iInterface + // Endpoint OUT + c[18] = 7; + c[19] = USB_DESC_ENDPOINT; + c[20] = MSC_EP_OUT; + c[21] = EP_BULK; + put16le(c + 22, MSC_EP_SIZE); + c[24] = 0; + // Endpoint IN + c[25] = 7; + c[26] = USB_DESC_ENDPOINT; + c[27] = MSC_EP_IN; + c[28] = EP_BULK; + put16le(c + 29, MSC_EP_SIZE); + c[31] = 0; + + // ---- Strings ---- + // Index 0: language ID (English, US) — esp_tinyusb expects index 0 to be the + // language; it builds the actual descriptor from a static "0x0409" string. + static const char kLang[] = {0x09, 0x04, 0}; + b->strings[0] = kLang; + b->strings[1] = manufacturer; + b->strings[2] = product; + + if (serial_override && serial_override[0] != '\0') { + snprintf(b->serial_buf, sizeof(b->serial_buf), "%s", serial_override); + } else { + uint8_t mac[6] = {}; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + snprintf(b->serial_buf, sizeof(b->serial_buf), "%02X%02X%02X%02X%02X%02X", mac[0], + mac[1], mac[2], mac[3], mac[4], mac[5]); + } + b->strings[3] = b->serial_buf; +} + +} // namespace grub_boot_switch +} // namespace esphome diff --git a/components/grub_boot_switch/usb_descriptors.h b/components/grub_boot_switch/usb_descriptors.h new file mode 100644 index 0000000..c3d2499 --- /dev/null +++ b/components/grub_boot_switch/usb_descriptors.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +namespace esphome { +namespace grub_boot_switch { + +// Built at runtime from configuration values so VID/PID/strings can be tuned +// from YAML. Lifetime: descriptors live forever once built (single device). +struct UsbDescriptorBundle { + // tusb_desc_device_t struct (18 bytes), kept opaque here to avoid leaking + // tusb headers into the rest of the component. + uint8_t device[18]; + + // Configuration descriptor: 9 (config) + 9 (interface) + 2*7 (endpoints) = 32 bytes. + uint8_t config[32]; + + // String descriptors: index 0 = lang (English), 1 = manufacturer, + // 2 = product, 3 = serial. ASCII C-strings, null terminated. + const char *strings[4]; + char serial_buf[24]; // backing store for the auto-generated serial. +}; + +// Populate the bundle from runtime values. The serial_buf is filled with the +// chip's MAC address ("E072A1D54520") if `serial_override` is null/empty. +void usb_descriptors_build(UsbDescriptorBundle *bundle, uint16_t vid, uint16_t pid, + const char *manufacturer, const char *product, + const char *serial_override); + +} // namespace grub_boot_switch +} // namespace esphome diff --git a/example.yaml b/example.yaml new file mode 100644 index 0000000..f6e8a41 --- /dev/null +++ b/example.yaml @@ -0,0 +1,36 @@ +esphome: + name: grub-boot-switch + friendly_name: GRUB boot switch + +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf + +logger: +api: +ota: + - platform: esphome + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + +external_components: + - source: + type: local + path: components + components: [grub_boot_switch] + +grub_boot_switch: + id: bootsw + # volume_serial: 0x55AA6922 # default — matches upstream GRUB hook + # volume_label: SWITCH # default + initial_target: 0 + +# Phase 4 will add the select platform here: +# +# select: +# - platform: grub_boot_switch +# name: "Boot Target" +# options: [Linux, Windows, Recovery] diff --git a/test/Makefile b/test/Makefile new file mode 100644 index 0000000..ad4f465 --- /dev/null +++ b/test/Makefile @@ -0,0 +1,52 @@ +CXX ?= g++ +CXXFLAGS ?= -std=c++17 -O0 -g -Wall -Wextra -Wno-unused-parameter +INCLUDES = -I../components/grub_boot_switch + +SRCS = test_disk.cpp \ + ../components/grub_boot_switch/fat12.cpp \ + ../components/grub_boot_switch/msc_disk.cpp + +BIN = test_disk +IMG = disk.img + +.PHONY: all build run verify clean + +all: verify + +build: $(BIN) + +$(BIN): $(SRCS) + $(CXX) $(CXXFLAGS) $(INCLUDES) -o $@ $(SRCS) + +# Generate a disk image (target=0 by default; pass T=N to override) +$(IMG): $(BIN) + ./$(BIN) $(IMG) $(or $(T),0) + +# Run the verifier suite. Requires mtools and `file`. +verify: $(IMG) + @echo "=== file(1) on disk image ===" + @file $(IMG) + @echo + @echo "=== mdir listing ===" + @MTOOLS_SKIP_CHECK=1 mdir -i $(IMG) :: + @echo + @echo "=== switch_position contents ===" + @MTOOLS_SKIP_CHECK=1 mtype -i $(IMG) ::switch_position + @echo + @echo "=== switch_position_grub.cfg contents ===" + @MTOOLS_SKIP_CHECK=1 mtype -i $(IMG) ::switch_position_grub.cfg + @echo + @echo "=== volume serial ===" + @MTOOLS_SKIP_CHECK=1 minfo -i $(IMG) :: | grep -i serial + +# Cycle through several target values to prove the file content tracks state +sweep: $(BIN) + @for t in 0 1 2 3 9 ; do \ + ./$(BIN) sweep_$$t.img $$t >/dev/null ; \ + echo "target=$$t:" ; \ + MTOOLS_SKIP_CHECK=1 mtype -i sweep_$$t.img ::switch_position_grub.cfg ; \ + done ; \ + rm -f sweep_*.img + +clean: + rm -f $(BIN) $(IMG) sweep_*.img diff --git a/test/test_disk.cpp b/test/test_disk.cpp new file mode 100644 index 0000000..064351c --- /dev/null +++ b/test/test_disk.cpp @@ -0,0 +1,59 @@ +// Host-side test harness for the FAT12 emulator. +// +// Builds a 64 KB disk image by walking LBAs 0..127 through MscDisk::read_lba, +// then verifies it with mtools. We mimic the two virtual files the production +// component installs in install_virtual_files_(). +// +// Build with: make -C test + +#include "msc_disk.h" + +#include +#include +#include +#include +#include + +using esphome::grub_boot_switch::MscDisk; + +int main(int argc, char **argv) { + const char *out_path = (argc > 1) ? argv[1] : "disk.img"; + const uint8_t target = (argc > 2) ? static_cast(atoi(argv[2])) : 0; + + MscDisk disk; + disk.set_volume_serial(0x55AA6922); + disk.set_volume_label("SWITCH"); + disk.set_file_date(2026, 5, 4); + + static const char kPrefix[] = "set os_hw_switch="; + static const size_t kPrefixLen = sizeof(kPrefix) - 1; + static const size_t kFileLen = kPrefixLen + 2; + + disk.add_file("switch_position", "SWITCH~1", " ", 1, + [target](uint8_t *out) { out[0] = '0' + (target % 10); }); + + disk.add_file("switch_position_grub.cfg", "SWITCH~1", "CFG", kFileLen, + [target](uint8_t *out) { + memcpy(out, kPrefix, kPrefixLen); + out[kPrefixLen] = '0' + (target % 10); + out[kPrefixLen + 1] = '\n'; + }); + + std::ofstream f(out_path, std::ios::binary); + if (!f) { + fprintf(stderr, "could not open %s\n", out_path); + return 1; + } + + uint8_t sector[512]; + for (uint32_t lba = 0; lba < disk.sector_count(); ++lba) { + disk.read_lba(lba, sector); + f.write(reinterpret_cast(sector), sizeof(sector)); + } + f.close(); + + fprintf(stderr, "wrote %u sectors (%u bytes) to %s with target=%u\n", + disk.sector_count(), disk.sector_count() * disk.sector_size(), + out_path, target); + return 0; +}