[tinyusb] Add tinyusb_keyboard component (#16079)

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Frederik Bolding
2026-10-12 12:56:28 -10:00
committed by GitHub
co-authored by J. Nick Koston J. Nick Koston
parent d08d324a83
commit 1e13b7f973
9 changed files with 318 additions and 1 deletions
+1
View File
@@ -605,6 +605,7 @@ esphome/components/thermopro_ble/* @sittner
esphome/components/thermostat/* @kbx81
esphome/components/time/* @esphome/core
esphome/components/tinyusb/* @kbx81
esphome/components/tinyusb_keyboard/* @frederikbolding
esphome/components/tlc5947/* @rnauber
esphome/components/tlc5971/* @IJIJI
esphome/components/tm1621/* @Philippe12
+1 -1
View File
@@ -34,7 +34,7 @@ CONF_VBUS_MONITOR_PIN = "vbus_monitor_pin"
# esp_tinyusb's driver install (descriptors_set fails with no class and no
# user-provided full_speed_config), which trips the task watchdog before
# loop() ever runs.
_USB_CLASS_COMPONENTS = ("usb_cdc_acm",)
_USB_CLASS_COMPONENTS = ("usb_cdc_acm", "tinyusb_keyboard")
tinyusb_ns = cg.esphome_ns.namespace("tinyusb")
TinyUSB = tinyusb_ns.class_("TinyUSB", cg.Component)
@@ -26,6 +26,8 @@ void TinyUSB::setup() {
}
// Start from esp_tinyusb defaults to keep required task settings valid across esp_tinyusb updates.
// Setters write into tusb_cfg_ before setup(), so anything they set has to survive the reset.
const uint8_t *full_speed_config = this->tusb_cfg_.descriptor.full_speed_config;
this->tusb_cfg_ = TINYUSB_DEFAULT_CONFIG();
this->tusb_cfg_.port = TINYUSB_PORT_FULL_SPEED_0;
this->tusb_cfg_.phy.skip_setup = false;
@@ -39,6 +41,7 @@ void TinyUSB::setup() {
.device = &this->usb_descriptor_,
.string = this->string_descriptor_,
.string_count = SIZE,
.full_speed_config = full_speed_config,
};
// Defense-in-depth: esp_tinyusb's tinyusb_descriptors_set() fails with
@@ -49,6 +49,8 @@ class TinyUSB final : public Component {
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<int8_t>(pin); }
/// Configuration descriptor from a class component that esp_tinyusb has no built-in default for (HID).
void set_full_speed_config(const uint8_t *descriptor) { this->tusb_cfg_.descriptor.full_speed_config = descriptor; }
protected:
char usb_desc_lang_id_[2] = {0x09, 0x04}; // defaults to english
@@ -0,0 +1,110 @@
import string
from esphome import automation
import esphome.codegen as cg
from esphome.components import esp32
from esphome.components.esp32 import (
VARIANT_ESP32H4,
VARIANT_ESP32P4,
VARIANT_ESP32S2,
VARIANT_ESP32S3,
VARIANT_ESP32S31,
add_idf_sdkconfig_option,
)
from esphome.components.tinyusb import TinyUSB
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_KEY
from esphome.types import ConfigType
CODEOWNERS = ["@frederikbolding"]
# The keyboard supplies the whole configuration descriptor, with no room for the CDC interfaces
CONFLICTS_WITH = ["usb_cdc_acm"]
DEPENDENCIES = ["tinyusb"]
DOMAIN = "tinyusb_keyboard"
CONF_MODIFIERS = "modifiers"
CONF_TINYUSB_ID = "tinyusb_id"
CONF_USAGE = "usage"
# Keyboard page usage ids run without gaps from HID_KEY_A (0x04) through a..z, 1..9 and 0
HID_KEY_A = 0x04
KEY_CODES = {
char: HID_KEY_A + index
for index, char in enumerate(string.ascii_lowercase + "1234567890")
}
tinyusb_keyboard_ns = cg.esphome_ns.namespace("tinyusb_keyboard")
TinyUSBKeyboard = tinyusb_keyboard_ns.class_("TinyUSBKeyboard", cg.Component)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(TinyUSBKeyboard),
cv.GenerateID(CONF_TINYUSB_ID): cv.use_id(TinyUSB),
}
).extend(cv.COMPONENT_SCHEMA),
esp32.only_on_variant(
supported=[
VARIANT_ESP32H4,
VARIANT_ESP32P4,
VARIANT_ESP32S2,
VARIANT_ESP32S3,
VARIANT_ESP32S31,
],
),
)
_KEY_CODE = cv.Any(cv.enum(KEY_CODES), cv.int_range(min=10, max=255))
def key_code(value: object) -> int:
"""A single lowercase letter or digit, or a keyboard usage id.
Usage ids 0 to 9 are never typed keys (error codes and the letters a to f), so an unquoted
digit is read as that digit.
"""
if isinstance(value, int) and 0 <= value <= 9:
value = str(value)
return _KEY_CODE(value)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
tinyusb = await cg.get_variable(config[CONF_TINYUSB_ID])
cg.add(tinyusb.set_full_speed_config(tinyusb_keyboard_ns.CONFIGURATION_DESCRIPTOR))
add_idf_sdkconfig_option("CONFIG_TINYUSB_HID_COUNT", 1)
KEYBOARD_ID_SCHEMA = {cv.GenerateID(): cv.use_id(TinyUSBKeyboard)}
automation.register_apply_action(
"tinyusb_keyboard.press",
automation.maybe_simple_id(
{
**KEYBOARD_ID_SCHEMA,
cv.Required(CONF_KEY): cv.templatable(key_code),
cv.Optional(CONF_MODIFIERS, default=0): cv.templatable(cv.uint8_t),
}
),
automation.ApplyCall(
"press_key({}, {})", ((CONF_KEY, cg.uint8), (CONF_MODIFIERS, cg.uint8))
),
)
automation.register_apply_action(
"tinyusb_keyboard.release",
automation.maybe_simple_id(KEYBOARD_ID_SCHEMA),
automation.ApplyCall("release_keys()"),
)
automation.register_apply_action(
"tinyusb_keyboard.media_press",
automation.maybe_simple_id(
{**KEYBOARD_ID_SCHEMA, cv.Required(CONF_USAGE): cv.templatable(cv.uint16_t)}
),
automation.ApplyCall("press_media({})", ((CONF_USAGE, cg.uint16),)),
)
automation.register_apply_action(
"tinyusb_keyboard.media_release",
automation.maybe_simple_id(KEYBOARD_ID_SCHEMA),
automation.ApplyCall("release_media()"),
)
@@ -0,0 +1,90 @@
#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 "keyboard.h"
#include "esphome/core/log.h"
namespace esphome::tinyusb_keyboard {
ESPHOME_LOG_TAG(TAG, "tinyusb_keyboard");
void TinyUSBKeyboard::dump_config() { ESP_LOGCONFIG(TAG, "TinyUSB Keyboard"); }
void TinyUSBKeyboard::set_keyboard_(uint8_t keycode, uint8_t modifiers) {
this->keycode_ = keycode;
this->modifiers_ = modifiers;
this->keyboard_pending_ = true;
this->flush_();
}
void TinyUSBKeyboard::set_consumer_(uint16_t usage) {
this->usage_ = usage;
this->consumer_pending_ = true;
this->flush_();
}
void TinyUSBKeyboard::flush_() {
if (!tud_mounted()) {
// No host is listening, and a key held from before it attached would be wrong, so drop the state
ESP_LOGV(TAG, "No host, dropping report");
this->keyboard_pending_ = false;
this->consumer_pending_ = false;
this->wakeup_requested_ = false;
this->disable_loop();
return;
}
if (tud_suspended()) {
// A key press wakes a sleeping host, like a real keyboard; the report goes out once the bus resumes.
// Signal once per suspend: a host that did not arm remote wakeup will not answer, and one that did
// must not be signalled on every pass.
if (!this->wakeup_requested_) {
this->wakeup_requested_ = true;
if (!tud_remote_wakeup()) {
ESP_LOGV(TAG, "Host did not allow remote wakeup");
}
}
this->enable_loop();
return;
}
this->wakeup_requested_ = false;
// tud_hid_*_report() returns false while the endpoint still holds the previous report, which is
// what happens for a press and a release in one action list. Keep retrying so the release gets out.
if (this->keyboard_pending_) {
uint8_t keycodes[6] = {this->keycode_};
if (tud_hid_keyboard_report(REPORT_ID_KEYBOARD, this->modifiers_, keycodes)) {
this->keyboard_pending_ = false;
}
}
if (this->consumer_pending_ && !this->keyboard_pending_ &&
tud_hid_report(REPORT_ID_CONSUMER, &this->usage_, sizeof(this->usage_))) {
this->consumer_pending_ = false;
}
if (this->keyboard_pending_ || this->consumer_pending_) {
this->enable_loop();
} else {
this->disable_loop();
}
}
void TinyUSBKeyboard::loop() { this->flush_(); }
} // namespace esphome::tinyusb_keyboard
// TinyUSB resolves these at link time when HID is enabled
extern "C" {
const uint8_t *tud_hid_descriptor_report_cb(uint8_t /*instance*/) {
return esphome::tinyusb_keyboard::HID_REPORT_DESCRIPTOR;
}
uint16_t tud_hid_get_report_cb(uint8_t /*instance*/, uint8_t /*report_id*/, hid_report_type_t /*report_type*/,
uint8_t * /*buffer*/, uint16_t /*reqlen*/) {
return 0;
}
// Keyboard LED state from the host; nothing consumes it yet
void tud_hid_set_report_cb(uint8_t /*instance*/, uint8_t /*report_id*/, hid_report_type_t /*report_type*/,
const uint8_t * /*buffer*/, uint16_t /*bufsize*/) {}
}
#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 ||
// USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4
@@ -0,0 +1,66 @@
#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/component.h"
#include "tusb.h"
// tusb.h only pulls the HID class in when CFG_TUD_HID is set, which the static analysis build does not do
#include "class/hid/hid_device.h"
namespace esphome::tinyusb_keyboard {
static constexpr uint8_t REPORT_ID_KEYBOARD = 1;
static constexpr uint8_t REPORT_ID_CONSUMER = 2;
static constexpr uint8_t ENDPOINT_IN = 0x81;
static constexpr uint8_t POLL_INTERVAL_MS = 10;
// One HID interface carrying a keyboard and a consumer control (media keys), told apart by report id.
// Report ids rule out the boot protocol, so the interface declares no boot protocol, as TinyUSB's
// composite example does.
inline constexpr uint8_t HID_REPORT_DESCRIPTOR[] = {
TUD_HID_REPORT_DESC_KEYBOARD(HID_REPORT_ID(REPORT_ID_KEYBOARD)),
TUD_HID_REPORT_DESC_CONSUMER(HID_REPORT_ID(REPORT_ID_CONSUMER)),
};
// esp_tinyusb ships default configuration descriptors for CDC, MSC and NCM only, so the keyboard supplies its own
inline constexpr uint8_t CONFIGURATION_DESCRIPTOR[] = {
TUD_CONFIG_DESCRIPTOR(1, 1, 0, TUD_CONFIG_DESC_LEN + TUD_HID_DESC_LEN, TUSB_DESC_CONFIG_ATT_REMOTE_WAKEUP, 100),
TUD_HID_DESCRIPTOR(0, 0, HID_ITF_PROTOCOL_NONE, sizeof(HID_REPORT_DESCRIPTOR), ENDPOINT_IN, CFG_TUD_HID_EP_BUFSIZE,
POLL_INTERVAL_MS),
};
class TinyUSBKeyboard final : public Component {
public:
void setup() override { this->disable_loop(); }
void loop() override;
void dump_config() override;
/// Press one key (HID usage id) with the given modifier bitmask; every other key is reported released.
void press_key(uint8_t keycode, uint8_t modifiers) { this->set_keyboard_(keycode, modifiers); }
void release_keys() { this->set_keyboard_(0, 0); }
/// Press one consumer control usage, for example 0xE9 for volume up.
void press_media(uint16_t usage) { this->set_consumer_(usage); }
void release_media() { this->set_consumer_(0); }
protected:
void set_keyboard_(uint8_t keycode, uint8_t modifiers);
void set_consumer_(uint16_t usage);
/// Sends whatever changed; a report the endpoint cannot take yet is retried from loop()
void flush_();
// The wanted state of each report, resent until the endpoint accepts it so a release is never lost.
// Only the latest state is kept, so consecutive key actions need a short delay between them.
uint16_t usage_{0};
uint8_t keycode_{0};
uint8_t modifiers_{0};
bool keyboard_pending_{false};
bool consumer_pending_{false};
bool wakeup_requested_{false};
};
} // namespace esphome::tinyusb_keyboard
#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 ||
// USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4
@@ -0,0 +1,43 @@
tinyusb:
tinyusb_keyboard:
id: kb
binary_sensor:
- platform: template
id: kb_letter
on_press:
- tinyusb_keyboard.press:
id: kb
key: a
on_release:
- tinyusb_keyboard.release: kb
- platform: template
id: kb_shift_digit
on_press:
- tinyusb_keyboard.press:
id: kb
key: "1"
modifiers: 0x02 # Left Shift
on_release:
- tinyusb_keyboard.release: kb
- platform: template
id: kb_raw_code
on_press:
- tinyusb_keyboard.press:
id: kb
key: 0x28 # Enter
modifiers: !lambda return 0;
on_release:
- tinyusb_keyboard.release: kb
- platform: template
id: kb_volume_up
on_press:
- tinyusb_keyboard.media_press:
id: kb
usage: 0xE9 # Volume Up
on_release:
- tinyusb_keyboard.media_release: kb
@@ -0,0 +1,2 @@
packages:
tinyusb_keyboard: !include common.yaml