Initial port of stecman/hw-boot-selection to ESP32-S3

ESPHome external_component that presents a synthetic 64 KB FAT12 disk
over the ESP32-S3's native USB-OTG, containing a one-byte switch_position
file and a switch_position_grub.cfg snippet.  The disk's volume serial
is fixed at 0x55AA6922 to stay drop-in compatible with the upstream
/etc/grub.d hook.

Components:
- fat12 / msc_disk: portable FAT12 sector synthesiser ported from
  reference/src/fat.c and the boot-sector / FAT / dir-entry generation
  in reference/src/usb.c.
- usb_descriptors / tusb_glue: TinyUSB MSC class with our own callbacks
  delegating to MscDisk::read_lba (linker --allow-multiple-definition
  picks ours over esp_tinyusb's storage-backed defaults).
- grub_boot_switch: top-level Component, NVS persistence on target
  changes, exposes targets table to sub-platforms.
- select: HA-facing select platform reading parent's targets map.

Validated end-to-end on an ESP32-S3-DevKitC-1: USB enumerates as
26ba:8003, /dev/sdb mounts FAT12, mtype reads back the live target,
and POST /select/boot_target/set?option=Windows updates both files
on the next host read.
This commit is contained in:
2026-05-04 18:25:41 +00:00
commit a911b9e4cb
19 changed files with 1497 additions and 0 deletions

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