Compare commits

..

3 Commits

Author SHA1 Message Date
4d35f094be Replace deprecated cv.only_with_esp_idf with cv.only_with_framework 2026-05-09 19:31:01 +00:00
93fc28d9f4 Merge gitea auto-init (README, keep our multi-author LICENSE) 2026-05-04 18:26:05 +00:00
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
19 changed files with 1492 additions and 13 deletions

26
.gitignore vendored Normal file
View File

@@ -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/

34
LICENSE
View File

@@ -1,18 +1,26 @@
MIT License MIT License
Copyright (c) 2026 adamksmith Copyright (c) 2021 Stephen Holdaway
(FAT12 sector synthesis, GRUB hook design — ported from
https://github.com/stecman/hw-boot-selection)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and Copyright (c) 2026 Adam K Smith
associated documentation files (the "Software"), to deal in the Software without restriction, including (ESP32-S3 port, ESPHome external_component, TinyUSB MSC integration)
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 Permission is hereby granted, free of charge, to any person obtaining a copy
portions of the Software. 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 SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT The above copyright notice and this permission notice shall be included in all
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO copies or substantial portions of the Software.
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 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
USE OR OTHER DEALINGS IN THE SOFTWARE. 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.

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.

View File

@@ -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_framework("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")

View File

@@ -0,0 +1,80 @@
#include "fat12.h"
#include <string.h>
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<const char *>(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

View File

@@ -0,0 +1,52 @@
#pragma once
#include <stdint.h>
#include <stddef.h>
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

View File

@@ -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<const tusb_desc_device_t *>(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<uint8_t>(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

View File

@@ -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 <atomic>
#include <functional>
#include <string>
#include <utility>
#include <vector>
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<std::pair<uint8_t, std::string>> &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<void(uint8_t)> &&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<uint8_t> current_target_{0};
std::vector<std::pair<uint8_t, std::string>> targets_;
std::vector<std::function<void(uint8_t)>> 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

View File

@@ -0,0 +1,157 @@
#include "msc_disk.h"
#include <string.h>
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

View File

@@ -0,0 +1,73 @@
#pragma once
#include "fat12.h"
#include <stdint.h>
#include <stddef.h>
#include <functional>
#include <vector>
#include <string>
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<void(uint8_t *output)>;
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<VirtualFile> files_;
};
} // namespace grub_boot_switch
} // namespace esphome

View File

@@ -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))

View File

@@ -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

View File

@@ -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

View File

@@ -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 <string.h>
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<uint16_t>(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<int32_t>(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<int32_t>(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"

View File

@@ -0,0 +1,110 @@
#include "usb_descriptors.h"
#include <string.h>
#include <stdio.h>
#include "esp_mac.h"
namespace esphome {
namespace grub_boot_switch {
// USB constants we need without dragging in <tusb.h> 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

View File

@@ -0,0 +1,31 @@
#pragma once
#include <stdint.h>
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

36
example.yaml Normal file
View File

@@ -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]

52
test/Makefile Normal file
View File

@@ -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

59
test/test_disk.cpp Normal file
View File

@@ -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 <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <string>
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<uint8_t>(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<const char *>(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;
}