diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 53c4ab00734..7ad88d3018d 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -1,4 +1,4 @@ -from esphome import final_validate as fv +from esphome import automation, final_validate as fv, pins import esphome.codegen as cg from esphome.components import esp32 from esphome.components.esp32 import ( @@ -12,17 +12,22 @@ from esphome.components.esp32 import ( ) import esphome.config_validation as cv from esphome.const import CONF_HARDWARE_UART, CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] CONFLICTS_WITH = ["usb_host"] +CONF_ON_MOUNT = "on_mount" +CONF_ON_UNMOUNT = "on_unmount" CONF_USB_LANG_ID = "usb_lang_id" CONF_USB_MANUFACTURER_STR = "usb_manufacturer_str" CONF_USB_PRODUCT_ID = "usb_product_id" CONF_USB_PRODUCT_STR = "usb_product_str" CONF_USB_SERIAL_STR = "usb_serial_str" CONF_USB_VENDOR_ID = "usb_vendor_id" +CONF_VBUS_MONITOR_PIN = "vbus_monitor_pin" # Components that provide a USB device class (CDC, HID, MSC, ...) on top of # tinyusb. Configuring `tinyusb:` without any of these triggers a 5s hang in @@ -33,6 +38,20 @@ _USB_CLASS_COMPONENTS = ("usb_cdc_acm",) tinyusb_ns = cg.esphome_ns.namespace("tinyusb") TinyUSB = tinyusb_ns.class_("TinyUSB", cg.Component) +IsMountedCondition = tinyusb_ns.class_("IsMountedCondition", automation.Condition) + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_MOUNT, + "add_on_mount_state_callback", + forwarder=automation.TriggerOnTrueForwarder, + ), + automation.CallbackAutomation( + CONF_ON_UNMOUNT, + "add_on_mount_state_callback", + forwarder=automation.TriggerOnFalseForwarder, + ), +) CONFIG_SCHEMA = cv.All( cv.Schema( @@ -44,6 +63,18 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_USB_MANUFACTURER_STR, default="ESPHome"): cv.string, cv.Optional(CONF_USB_PRODUCT_STR, default="ESPHome"): cv.string, cv.Optional(CONF_USB_SERIAL_STR, default=""): cv.string, + # esp_tinyusb monitors VBUS on the S31 through a GPIO interrupt and needs + # the GPIO ISR service installed first, which would collide with the esp32 + # platform's own lazy install and disable other interrupts. The other + # variants watch the pin in the OTG hardware. + cv.Optional(CONF_VBUS_MONITOR_PIN): cv.All( + pins.internal_gpio_input_pin_number, + esp32.only_on_variant( + unsupported=[VARIANT_ESP32S31], msg_prefix=CONF_VBUS_MONITOR_PIN + ), + ), + cv.Optional(CONF_ON_MOUNT): automation.validate_automation({}), + cv.Optional(CONF_ON_UNMOUNT): automation.validate_automation({}), } ).extend(cv.COMPONENT_SCHEMA), esp32.only_on_variant( @@ -93,9 +124,28 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_usb_desc_product(config[CONF_USB_PRODUCT_STR])) if config[CONF_USB_SERIAL_STR]: cg.add(var.set_usb_desc_serial(config[CONF_USB_SERIAL_STR])) + if (vbus_pin := config.get(CONF_VBUS_MONITOR_PIN)) is not None: + cg.add(var.set_vbus_monitor_pin(vbus_pin)) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) add_idf_component(name="espressif/esp_tinyusb", ref="2.2.1") add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_ESPRESSIF_VID", False) add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_DEFAULT_PID", False) add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_BCD_DEVICE", 0x0100) + + +@automation.register_condition( + "tinyusb.is_mounted", + IsMountedCondition, + cv.Schema({cv.GenerateID(): cv.use_id(TinyUSB)}), +) +async def tinyusb_is_mounted_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + paren = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(condition_id, template_arg, paren) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index c8c36f0ffb6..3fab9de0086 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -9,6 +9,14 @@ namespace esphome::tinyusb { static const char *const TAG = "tinyusb"; +// Runs on the TinyUSB task: only wake the main loop, which reads the state and runs +// the automations. +static void tinyusb_event_cb(tinyusb_event_t *event, void *arg) { + if (event->id == TINYUSB_EVENT_ATTACHED || event->id == TINYUSB_EVENT_DETACHED) { + static_cast(arg)->enable_loop_soon_any_context(); + } +} + void TinyUSB::setup() { // Use the device's MAC address as its serial number if no serial number is defined if (this->string_descriptor_[SERIAL_NUMBER] == nullptr) { @@ -21,6 +29,12 @@ void TinyUSB::setup() { this->tusb_cfg_ = TINYUSB_DEFAULT_CONFIG(); this->tusb_cfg_.port = TINYUSB_PORT_FULL_SPEED_0; this->tusb_cfg_.phy.skip_setup = false; + // Without VBUS monitoring the OTG core only sees a cable pull as the bus going idle + // (a suspend), so TinyUSB never reports a detach and stays "mounted". + if (this->vbus_monitor_pin_ >= 0) { + this->tusb_cfg_.phy.self_powered = true; + this->tusb_cfg_.phy.vbus_monitor_io = this->vbus_monitor_pin_; + } this->tusb_cfg_.descriptor = { .device = &this->usb_descriptor_, .string = this->string_descriptor_, @@ -42,11 +56,26 @@ void TinyUSB::setup() { } #endif + this->tusb_cfg_.event_cb = tinyusb_event_cb; + this->tusb_cfg_.event_arg = this; esp_err_t result = tinyusb_driver_install(&this->tusb_cfg_); if (result != ESP_OK) { ESP_LOGE(TAG, "tinyusb_driver_install failed: %s", esp_err_to_name(result)); this->mark_failed(); + return; } + // loop() only reports mount changes; the mount hooks wake it when one happens. + this->disable_loop(); +} + +void TinyUSB::loop() { + const bool mounted = tud_mounted(); + if (mounted != this->last_reported_mounted_) { + this->last_reported_mounted_ = mounted; + ESP_LOGD(TAG, "USB host %s", mounted ? LOG_STR_LITERAL("mounted") : LOG_STR_LITERAL("unmounted")); + this->mount_state_callback_.call(mounted); + } + this->disable_loop(); } void TinyUSB::dump_config() { @@ -56,9 +85,12 @@ void TinyUSB::dump_config() { " Vendor ID: 0x%04X\n" " Manufacturer: '%s'\n" " Product: '%s'\n" - " Serial: '%s'\n", + " Serial: '%s'", this->usb_descriptor_.idProduct, this->usb_descriptor_.idVendor, this->string_descriptor_[MANUFACTURER], this->string_descriptor_[PRODUCT], this->string_descriptor_[SERIAL_NUMBER]); + if (this->vbus_monitor_pin_ >= 0) { + ESP_LOGCONFIG(TAG, " VBUS Monitor Pin: GPIO%d", this->vbus_monitor_pin_); + } } } // namespace esphome::tinyusb diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index e85fea9d21a..f7f574ec6d5 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -1,8 +1,11 @@ #pragma once #if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) +#include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include #include "tinyusb.h" #include "tusb.h" @@ -23,9 +26,17 @@ static const char *const DEFAULT_USB_STR = "ESPHome"; class TinyUSB final : public Component { public: void setup() override; + void loop() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::BUS; } + /// True while a USB host has enumerated and configured the device. + bool is_mounted() const { return tud_mounted(); } + /// Called with the new mount state whenever a host mounts or unmounts the device. + template void add_on_mount_state_callback(F &&callback) { + this->mount_state_callback_.add(std::forward(callback)); + } + void set_usb_desc_product_id(uint16_t product_id) { this->usb_descriptor_.idProduct = product_id; } void set_usb_desc_vendor_id(uint16_t vendor_id) { this->usb_descriptor_.idVendor = vendor_id; } void set_usb_desc_lang_id(uint16_t lang_id) { @@ -37,6 +48,8 @@ class TinyUSB final : public Component { } void set_usb_desc_product(const char *usb_desc_product) { this->string_descriptor_[PRODUCT] = usb_desc_product; } void set_usb_desc_serial(const char *usb_desc_serial) { this->string_descriptor_[SERIAL_NUMBER] = usb_desc_serial; } + /// Self-powered device: watch VBUS on this GPIO so a cable pull becomes a detach. + void set_vbus_monitor_pin(int pin) { this->vbus_monitor_pin_ = static_cast(pin); } protected: char usb_desc_lang_id_[2] = {0x09, 0x04}; // defaults to english @@ -50,6 +63,11 @@ class TinyUSB final : public Component { nullptr, // 5: Terminator }; + LazyCallbackManager mount_state_callback_; + // Edge-detection baseline for loop(); is_mounted() reads the live state instead. + bool last_reported_mounted_{false}; + int8_t vbus_monitor_pin_{-1}; + tinyusb_config_t tusb_cfg_{}; tusb_desc_device_t usb_descriptor_{ .bLength = sizeof(tusb_desc_device_t), @@ -69,6 +87,15 @@ class TinyUSB final : public Component { }; }; +template class IsMountedCondition final : public Condition { + public: + explicit IsMountedCondition(TinyUSB *parent) : parent_(parent) {} + bool check(const Ts &...) override { return this->parent_->is_mounted(); } + + protected: + TinyUSB *parent_; +}; + } // namespace esphome::tinyusb #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/tests/components/tinyusb/common.yaml b/tests/components/tinyusb/common.yaml index 674e89dbe87..32db1999afe 100644 --- a/tests/components/tinyusb/common.yaml +++ b/tests/components/tinyusb/common.yaml @@ -6,6 +6,15 @@ tinyusb: usb_product_str: ESPHomeTestProduct usb_serial_str: ESPHomeTestSerialNumber usb_vendor_id: 0x2345 + on_mount: + - logger.log: USB host mounted + - if: + condition: + tinyusb.is_mounted: + then: + - logger.log: USB host is mounted + on_unmount: + - logger.log: USB host unmounted # tinyusb requires at least one USB class companion; usb_cdc_acm satisfies that. usb_cdc_acm: diff --git a/tests/components/tinyusb/test.esp32-p4-idf.yaml b/tests/components/tinyusb/test.esp32-p4-idf.yaml index dade44d145b..7a37fcf41b8 100644 --- a/tests/components/tinyusb/test.esp32-p4-idf.yaml +++ b/tests/components/tinyusb/test.esp32-p4-idf.yaml @@ -1 +1,7 @@ -<<: !include common.yaml +packages: + tinyusb: !include common.yaml + +# VBUS monitoring is per variant: the OTG hardware watches the pin here, while the +# S31 would need the GPIO ISR path and rejects the key. +tinyusb: + vbus_monitor_pin: 4 diff --git a/tests/components/tinyusb/test.esp32-s2-idf.yaml b/tests/components/tinyusb/test.esp32-s2-idf.yaml index 09b98ada401..67ea24f2c6c 100644 --- a/tests/components/tinyusb/test.esp32-s2-idf.yaml +++ b/tests/components/tinyusb/test.esp32-s2-idf.yaml @@ -1,4 +1,10 @@ -<<: !include common.yaml +packages: + tinyusb: !include common.yaml + +# VBUS monitoring is per variant: the OTG hardware watches the pin here, while the +# S31 would need the GPIO ISR path and rejects the key. +tinyusb: + vbus_monitor_pin: 4 # S2 defaults logger to USB_CDC, which conflicts with tinyusb on the shared # USB OTG peripheral; route the logger to UART0 so the fixture builds. diff --git a/tests/components/tinyusb/test.esp32-s3-idf.yaml b/tests/components/tinyusb/test.esp32-s3-idf.yaml index dade44d145b..7a37fcf41b8 100644 --- a/tests/components/tinyusb/test.esp32-s3-idf.yaml +++ b/tests/components/tinyusb/test.esp32-s3-idf.yaml @@ -1 +1,7 @@ -<<: !include common.yaml +packages: + tinyusb: !include common.yaml + +# VBUS monitoring is per variant: the OTG hardware watches the pin here, while the +# S31 would need the GPIO ISR path and rejects the key. +tinyusb: + vbus_monitor_pin: 4