Initial port of stecman/hw-boot-selection to ESP32-S3
ESPHome external_component that presents a synthetic 64 KB FAT12 disk over the ESP32-S3's native USB-OTG, containing a one-byte switch_position file and a switch_position_grub.cfg snippet. The disk's volume serial is fixed at 0x55AA6922 to stay drop-in compatible with the upstream /etc/grub.d hook. Components: - fat12 / msc_disk: portable FAT12 sector synthesiser ported from reference/src/fat.c and the boot-sector / FAT / dir-entry generation in reference/src/usb.c. - usb_descriptors / tusb_glue: TinyUSB MSC class with our own callbacks delegating to MscDisk::read_lba (linker --allow-multiple-definition picks ours over esp_tinyusb's storage-backed defaults). - grub_boot_switch: top-level Component, NVS persistence on target changes, exposes targets table to sub-platforms. - select: HA-facing select platform reading parent's targets map. Validated end-to-end on an ESP32-S3-DevKitC-1: USB enumerates as 26ba:8003, /dev/sdb mounts FAT12, mtype reads back the live target, and POST /select/boot_target/set?option=Windows updates both files on the next host read.
This commit is contained in:
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal 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/
|
||||||
26
LICENSE
Normal file
26
LICENSE
Normal file
@@ -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.
|
||||||
256
PLAN.md
Normal file
256
PLAN.md
Normal 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
|
||||||
|
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<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.
|
||||||
102
components/grub_boot_switch/__init__.py
Normal file
102
components/grub_boot_switch/__init__.py
Normal 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_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")
|
||||||
80
components/grub_boot_switch/fat12.cpp
Normal file
80
components/grub_boot_switch/fat12.cpp
Normal 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
|
||||||
52
components/grub_boot_switch/fat12.h
Normal file
52
components/grub_boot_switch/fat12.h
Normal 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
|
||||||
116
components/grub_boot_switch/grub_boot_switch.cpp
Normal file
116
components/grub_boot_switch/grub_boot_switch.cpp
Normal 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
|
||||||
82
components/grub_boot_switch/grub_boot_switch.h
Normal file
82
components/grub_boot_switch/grub_boot_switch.h
Normal 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
|
||||||
157
components/grub_boot_switch/msc_disk.cpp
Normal file
157
components/grub_boot_switch/msc_disk.cpp
Normal 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
|
||||||
73
components/grub_boot_switch/msc_disk.h
Normal file
73
components/grub_boot_switch/msc_disk.h
Normal 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
|
||||||
51
components/grub_boot_switch/select/__init__.py
Normal file
51
components/grub_boot_switch/select/__init__.py
Normal 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))
|
||||||
44
components/grub_boot_switch/select/boot_target_select.cpp
Normal file
44
components/grub_boot_switch/select/boot_target_select.cpp
Normal 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
|
||||||
24
components/grub_boot_switch/select/boot_target_select.h
Normal file
24
components/grub_boot_switch/select/boot_target_select.h
Normal 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
|
||||||
120
components/grub_boot_switch/tusb_glue.cpp
Normal file
120
components/grub_boot_switch/tusb_glue.cpp
Normal 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"
|
||||||
110
components/grub_boot_switch/usb_descriptors.cpp
Normal file
110
components/grub_boot_switch/usb_descriptors.cpp
Normal 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
|
||||||
31
components/grub_boot_switch/usb_descriptors.h
Normal file
31
components/grub_boot_switch/usb_descriptors.h
Normal 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
36
example.yaml
Normal 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
52
test/Makefile
Normal 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
59
test/test_disk.cpp
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user