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:
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
|
||||
Reference in New Issue
Block a user