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,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")

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