From 666fb7cf39aeaf2e2a7890695099535ed8b5a67c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 6 Mar 2026 07:18:28 -0500 Subject: [PATCH 01/14] [sx127x][sx126x][max6956] Fix null deref, unterminated string, and pin bounds check (#14529) Co-authored-by: Claude Opus 4.6 --- esphome/components/max6956/max6956.cpp | 2 ++ esphome/components/sx126x/sx126x.cpp | 3 ++- esphome/components/sx127x/sx127x.cpp | 5 +++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/esphome/components/max6956/max6956.cpp b/esphome/components/max6956/max6956.cpp index a350e66ee0e..ce45541b635 100644 --- a/esphome/components/max6956/max6956.cpp +++ b/esphome/components/max6956/max6956.cpp @@ -111,6 +111,8 @@ void MAX6956::write_brightness_mode() { } void MAX6956::set_pin_brightness(uint8_t pin, float brightness) { + if (pin < MAX6956_MIN || pin > MAX6956_MAX) + return; uint8_t reg_addr = MAX6956_CURRENT_START + (pin - MAX6956_MIN) / 2; uint8_t config = 0; uint8_t shift = 4 * (pin % 2); diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 64cd24b1713..ec62fad10af 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -155,7 +155,8 @@ void SX126x::configure() { } // check silicon version to make sure hw is ok - this->read_register_(REG_VERSION_STRING, (uint8_t *) this->version_, 16); + this->read_register_(REG_VERSION_STRING, (uint8_t *) this->version_, sizeof(this->version_)); + this->version_[sizeof(this->version_) - 1] = '\0'; if (strncmp(this->version_, "SX126", 5) != 0 && strncmp(this->version_, "LLCC68", 6) != 0) { this->mark_failed(); return; diff --git a/esphome/components/sx127x/sx127x.cpp b/esphome/components/sx127x/sx127x.cpp index f6aa11b6347..66957a73424 100644 --- a/esphome/components/sx127x/sx127x.cpp +++ b/esphome/components/sx127x/sx127x.cpp @@ -260,6 +260,11 @@ SX127xError SX127x::transmit_packet(const std::vector &packet) { return SX127xError::INVALID_PARAMS; } + if (this->dio0_pin_ == nullptr) { + ESP_LOGE(TAG, "DIO0 pin not configured, cannot wait for transmit completion"); + return SX127xError::INVALID_PARAMS; + } + SX127xError ret = SX127xError::NONE; if (this->modulation_ == MOD_LORA) { this->set_mode_standby(); From 6c07c15c504c7f0a66ab1a9e2c2f91943526ffa2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 6 Mar 2026 07:18:56 -0500 Subject: [PATCH 02/14] [mipi_dsi][e131] Fix semaphore cast, missing return, and light count overread (#14530) Co-authored-by: Claude Opus 4.6 --- esphome/components/e131/e131_addressable_light_effect.cpp | 4 +++- esphome/components/mipi_dsi/mipi_dsi.cpp | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/e131/e131_addressable_light_effect.cpp b/esphome/components/e131/e131_addressable_light_effect.cpp index 7d62f739a24..f6010a7cc9f 100644 --- a/esphome/components/e131/e131_addressable_light_effect.cpp +++ b/esphome/components/e131/e131_addressable_light_effect.cpp @@ -54,8 +54,10 @@ bool E131AddressableLightEffect::process_(int universe, const E131Packet &packet int32_t output_offset = (universe - first_universe_) * get_lights_per_universe(); // limit amount of lights per universe and received + // packet.count is the number of DMX bytes including start code; divide by channels to get the number of lights + int lights_in_packet = (packet.count > 0) ? (packet.count - 1) / channels_ : 0; int output_end = - std::min(it->size(), std::min(output_offset + get_lights_per_universe(), output_offset + packet.count - 1)); + std::min(it->size(), std::min(output_offset + get_lights_per_universe(), output_offset + lights_in_packet)); auto *input_data = packet.values + 1; auto effect_name = get_name(); diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 4d45cfb7990..815b9d75a1d 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -10,7 +10,7 @@ namespace mipi_dsi { static constexpr size_t MIPI_DSI_MAX_CMD_LOG_BYTES = 64; static bool notify_refresh_ready(esp_lcd_panel_handle_t panel, esp_lcd_dpi_panel_event_data_t *edata, void *user_ctx) { - auto *sem = static_cast(user_ctx); + auto sem = static_cast(user_ctx); BaseType_t need_yield = pdFALSE; xSemaphoreGiveFromISR(sem, &need_yield); return (need_yield == pdTRUE); @@ -190,6 +190,7 @@ void MIPI_DSI::draw_pixels_at(int x_start, int y_start, int w, int h, const uint if (bitness != this->color_depth_) { display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + return; } this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); } From c0b7f41397e41ff53ed2d551fcd0e3cf03f89147 Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Fri, 6 Mar 2026 13:21:44 +0100 Subject: [PATCH 03/14] [esp32] Fix wrong variable usage in P4 pin validation error msg (#14539) --- esphome/components/esp32/gpio_esp32_p4.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32/gpio_esp32_p4.py b/esphome/components/esp32/gpio_esp32_p4.py index b98b567da2c..2726c5932fa 100644 --- a/esphome/components/esp32/gpio_esp32_p4.py +++ b/esphome/components/esp32/gpio_esp32_p4.py @@ -33,7 +33,7 @@ def esp32_p4_validate_supports(value): is_input = mode[CONF_INPUT] if num < 0 or num > 54: - raise cv.Invalid(f"Invalid pin number: {value} (must be 0-54)") + raise cv.Invalid(f"Invalid pin number: {num} (must be 0-54)") if is_input: # All ESP32 pins support input mode pass From 5084c32f3c3da1bb857b79f6200825ec456000c8 Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Fri, 6 Mar 2026 13:22:11 +0100 Subject: [PATCH 04/14] [esp32] Fix ESP32-S3 pin validation error message (#14540) --- esphome/components/esp32/gpio_esp32_s3.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/gpio_esp32_s3.py b/esphome/components/esp32/gpio_esp32_s3.py index 71205046933..aea378f499d 100644 --- a/esphome/components/esp32/gpio_esp32_s3.py +++ b/esphome/components/esp32/gpio_esp32_s3.py @@ -29,7 +29,7 @@ _LOGGER = logging.getLogger(__name__) def esp32_s3_validate_gpio_pin(value): if value < 0 or value > 48: - raise cv.Invalid(f"Invalid pin number: {value} (must be 0-46)") + raise cv.Invalid(f"Invalid pin number: {value} (must be 0-48)") if value in _ESP_32S3_SPI_PSRAM_PINS: raise cv.Invalid( @@ -55,7 +55,7 @@ def esp32_s3_validate_supports(value): is_input = mode[CONF_INPUT] if num < 0 or num > 48: - raise cv.Invalid(f"Invalid pin number: {num} (must be 0-46)") + raise cv.Invalid(f"Invalid pin number: {num} (must be 0-48)") if is_input: # All ESP32 pins support input mode pass From e59a2b3eded37c9de46d8148b2f8cb40b46d06ac Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Fri, 6 Mar 2026 17:25:44 +0100 Subject: [PATCH 05/14] [nrf52] prepare for usb cdc (#14174) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 2 ++ esphome/core/event_pool.h | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index a14d3af69ef..8ad84656ede 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -464,6 +464,8 @@ def only_on_variant(*, supported=None, unsupported=None, msg_prefix="This featur unsupported = [unsupported] def validator_(obj): + if not CORE.is_esp32: + raise cv.Invalid(f"{msg_prefix} is only available on ESP32") variant = get_esp32_variant() if supported is not None and variant not in supported: raise cv.Invalid( diff --git a/esphome/core/event_pool.h b/esphome/core/event_pool.h index 928a4e7dee2..99541d4a179 100644 --- a/esphome/core/event_pool.h +++ b/esphome/core/event_pool.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ESP32) +#if defined(USE_ESP32) || defined(USE_ZEPHYR) #include #include From 07e51886f3563061c09e6a8c9c36ab94300b0b1f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 6 Mar 2026 06:57:52 -1000 Subject: [PATCH 06/14] [core] Move entity icon strings to PROGMEM on ESP8266 (#14437) --- esphome/components/api/api_connection.h | 3 +- esphome/components/mqtt/mqtt_component.cpp | 10 ++--- esphome/components/mqtt/mqtt_component.h | 4 +- esphome/components/web_server/web_server.cpp | 3 +- esphome/config_validation.py | 17 ++++--- esphome/core/config.py | 4 ++ esphome/core/entity_base.cpp | 38 ++++++++++++++-- esphome/core/entity_base.h | 33 +++++++++++--- esphome/core/entity_helpers.py | 47 +++++++++++++++++--- tests/unit_tests/core/test_entity_helpers.py | 17 +++++++ tests/unit_tests/test_config_validation.py | 12 +++++ 11 files changed, 158 insertions(+), 30 deletions(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index aae8db3c688..88f0ef82d66 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -354,7 +354,8 @@ class APIConnection final : public APIServerConnectionBase { // Set common EntityBase properties #ifdef USE_ENTITY_ICON - msg.icon = entity->get_icon_ref(); + char icon_buf[MAX_ICON_LENGTH]; + msg.icon = StringRef(entity->get_icon_to(icon_buf)); #endif msg.disabled_by_default = entity->is_disabled_by_default(); msg.entity_category = static_cast(entity->get_entity_category()); diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index f49069960b3..98fa10def95 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -209,12 +209,11 @@ bool MQTTComponent::send_discovery_() { if (this->is_disabled_by_default_()) root[MQTT_ENABLED_BY_DEFAULT] = false; - // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - const auto icon_ref = this->get_icon_ref_(); - if (!icon_ref.empty()) { - root[MQTT_ICON] = icon_ref; + char icon_buf[MAX_ICON_LENGTH]; + const char *icon = this->get_icon_to_(icon_buf); + if (icon[0] != '\0') { + root[MQTT_ICON] = icon; } - // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) const auto entity_category = this->get_entity()->get_entity_category(); if (entity_category != ENTITY_CATEGORY_NONE) { @@ -413,7 +412,6 @@ const StringRef &MQTTComponent::friendly_name_() const { return this->get_entity StringRef MQTTComponent::get_default_object_id_to_(std::span buf) const { return this->get_entity()->get_object_id_to(buf); } -StringRef MQTTComponent::get_icon_ref_() const { return this->get_entity()->get_icon_ref(); } bool MQTTComponent::is_disabled_by_default_() const { return this->get_entity()->is_disabled_by_default(); } bool MQTTComponent::compute_is_internal_() { if (this->custom_state_topic_.has_value()) { diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index 0ffe6341d37..2403ef64ea3 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -298,8 +298,8 @@ class MQTTComponent : public Component { /// Get the friendly name of this MQTT component. const StringRef &friendly_name_() const; - /// Get the icon field of this component as StringRef - StringRef get_icon_ref_() const; + /// Get the icon field of this component into a stack buffer + const char *get_icon_to_(std::span buf) const { return this->get_entity()->get_icon_to(buf); } /// Get whether the underlying Entity is disabled by default bool is_disabled_by_default_() const; diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 6b94a103cc9..bc90c88e57f 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -568,7 +568,8 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J } #endif #ifdef USE_ENTITY_ICON - root[ESPHOME_F("icon")] = obj->get_icon_ref().c_str(); + char icon_buf[MAX_ICON_LENGTH]; + root[ESPHOME_F("icon")] = obj->get_icon_to(icon_buf); #endif root[ESPHOME_F("entity_category")] = obj->get_entity_category(); bool is_disabled = obj->is_disabled_by_default(); diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 3b0e4da298a..1eac53e9b20 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -400,14 +400,21 @@ def string_strict(value): def icon(value): """Validate that a given config value is a valid icon.""" + from esphome.core.config import ICON_MAX_LENGTH + value = string_strict(value) if not value: return value - if re.match("^[\\w\\-]+:[\\w\\-]+$", value): - return value - raise Invalid( - 'Icons must match the format "[icon pack]:[icon]", e.g. "mdi:home-assistant"' - ) + if not re.match("^[\\w\\-]+:[\\w\\-]+$", value): + raise Invalid( + 'Icons must match the format "[icon pack]:[icon]", e.g. "mdi:home-assistant"' + ) + if len(value) > ICON_MAX_LENGTH: + raise Invalid( + f"Icon string is too long ({len(value)} chars, max {ICON_MAX_LENGTH}). " + "Icons are stored in PROGMEM with a 64-byte buffer limit." + ) + return value def sub_device_id(value: str | None) -> core.ID | None: diff --git a/esphome/core/config.py b/esphome/core/config.py index 4f526404fe8..9093ab3fe9f 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -188,6 +188,10 @@ else: # Keep in sync with ESPHOME_FRIENDLY_NAME_MAX_LEN in esphome/core/entity_base.h FRIENDLY_NAME_MAX_LEN = 120 +# Max icon string length (63 chars + null = 64-byte PROGMEM buffer) +# Keep in sync with MAX_ICON_LENGTH in esphome/core/entity_base.h +ICON_MAX_LENGTH = 63 + AREA_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(Area), diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index eafc04f92a4..12652775722 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -1,6 +1,7 @@ #include "esphome/core/entity_base.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome { @@ -72,7 +73,27 @@ std::string EntityBase::get_unit_of_measurement() const { return std::string(this->get_unit_of_measurement_ref().c_str()); } -// Entity icon (from index) +// Entity icon — buffer-based API for PROGMEM safety on ESP8266 +const char *EntityBase::get_icon_to([[maybe_unused]] std::span buffer) const { +#ifdef USE_ENTITY_ICON + const uint8_t idx = this->icon_idx_; +#else + const uint8_t idx = 0; +#endif +#ifdef USE_ESP8266 + if (idx == 0) + return ""; + const char *icon = entity_icon_lookup(idx); + ESPHOME_strncpy_P(buffer.data(), icon, buffer.size() - 1); + buffer[buffer.size() - 1] = '\0'; + return buffer.data(); +#else + return entity_icon_lookup(idx); +#endif +} + +#ifndef USE_ESP8266 +// Deprecated icon accessors — not available on ESP8266 (rodata is RAM) StringRef EntityBase::get_icon_ref() const { #ifdef USE_ENTITY_ICON return StringRef(entity_icon_lookup(this->icon_idx_)); @@ -80,7 +101,14 @@ StringRef EntityBase::get_icon_ref() const { return StringRef(entity_icon_lookup(0)); #endif } -std::string EntityBase::get_icon() const { return std::string(this->get_icon_ref().c_str()); } +std::string EntityBase::get_icon() const { +#ifdef USE_ENTITY_ICON + return std::string(entity_icon_lookup(this->icon_idx_)); +#else + return std::string(entity_icon_lookup(0)); +#endif +} +#endif // !USE_ESP8266 // Entity Object ID - computed on-demand from name std::string EntityBase::get_object_id() const { @@ -154,8 +182,10 @@ ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t ve #ifdef USE_ENTITY_ICON void log_entity_icon(const char *tag, const char *prefix, const EntityBase &obj) { - if (!obj.get_icon_ref().empty()) { - ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj.get_icon_ref().c_str()); + char icon_buf[MAX_ICON_LENGTH]; + const char *icon = obj.get_icon_to(icon_buf); + if (icon[0] != '\0') { + ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, icon); } } #endif diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 54d4ae311f2..1ce1e658e02 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -36,6 +36,10 @@ static constexpr size_t OBJECT_ID_MAX_LEN = 128; // Maximum state length that Home Assistant will accept without raising ValueError static constexpr size_t MAX_STATE_LEN = 255; +// Maximum icon string buffer size (63 chars + null terminator) +// Icons are stored in PROGMEM; on ESP8266 they must be copied to a stack buffer. +static constexpr size_t MAX_ICON_LENGTH = 64; + enum EntityCategory : uint8_t { ENTITY_CATEGORY_NONE = 0, ENTITY_CATEGORY_CONFIG = 1, @@ -124,12 +128,31 @@ class EntityBase { "2026.3.0") std::string get_unit_of_measurement() const; - // Get/set this entity's icon - ESPDEPRECATED( - "Use get_icon_ref() instead for better performance (avoids string copy). Will be removed in ESPHome 2026.5.0", - "2025.11.0") - std::string get_icon() const; + // Get this entity's icon into a stack buffer. + // On ESP32: returns pointer to PROGMEM string directly (buffer unused). + // On ESP8266: copies from PROGMEM to buffer, returns buffer pointer. + const char *get_icon_to(std::span buffer) const; + +#ifdef USE_ESP8266 + // On ESP8266, rodata is RAM. Icons are in PROGMEM and cannot be accessed + // directly as const char*. Use get_icon_to() with a stack buffer instead. + template StringRef get_icon_ref() const { + static_assert(sizeof(T) == 0, + "get_icon_ref() unavailable on ESP8266 (rodata is RAM). Use get_icon_to() with a stack buffer."); + return StringRef(""); + } + template std::string get_icon() const { + static_assert(sizeof(T) == 0, + "get_icon() unavailable on ESP8266 (rodata is RAM). Use get_icon_to() with a stack buffer."); + return ""; + } +#else + // Deprecated: use get_icon_to() instead. Icons are in PROGMEM. + ESPDEPRECATED("Use get_icon_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0") StringRef get_icon_ref() const; + ESPDEPRECATED("Use get_icon_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0") + std::string get_icon() const; +#endif #ifdef USE_DEVICES // Get/set this entity's device id diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 551e35df65c..01fa27b833a 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -17,6 +17,7 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core.config import ICON_MAX_LENGTH from esphome.cpp_generator import MockObj, RawStatement, add, get_variable import esphome.final_validate as fv from esphome.helpers import cpp_string_escape, fnv1_hash_object_id, sanitize, snake_case @@ -78,6 +79,8 @@ def _generate_category_code( table_var: str, lookup_fn: str, strings: dict[str, int], + *, + progmem_strings: bool = False, ) -> str: """Generate C++ code for one string category (PROGMEM pointer table + lookup). @@ -85,14 +88,40 @@ def _generate_category_code( in flash (via PROGMEM) and read with progmem_read_ptr(). String literals themselves remain in RAM but benefit from linker string deduplication. Index 0 means "not set" and returns empty string. + + When progmem_strings=True, each string is declared as a separate PROGMEM + char array. This ensures the string data itself is in flash on ESP8266 + (where .rodata is RAM). On other platforms PROGMEM is a no-op. """ if not strings: return "" sorted_strings = sorted(strings.items(), key=lambda x: x[1]) - entries = ", ".join(cpp_string_escape(s) for s, _ in sorted_strings) count = len(sorted_strings) + if progmem_strings: + # Emit individual PROGMEM char arrays so string data lives in flash + lines: list[str] = [] + var_names: list[str] = [] + for i, (s, _) in enumerate(sorted_strings): + var_name = f"{table_var}_STR_{i}" + var_names.append(var_name) + lines.append( + f"static const char {var_name}[] PROGMEM = {cpp_string_escape(s)};" + ) + entries = ", ".join(var_names) + # Empty string must also be PROGMEM — on ESP8266, callers use strncpy_P + empty_var = f"{table_var}_EMPTY" + lines.append(f'static const char {empty_var}[] PROGMEM = "";') + lines.append(f"static const char *const {table_var}[] PROGMEM = {{{entries}}};") + lines.append(f"const char *{lookup_fn}(uint8_t index) {{") + lines.append(f" if (index == 0 || index > {count}) return {empty_var};") + lines.append(f" return progmem_read_ptr(&{table_var}[index - 1]);") + lines.append("}") + return "\n".join(lines) + "\n" + + entries = ", ".join(cpp_string_escape(s) for s, _ in sorted_strings) + return ( f"static const char *const {table_var}[] PROGMEM = {{{entries}}};\n" f"const char *{lookup_fn}(uint8_t index) {{\n" @@ -103,9 +132,9 @@ def _generate_category_code( _CATEGORY_CONFIGS = ( - ("ENTITY_DC_TABLE", "entity_device_class_lookup", "device_classes"), - ("ENTITY_UOM_TABLE", "entity_uom_lookup", "units"), - ("ENTITY_ICON_TABLE", "entity_icon_lookup", "icons"), + ("ENTITY_DC_TABLE", "entity_device_class_lookup", "device_classes", False), + ("ENTITY_UOM_TABLE", "entity_uom_lookup", "units", False), + ("ENTITY_ICON_TABLE", "entity_icon_lookup", "icons", True), ) @@ -117,8 +146,10 @@ async def _generate_tables_job() -> None: """ pool = _get_pool() parts = ["namespace esphome {"] - for table_var, lookup_fn, attr in _CATEGORY_CONFIGS: - code = _generate_category_code(table_var, lookup_fn, getattr(pool, attr)) + for table_var, lookup_fn, attr, progmem_strs in _CATEGORY_CONFIGS: + code = _generate_category_code( + table_var, lookup_fn, getattr(pool, attr), progmem_strings=progmem_strs + ) if code: parts.append(code) parts.append("} // namespace esphome") @@ -160,6 +191,10 @@ def register_unit_of_measurement(value: str) -> int: def register_icon(value: str) -> int: """Register an icon string and return its 1-based index.""" + if value and len(value) > ICON_MAX_LENGTH: + raise ValueError( + f"Icon string too long ({len(value)} chars, max {ICON_MAX_LENGTH}): '{value}'" + ) return _register_string(value, _get_pool().icons, _MAX_ICONS, "icon") diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index a5cfad5ab69..79bc3095b92 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -23,6 +23,7 @@ from esphome.core.entity_helpers import ( _setup_entity_impl, entity_duplicate_validator, get_base_entity_object_id, + register_icon, setup_entity, ) from esphome.cpp_generator import MockObj @@ -909,6 +910,22 @@ def test_register_string_overflow() -> None: _register_string("overflow", category, 3, "test") +def test_register_icon_max_length() -> None: + """Test register_icon rejects icons exceeding 63 characters.""" + # 63 chars should succeed + max_icon = "mdi:" + "a" * 59 # 63 total + idx = register_icon(max_icon) + assert idx > 0 + + # 64 chars should fail + too_long = "mdi:" + "a" * 60 # 64 total + with pytest.raises(ValueError, match="Icon string too long"): + register_icon(too_long) + + # Empty string returns 0 + assert register_icon("") == 0 + + @pytest.mark.asyncio async def test_setup_entity_with_entity_category( setup_test_environment: list[str], diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 9602010ad30..c1849daf4ba 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -148,6 +148,18 @@ def test_icon__invalid(): config_validation.icon("foo") +def test_icon__max_length(): + """Test that icons exceeding 63 characters are rejected.""" + # Exactly 63 chars should pass + max_icon = "mdi:" + "a" * 59 # 63 chars total + assert config_validation.icon(max_icon) == max_icon + + # 64 chars should fail + too_long = "mdi:" + "a" * 60 # 64 chars total + with pytest.raises(Invalid, match="Icon string is too long"): + config_validation.icon(too_long) + + @pytest.mark.parametrize("value", ("True", "YES", "on", "enAblE", True)) def test_boolean__valid_true(value): assert config_validation.boolean(value) is True From 74e4b69654c2dd77f81cd712fe1c7ece3db78bfa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 6 Mar 2026 06:58:13 -1000 Subject: [PATCH 07/14] [core] Replace Application name/friendly_name std::string with StringRef (#14532) Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> --- .../components/api/api_frame_helper_noise.cpp | 2 +- esphome/components/esp32_ble/ble.cpp | 2 +- esphome/components/mdns/mdns_component.cpp | 2 +- esphome/components/mqtt/mqtt_component.cpp | 6 +- esphome/components/openthread/openthread.cpp | 2 +- .../components/web_server/web_server_v1.cpp | 2 +- esphome/components/wifi/wifi_component.cpp | 2 +- .../wifi/wifi_component_esp8266.cpp | 2 +- esphome/core/application.h | 54 +++++++------ esphome/core/config.py | 60 +++++++++++++-- esphome/core/defines.h | 1 + esphome/core/entity_base.cpp | 6 +- tests/dummy_main.cpp | 4 +- tests/unit_tests/core/test_config.py | 77 +++++++++++++++++++ 14 files changed, 181 insertions(+), 41 deletions(-) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 3ae35e9be81..ba4f2f0642d 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -269,7 +269,7 @@ APIError APINoiseFrameHelper::state_action_() { } if (state_ == State::SERVER_HELLO) { // send server hello - const std::string &name = App.get_name(); + const auto &name = App.get_name(); char mac[MAC_ADDRESS_BUFFER_SIZE]; get_mac_address_into_buffer(mac); diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 9d260188003..bbe972b9f33 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -273,7 +273,7 @@ bool ESP32BLE::ble_setup_() { device_name = this->name_; } } else { - const std::string &app_name = App.get_name(); + const auto &app_name = App.get_name(); size_t name_len = app_name.length(); if (name_len > 20) { if (App.is_name_add_mac_suffix_enabled()) { diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 5e5e1279d95..342a6e6c645 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -59,7 +59,7 @@ void MDNSComponent::compile_records_(StaticVectorget_port(); - const std::string &friendly_name = App.get_friendly_name(); + const auto &friendly_name = App.get_friendly_name(); bool friendly_name_empty = friendly_name.empty(); // Calculate exact capacity for txt_records diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index 98fa10def95..d31a78b0900 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -267,7 +267,7 @@ bool MQTTComponent::send_discovery_() { root[MQTT_UNIQUE_ID] = unique_id_buf; } - const std::string &node_name = App.get_name(); + const auto &node_name = App.get_name(); if (discovery_info.object_id_generator == MQTT_DEVICE_NAME_OBJECT_ID_GENERATOR) { // node_name (max 31) + "_" (1) + object_id (max 128) + null char object_id_full[ESPHOME_DEVICE_NAME_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 1]; @@ -275,8 +275,8 @@ bool MQTTComponent::send_discovery_() { root[MQTT_OBJECT_ID] = object_id_full; } - const std::string &friendly_name_ref = App.get_friendly_name(); - const std::string &node_friendly_name = friendly_name_ref.empty() ? node_name : friendly_name_ref; + const auto &friendly_name_ref = App.get_friendly_name(); + const auto &node_friendly_name = friendly_name_ref.empty() ? node_name : friendly_name_ref; const char *node_area = App.get_area(); JsonObject device_info = root[MQTT_DEVICE].to(); diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 9452f5a41eb..fb814812997 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -132,7 +132,7 @@ void OpenThreadSrpComponent::setup() { // set the host name uint16_t size; char *existing_host_name = otSrpClientBuffersGetHostNameString(instance, &size); - const std::string &host_name = App.get_name(); + const auto &host_name = App.get_name(); uint16_t host_name_len = host_name.size(); if (host_name_len > size) { ESP_LOGW(TAG, "Hostname is too long, choose a shorter project name"); diff --git a/esphome/components/web_server/web_server_v1.cpp b/esphome/components/web_server/web_server_v1.cpp index f7b90018dc6..85a4e80541b 100644 --- a/esphome/components/web_server/web_server_v1.cpp +++ b/esphome/components/web_server/web_server_v1.cpp @@ -75,7 +75,7 @@ void WebServer::set_js_url(const char *js_url) { this->js_url_ = js_url; } void WebServer::handle_index_request(AsyncWebServerRequest *request) { AsyncResponseStream *stream = request->beginResponseStream(ESPHOME_F("text/html")); - const std::string &title = App.get_name(); + const auto &title = App.get_name(); stream->print(ESPHOME_F("")); stream->print(title.c_str()); diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 8b60810d28a..60764955cc9 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -913,7 +913,7 @@ void WiFiComponent::setup_ap_config_() { static constexpr size_t AP_SSID_PREFIX_LEN = 25; static constexpr size_t AP_SSID_SUFFIX_LEN = 7; - const std::string &app_name = App.get_name(); + const auto &app_name = App.get_name(); const char *name_ptr = app_name.c_str(); size_t name_len = app_name.length(); diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 355832b4340..a9b26c5935e 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -212,7 +212,7 @@ network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { return addresses; } bool WiFiComponent::wifi_apply_hostname_() { - const std::string &hostname = App.get_name(); + const auto &hostname = App.get_name(); bool ret = wifi_station_set_hostname(const_cast<char *>(hostname.c_str())); if (!ret) { ESP_LOGV(TAG, "Set hostname failed"); diff --git a/esphome/core/application.h b/esphome/core/application.h index 40f8a00edd3..87f9fdf59a1 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -138,26 +138,36 @@ static constexpr uint32_t TEARDOWN_TIMEOUT_REBOOT_MS = 1000; // 1 second for qu class Application { public: - void pre_setup(const std::string &name, const std::string &friendly_name, bool name_add_mac_suffix) { +#ifdef ESPHOME_NAME_ADD_MAC_SUFFIX + /// Pre-setup with MAC suffix: overwrites placeholder in mutable static buffers with actual MAC. + void pre_setup(char *name, size_t name_len, char *friendly_name, size_t friendly_name_len) { arch_init(); - this->name_add_mac_suffix_ = name_add_mac_suffix; - if (name_add_mac_suffix) { - // MAC address length: 12 hex chars + null terminator - constexpr size_t mac_address_len = 13; - // MAC address suffix length (last 6 characters of 12-char MAC address string) - constexpr size_t mac_address_suffix_len = 6; - char mac_addr[mac_address_len]; - get_mac_address_into_buffer(mac_addr); - const char *mac_suffix_ptr = mac_addr + mac_address_suffix_len; - this->name_ = make_name_with_suffix(name, '-', mac_suffix_ptr, mac_address_suffix_len); - if (!friendly_name.empty()) { - this->friendly_name_ = make_name_with_suffix(friendly_name, ' ', mac_suffix_ptr, mac_address_suffix_len); - } - } else { - this->name_ = name; - this->friendly_name_ = friendly_name; + this->name_add_mac_suffix_ = true; + // MAC address length: 12 hex chars + null terminator + constexpr size_t mac_address_len = 13; + // MAC address suffix length (last 6 characters of 12-char MAC address string) + constexpr size_t mac_address_suffix_len = 6; + char mac_addr[mac_address_len]; + get_mac_address_into_buffer(mac_addr); + // Overwrite the placeholder suffix in the mutable static buffers with actual MAC + // name is always non-empty (validated by validate_hostname in Python config) + memcpy(name + name_len - mac_address_suffix_len, mac_addr + mac_address_suffix_len, mac_address_suffix_len); + if (friendly_name_len > 0) { + memcpy(friendly_name + friendly_name_len - mac_address_suffix_len, mac_addr + mac_address_suffix_len, + mac_address_suffix_len); } + this->name_ = StringRef(name, name_len); + this->friendly_name_ = StringRef(friendly_name, friendly_name_len); } +#else + /// Pre-setup without MAC suffix: StringRef points directly at const string literals in flash. + void pre_setup(const char *name, size_t name_len, const char *friendly_name, size_t friendly_name_len) { + arch_init(); + this->name_add_mac_suffix_ = false; + this->name_ = StringRef(name, name_len); + this->friendly_name_ = StringRef(friendly_name, friendly_name_len); + } +#endif #ifdef USE_DEVICES void register_device(Device *device) { this->devices_.push_back(device); } @@ -274,10 +284,10 @@ class Application { void loop(); /// Get the name of this Application set by pre_setup(). - const std::string &get_name() const { return this->name_; } + const StringRef &get_name() const { return this->name_; } /// Get the friendly name of this Application set by pre_setup(). - const std::string &get_friendly_name() const { return this->friendly_name_; } + const StringRef &get_friendly_name() const { return this->friendly_name_; } /// Get the area of this Application set by pre_setup(). const char *get_area() const { @@ -627,9 +637,9 @@ class Application { #endif #endif - // std::string members (typically 24-32 bytes each) - std::string name_; - std::string friendly_name_; + // StringRef members (8 bytes each: pointer + size) + StringRef name_; + StringRef friendly_name_; // 4-byte members uint32_t last_loop_{0}; diff --git a/esphome/core/config.py b/esphome/core/config.py index 9093ab3fe9f..8631726a021 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -50,6 +50,7 @@ from esphome.core import ( ) from esphome.helpers import ( copy_file_if_changed, + cpp_string_escape, fnv1a_32bit_hash, get_str_env, walk_files, @@ -58,6 +59,38 @@ from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) +# C++ variable names and separators for app name buffers (used with MAC suffix) +_APP_NAME_BUF_VAR = "esphome_app_name_buf" +_APP_NAME_MAC_SEP = "-" +_APP_FRIENDLY_NAME_BUF_VAR = "esphome_app_friendly_name_buf" +_APP_FRIENDLY_NAME_MAC_SEP = " " +# Placeholder suffix for MAC address (last 6 hex chars) +_MAC_SUFFIX_PLACEHOLDER = "XXXXXX" + + +def make_app_name_cpp( + value: str, var_name: str, sep: str, *, add_mac_suffix: bool +) -> tuple[str, str | None, int]: + """Compute C++ expression and optional global declaration for an app name. + + Returns (cpp_expr, global_decl_or_none, byte_length). + - cpp_expr: The C++ expression to pass to pre_setup (var name or string literal). + - global_decl: A static char[] declaration string, or None if not needed. + - byte_length: The UTF-8 byte length of the string value. + """ + if add_mac_suffix: + buf_value = "" if not value else f"{value}{sep}{_MAC_SUFFIX_PLACEHOLDER}" + escaped = cpp_string_escape(buf_value) + return ( + var_name, + f"static char {var_name}[] = {escaped};", + len(buf_value.encode("utf-8")), + ) + if not value: + return '""', None, 0 + return cpp_string_escape(value), None, len(value.encode("utf-8")) + + StartupTrigger = cg.esphome_ns.class_( "StartupTrigger", cg.Component, automation.Trigger.template() ) @@ -78,6 +111,8 @@ VALID_INCLUDE_EXTS = {".h", ".hpp", ".tcc", ".ino", ".cpp", ".c"} def validate_hostname(config): # Keep in sync with ESPHOME_DEVICE_NAME_MAX_LEN in esphome/core/entity_base.h + if not config[CONF_NAME]: + raise cv.Invalid("Hostname must not be empty", path=[CONF_NAME]) max_length = 31 if config[CONF_NAME_ADD_MAC_SUFFIX]: max_length -= 7 # "-AABBCC" is appended when add mac suffix option is used @@ -555,13 +590,28 @@ async def to_code(config: ConfigType) -> None: # Construct App via placement new — see application.cpp for storage details cg.add_global(cg.RawStatement("#include <new>")) cg.add(cg.RawExpression("new (&App) Application()")) - cg.add( - cg.App.pre_setup( - config[CONF_NAME], - config[CONF_FRIENDLY_NAME], - config[CONF_NAME_ADD_MAC_SUFFIX], + name = config[CONF_NAME] + friendly_name = config[CONF_FRIENDLY_NAME] + name_add_mac_suffix = config[CONF_NAME_ADD_MAC_SUFFIX] + + def _emit_app_name( + value: str, var_name: str, sep: str + ) -> tuple[cg.Expression, int]: + """Emit codegen for an app name and return (expression, byte_length).""" + cpp_expr, global_decl, byte_len = make_app_name_cpp( + value, var_name, sep, add_mac_suffix=name_add_mac_suffix ) + if global_decl is not None: + cg.add_global(cg.RawStatement(global_decl)) + return cg.RawExpression(cpp_expr), byte_len + + name_expr, name_len = _emit_app_name(name, _APP_NAME_BUF_VAR, _APP_NAME_MAC_SEP) + friendly_expr, friendly_len = _emit_app_name( + friendly_name, _APP_FRIENDLY_NAME_BUF_VAR, _APP_FRIENDLY_NAME_MAC_SEP ) + if name_add_mac_suffix: + cg.add_define("ESPHOME_NAME_ADD_MAC_SUFFIX") + cg.add(cg.App.pre_setup(name_expr, name_len, friendly_expr, friendly_len)) # Define component count for static allocation cg.add_define("ESPHOME_COMPONENT_COUNT", len(CORE.component_ids)) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index be5fdc9006e..c5f38ab9aab 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -13,6 +13,7 @@ #define ESPHOME_PROJECT_VERSION "v2" #define ESPHOME_PROJECT_VERSION_30 "v2" #define ESPHOME_VARIANT "ESP32" +#define ESPHOME_NAME_ADD_MAC_SUFFIX #define ESPHOME_DEBUG_SCHEDULER #define ESPHOME_DEBUG_API diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 12652775722..37e7fcc9987 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -23,13 +23,13 @@ void EntityBase::set_name(const char *name, uint32_t object_id_hash) { // Bug-for-bug compatibility with OLD behavior: // - With MAC suffix: OLD code used App.get_friendly_name() directly (no fallback) // - Without MAC suffix: OLD code used pre-computed object_id with fallback to device name - const std::string &friendly = App.get_friendly_name(); + const auto &friendly = App.get_friendly_name(); if (App.is_name_add_mac_suffix_enabled()) { // MAC suffix enabled - use friendly_name directly (even if empty) for compatibility - this->name_ = StringRef(friendly); + this->name_ = friendly; } else { // No MAC suffix - fallback to device name if friendly_name is empty - this->name_ = StringRef(!friendly.empty() ? friendly : App.get_name()); + this->name_ = !friendly.empty() ? friendly : App.get_name(); } } this->flags_.has_own_name = false; diff --git a/tests/dummy_main.cpp b/tests/dummy_main.cpp index 3ccf35e04d2..6fa0c08aa3d 100644 --- a/tests/dummy_main.cpp +++ b/tests/dummy_main.cpp @@ -12,7 +12,9 @@ using namespace esphome; void setup() { - App.pre_setup("livingroom", "LivingRoom", false); + static char name[] = "livingroom"; + static char friendly_name[] = "LivingRoom"; + App.pre_setup(name, sizeof(name) - 1, friendly_name, sizeof(friendly_name) - 1); auto *log = new logger::Logger(115200); // NOLINT log->pre_setup(); log->set_uart_selection(logger::UART_SELECTION_UART0); diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 88801a9ca03..474d31a90af 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -23,6 +23,7 @@ from esphome.const import ( from esphome.core import CORE, config from esphome.core.config import ( Area, + make_app_name_cpp, preload_core_config, valid_include, valid_project_name, @@ -969,3 +970,79 @@ def test_config_hash_different_for_different_configs() -> None: hash2 = CORE.config_hash assert hash1 != hash2 + + +def test_make_app_name_cpp_no_mac_simple() -> None: + """Test simple name without MAC suffix returns string literal.""" + cpp_expr, global_decl, byte_len = make_app_name_cpp( + "my-device", "buf", "-", add_mac_suffix=False + ) + assert cpp_expr == '"my-device"' + assert global_decl is None + assert byte_len == 9 + + +def test_make_app_name_cpp_no_mac_empty() -> None: + """Test empty name without MAC suffix.""" + cpp_expr, global_decl, byte_len = make_app_name_cpp( + "", "buf", "-", add_mac_suffix=False + ) + assert cpp_expr == '""' + assert global_decl is None + assert byte_len == 0 + + +def test_make_app_name_cpp_mac_suffix() -> None: + """Test name with MAC suffix emits static buffer.""" + cpp_expr, global_decl, byte_len = make_app_name_cpp( + "my-device", "esphome_app_name_buf", "-", add_mac_suffix=True + ) + assert cpp_expr == "esphome_app_name_buf" + assert global_decl is not None + assert "static char esphome_app_name_buf[]" in global_decl + assert "my-device-XXXXXX" in global_decl + assert byte_len == len("my-device-XXXXXX") + + +def test_make_app_name_cpp_mac_suffix_empty() -> None: + """Test empty name with MAC suffix emits empty static buffer.""" + cpp_expr, global_decl, byte_len = make_app_name_cpp( + "", "esphome_app_name_buf", "-", add_mac_suffix=True + ) + assert cpp_expr == "esphome_app_name_buf" + assert global_decl is not None + assert "static char esphome_app_name_buf[]" in global_decl + assert byte_len == 0 + + +def test_make_app_name_cpp_mac_suffix_space_sep() -> None: + """Test friendly name uses space separator for MAC suffix.""" + cpp_expr, global_decl, byte_len = make_app_name_cpp( + "My Device", "esphome_app_friendly_name_buf", " ", add_mac_suffix=True + ) + assert cpp_expr == "esphome_app_friendly_name_buf" + assert global_decl is not None + assert "My Device XXXXXX" in global_decl + assert byte_len == len("My Device XXXXXX") + + +def test_make_app_name_cpp_non_ascii_utf8_length() -> None: + """Test non-ASCII characters use UTF-8 byte length.""" + _, global_decl, byte_len = make_app_name_cpp( + "café", "buf", "-", add_mac_suffix=False + ) + assert byte_len == len("café".encode()) # 5 bytes, not 4 chars + assert global_decl is None + + +def test_make_app_name_cpp_non_ascii_mac_suffix_utf8_length() -> None: + """Test non-ASCII with MAC suffix uses UTF-8 byte length.""" + _, _, byte_len = make_app_name_cpp("café", "buf", "-", add_mac_suffix=True) + assert byte_len == len("café-XXXXXX".encode()) + + +def test_make_app_name_cpp_special_chars_escaped() -> None: + """Test special characters are properly escaped in C++ string.""" + cpp_expr, _, _ = make_app_name_cpp('my "device"', "buf", "-", add_mac_suffix=False) + # cpp_string_escape uses octal escapes for quotes + assert '"' not in cpp_expr[1:-1] # no unescaped quotes inside the outer quotes From a16b8fc0ac30a015df61555d59fb98e19a9efe6a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" <nick@koston.org> Date: Fri, 6 Mar 2026 07:00:31 -1000 Subject: [PATCH 08/14] [rp2040] Fix Pico W LED pin and auto-generate board definitions for arduino-pico 5.5.x (#14528) --- esphome/components/rp2040/boards.jinja2 | 25 + esphome/components/rp2040/boards.py | 2199 ++++++++++++++++- esphome/components/rp2040/generate_boards.py | 186 ++ esphome/components/rp2040/gpio.py | 22 +- .../components/test_rp2040_generate_boards.py | 273 ++ 5 files changed, 2688 insertions(+), 17 deletions(-) create mode 100644 esphome/components/rp2040/boards.jinja2 create mode 100644 esphome/components/rp2040/generate_boards.py create mode 100644 tests/unit_tests/components/test_rp2040_generate_boards.py diff --git a/esphome/components/rp2040/boards.jinja2 b/esphome/components/rp2040/boards.jinja2 new file mode 100644 index 00000000000..989fb83701a --- /dev/null +++ b/esphome/components/rp2040/boards.jinja2 @@ -0,0 +1,25 @@ +# Auto-generated by generate_boards.py — do not edit manually +# To regenerate: python esphome/components/rp2040/generate_boards.py <arduino-pico-path> + +# arduino-pico maps pins >= {{ cyw43_gpio_offset }} to CYW43 wireless chip GPIOs +CYW43_GPIO_OFFSET = {{ cyw43_gpio_offset }} +CYW43_MAX_GPIO = {{ cyw43_max_gpio }} +DEFAULT_MAX_PIN = {{ default_max_pin }} + +RP2040_BASE_PINS = {} + +RP2040_BOARD_PINS = { +{%- for name, pins in board_pins %} + {{ name | repr }}: {{ pins | format_pins }}, +{%- endfor %} +} + +BOARDS = { +{%- for name, info in boards %} + {{ name | repr }}: { + {%- for key, value in info.items() %} + {{ key | repr }}: {{ value | repr }}, + {%- endfor %} + }, +{%- endfor %} +} diff --git a/esphome/components/rp2040/boards.py b/esphome/components/rp2040/boards.py index c761efba586..c99934567a1 100644 --- a/esphome/components/rp2040/boards.py +++ b/esphome/components/rp2040/boards.py @@ -1,28 +1,2205 @@ +# Auto-generated by generate_boards.py — do not edit manually +# To regenerate: python esphome/components/rp2040/generate_boards.py <arduino-pico-path> + +# arduino-pico maps pins >= 64 to CYW43 wireless chip GPIOs +CYW43_GPIO_OFFSET = 64 +CYW43_MAX_GPIO = 66 +DEFAULT_MAX_PIN = 29 + RP2040_BASE_PINS = {} RP2040_BOARD_PINS = { - "pico": { - "SDA": 4, - "SCL": 5, - "LED": 25, - "SDA1": 26, - "SCL1": 27, + "0xcb_helios": { + "LED": 17, + "MISO": 20, + "MOSI": 23, + "RX": 1, + "SCK": 22, + "SCL1": 3, + "SDA1": 2, + "SS": 21, + "TX": 0, }, - "rpipico": "pico", - "rpipicow": { - "SDA": 4, + "DudesCab": { + "LED": 25, + "MISO": 4, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 19, + "SCL1": 11, + "SDA": 18, + "SDA1": 10, + "SS": 5, + "TX": 0, + }, + "MyRP_2350B": { + "LED": 25, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, "SCL": 5, - "LED": 32, - "SDA1": 26, "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, + "MyRP_bot": { + "LED": 25, + "MISO": 12, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 17, + "SCL1": 4, + "SDA": 16, + "SDA1": 5, + "SS": 13, + }, + "adafruit_feather": { + "LED": 13, + "MISO": 20, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 3, + "SCL1": 25, + "SDA": 2, + "SDA1": 24, + "SS": 17, + "TX": 0, + }, + "adafruit_feather_adalogger": { + "LED": 13, + "MISO": 8, + "MOSI": 15, + "RX": 1, + "SCK": 14, + "SCL": 3, + "SDA": 2, + "SS": 13, + "TX": 0, + }, + "adafruit_feather_can": { + "LED": 13, + "MISO": 8, + "MOSI": 15, + "RX": 1, + "SCK": 14, + "SCL": 3, + "SDA": 2, + "SS": 13, + "TX": 0, + }, + "adafruit_feather_dvi": { + "LED": 13, + "MISO": 8, + "MOSI": 15, + "RX": 1, + "SCK": 14, + "SCL": 3, + "SDA": 2, + "SS": 13, + "TX": 0, + }, + "adafruit_feather_prop_maker": { + "LED": 13, + "MISO": 8, + "MOSI": 15, + "RX": 1, + "SCK": 14, + "SCL": 3, + "SDA": 2, + "SS": 13, + "TX": 0, + }, + "adafruit_feather_rfm": { + "LED": 13, + "MISO": 8, + "MOSI": 15, + "RX": 1, + "SCK": 14, + "SCL": 3, + "SDA": 2, + "SS": 13, + "TX": 0, + }, + "adafruit_feather_rp2350_adalogger": { + "LED": 7, + "MISO": 20, + "MOSI": 23, + "RX": 1, + "SCK": 22, + "SCL": 3, + "SCL1": 31, + "SDA": 2, + "SDA1": 31, + "SS": 13, + "TX": 0, + }, + "adafruit_feather_rp2350_hstx": { + "LED": 7, + "MISO": 20, + "MOSI": 23, + "RX": 1, + "SCK": 22, + "SCL": 3, + "SCL1": 31, + "SDA": 2, + "SDA1": 31, + "SS": 21, + "TX": 0, + }, + "adafruit_feather_scorpio": { + "LED": 13, + "MISO": 8, + "MOSI": 15, + "RX": 1, + "SCK": 14, + "SCL": 3, + "SDA": 2, + "SS": 13, + "TX": 0, + }, + "adafruit_feather_thinkink": { + "LED": 13, + "MISO": 8, + "MOSI": 15, + "RX": 1, + "SCK": 14, + "SCL": 3, + "SDA": 2, + "SS": 13, + "TX": 0, + }, + "adafruit_feather_usb_host": { + "LED": 13, + "MISO": 8, + "MOSI": 15, + "RX": 1, + "SCK": 14, + "SCL": 3, + "SDA": 2, + "SS": 13, + "TX": 0, + }, + "adafruit_floppsy": { + "LED": 28, + "MISO": 20, + "MOSI": 19, + "SCK": 18, + "SCL": 17, + "SDA": 16, + "SS": 24, + }, + "adafruit_fruitjam": { + "LED": 29, + "MISO": 36, + "MOSI": 35, + "RX": 9, + "SCK": 34, + "SCL": 21, + "SDA": 20, + "SS": 39, + "TX": 8, + }, + "adafruit_itsybitsy": { + "LED": 11, + "MISO": 20, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 25, + "SCL1": 3, + "SDA": 24, + "SDA1": 2, + "TX": 0, + }, + "adafruit_kb2040": { + "MISO": 20, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 13, + "SCL1": 3, + "SDA": 12, + "SDA1": 2, + "TX": 0, + }, + "adafruit_macropad2040": {"LED": 13, "SCL": 21, "SDA": 20}, + "adafruit_metro": { + "LED": 13, + "MISO": 20, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 17, + "SCL1": 3, + "SDA": 16, + "SDA1": 2, + "SS": 23, + "TX": 0, + }, + "adafruit_metro_rp2350": { + "LED": 23, + "MISO": 28, + "MOSI": 31, + "RX": 1, + "SCK": 30, + "SCL": 21, + "SDA": 20, + "SS": 29, + "TX": 0, + }, + "adafruit_qtpy": { + "MISO": 4, + "MOSI": 3, + "RX": 29, + "SCK": 6, + "SCL": 25, + "SCL1": 23, + "SDA": 24, + "SDA1": 22, + "TX": 28, + }, + "adafruit_stemmafriend": { + "LED": 12, + "MISO": 4, + "MOSI": 7, + "RX": 27, + "SCK": 2, + "SCL": 21, + "SCL1": 27, + "SDA": 20, + "SDA1": 26, + "SS": 1, + "TX": 26, + }, + "adafruit_trinkeyrp2040qt": {"RX": 17, "SCL": 17, "SDA": 16, "TX": 16}, + "akana_r1": { + "LED": 25, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 7, + "SDA": 4, + "SDA1": 6, + "SS": 17, + "TX": 0, + }, + "amken_bunny": {"LED": 24, "RX": 1, "TX": 0}, + "amken_revelop": {"LED": 24, "RX": 1, "SCL": 29, "SDA": 28, "TX": 0}, + "amken_revelop_es": {"LED": 5, "MISO": 0, "MOSI": 3, "SCK": 2, "SS": 1, "TX": 20}, + "amken_revelop_plus": {"LED": 24, "RX": 1, "SCL": 29, "SDA": 28, "TX": 0}, + "artronshop_rp2_nano": { + "LED": 13, + "MISO": 4, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 17, + "SCL1": 19, + "SDA": 16, + "SDA1": 18, + "SS": 5, + "TX": 0, + }, + "bigtreetech_SKR_Pico": {"LED": 13, "RX": 1, "TX": 0}, + "breadstick_raspberry": { + "RX": 21, + "SCL": 13, + "SCL1": 23, + "SDA": 12, + "SDA1": 22, + "TX": 20, + }, + "bridgetek_idm2040_43a": { + "LED": 25, + "MISO": 4, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 5, + "TX": 0, + }, + "bridgetek_idm2040_7a": { + "LED": 25, + "MISO": 4, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 5, + "TX": 0, + }, + "challenger_2040_lora": { + "LED": 24, + "MISO": 20, + "MOSI": 23, + "RX": 17, + "SCK": 22, + "SCL": 1, + "SDA": 0, + "SS": 21, + "TX": 16, + }, + "challenger_2040_lte": { + "LED": 19, + "MISO": 20, + "MOSI": 23, + "RX": 17, + "SCK": 22, + "SCL": 1, + "SDA": 0, + "SS": 21, + "TX": 16, + }, + "challenger_2040_nfc": { + "LED": 24, + "MISO": 20, + "MOSI": 23, + "RX": 17, + "SCK": 22, + "SCL": 1, + "SCL1": 11, + "SDA": 0, + "SDA1": 10, + "SS": 21, + "TX": 16, + }, + "challenger_2040_sdrtc": { + "LED": 24, + "MISO": 20, + "MOSI": 23, + "RX": 17, + "SCK": 22, + "SCL": 1, + "SDA": 0, + "SS": 21, + "TX": 16, + }, + "challenger_2040_subghz": { + "LED": 24, + "MISO": 20, + "MOSI": 23, + "RX": 17, + "SCK": 22, + "SCL": 1, + "SDA": 0, + "SS": 21, + "TX": 16, + }, + "challenger_2040_uwb": { + "LED": 24, + "MISO": 20, + "MOSI": 23, + "RX": 17, + "SCK": 22, + "SCL": 1, + "SDA": 0, + "SS": 21, + "TX": 16, + }, + "challenger_2040_wifi": { + "LED": 12, + "MISO": 24, + "MOSI": 23, + "RX": 17, + "SCK": 22, + "SCL": 1, + "SDA": 0, + "SS": 21, + "TX": 16, + }, + "challenger_2040_wifi6_ble": { + "LED": 10, + "MISO": 24, + "MOSI": 23, + "RX": 17, + "SCK": 22, + "SCL": 1, + "SDA": 0, + "SS": 21, + "TX": 16, + }, + "challenger_2040_wifi_ble": { + "LED": 10, + "MISO": 24, + "MOSI": 23, + "RX": 17, + "SCK": 22, + "SCL": 1, + "SDA": 0, + "SS": 21, + "TX": 16, + }, + "challenger_2350_bconnect": { + "LED": 7, + "MISO": 16, + "MOSI": 19, + "RX": 13, + "SCK": 18, + "SCL": 21, + "SCL1": 11, + "SDA": 20, + "SDA1": 10, + "SS": 17, + "TX": 12, + }, + "challenger_2350_wifi6_ble5": { + "LED": 7, + "MISO": 16, + "MOSI": 19, + "RX": 13, + "SCK": 18, + "SCL": 21, + "SCL1": 31, + "SDA": 20, + "SDA1": 31, + "SS": 17, + "TX": 12, + }, + "challenger_nb_2040_wifi": { + "LED": 12, + "MISO": 24, + "MOSI": 23, + "RX": 17, + "SCK": 22, + "SCL": 1, + "SDA": 0, + "SS": 21, + "TX": 16, + }, + "connectivity_2040_lte_wifi_ble": { + "LED": 19, + "MISO": 20, + "MOSI": 23, + "RX": 17, + "SCK": 22, + "SCL": 1, + "SDA": 0, + "SS": 21, + "TX": 16, + }, + "cytron_iriv_io_controller": { + "LED": 29, + "MISO": 20, + "MOSI": 19, + "RX": 31, + "SCK": 22, + "SCL": 17, + "SCL1": 31, + "SDA": 16, + "SDA1": 31, + "SS": 21, + "TX": 31, + }, + "cytron_maker_nano_rp2040": { + "LED": 2, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 1, + "SCL1": 27, + "SDA": 0, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, + "cytron_maker_pi_rp2040": { + "LED": 3, + "RX": 1, + "SCL": 17, + "SCL1": 3, + "SDA": 16, + "SDA1": 2, + "TX": 0, + }, + "cytron_maker_uno_rp2040": { + "LED": 3, + "MISO": 12, + "MOSI": 11, + "RX": 1, + "SCK": 10, + "SCL": 21, + "SCL1": 27, + "SDA": 20, + "SDA1": 26, + "SS": 13, + "TX": 0, + }, + "cytron_motion_2350_pro": { + "LED": 2, + "MISO": 4, + "MOSI": 7, + "RX": 1, + "SCK": 6, + "SCL": 17, + "SCL1": 27, + "SDA": 16, + "SDA1": 26, + "SS": 5, + "TX": 0, + }, + "datanoisetv_picoadk": { + "LED": 15, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 9, + "SCL1": 7, + "SDA": 8, + "SDA1": 6, + "SS": 17, + "TX": 0, + }, + "datanoisetv_picoadk_v2": { + "LED": 2, + "MISO": 8, + "MOSI": 7, + "RX": 13, + "SCK": 6, + "SCL": 9, + "SCL1": 7, + "SDA": 8, + "SDA1": 6, + "SS": 5, + "TX": 12, + }, + "degz_suibo": { + "LED": 25, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, + "dfrobot_beetle_rp2040": { + "LED": 13, + "MISO": 0, + "MOSI": 3, + "RX": 29, + "SCK": 2, + "SCL": 5, + "SCL1": 3, + "SDA": 4, + "SDA1": 2, + "SS": 1, + "TX": 28, + }, + "electroniccats_huntercat_nfc": {"LED": 8, "RX": 1, "SCL": 5, "SDA": 4, "TX": 0}, + "evn_alpha": { + "LED": 25, + "MISO": 0, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 5, + "SCL1": 7, + "SDA": 4, + "SDA1": 6, + "SS": 1, + "TX": 0, + }, + "extelec_rc2040": { + "LED": 25, + "MISO": 4, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 5, + "SDA": 4, + "SS": 5, + "TX": 0, + }, + "flyboard2040_core": { + "LED": 25, + "MISO": 4, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 17, + "SCL1": 15, + "SDA": 16, + "SDA1": 14, + "SS": 5, + "TX": 0, + }, + "geeekpi_rp2040_plus": { + "LED": 25, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, + "generic": { + "LED": 25, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, + "generic_rp2350": { + "LED": 25, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, + "groundstudio_marble_pico": { + "LED": 25, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, + "ilabs_rpico32": { + "MISO": 24, + "MOSI": 23, + "RX": 1, + "SCK": 22, + "SCL": 5, + "SDA": 4, + "SS": 21, + "TX": 0, + }, + "jumperless_v1": { + "LED": 25, + "MISO": 0, + "MOSI": 3, + "RX": 17, + "SCK": 2, + "SCL": 5, + "SCL1": 19, + "SDA": 4, + "SDA1": 18, + "SS": 1, + "TX": 16, + }, + "jumperless_v5": { + "LED": 17, + "MISO": 20, + "MOSI": 23, + "RX": 1, + "SCK": 22, + "SCL": 5, + "SCL1": 23, + "SDA": 4, + "SDA1": 22, + "SS": 21, + "TX": 0, + }, + "melopero_cookie_rp2040": { + "LED": 21, + "MISO": 20, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 13, + "SCL1": 3, + "SDA": 12, + "SDA1": 2, + "SS": 1, + "TX": 0, + }, + "melopero_shake_rp2040": { + "LED": 25, + "MISO": 20, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 9, + "SCL1": 3, + "SDA": 8, + "SDA1": 2, + "SS": 1, + "TX": 0, + }, + "mksthr36": { + "MISO": 16, + "MOSI": 19, + "SCK": 18, + "SCL": 23, + "SDA": 22, + "SS": 17, + "TX": 6, + }, + "mksthr42": { + "MISO": 16, + "MOSI": 19, + "SCK": 18, + "SCL": 23, + "SDA": 22, + "SS": 17, + "TX": 6, + }, + "nekosystems_bl2040_mini": { + "LED": 6, + "MISO": 16, + "MOSI": 19, + "RX": 13, + "SCK": 18, + "SCL": 25, + "SCL1": 23, + "SDA": 24, + "SDA1": 22, + "SS": 17, + "TX": 12, + }, + "newsan_archi": { + "MISO": 4, + "MOSI": 3, + "RX": 17, + "SCK": 2, + "SCL": 1, + "SCL1": 7, + "SDA": 0, + "SDA1": 6, + "SS": 5, + "TX": 16, + }, + "nullbits_bit_c_pro": { + "LED": 18, + "MISO": 20, + "MOSI": 23, + "RX": 1, + "SCK": 22, + "SCL": 3, + "SCL1": 5, + "SDA": 2, + "SDA1": 4, + "SS": 21, + "TX": 0, + }, + "olimex_pico2bb48": { + "LED": 25, + "MISO": 4, + "MOSI": 7, + "RX": 1, + "SCK": 6, + "SCL": 13, + "SCL1": 3, + "SDA": 12, + "SDA1": 2, + "SS": 5, + "TX": 0, + }, + "olimex_pico2xl": { + "LED": 25, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, + "olimex_pico2xxl": { + "LED": 25, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, + "picolume": { + "LED": 25, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, + "pimoroni_explorer": { + "MISO": 10, + "MOSI": 10, + "RX": 10, + "SCK": 10, + "SCL": 21, + "SCL1": 10, + "SDA": 20, + "SDA1": 10, + "SS": 10, + "TX": 10, + }, + "pimoroni_pico_plus_2": { + "LED": 25, + "MISO": 32, + "MOSI": 35, + "RX": 1, + "SCK": 34, + "SCL": 5, + "SCL1": 7, + "SDA": 4, + "SDA1": 6, + "SS": 33, + "TX": 0, + }, + "pimoroni_pico_plus_2w": { + "LED": 64, + "MISO": 32, + "MOSI": 35, + "RX": 1, + "SCK": 34, + "SCL": 5, + "SCL1": 7, + "SDA": 4, + "SDA1": 6, + "SS": 33, + "TX": 0, + }, + "pimoroni_plasma2040": {"LED": 16, "SCL": 21, "SDA": 20}, + "pimoroni_plasma2350": { + "LED": 16, + "MISO": 31, + "MOSI": 31, + "RX": 31, + "SCK": 31, + "SCL": 21, + "SCL1": 31, + "SDA": 20, + "SDA1": 31, + "SS": 31, + "TX": 31, + }, + "pimoroni_plasma2350w": { + "LED": 16, + "MISO": 24, + "MOSI": 24, + "RX": 31, + "SCK": 29, + "SCL": 21, + "SCL1": 31, + "SDA": 20, + "SDA1": 31, + "SS": 25, + "TX": 31, + }, + "pimoroni_servo2040": {"LED": 18, "SCL": 21, "SDA": 20}, + "pimoroni_tiny2040": { + "LED": 19, + "MISO": 4, + "MOSI": 7, + "RX": 1, + "SCK": 6, + "SCL": 5, + "SCL1": 7, + "SDA": 4, + "SDA1": 6, + "SS": 17, + "TX": 0, + }, + "pimoroni_tiny2350": { + "LED": 19, + "MISO": 4, + "MOSI": 7, + "RX": 1, + "SCK": 6, + "SCL": 13, + "SCL1": 7, + "SDA": 12, + "SDA1": 6, + "SS": 5, + "TX": 0, + }, + "pintronix_pinmax": { + "LED": 27, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SDA": 4, + "SS": 17, + "TX": 0, + }, + "rakwireless_rak11300": { + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 3, + "SCL1": 21, + "SDA": 2, + "SDA1": 20, + "SS": 17, + "TX": 0, + }, + "rpipico": { + "LED": 25, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, + "rpipico2": { + "LED": 25, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, + "rpipico2w": { + "LED": 64, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, + "rpipicow": { + "LED": 64, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, + "sea_picro": { + "MISO": 20, + "MOSI": 23, + "RX": 1, + "SCK": 22, + "SCL": 5, + "SCL1": 3, + "SDA": 4, + "SDA1": 2, + "SS": 21, + "TX": 0, + }, + "seeed_indicator_rp2040": { + "MISO": 0, + "MOSI": 3, + "RX": 17, + "SCK": 2, + "SCL": 21, + "SCL1": 15, + "SDA": 20, + "SDA1": 14, + "SS": 1, + "TX": 16, + }, + "seeed_xiao_rp2040": { + "LED": 17, + "MISO": 4, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 7, + "SDA": 6, + "TX": 0, + }, + "seeed_xiao_rp2350": { + "LED": 25, + "MISO": 4, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 17, + "SCL1": 7, + "SDA": 16, + "SDA1": 6, + "SS": 5, + "TX": 0, + }, + "silicognition_rp2040_shim": { + "MISO": 12, + "MOSI": 11, + "RX": 1, + "SCK": 10, + "SCL": 17, + "SDA": 16, + "SS": 21, + "TX": 0, + }, + "soldered_nula_rp2350": { + "MISO": 2, + "MOSI": 3, + "RX": 1, + "SCK": 4, + "SCL": 9, + "SCL1": 31, + "SDA": 8, + "SDA1": 30, + "SS": 5, + "TX": 0, + }, + "solderparty_rp2040_stamp": { + "LED": 20, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 3, + "SDA": 4, + "SDA1": 2, + "SS": 17, + "TX": 0, + }, + "solderparty_rp2350_stamp": { + "LED": 3, + "MISO": 20, + "MOSI": 23, + "RX": 1, + "SCK": 22, + "SCL": 5, + "SCL1": 3, + "SDA": 4, + "SDA1": 2, + "SS": 21, + "TX": 0, + }, + "solderparty_rp2350_stamp_xl": { + "LED": 3, + "MISO": 20, + "MOSI": 23, + "RX": 1, + "SCK": 22, + "SCL": 5, + "SCL1": 3, + "SDA": 4, + "SDA1": 2, + "SS": 21, + "TX": 0, + }, + "sparkfun_iotnode_lorawanrp2350": { + "LED": 25, + "MISO": 12, + "MOSI": 15, + "RX": 19, + "SCK": 14, + "SCL": 21, + "SCL1": 31, + "SDA": 20, + "SDA1": 31, + "SS": 13, + "TX": 18, + }, + "sparkfun_iotredboard_rp2350": { + "LED": 25, + "MISO": 20, + "MOSI": 23, + "RX": 1, + "SCK": 22, + "SCL": 5, + "SCL1": 31, + "SDA": 4, + "SDA1": 30, + "SS": 21, + "TX": 0, + }, + "sparkfun_micromodrp2040": { + "LED": 25, + "MISO": 20, + "MOSI": 23, + "RX": 1, + "SCK": 22, + "SCL": 5, + "SDA": 4, + "SS": 21, + "TX": 0, + }, + "sparkfun_promicrorp2040": { + "LED": 25, + "MISO": 20, + "MOSI": 23, + "RX": 1, + "SCK": 22, + "SCL": 17, + "SDA": 16, + "SS": 21, + "TX": 0, + }, + "sparkfun_promicrorp2350": { + "LED": 25, + "MISO": 20, + "MOSI": 23, + "RX": 1, + "SCK": 22, + "SCL": 17, + "SCL1": 31, + "SDA": 16, + "SDA1": 31, + "SS": 21, + "TX": 0, + }, + "sparkfun_thingplusrp2040": { + "LED": 25, + "MISO": 4, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 17, + "SCL1": 7, + "SDA": 16, + "SDA1": 6, + "TX": 0, + }, + "sparkfun_thingplusrp2350": { + "LED": 64, + "MISO": 4, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 7, + "SCL1": 31, + "SDA": 6, + "SDA1": 31, + "SS": 9, + "TX": 0, + }, + "sparkfun_xrp_controller": { + "LED": 64, + "MISO": 16, + "MOSI": 19, + "RX": 13, + "SCK": 18, + "SCL": 5, + "SCL1": 39, + "SDA": 4, + "SDA1": 38, + "SS": 17, + "TX": 12, + }, + "sparkfun_xrp_controller_beta": {"LED": 64, "SCL": 19, "SDA": 18}, + "upesy_rp2040_devkit": { + "LED": 25, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 23, + "SDA": 4, + "SDA1": 22, + "SS": 17, + "TX": 0, + }, + "vccgnd_yd_rp2040": { + "LED": 25, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, + "vicharak_shrike-lite": { + "LED": 4, + "MISO": 20, + "MOSI": 19, + "RX": 17, + "SCK": 18, + "SCL": 25, + "SCL1": 7, + "SDA": 24, + "SDA1": 6, + "SS": 21, + "TX": 16, + }, + "viyalab_mizu": { + "LED": 25, + "MISO": 16, + "MOSI": 7, + "RX": 1, + "SCK": 6, + "SCL": 5, + "SCL1": 3, + "SDA": 4, + "SDA1": 2, + "SS": 17, + "TX": 0, + }, + "waveshare_rp2040_lcd_0_96": { + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 9, + "SCL1": 7, + "SDA": 8, + "SDA1": 6, + "SS": 17, + "TX": 0, + }, + "waveshare_rp2040_lcd_1_28": { + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 9, + "SCL1": 7, + "SDA": 8, + "SDA1": 6, + "SS": 17, + "TX": 0, + }, + "waveshare_rp2040_lora": { + "LED": 25, + "MISO": 4, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 5, + "SCL1": 7, + "SDA": 4, + "SDA1": 6, + "SS": 5, + "TX": 0, + }, + "waveshare_rp2040_matrix": { + "MISO": 4, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 5, + "TX": 0, + }, + "waveshare_rp2040_one": { + "MISO": 4, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 5, + "TX": 0, + }, + "waveshare_rp2040_pizero": { + "MISO": 20, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 21, + "TX": 0, + }, + "waveshare_rp2040_plus": { + "LED": 25, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 9, + "SCL1": 7, + "SDA": 8, + "SDA1": 6, + "SS": 17, + "TX": 0, + }, + "waveshare_rp2040_zero": { + "MISO": 4, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 5, + "TX": 0, + }, + "waveshare_rp2350_lcd_0_96": { + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 9, + "SCL1": 7, + "SDA": 8, + "SDA1": 6, + "SS": 17, + "TX": 0, + }, + "waveshare_rp2350_pizero": { + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, + "waveshare_rp2350_plus": { + "LED": 25, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 9, + "SCL1": 7, + "SDA": 8, + "SDA1": 6, + "SS": 17, + "TX": 0, + }, + "waveshare_rp2350_zero": { + "MISO": 4, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 5, + "TX": 0, + }, + "waveshare_rp2350b_plus_w": { + "LED": 23, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 9, + "SCL1": 7, + "SDA": 8, + "SDA1": 6, + "SS": 17, + "TX": 0, + }, + "wiznet_55rp20_evb_pico": { + "LED": 19, + "MISO": 2, + "MOSI": 3, + "RX": 1, + "SCK": 4, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 5, + "TX": 0, + }, + "wiznet_wizfi360_evb_pico": { + "LED": 25, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 9, + "SCL1": 27, + "SDA": 8, + "SDA1": 26, + "SS": 17, + "TX": 0, }, } BOARDS = { + "0xcb_helios": { + "name": "0xCB Helios", + "mcu": "rp2040", + "max_pin": 29, + }, + "DudesCab": { + "name": "L'atelier d'Arnoz DudesCab", + "mcu": "rp2040", + "max_pin": 29, + }, + "MyRP_2350B": { + "name": "MyMakers RP2350B", + "mcu": "rp2350", + "max_pin": 47, + }, + "MyRP_bot": { + "name": "MyMakers RP2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "adafruit_feather": { + "name": "Adafruit Feather RP2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "adafruit_feather_adalogger": { + "name": "Adafruit Feather RP2040 Adalogger", + "mcu": "rp2040", + "max_pin": 29, + }, + "adafruit_feather_can": { + "name": "Adafruit Feather RP2040 CAN", + "mcu": "rp2040", + "max_pin": 29, + }, + "adafruit_feather_dvi": { + "name": "Adafruit Feather RP2040 DVI", + "mcu": "rp2040", + "max_pin": 29, + }, + "adafruit_feather_prop_maker": { + "name": "Adafruit Feather RP2040 Prop-Maker", + "mcu": "rp2040", + "max_pin": 29, + }, + "adafruit_feather_rfm": { + "name": "Adafruit Feather RP2040 RFM", + "mcu": "rp2040", + "max_pin": 29, + }, + "adafruit_feather_rp2350_adalogger": { + "name": "Adafruit Feather RP2350 Adalogger", + "mcu": "rp2350", + "max_pin": 47, + }, + "adafruit_feather_rp2350_hstx": { + "name": "Adafruit Feather RP2350 HSTX", + "mcu": "rp2350", + "max_pin": 47, + }, + "adafruit_feather_scorpio": { + "name": "Adafruit Feather RP2040 SCORPIO", + "mcu": "rp2040", + "max_pin": 29, + }, + "adafruit_feather_thinkink": { + "name": "Adafruit Feather RP2040 ThinkINK", + "mcu": "rp2040", + "max_pin": 29, + }, + "adafruit_feather_usb_host": { + "name": "Adafruit Feather RP2040 USB Host", + "mcu": "rp2040", + "max_pin": 29, + }, + "adafruit_floppsy": { + "name": "Adafruit Floppsy", + "mcu": "rp2040", + "max_pin": 29, + }, + "adafruit_fruitjam": { + "name": "Adafruit Fruit Jam RP2350", + "mcu": "rp2350", + "max_pin": 47, + }, + "adafruit_itsybitsy": { + "name": "Adafruit ItsyBitsy RP2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "adafruit_kb2040": { + "name": "Adafruit KB2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "adafruit_macropad2040": { + "name": "Adafruit MacroPad RP2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "adafruit_metro": { + "name": "Adafruit Metro RP2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "adafruit_metro_rp2350": { + "name": "Adafruit Metro RP2350", + "mcu": "rp2350", + "max_pin": 47, + }, + "adafruit_qtpy": { + "name": "Adafruit QT Py RP2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "adafruit_stemmafriend": { + "name": "Adafruit STEMMA Friend RP2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "adafruit_trinkeyrp2040qt": { + "name": "Adafruit Trinkey RP2040 QT", + "mcu": "rp2040", + "max_pin": 29, + }, + "akana_r1": { + "name": "METE HOCA Akana R1", + "mcu": "rp2040", + "max_pin": 29, + }, + "amken_bunny": { + "name": "Amken BunnyBoard", + "mcu": "rp2040", + "max_pin": 29, + }, + "amken_revelop": { + "name": "Amken Revelop", + "mcu": "rp2040", + "max_pin": 29, + }, + "amken_revelop_es": { + "name": "Amken Revelop eS", + "mcu": "rp2040", + "max_pin": 29, + }, + "amken_revelop_plus": { + "name": "Amken Revelop Plus", + "mcu": "rp2040", + "max_pin": 29, + }, + "arduino_nano_connect": { + "name": "Arduino Nano RP2040 Connect", + "mcu": "rp2040", + "max_pin": 29, + }, + "artronshop_rp2_nano": { + "name": "ArtronShop RP2 Nano", + "mcu": "rp2040", + "max_pin": 29, + }, + "bigtreetech_SKR_Pico": { + "name": "BIGTREETECH SKR-Pico", + "mcu": "rp2040", + "max_pin": 29, + }, + "breadstick_raspberry": { + "name": "Breadstick Raspberry", + "mcu": "rp2040", + "max_pin": 29, + }, + "bridgetek_idm2040_43a": { + "name": "BridgeTek IDM2040-43A", + "mcu": "rp2040", + "max_pin": 29, + }, + "bridgetek_idm2040_7a": { + "name": "BridgeTek IDM2040-7A", + "mcu": "rp2040", + "max_pin": 29, + }, + "challenger_2040_lora": { + "name": "iLabs Challenger 2040 LoRa", + "mcu": "rp2040", + "max_pin": 29, + }, + "challenger_2040_lte": { + "name": "iLabs Challenger 2040 LTE", + "mcu": "rp2040", + "max_pin": 29, + }, + "challenger_2040_nfc": { + "name": "iLabs Challenger 2040 NFC", + "mcu": "rp2040", + "max_pin": 29, + }, + "challenger_2040_sdrtc": { + "name": "iLabs Challenger 2040 SD/RTC", + "mcu": "rp2040", + "max_pin": 29, + }, + "challenger_2040_subghz": { + "name": "iLabs Challenger 2040 SubGHz", + "mcu": "rp2040", + "max_pin": 29, + }, + "challenger_2040_uwb": { + "name": "iLabs Challenger 2040 UWB", + "mcu": "rp2040", + "max_pin": 29, + }, + "challenger_2040_wifi": { + "name": "iLabs Challenger 2040 WiFi", + "mcu": "rp2040", + "max_pin": 29, + }, + "challenger_2040_wifi6_ble": { + "name": "iLabs Challenger 2040 WiFi6/BLE", + "mcu": "rp2040", + "max_pin": 29, + }, + "challenger_2040_wifi_ble": { + "name": "iLabs Challenger 2040 WiFi/BLE", + "mcu": "rp2040", + "max_pin": 29, + }, + "challenger_2350_bconnect": { + "name": "iLabs Challenger 2350 BConnect", + "mcu": "rp2350", + "max_pin": 47, + }, + "challenger_2350_wifi6_ble5": { + "name": "iLabs Challenger 2350 WiFi/BLE", + "mcu": "rp2350", + "max_pin": 47, + }, + "challenger_nb_2040_wifi": { + "name": "iLabs Challenger NB 2040 WiFi", + "mcu": "rp2040", + "max_pin": 29, + }, + "connectivity_2040_lte_wifi_ble": { + "name": "iLabs Connectivity 2040 LTE/WiFi/BLE", + "mcu": "rp2040", + "max_pin": 29, + }, + "cytron_iriv_io_controller": { + "name": "Cytron IRIV IO Controller", + "mcu": "rp2350", + "max_pin": 47, + }, + "cytron_maker_nano_rp2040": { + "name": "Cytron Maker Nano RP2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "cytron_maker_pi_rp2040": { + "name": "Cytron Maker Pi RP2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "cytron_maker_uno_rp2040": { + "name": "Cytron Maker Uno RP2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "cytron_motion_2350_pro": { + "name": "Cytron Motion 2350 Pro", + "mcu": "rp2350", + "max_pin": 47, + }, + "datanoisetv_picoadk": { + "name": "DatanoiseTV PicoADK", + "mcu": "rp2040", + "max_pin": 29, + }, + "datanoisetv_picoadk_v2": { + "name": "DatanoiseTV PicoADK v2", + "mcu": "rp2350", + "max_pin": 47, + }, + "degz_suibo": { + "name": "Degz Robotics Suibo RP2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "dfrobot_beetle_rp2040": { + "name": "DFRobot Beetle RP2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "electroniccats_huntercat_nfc": { + "name": "ElectronicCats HunterCat NFC RP2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "evn_alpha": { + "name": "EVN Alpha", + "mcu": "rp2040", + "max_pin": 29, + }, + "extelec_rc2040": { + "name": "ExtremeElectronics RC2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "flyboard2040_core": { + "name": "DeRuiLab FlyBoard2040Core", + "mcu": "rp2040", + "max_pin": 29, + }, + "geeekpi_rp2040_plus": { + "name": "GeeekPi RP2040 Plus", + "mcu": "rp2040", + "max_pin": 29, + }, + "generic": { + "name": "Generic RP2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "generic_rp2350": { + "name": "Generic RP2350", + "mcu": "rp2350", + "max_pin": 47, + }, + "groundstudio_marble_pico": { + "name": "GroundStudio Marble Pico", + "mcu": "rp2040", + "max_pin": 29, + }, + "ilabs_rpico32": { + "name": "iLabs RPICO32", + "mcu": "rp2040", + "max_pin": 29, + }, + "jumperless_v1": { + "name": "Architeuthis Flux Jumperless", + "mcu": "rp2040", + "max_pin": 29, + }, + "jumperless_v5": { + "name": "Architeuthis Flux Jumperless V5", + "mcu": "rp2350", + "max_pin": 47, + }, + "melopero_cookie_rp2040": { + "name": "Melopero Cookie RP2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "melopero_shake_rp2040": { + "name": "Melopero Shake RP2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "mksthr36": { + "name": "Makerbase MKS THR36", + "mcu": "rp2040", + "max_pin": 29, + }, + "mksthr42": { + "name": "Makerbase MKS THR42", + "mcu": "rp2040", + "max_pin": 29, + }, + "nekosystems_bl2040_mini": { + "name": "Neko Systems BL2040 Mini", + "mcu": "rp2040", + "max_pin": 29, + }, + "newsan_archi": { + "name": "Newsan Archi", + "mcu": "rp2040", + "max_pin": 29, + }, + "nullbits_bit_c_pro": { + "name": "nullbits Bit-C PRO", + "mcu": "rp2040", + "max_pin": 29, + }, + "olimex_pico2bb48": { + "name": "Olimex Pico2BB48", + "mcu": "rp2350", + "max_pin": 47, + }, + "olimex_pico2xl": { + "name": "Olimex Pico2XL", + "mcu": "rp2350", + "max_pin": 47, + }, + "olimex_pico2xxl": { + "name": "Olimex Pico2XXL", + "mcu": "rp2350", + "max_pin": 47, + }, + "olimex_rp2040pico30": { + "name": "Olimex RP2040-Pico30", + "mcu": "rp2040", + "max_pin": 29, + }, + "picolume": { + "name": "PicoLume Transceiver", + "mcu": "rp2040", + "max_pin": 29, + }, + "pimoroni_explorer": { + "name": "Pimoroni Explorer", + "mcu": "rp2350", + "max_pin": 47, + }, + "pimoroni_pga2040": { + "name": "Pimoroni PGA2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "pimoroni_pga2350": { + "name": "Pimoroni PGA2350", + "mcu": "rp2350", + "max_pin": 47, + }, + "pimoroni_pico_plus_2": { + "name": "Pimoroni PicoPlus2", + "mcu": "rp2350", + "max_pin": 47, + }, + "pimoroni_pico_plus_2w": { + "name": "Pimoroni PicoPlus2W", + "mcu": "rp2350", + "max_pin": 47, + "max_virtual_pin": 64, + }, + "pimoroni_plasma2040": { + "name": "Pimoroni Plasma2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "pimoroni_plasma2350": { + "name": "Pimoroni Plasma2350", + "mcu": "rp2350", + "max_pin": 47, + }, + "pimoroni_plasma2350w": { + "name": "Pimoroni Plasma2350W", + "mcu": "rp2350", + "max_pin": 47, + }, + "pimoroni_servo2040": { + "name": "Pimoroni Servo2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "pimoroni_tiny2040": { + "name": "Pimoroni Tiny2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "pimoroni_tiny2350": { + "name": "Pimoroni Tiny2350", + "mcu": "rp2350", + "max_pin": 47, + }, + "pintronix_pinmax": { + "name": "Pintronix PinMax", + "mcu": "rp2040", + "max_pin": 29, + }, + "rakwireless_rak11300": { + "name": "RAKwireless RAK11300", + "mcu": "rp2040", + "max_pin": 29, + }, + "redscorp_rp2040_eins": { + "name": "redscorp RP2040-Eins", + "mcu": "rp2040", + "max_pin": 29, + }, + "redscorp_rp2040_promini": { + "name": "redscorp RP2040-ProMini", + "mcu": "rp2040", + "max_pin": 29, + }, "rpipico": { "name": "Raspberry Pi Pico", + "mcu": "rp2040", + "max_pin": 29, + }, + "rpipico2": { + "name": "Raspberry Pi Pico 2", + "mcu": "rp2350", + "max_pin": 47, + }, + "rpipico2w": { + "name": "Raspberry Pi Pico 2W", + "mcu": "rp2350", + "max_pin": 47, + "max_virtual_pin": 64, }, "rpipicow": { "name": "Raspberry Pi Pico W", + "mcu": "rp2040", + "max_pin": 29, + "max_virtual_pin": 64, + }, + "sea_picro": { + "name": "Generic Sea-Picro", + "mcu": "rp2040", + "max_pin": 29, + }, + "seeed_indicator_rp2040": { + "name": "Seeed INDICATOR RP2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "seeed_xiao_rp2040": { + "name": "Seeed XIAO RP2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "seeed_xiao_rp2350": { + "name": "Seeed XIAO RP2350", + "mcu": "rp2350", + "max_pin": 47, + }, + "silicognition_rp2040_shim": { + "name": "Silicognition RP2040-Shim", + "mcu": "rp2040", + "max_pin": 29, + }, + "soldered_nula_rp2350": { + "name": "Soldered Electronics NULA RP2350", + "mcu": "rp2350", + "max_pin": 47, + }, + "solderparty_rp2040_stamp": { + "name": "Solder Party RP2040 Stamp", + "mcu": "rp2040", + "max_pin": 29, + }, + "solderparty_rp2350_stamp": { + "name": "Solder Party RP2350 Stamp", + "mcu": "rp2350", + "max_pin": 47, + }, + "solderparty_rp2350_stamp_xl": { + "name": "Solder Party RP2350 Stamp XL", + "mcu": "rp2350", + "max_pin": 47, + }, + "sparkfun_iotnode_lorawanrp2350": { + "name": "SparkFun IoT Node LoRaWAN", + "mcu": "rp2350", + "max_pin": 47, + }, + "sparkfun_iotredboard_rp2350": { + "name": "SparkFun IoT RedBoard RP2350", + "mcu": "rp2350", + "max_pin": 47, + }, + "sparkfun_micromodrp2040": { + "name": "SparkFun MicroMod RP2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "sparkfun_promicrorp2040": { + "name": "SparkFun ProMicro RP2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "sparkfun_promicrorp2350": { + "name": "SparkFun ProMicro RP2350", + "mcu": "rp2350", + "max_pin": 47, + }, + "sparkfun_thingplusrp2040": { + "name": "SparkFun Thing Plus RP2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "sparkfun_thingplusrp2350": { + "name": "SparkFun Thing Plus RP2350", + "mcu": "rp2350", + "max_pin": 47, + "max_virtual_pin": 64, + }, + "sparkfun_xrp_controller": { + "name": "SparkFun XRP Controller", + "mcu": "rp2350", + "max_pin": 47, + "max_virtual_pin": 64, + }, + "sparkfun_xrp_controller_beta": { + "name": "SparkFun XRP Controller (Beta)", + "mcu": "rp2040", + "max_pin": 29, + "max_virtual_pin": 64, + }, + "upesy_rp2040_devkit": { + "name": "uPesy RP2040 DevKit", + "mcu": "rp2040", + "max_pin": 29, + }, + "vccgnd_yd_rp2040": { + "name": "VCC-GND YD RP2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "vicharak_shrike-lite": { + "name": "Vicharak Shrike-Lite", + "mcu": "rp2040", + "max_pin": 29, + }, + "viyalab_mizu": { + "name": "Viyalab Mizu RP2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "waveshare_rp2040_lcd_0_96": { + "name": "Waveshare RP2040 LCD 0.96", + "mcu": "rp2040", + "max_pin": 29, + }, + "waveshare_rp2040_lcd_1_28": { + "name": "Waveshare RP2040 LCD 1.28", + "mcu": "rp2040", + "max_pin": 29, + }, + "waveshare_rp2040_lora": { + "name": "Waveshare RP2040 LoRa", + "mcu": "rp2040", + "max_pin": 29, + }, + "waveshare_rp2040_matrix": { + "name": "Waveshare RP2040 Matrix", + "mcu": "rp2040", + "max_pin": 29, + }, + "waveshare_rp2040_one": { + "name": "Waveshare RP2040 One", + "mcu": "rp2040", + "max_pin": 29, + }, + "waveshare_rp2040_pizero": { + "name": "Waveshare RP2040 PiZero", + "mcu": "rp2040", + "max_pin": 29, + }, + "waveshare_rp2040_plus": { + "name": "Waveshare RP2040 Plus", + "mcu": "rp2040", + "max_pin": 29, + }, + "waveshare_rp2040_zero": { + "name": "Waveshare RP2040 Zero", + "mcu": "rp2040", + "max_pin": 29, + }, + "waveshare_rp2350_lcd_0_96": { + "name": "Waveshare RP2350 LCD 0.96", + "mcu": "rp2350", + "max_pin": 47, + }, + "waveshare_rp2350_pizero": { + "name": "Waveshare RP2350 PiZero", + "mcu": "rp2350", + "max_pin": 47, + }, + "waveshare_rp2350_plus": { + "name": "Waveshare RP2350 Plus", + "mcu": "rp2350", + "max_pin": 47, + }, + "waveshare_rp2350_zero": { + "name": "Waveshare RP2350 Zero", + "mcu": "rp2350", + "max_pin": 47, + }, + "waveshare_rp2350b_plus_w": { + "name": "Waveshare RP2350B Plus W", + "mcu": "rp2350", + "max_pin": 47, + }, + "wiznet_5100s_evb_pico": { + "name": "WIZnet W5100S-EVB-Pico", + "mcu": "rp2040", + "max_pin": 29, + }, + "wiznet_5100s_evb_pico2": { + "name": "WIZnet W5100S-EVB-Pico2", + "mcu": "rp2350", + "max_pin": 47, + }, + "wiznet_5500_evb_pico": { + "name": "WIZnet W5500-EVB-Pico", + "mcu": "rp2040", + "max_pin": 29, + }, + "wiznet_5500_evb_pico2": { + "name": "WIZnet W5500-EVB-Pico2", + "mcu": "rp2350", + "max_pin": 47, + }, + "wiznet_55rp20_evb_pico": { + "name": "WIZnet W55RP20-EVB-Pico", + "mcu": "rp2040", + "max_pin": 29, + }, + "wiznet_6300_evb_pico": { + "name": "WIZnet W6300-EVB-Pico", + "mcu": "rp2040", + "max_pin": 29, + }, + "wiznet_6300_evb_pico2": { + "name": "WIZnet W6300-EVB-Pico2", + "mcu": "rp2350", + "max_pin": 47, + }, + "wiznet_wizfi360_evb_pico": { + "name": "WIZnet WizFi360-EVB-Pico", + "mcu": "rp2040", + "max_pin": 29, }, } diff --git a/esphome/components/rp2040/generate_boards.py b/esphome/components/rp2040/generate_boards.py new file mode 100644 index 00000000000..a0e3699f37b --- /dev/null +++ b/esphome/components/rp2040/generate_boards.py @@ -0,0 +1,186 @@ +"""Generate boards.py from arduino-pico board definitions. + +Usage: python esphome/components/rp2040/generate_boards.py <arduino-pico-path> +""" + +import json +from pathlib import Path +import re +import sys + +from jinja2 import Environment, FileSystemLoader + +# Map arduino-pico pin defines to ESPHome-friendly names +PIN_NAME_MAP = { + "LED": "LED", + "WIRE0_SDA": "SDA", + "WIRE0_SCL": "SCL", + "WIRE1_SDA": "SDA1", + "WIRE1_SCL": "SCL1", + "SPI0_MISO": "MISO", + "SPI0_MOSI": "MOSI", + "SPI0_SCK": "SCK", + "SPI0_SS": "SS", + "SERIAL1_TX": "TX", + "SERIAL1_RX": "RX", +} + +# arduino-pico maps pins >= 64 to CYW43 wireless chip GPIOs (pin - 64) +CYW43_GPIO_OFFSET = 64 +# CYW43 has 3 GPIOs: 0=LED, 1=VBUS_SENSE, 2=REG_ON +CYW43_GPIO_COUNT = 3 + +# Max GPIO pin per MCU (hardware specs from datasheets) +MCU_MAX_PIN = { + "rp2040": 29, # GPIO 0-29 + "rp2350": 47, # GPIO 0-47 (RP2350A) +} +DEFAULT_MAX_PIN = 29 + +PIN_DEFINE_RE = re.compile(r"#define\s+PIN_(\w+)\s+\((\d+)u\)") + + +def parse_variant_pins(variant_dir: Path) -> dict[str, int]: + """Parse pins_arduino.h and return mapped pin names.""" + header = variant_dir / "pins_arduino.h" + if not header.exists(): + return {} + + pins = {} + for match in PIN_DEFINE_RE.finditer(header.read_text(encoding="utf-8")): + raw_name = match.group(1) + value = int(match.group(2)) + if raw_name in PIN_NAME_MAP: + pins[PIN_NAME_MAP[raw_name]] = value + return pins + + +def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]: + """Load all board definitions and return (board_pins, boards) dicts.""" + json_dir = arduino_pico_path / "tools" / "json" + variants_dir = arduino_pico_path / "variants" + + board_pins = {} + boards = {} + variant_pins_cache: dict[str, dict[str, int]] = {} + + for json_file in sorted(json_dir.glob("*.json")): + board_name = json_file.stem + with open(json_file, encoding="utf-8") as f: + data = json.load(f) + + build = data.get("build", {}) + mcu = build.get("mcu", "rp2040") + variant = build.get("variant", board_name) + name = data.get("name", board_name) + vendor = data.get("vendor", "") + + display_name = f"{vendor} {name}".strip() if vendor else name + + boards[board_name] = { + "name": display_name, + "mcu": mcu, + "max_pin": MCU_MAX_PIN.get(mcu, DEFAULT_MAX_PIN), + } + + # Get pins for this variant + if variant not in variant_pins_cache: + variant_dir = variants_dir / variant + variant_pins_cache[variant] = parse_variant_pins(variant_dir) + + pins = variant_pins_cache[variant] + if pins: + max_pin = boards[board_name]["max_pin"] + cyw43_max = CYW43_GPIO_OFFSET + CYW43_GPIO_COUNT - 1 + # Filter out placeholder values (e.g. 99 = "not connected") + filtered = { + name: value + for name, value in pins.items() + if value <= max_pin or CYW43_GPIO_OFFSET <= value <= cyw43_max + } + if filtered: + board_pins[board_name] = filtered + + # Compute max_virtual_pin per board from pin maps + for board_name, pins in board_pins.items(): + if isinstance(pins, str): + continue + virtual_pins = [v for v in pins.values() if v >= CYW43_GPIO_OFFSET] + if virtual_pins and board_name in boards: + boards[board_name]["max_virtual_pin"] = max(virtual_pins) + + # Deduplicate: if board pins match its variant's pins, use string alias + for board_name in list(board_pins.keys()): + if board_name not in boards: + continue + build_variant = _get_variant(json_dir / f"{board_name}.json") + if ( + build_variant + and build_variant != board_name + and build_variant in board_pins + and board_pins[board_name] == board_pins[build_variant] + ): + board_pins[board_name] = build_variant + + return board_pins, boards + + +def _get_variant(json_file: Path) -> str | None: + """Get variant name from a board JSON file.""" + if not json_file.exists(): + return None + with open(json_file, encoding="utf-8") as f: + data = json.load(f) + return data.get("build", {}).get("variant") + + +_TEMPLATE_DIR = Path(__file__).parent + + +def _format_pins(pins: dict[str, int] | str) -> str: + """Jinja2 filter to format a pin dict or alias as Python source.""" + if isinstance(pins, str): + return repr(pins) + items = ", ".join(f"{k!r}: {v}" for k, v in sorted(pins.items())) + return f"{{{items}}}" + + +_jinja_env = Environment( + loader=FileSystemLoader(_TEMPLATE_DIR), keep_trailing_newline=True +) +_jinja_env.filters["format_pins"] = _format_pins +_jinja_env.filters["repr"] = repr + + +def generate(arduino_pico_path: Path) -> str: + """Generate boards.py content.""" + board_pins, boards = load_boards(arduino_pico_path) + + template = _jinja_env.get_template("boards.jinja2") + return template.render( + cyw43_gpio_offset=CYW43_GPIO_OFFSET, + cyw43_max_gpio=CYW43_GPIO_OFFSET + CYW43_GPIO_COUNT - 1, + default_max_pin=DEFAULT_MAX_PIN, + board_pins=sorted(board_pins.items()), + boards=sorted(boards.items()), + ) + + +def main(): + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} <arduino-pico-path>", file=sys.stderr) + sys.exit(1) + + arduino_pico_path = Path(sys.argv[1]) + if not (arduino_pico_path / "tools" / "json").exists(): + print(f"Error: {arduino_pico_path}/tools/json not found", file=sys.stderr) + sys.exit(1) + + output = generate(arduino_pico_path) + output_file = Path(__file__).parent / "boards.py" + output_file.write_text(output, encoding="utf-8") + print(f"Generated {output_file}") + + +if __name__ == "__main__": + main() diff --git a/esphome/components/rp2040/gpio.py b/esphome/components/rp2040/gpio.py index 193e567d173..18fb09f76a4 100644 --- a/esphome/components/rp2040/gpio.py +++ b/esphome/components/rp2040/gpio.py @@ -54,19 +54,29 @@ def _translate_pin(value): return _lookup_pin(value) +def _board_max_virtual_pin(board): + """Get the max CYW43 virtual pin for this board, or None if no virtual pins.""" + return boards.BOARDS.get(board, {}).get("max_virtual_pin") + + def validate_gpio_pin(value): value = _translate_pin(value) board = CORE.data[KEY_RP2040][KEY_BOARD] - if board == "rpipicow" and value == 32: - return value # Special case for Pico-w LED pin - if value < 0 or value > 29: - raise cv.Invalid(f"RP2040: Invalid pin number: {value}") + max_virtual = _board_max_virtual_pin(board) + if max_virtual is not None and boards.CYW43_GPIO_OFFSET <= value <= max_virtual: + return value + max_pin = boards.BOARDS.get(board, {}).get("max_pin", boards.DEFAULT_MAX_PIN) + if value < 0 or value > max_pin: + raise cv.Invalid(f"Invalid pin number: {value} (max {max_pin} for this board)") return value def validate_supports(value): board = CORE.data[KEY_RP2040][KEY_BOARD] - if board != "rpipicow" or value[CONF_NUMBER] != 32: + if ( + _board_max_virtual_pin(board) is None + or value[CONF_NUMBER] < boards.CYW43_GPIO_OFFSET + ): return value mode = value[CONF_MODE] is_input = mode[CONF_INPUT] @@ -75,7 +85,7 @@ def validate_supports(value): is_pullup = mode[CONF_PULLUP] is_pulldown = mode[CONF_PULLDOWN] if not is_output or is_input or is_open_drain or is_pullup or is_pulldown: - raise cv.Invalid("Only output mode is supported for Pico-w LED pin") + raise cv.Invalid("Only output mode is supported for CYW43 virtual pins") return value diff --git a/tests/unit_tests/components/test_rp2040_generate_boards.py b/tests/unit_tests/components/test_rp2040_generate_boards.py new file mode 100644 index 00000000000..2e40ed08ba1 --- /dev/null +++ b/tests/unit_tests/components/test_rp2040_generate_boards.py @@ -0,0 +1,273 @@ +"""Tests for rp2040 generate_boards.py.""" + +from __future__ import annotations + +import json +from pathlib import Path +import textwrap + +import pytest + +from esphome.components.rp2040.generate_boards import load_boards, parse_variant_pins + +PICO_PINS_HEADER = textwrap.dedent("""\ + #pragma once + #define PIN_LED (25u) + #define PIN_SERIAL1_TX (0u) + #define PIN_SERIAL1_RX (1u) + #define PIN_WIRE0_SDA (4u) + #define PIN_WIRE0_SCL (5u) + #define PIN_WIRE1_SDA (26u) + #define PIN_WIRE1_SCL (27u) + #define PIN_SPI0_MISO (16u) + #define PIN_SPI0_MOSI (19u) + #define PIN_SPI0_SCK (18u) + #define PIN_SPI0_SS (17u) + #include "../generic/common.h" +""") + +PICOW_PINS_HEADER = textwrap.dedent("""\ + #pragma once + #include <cyw43_wrappers.h> + #define PIN_LED (64u) + #define PIN_WIRE0_SDA (4u) + #define PIN_WIRE0_SCL (5u) + #include "../generic/common.h" +""") + + +@pytest.fixture() +def arduino_pico(tmp_path: Path) -> Path: + """Create a minimal arduino-pico directory structure.""" + json_dir = tmp_path / "tools" / "json" + json_dir.mkdir(parents=True) + variants_dir = tmp_path / "variants" + variants_dir.mkdir() + + generic_dir = variants_dir / "generic" + generic_dir.mkdir() + (generic_dir / "common.h").write_text("#pragma once\n") + + return tmp_path + + +def _add_board( + arduino_pico: Path, + board_name: str, + mcu: str = "rp2040", + variant: str | None = None, + vendor: str = "", + name: str | None = None, + pins_header: str | None = None, +) -> None: + """Add a board JSON and variant to the fake arduino-pico tree.""" + if variant is None: + variant = board_name + if name is None: + name = board_name + + json_dir = arduino_pico / "tools" / "json" + variants_dir = arduino_pico / "variants" + + board_json = { + "build": { + "mcu": mcu, + "variant": variant, + }, + "name": name, + "vendor": vendor, + } + (json_dir / f"{board_name}.json").write_text(json.dumps(board_json)) + + variant_dir = variants_dir / variant + variant_dir.mkdir(exist_ok=True) + if pins_header is not None: + (variant_dir / "pins_arduino.h").write_text(pins_header) + + +def test_parse_basic_pins(tmp_path: Path) -> None: + variant_dir = tmp_path / "rpipico" + variant_dir.mkdir() + (variant_dir / "pins_arduino.h").write_text(PICO_PINS_HEADER) + + pins = parse_variant_pins(variant_dir) + assert pins["LED"] == 25 + assert pins["SDA"] == 4 + assert pins["SCL"] == 5 + assert pins["SDA1"] == 26 + assert pins["SCL1"] == 27 + assert pins["MISO"] == 16 + assert pins["MOSI"] == 19 + assert pins["SCK"] == 18 + assert pins["SS"] == 17 + assert pins["TX"] == 0 + assert pins["RX"] == 1 + + +def test_parse_cyw43_led_pin(tmp_path: Path) -> None: + variant_dir = tmp_path / "rpipicow" + variant_dir.mkdir() + (variant_dir / "pins_arduino.h").write_text(PICOW_PINS_HEADER) + + pins = parse_variant_pins(variant_dir) + assert pins["LED"] == 64 + + +def test_parse_missing_header(tmp_path: Path) -> None: + variant_dir = tmp_path / "noheader" + variant_dir.mkdir() + assert parse_variant_pins(variant_dir) == {} + + +def test_parse_unmapped_defines_ignored(tmp_path: Path) -> None: + variant_dir = tmp_path / "custom" + variant_dir.mkdir() + (variant_dir / "pins_arduino.h").write_text( + "#define PIN_NEOPIXEL (16u)\n#define PIN_LED (25u)\n" + ) + + pins = parse_variant_pins(variant_dir) + assert "NEOPIXEL" not in pins + assert pins["LED"] == 25 + + +def test_load_basic_board(arduino_pico: Path) -> None: + _add_board( + arduino_pico, + "rpipico", + vendor="Raspberry Pi", + name="Pico", + pins_header=PICO_PINS_HEADER, + ) + + board_pins, boards = load_boards(arduino_pico) + + assert "rpipico" in boards + assert boards["rpipico"]["name"] == "Raspberry Pi Pico" + assert boards["rpipico"]["mcu"] == "rp2040" + assert boards["rpipico"]["max_pin"] == 29 + + assert "rpipico" in board_pins + assert board_pins["rpipico"]["LED"] == 25 + assert board_pins["rpipico"]["SDA"] == 4 + + +def test_load_rp2350_board(arduino_pico: Path) -> None: + _add_board( + arduino_pico, + "rpipico2", + mcu="rp2350", + vendor="Raspberry Pi", + name="Pico 2", + pins_header=PICO_PINS_HEADER, + ) + + _, boards = load_boards(arduino_pico) + + assert boards["rpipico2"]["mcu"] == "rp2350" + assert boards["rpipico2"]["max_pin"] == 47 + + +def test_cyw43_board_has_max_virtual_pin(arduino_pico: Path) -> None: + _add_board( + arduino_pico, + "rpipicow", + vendor="Raspberry Pi", + name="Pico W", + pins_header=PICOW_PINS_HEADER, + ) + + _, boards = load_boards(arduino_pico) + + assert boards["rpipicow"]["max_virtual_pin"] == 64 + + +def test_non_cyw43_board_has_no_max_virtual_pin(arduino_pico: Path) -> None: + _add_board( + arduino_pico, + "rpipico", + vendor="Raspberry Pi", + name="Pico", + pins_header=PICO_PINS_HEADER, + ) + + _, boards = load_boards(arduino_pico) + + assert "max_virtual_pin" not in boards["rpipico"] + + +def test_board_without_variant_header(arduino_pico: Path) -> None: + _add_board(arduino_pico, "novariant", name="No Variant") + + board_pins, boards = load_boards(arduino_pico) + + assert "novariant" in boards + assert "novariant" not in board_pins + + +def test_shared_variant_deduplicates(arduino_pico: Path) -> None: + """Two boards sharing the same variant should alias.""" + _add_board(arduino_pico, "base_board", pins_header=PICO_PINS_HEADER) + _add_board(arduino_pico, "alias_board", variant="base_board") + + board_pins, _ = load_boards(arduino_pico) + + assert board_pins["base_board"] == parse_variant_pins( + arduino_pico / "variants" / "base_board" + ) + assert board_pins["alias_board"] == "base_board" + + +def test_display_name_with_vendor(arduino_pico: Path) -> None: + _add_board(arduino_pico, "testboard", vendor="Acme", name="Widget") + _, boards = load_boards(arduino_pico) + assert boards["testboard"]["name"] == "Acme Widget" + + +def test_display_name_without_vendor(arduino_pico: Path) -> None: + _add_board(arduino_pico, "testboard", vendor="", name="Widget") + _, boards = load_boards(arduino_pico) + assert boards["testboard"]["name"] == "Widget" + + +def test_unknown_mcu_gets_default_max_pin(arduino_pico: Path) -> None: + _add_board(arduino_pico, "future", mcu="rp2450", pins_header=PICO_PINS_HEADER) + _, boards = load_boards(arduino_pico) + assert boards["future"]["max_pin"] == 29 + + +def test_placeholder_pins_filtered_out(arduino_pico: Path) -> None: + """Pins with placeholder values like 99 should be filtered out.""" + header = textwrap.dedent("""\ + #pragma once + #define PIN_LED (25u) + #define PIN_WIRE0_SDA (4u) + #define PIN_WIRE0_SCL (5u) + #define PIN_WIRE1_SDA (99u) + #define PIN_WIRE1_SCL (99u) + """) + _add_board(arduino_pico, "placeholder", pins_header=header) + + board_pins, boards = load_boards(arduino_pico) + + assert "SDA1" not in board_pins["placeholder"] + assert "SCL1" not in board_pins["placeholder"] + assert board_pins["placeholder"]["LED"] == 25 + assert "max_virtual_pin" not in boards["placeholder"] + + +def test_placeholder_pins_not_treated_as_virtual(arduino_pico: Path) -> None: + """Pin 99 should not cause max_virtual_pin to be set.""" + header = textwrap.dedent("""\ + #pragma once + #define PIN_LED (64u) + #define PIN_WIRE0_SDA (4u) + #define PIN_WIRE0_SCL (5u) + #define PIN_SPI0_MISO (99u) + """) + _add_board(arduino_pico, "badpin", pins_header=header) + + board_pins, boards = load_boards(arduino_pico) + + assert "MISO" not in board_pins["badpin"] + assert boards["badpin"]["max_virtual_pin"] == 64 From 82629c397f699af8b263e66e7cc904bebcc297d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" <nick@koston.org> Date: Fri, 6 Mar 2026 07:01:50 -1000 Subject: [PATCH 09/14] [hlk_fm22x] Fix oversized response rejection breaking GET_ALL_FACE_IDS (#14506) --- esphome/components/hlk_fm22x/hlk_fm22x.cpp | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.cpp b/esphome/components/hlk_fm22x/hlk_fm22x.cpp index 18d26f057a8..7c7c8782dee 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.cpp +++ b/esphome/components/hlk_fm22x/hlk_fm22x.cpp @@ -133,24 +133,22 @@ void HlkFm22xComponent::recv_command_() { checksum ^= byte; length |= byte; - if (length > HLK_FM22X_MAX_RESPONSE_SIZE) { - ESP_LOGE(TAG, "Response too large: %u bytes", length); - // Discard exactly the remaining payload and checksum for this frame - for (uint16_t i = 0; i < length + 1 && this->available() > 0; ++i) - this->read(); - return; - } - + // Read up to buffer size; discard excess bytes while still computing checksum + // GET_ALL_FACE_IDS can return all enrolled face data (hundreds of bytes) + // but handlers only need the first few bytes + size_t to_store = std::min(static_cast<size_t>(length), HLK_FM22X_MAX_RESPONSE_SIZE); for (uint16_t idx = 0; idx < length; ++idx) { byte = this->read(); checksum ^= byte; - this->recv_buf_[idx] = byte; + if (idx < to_store) { + this->recv_buf_[idx] = byte; + } } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_pretty_size(HLK_FM22X_MAX_RESPONSE_SIZE)]; ESP_LOGV(TAG, "Recv type: 0x%.2X, data: %s", response_type, - format_hex_pretty_to(hex_buf, this->recv_buf_.data(), length)); + format_hex_pretty_to(hex_buf, this->recv_buf_.data(), to_store)); #endif byte = this->read(); @@ -160,10 +158,10 @@ void HlkFm22xComponent::recv_command_() { } switch (response_type) { case HlkFm22xResponseType::NOTE: - this->handle_note_(this->recv_buf_.data(), length); + this->handle_note_(this->recv_buf_.data(), to_store); break; case HlkFm22xResponseType::REPLY: - this->handle_reply_(this->recv_buf_.data(), length); + this->handle_reply_(this->recv_buf_.data(), to_store); break; default: ESP_LOGW(TAG, "Unexpected response type: 0x%.2X", response_type); From 6e3bc7b1ddb5b8ac91ecf0653087dc835264ca8c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" <nick@koston.org> Date: Fri, 6 Mar 2026 07:33:05 -1000 Subject: [PATCH 10/14] [ci] Use pull_request_target for codeowner approved label workflow (#14561) --- .github/scripts/codeowners.js | 2 +- .../codeowner-approved-label-update.yml | 63 +++++---------- .../workflows/codeowner-approved-label.yml | 78 ------------------- 3 files changed, 21 insertions(+), 122 deletions(-) delete mode 100644 .github/workflows/codeowner-approved-label.yml diff --git a/.github/scripts/codeowners.js b/.github/scripts/codeowners.js index 5d69c11b1a2..9b2f2922c01 100644 --- a/.github/scripts/codeowners.js +++ b/.github/scripts/codeowners.js @@ -2,7 +2,7 @@ // // Used by: // - codeowner-review-request.yml -// - codeowner-approved-label.yml + codeowner-approved-label-update.yml +// - codeowner-approved-label-update.yml // - auto-label-pr/detectors.js (detectCodeOwner) /** diff --git a/.github/workflows/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml index 9168cce1d6b..c2eb886913e 100644 --- a/.github/workflows/codeowner-approved-label-update.yml +++ b/.github/workflows/codeowner-approved-label-update.yml @@ -1,13 +1,15 @@ -# Fallback for fork PRs: phase 1 (codeowner-approved-label.yml) handles -# non-fork PRs directly but can't write labels on fork PRs (read-only token). -# This workflow re-determines the action and applies it if needed. +# Adds/removes a 'code-owner-approved' label when a component-specific +# codeowner approves (or dismisses) a PR. +# +# Uses pull_request_target so that fork PRs do not require workflow approval. +# The label is reconciled on every PR update; for review events specifically, +# this means the label is applied on the next push after a codeowner review. -name: Codeowner Approved Label Update +name: Codeowner Approved Label on: - workflow_run: - workflows: ["Codeowner Approved Label"] - types: [completed] + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] permissions: issues: write @@ -15,51 +17,23 @@ permissions: contents: read jobs: - update-label: + codeowner-approved: name: Run - if: > - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'pull_request_review' + if: ${{ github.repository == 'esphome/esphome' }} runs-on: ubuntu-latest steps: - - name: Get PR details - id: pr - env: - GH_TOKEN: ${{ github.token }} - HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - REPO: ${{ github.repository }} - run: | - pr_data=$(gh pr list --repo "$REPO" --state open --search "$HEAD_SHA" \ - --json number,baseRefName --jq '.[0] // empty') - - if [ -z "$pr_data" ]; then - echo "No open PR found for SHA $HEAD_SHA, skipping" - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - pr_number=$(echo "$pr_data" | jq -r '.number') - base_ref=$(echo "$pr_data" | jq -r '.baseRefName') - - echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" - echo "base_ref=$base_ref" >> "$GITHUB_OUTPUT" - echo "Found PR #$pr_number targeting $base_ref" - - - name: Checkout base repository - if: steps.pr.outputs.skip != 'true' + - name: Checkout base branch uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - repository: ${{ github.repository }} - ref: ${{ steps.pr.outputs.base_ref }} + ref: ${{ github.event.pull_request.base.sha }} sparse-checkout: | .github/scripts/codeowners.js CODEOWNERS - - name: Update label - if: steps.pr.outputs.skip != 'true' + - name: Check codeowner approval and update label uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: - PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + PR_NUMBER: ${{ github.event.pull_request.number }} with: script: | const { loadCodeowners, determineLabelAction, LabelAction } = require('./.github/scripts/codeowners.js'); @@ -76,6 +50,11 @@ jobs: github, owner, repo, pr_number, codeownersPatterns, LABEL_NAME ); + if (action === LabelAction.NONE) { + console.log('No label change needed'); + return; + } + if (action === LabelAction.ADD) { await github.rest.issues.addLabels({ owner, repo, issue_number: pr_number, labels: [LABEL_NAME] @@ -90,6 +69,4 @@ jobs: } catch (error) { if (error.status !== 404) throw error; } - } else { - console.log('No label change needed'); } diff --git a/.github/workflows/codeowner-approved-label.yml b/.github/workflows/codeowner-approved-label.yml deleted file mode 100644 index 12199bd0b04..00000000000 --- a/.github/workflows/codeowner-approved-label.yml +++ /dev/null @@ -1,78 +0,0 @@ -# Adds/removes a 'code-owner-approved' label when a component-specific -# codeowner approves (or dismisses) a PR. -# -# Handles non-fork PRs directly. For fork PRs the GITHUB_TOKEN is read-only, -# so label writes are deferred to codeowner-approved-label-update.yml which -# triggers via workflow_run with write permissions. - -name: Codeowner Approved Label - -on: - pull_request_review: - types: [submitted, dismissed] - -permissions: - issues: write - pull-requests: read - contents: read - -jobs: - codeowner-approved: - name: Run - if: ${{ github.repository == 'esphome/esphome' }} - runs-on: ubuntu-latest - steps: - - name: Checkout base branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.event.pull_request.base.sha }} - sparse-checkout: | - .github/scripts/codeowners.js - CODEOWNERS - - - name: Check codeowner approval and update label - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - with: - script: | - const { loadCodeowners, determineLabelAction, LabelAction } = require('./.github/scripts/codeowners.js'); - - const owner = context.repo.owner; - const repo = context.repo.repo; - const pr_number = parseInt(process.env.PR_NUMBER, 10); - const LABEL_NAME = 'code-owner-approved'; - - console.log(`Processing PR #${pr_number} for codeowner approval label`); - - const codeownersPatterns = loadCodeowners(); - const action = await determineLabelAction( - github, owner, repo, pr_number, codeownersPatterns, LABEL_NAME - ); - - if (action === LabelAction.NONE) { - console.log('No label change needed'); - return; - } - - try { - if (action === LabelAction.ADD) { - await github.rest.issues.addLabels({ - owner, repo, issue_number: pr_number, labels: [LABEL_NAME] - }); - console.log(`Added '${LABEL_NAME}' label`); - } else if (action === LabelAction.REMOVE) { - await github.rest.issues.removeLabel({ - owner, repo, issue_number: pr_number, name: LABEL_NAME - }); - console.log(`Removed '${LABEL_NAME}' label`); - } - } catch (error) { - if (error.status === 403) { - console.log('Fork PR: deferring label write to phase 2 workflow'); - } else if (error.status === 404) { - console.log('Label already removed'); - } else { - throw error; - } - } From 6b53ccc85ab90467933df165c22deab9095ec178 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" <nick@home-assistant.io> Date: Fri, 6 Mar 2026 07:39:49 -1000 Subject: [PATCH 11/14] make it safer --- esphome/core/entity_base.cpp | 2 +- esphome/core/entity_base.h | 13 ++++++++++--- esphome/core/entity_helpers.py | 8 ++++---- .../binary_sensor/test_binary_sensor.py | 2 +- tests/component_tests/button/test_button.py | 2 +- tests/component_tests/sensor/test_sensor.py | 2 +- tests/component_tests/text/test_text.py | 2 +- .../component_tests/text_sensor/test_text_sensor.py | 10 +++++----- tests/unit_tests/core/test_entity_helpers.py | 10 +++++----- 9 files changed, 29 insertions(+), 22 deletions(-) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index d06f6ad4700..f1f9f6dfba5 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -11,7 +11,7 @@ static const char *const TAG = "entity_base"; // Entity Name const StringRef &EntityBase::get_name() const { return this->name_; } -void EntityBase::configure_entity(const char *name, uint32_t object_id_hash, uint32_t entity_strings_packed) { +void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_strings_packed) { this->name_ = StringRef(name); if (this->name_.empty()) { #ifdef USE_DEVICES diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 8eddce93173..945cbf14778 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -12,6 +12,10 @@ #include "device.h" #endif +// Forward declarations for friend access from codegen-generated setup() +void setup(); // NOLINT(readability-redundant-declaration) - may be declared in Arduino.h +void original_setup(); // NOLINT(readability-redundant-declaration) - used by cpp unit tests + namespace esphome { // Extern lookup functions for entity string tables. @@ -52,9 +56,6 @@ class EntityBase { // Get the name of this Entity const StringRef &get_name() const; - /// Combined entity setup from codegen: set name, object_id hash, and entity string indices. - void configure_entity(const char *name, uint32_t object_id_hash, uint32_t entity_strings_packed); - // Get whether this Entity has its own name or it should use the device friendly_name. bool has_own_name() const { return this->flags_.has_own_name; } @@ -201,6 +202,12 @@ class EntityBase { } protected: + friend void ::setup(); + friend void ::original_setup(); + + /// Combined entity setup from codegen: set name, object_id hash, and entity string indices. + void configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_strings_packed); + /// Non-template helper for make_entity_preference() to avoid code bloat. /// When preference hash algorithm changes, migration logic goes here. ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version); diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 127f0bb3ed6..5ce74a70827 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -34,7 +34,7 @@ _KEY_ICON_IDX = "_entity_icon_idx" _KEY_ENTITY_NAME = "_entity_name" _KEY_OBJECT_ID_HASH = "_entity_object_id_hash" -# Bit layout for entity_strings_packed in configure_entity() — must match C++ in entity_base.h: +# Bit layout for entity_strings_packed in configure_entity_() — must match C++ in entity_base.h: # [23..16] icon (8 bits) | [15..8] UoM (8 bits) | [7..0] device_class (8 bits) _DC_SHIFT = 0 _UOM_SHIFT = 8 @@ -217,7 +217,7 @@ def setup_unit_of_measurement(config: ConfigType) -> None: def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: - """Emit a single configure_entity() call with name, hash, and packed string indices. + """Emit a single configure_entity_() call with name, hash, and packed string indices. Call this at the end of each component's setup function, after setup_entity() and any register_device_class/register_unit_of_measurement calls. @@ -228,7 +228,7 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: uom_idx = config.get(_KEY_UOM_IDX, 0) icon_idx = config.get(_KEY_ICON_IDX, 0) packed = (dc_idx << _DC_SHIFT) | (uom_idx << _UOM_SHIFT) | (icon_idx << _ICON_SHIFT) - add(var.configure_entity(entity_name, object_id_hash, packed)) + add(var.configure_entity_(entity_name, object_id_hash, packed)) def get_base_entity_object_id( @@ -330,7 +330,7 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> device: MockObj = await get_variable(device_id_obj) add(var.set_device(device)) - # Pre-compute entity name and object_id hash for configure_entity() + # Pre-compute entity name and object_id hash for configure_entity_() # which is emitted later by finalize_entity_strings(). # For named entities: pre-compute hash from entity name # For empty-name entities: pass 0, C++ calculates hash at runtime from diff --git a/tests/component_tests/binary_sensor/test_binary_sensor.py b/tests/component_tests/binary_sensor/test_binary_sensor.py index d36d4a4e10a..fbc2f37d9a1 100644 --- a/tests/component_tests/binary_sensor/test_binary_sensor.py +++ b/tests/component_tests/binary_sensor/test_binary_sensor.py @@ -29,7 +29,7 @@ def test_binary_sensor_sets_mandatory_fields(generate_main): ) # Then - assert 'bs_1->configure_entity("test bs1",' in main_cpp + assert 'bs_1->configure_entity_("test bs1",' in main_cpp assert "bs_1->set_pin(" in main_cpp diff --git a/tests/component_tests/button/test_button.py b/tests/component_tests/button/test_button.py index da90f2c1a55..9f94d61c8c4 100644 --- a/tests/component_tests/button/test_button.py +++ b/tests/component_tests/button/test_button.py @@ -26,7 +26,7 @@ def test_button_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/button/test_button.yaml") # Then - assert 'wol_1->configure_entity("wol_test_1",' in main_cpp + assert 'wol_1->configure_entity_("wol_test_1",' in main_cpp assert "wol_2->set_macaddr(18, 52, 86, 120, 144, 171);" in main_cpp diff --git a/tests/component_tests/sensor/test_sensor.py b/tests/component_tests/sensor/test_sensor.py index c489f99b503..1fd9322c079 100644 --- a/tests/component_tests/sensor/test_sensor.py +++ b/tests/component_tests/sensor/test_sensor.py @@ -11,4 +11,4 @@ def test_sensor_device_class_set(generate_main): main_cpp = generate_main("tests/component_tests/sensor/test_sensor.yaml") # Then - assert "s_1->configure_entity(" in main_cpp + assert "s_1->configure_entity_(" in main_cpp diff --git a/tests/component_tests/text/test_text.py b/tests/component_tests/text/test_text.py index 2d168aa79dd..3ceaa9b8f81 100644 --- a/tests/component_tests/text/test_text.py +++ b/tests/component_tests/text/test_text.py @@ -25,7 +25,7 @@ def test_text_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/text/test_text.yaml") # Then - assert 'it_1->configure_entity("test 1 text",' in main_cpp + assert 'it_1->configure_entity_("test 1 text",' in main_cpp def test_text_config_value_internal_set(generate_main): diff --git a/tests/component_tests/text_sensor/test_text_sensor.py b/tests/component_tests/text_sensor/test_text_sensor.py index 2203cce5617..cdbb9d2b66e 100644 --- a/tests/component_tests/text_sensor/test_text_sensor.py +++ b/tests/component_tests/text_sensor/test_text_sensor.py @@ -25,9 +25,9 @@ def test_text_sensor_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/text_sensor/test_text_sensor.yaml") # Then - assert 'ts_1->configure_entity("Template Text Sensor 1",' in main_cpp - assert 'ts_2->configure_entity("Template Text Sensor 2",' in main_cpp - assert 'ts_3->configure_entity("Template Text Sensor 3",' in main_cpp + assert 'ts_1->configure_entity_("Template Text Sensor 1",' in main_cpp + assert 'ts_2->configure_entity_("Template Text Sensor 2",' in main_cpp + assert 'ts_3->configure_entity_("Template Text Sensor 3",' in main_cpp def test_text_sensor_config_value_internal_set(generate_main): @@ -54,5 +54,5 @@ def test_text_sensor_device_class_set(generate_main): main_cpp = generate_main("tests/component_tests/text_sensor/test_text_sensor.yaml") # Then - assert "ts_2->configure_entity(" in main_cpp - assert "ts_3->configure_entity(" in main_cpp + assert "ts_2->configure_entity_(" in main_cpp + assert "ts_3->configure_entity_(" in main_cpp diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 0a9f70ca75a..7531e210608 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -31,10 +31,10 @@ from esphome.helpers import sanitize, snake_case from .common import load_config_from_fixture -# Pre-compiled regex pattern for extracting names from configure_entity/set_name calls -# Matches: .configure_entity("name", ...) or .set_name("name", ...) +# Pre-compiled regex pattern for extracting names from configure_entity_/set_name calls +# Matches: .configure_entity_("name", ...) or .set_name("name", ...) ENTITY_NAME_PATTERN = re.compile( - r'\.(?:configure_entity|set_name)\(["\']([^"\']*)["\']' + r'\.(?:configure_entity_|set_name)\(["\']([^"\']*)["\']' ) FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "entity_helpers" @@ -291,7 +291,7 @@ def extract_object_id_from_config(config: dict[str, Any]) -> str | None: def extract_object_id_from_expressions(expressions: list[str]) -> str | None: - """Extract the object ID from configure_entity() calls in generated expressions.""" + """Extract the object ID from configure_entity_() calls in generated expressions.""" for expr in expressions: if match := ENTITY_NAME_PATTERN.search(expr): name = match.group(1) @@ -954,7 +954,7 @@ async def test_setup_entity_direct_call(setup_test_environment: list[str]) -> No # Direct call mode: await setup_entity(var, config, "camera") await setup_entity(var, config, "camera") - # Should have emitted configure_entity + # Should have emitted configure_entity_ object_id = extract_object_id_from_expressions(added_expressions) assert object_id == "my_camera" From 65b7c73bf3fdbbe9260040b96e758561dc9be548 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" <nick@koston.org> Date: Fri, 6 Mar 2026 08:02:34 -1000 Subject: [PATCH 12/14] [sgp4x] Fix undefined behavior from mutating entity config at runtime (#14562) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> --- esphome/components/sgp4x/sgp4x.cpp | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index 23589265ca0..44d0a54080b 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -35,13 +35,9 @@ void SGP4xComponent::setup() { this->self_test_time_ = SPG40_SELFTEST_TIME; this->measure_time_ = SGP40_MEASURE_TIME; if (this->nox_sensor_) { - ESP_LOGE(TAG, "SGP41 required for NOx"); - // disable the sensor - this->nox_sensor_->set_disabled_by_default(true); - // make sure it's not visible in HA - this->nox_sensor_->set_internal(true); - this->nox_sensor_->state = NAN; - // remove pointer to sensor + ESP_LOGE(TAG, "SGP41 required for NOx, disabling NOx sensor"); + // Drop the pointer so update() never publishes to it. + // The entity remains registered but will never receive state updates. this->nox_sensor_ = nullptr; } } else if (featureset == SGP41_FEATURESET) { From b2378e830e947ecc8d79f197abf838868927053e Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht <rupprecht.thomas@gmail.com> Date: Fri, 6 Mar 2026 19:11:52 +0100 Subject: [PATCH 13/14] [rtttl] Add AudioStreamInfo and set volume (#14439) Co-authored-by: J. Nick Koston <nick@koston.org> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/rtttl/rtttl.cpp | 40 ++++++++++-------------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index 4ccfc539eac..9bf0450993c 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -29,11 +29,6 @@ static constexpr uint8_t REPEATING_NOTE_GAP_MS = 10; static constexpr uint16_t SAMPLE_BUFFER_SIZE = 2048; static constexpr uint16_t SAMPLE_RATE = 16000; -struct SpeakerSample { - int8_t left{0}; - int8_t right{0}; -}; - inline double deg2rad(double degrees) { static constexpr double PI_ON_180 = M_PI / 180.0; return degrees * PI_ON_180; @@ -108,6 +103,9 @@ void Rtttl::loop() { } } else if (this->state_ == State::INIT) { if (this->speaker_->is_stopped()) { + audio::AudioStreamInfo audio_stream_info = audio::AudioStreamInfo(16, 1, SAMPLE_RATE); + this->speaker_->set_audio_stream_info(audio_stream_info); + this->speaker_->set_volume(this->gain_); this->speaker_->start(); this->set_state_(State::STARTING); } @@ -120,35 +118,27 @@ void Rtttl::loop() { return; } if (this->samples_sent_ != this->samples_count_) { - SpeakerSample sample[SAMPLE_BUFFER_SIZE + 2]; + int16_t sample[SAMPLE_BUFFER_SIZE]; uint16_t sample_index = 0; double rem = 0.0; - while (true) { + while (sample_index < SAMPLE_BUFFER_SIZE && this->samples_sent_ < this->samples_count_) { // Try and send out the remainder of the existing note, one per `loop()` if (this->samples_per_wave_ != 0 && this->samples_sent_ >= this->samples_gap_) { // Play note rem = ((this->samples_sent_ << 10) % this->samples_per_wave_) * (360.0 / this->samples_per_wave_); - - int8_t val = (127 * this->gain_) * sin(deg2rad(rem)); - - sample[sample_index].left = val; - sample[sample_index].right = val; + sample[sample_index] = INT16_MAX * sin(deg2rad(rem)); } else { - sample[sample_index].left = 0; - sample[sample_index].right = 0; - } - - if (sample_index >= SAMPLE_BUFFER_SIZE || this->samples_sent_ >= this->samples_count_) { - break; + sample[sample_index] = 0; } this->samples_sent_++; sample_index++; } if (sample_index > 0) { - size_t bytes_to_send = sample_index * sizeof(SpeakerSample); - size_t send = this->speaker_->play((uint8_t *) (&sample), bytes_to_send); - if (send != bytes_to_send) { - this->samples_sent_ -= (sample_index - (send / sizeof(SpeakerSample))); + size_t bytes = sample_index * sizeof(int16_t); + size_t sent_bytes = this->speaker_->play((uint8_t *) (&sample), bytes); + size_t samples_sent = sent_bytes / sizeof(int16_t); + if (samples_sent != sample_index) { + this->samples_sent_ -= (sample_index - samples_sent); } return; } @@ -408,11 +398,7 @@ void Rtttl::finish_() { #ifdef USE_SPEAKER if (this->speaker_ != nullptr) { - SpeakerSample sample[2]; - sample[0].left = 0; - sample[0].right = 0; - sample[1].left = 0; - sample[1].right = 0; + int16_t sample[2] = {0, 0}; this->speaker_->play((uint8_t *) (&sample), sizeof(sample)); this->speaker_->finish(); this->set_state_(State::STOPPING); From 8a915dcbbed3af2e285dce91f32c03743310ca21 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" <nick@koston.org> Date: Fri, 6 Mar 2026 08:34:27 -1000 Subject: [PATCH 14/14] [core] Move device class strings to PROGMEM on ESP8266 (#14443) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/api/api_connection.cpp | 84 ++++++++++++++----- esphome/components/api/api_connection.h | 34 ++------ .../components/mqtt/mqtt_binary_sensor.cpp | 6 +- esphome/components/mqtt/mqtt_button.cpp | 6 -- esphome/components/mqtt/mqtt_component.cpp | 5 ++ esphome/components/mqtt/mqtt_cover.cpp | 7 +- esphome/components/mqtt/mqtt_event.cpp | 7 -- esphome/components/mqtt/mqtt_number.cpp | 4 - esphome/components/mqtt/mqtt_sensor.cpp | 5 -- esphome/components/mqtt/mqtt_text_sensor.cpp | 6 -- esphome/components/mqtt/mqtt_valve.cpp | 7 +- esphome/components/web_server/web_server.cpp | 3 +- esphome/core/config.py | 6 ++ esphome/core/entity_base.cpp | 37 +++++++- esphome/core/entity_base.h | 33 ++++++-- esphome/core/entity_helpers.py | 8 +- tests/unit_tests/core/test_entity_helpers.py | 17 ++++ 17 files changed, 167 insertions(+), 108 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 98ba1abe0b5..77920432c0d 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -396,6 +396,48 @@ uint16_t APIConnection::encode_message_to_buffer(ProtoMessage &msg, uint8_t mess return static_cast<uint16_t>(header_padding + calculated_size + footer_size); } +uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResponseProtoMessage &msg, + uint8_t message_type, APIConnection *conn, + uint32_t remaining_size) { + // Set common fields that are shared by all entity types + msg.key = entity->get_object_id_hash(); + + // API 1.14+ clients compute object_id client-side from the entity name + // For older clients, we must send object_id for backward compatibility + // See: https://github.com/esphome/backlog/issues/76 + // TODO: Remove this backward compat code before 2026.7.0 - all clients should support API 1.14 by then + // Buffer must remain in scope until encode_message_to_buffer is called + char object_id_buf[OBJECT_ID_MAX_LEN]; + if (!conn->client_supports_api_version(1, 14)) { + msg.object_id = entity->get_object_id_to(object_id_buf); + } + + if (entity->has_own_name()) { + msg.name = entity->get_name(); + } + + // Set common EntityBase properties +#ifdef USE_ENTITY_ICON + char icon_buf[MAX_ICON_LENGTH]; + msg.icon = StringRef(entity->get_icon_to(icon_buf)); +#endif + msg.disabled_by_default = entity->is_disabled_by_default(); + msg.entity_category = static_cast<enums::EntityCategory>(entity->get_entity_category()); +#ifdef USE_DEVICES + msg.device_id = entity->get_device_id(); +#endif + return encode_message_to_buffer(msg, message_type, conn, remaining_size); +} + +uint16_t APIConnection::fill_and_encode_entity_info_with_device_class(EntityBase *entity, InfoResponseProtoMessage &msg, + StringRef &device_class_field, + uint8_t message_type, APIConnection *conn, + uint32_t remaining_size) { + char dc_buf[MAX_DEVICE_CLASS_LENGTH]; + device_class_field = StringRef(entity->get_device_class_to(dc_buf)); + return fill_and_encode_entity_info(entity, msg, message_type, conn, remaining_size); +} + #ifdef USE_BINARY_SENSOR bool APIConnection::send_binary_sensor_state(binary_sensor::BinarySensor *binary_sensor) { return this->send_message_smart_(binary_sensor, BinarySensorStateResponse::MESSAGE_TYPE, @@ -414,10 +456,9 @@ uint16_t APIConnection::try_send_binary_sensor_state(EntityBase *entity, APIConn uint16_t APIConnection::try_send_binary_sensor_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) { auto *binary_sensor = static_cast<binary_sensor::BinarySensor *>(entity); ListEntitiesBinarySensorResponse msg; - msg.device_class = binary_sensor->get_device_class_ref(); msg.is_status_binary_sensor = binary_sensor->is_status_binary_sensor(); - return fill_and_encode_entity_info(binary_sensor, msg, ListEntitiesBinarySensorResponse::MESSAGE_TYPE, conn, - remaining_size); + return fill_and_encode_entity_info_with_device_class( + binary_sensor, msg, msg.device_class, ListEntitiesBinarySensorResponse::MESSAGE_TYPE, conn, remaining_size); } #endif @@ -443,8 +484,8 @@ uint16_t APIConnection::try_send_cover_info(EntityBase *entity, APIConnection *c msg.supports_position = traits.get_supports_position(); msg.supports_tilt = traits.get_supports_tilt(); msg.supports_stop = traits.get_supports_stop(); - msg.device_class = cover->get_device_class_ref(); - return fill_and_encode_entity_info(cover, msg, ListEntitiesCoverResponse::MESSAGE_TYPE, conn, remaining_size); + return fill_and_encode_entity_info_with_device_class(cover, msg, msg.device_class, + ListEntitiesCoverResponse::MESSAGE_TYPE, conn, remaining_size); } void APIConnection::on_cover_command_request(const CoverCommandRequest &msg) { ENTITY_COMMAND_MAKE_CALL(cover::Cover, cover, cover) @@ -609,9 +650,9 @@ uint16_t APIConnection::try_send_sensor_info(EntityBase *entity, APIConnection * msg.unit_of_measurement = sensor->get_unit_of_measurement_ref(); msg.accuracy_decimals = sensor->get_accuracy_decimals(); msg.force_update = sensor->get_force_update(); - msg.device_class = sensor->get_device_class_ref(); msg.state_class = static_cast<enums::SensorStateClass>(sensor->get_state_class()); - return fill_and_encode_entity_info(sensor, msg, ListEntitiesSensorResponse::MESSAGE_TYPE, conn, remaining_size); + return fill_and_encode_entity_info_with_device_class(sensor, msg, msg.device_class, + ListEntitiesSensorResponse::MESSAGE_TYPE, conn, remaining_size); } #endif @@ -631,8 +672,8 @@ uint16_t APIConnection::try_send_switch_info(EntityBase *entity, APIConnection * auto *a_switch = static_cast<switch_::Switch *>(entity); ListEntitiesSwitchResponse msg; msg.assumed_state = a_switch->assumed_state(); - msg.device_class = a_switch->get_device_class_ref(); - return fill_and_encode_entity_info(a_switch, msg, ListEntitiesSwitchResponse::MESSAGE_TYPE, conn, remaining_size); + return fill_and_encode_entity_info_with_device_class(a_switch, msg, msg.device_class, + ListEntitiesSwitchResponse::MESSAGE_TYPE, conn, remaining_size); } void APIConnection::on_switch_command_request(const SwitchCommandRequest &msg) { ENTITY_COMMAND_GET(switch_::Switch, a_switch, switch) @@ -661,9 +702,8 @@ uint16_t APIConnection::try_send_text_sensor_state(EntityBase *entity, APIConnec uint16_t APIConnection::try_send_text_sensor_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) { auto *text_sensor = static_cast<text_sensor::TextSensor *>(entity); ListEntitiesTextSensorResponse msg; - msg.device_class = text_sensor->get_device_class_ref(); - return fill_and_encode_entity_info(text_sensor, msg, ListEntitiesTextSensorResponse::MESSAGE_TYPE, conn, - remaining_size); + return fill_and_encode_entity_info_with_device_class( + text_sensor, msg, msg.device_class, ListEntitiesTextSensorResponse::MESSAGE_TYPE, conn, remaining_size); } #endif @@ -776,11 +816,11 @@ uint16_t APIConnection::try_send_number_info(EntityBase *entity, APIConnection * ListEntitiesNumberResponse msg; msg.unit_of_measurement = number->get_unit_of_measurement_ref(); msg.mode = static_cast<enums::NumberMode>(number->traits.get_mode()); - msg.device_class = number->get_device_class_ref(); msg.min_value = number->traits.get_min_value(); msg.max_value = number->traits.get_max_value(); msg.step = number->traits.get_step(); - return fill_and_encode_entity_info(number, msg, ListEntitiesNumberResponse::MESSAGE_TYPE, conn, remaining_size); + return fill_and_encode_entity_info_with_device_class(number, msg, msg.device_class, + ListEntitiesNumberResponse::MESSAGE_TYPE, conn, remaining_size); } void APIConnection::on_number_command_request(const NumberCommandRequest &msg) { ENTITY_COMMAND_MAKE_CALL(number::Number, number, number) @@ -925,8 +965,8 @@ void APIConnection::on_select_command_request(const SelectCommandRequest &msg) { uint16_t APIConnection::try_send_button_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) { auto *button = static_cast<button::Button *>(entity); ListEntitiesButtonResponse msg; - msg.device_class = button->get_device_class_ref(); - return fill_and_encode_entity_info(button, msg, ListEntitiesButtonResponse::MESSAGE_TYPE, conn, remaining_size); + return fill_and_encode_entity_info_with_device_class(button, msg, msg.device_class, + ListEntitiesButtonResponse::MESSAGE_TYPE, conn, remaining_size); } void esphome::api::APIConnection::on_button_command_request(const ButtonCommandRequest &msg) { ENTITY_COMMAND_GET(button::Button, button, button) @@ -986,11 +1026,11 @@ uint16_t APIConnection::try_send_valve_info(EntityBase *entity, APIConnection *c auto *valve = static_cast<valve::Valve *>(entity); ListEntitiesValveResponse msg; auto traits = valve->get_traits(); - msg.device_class = valve->get_device_class_ref(); msg.assumed_state = traits.get_is_assumed_state(); msg.supports_position = traits.get_supports_position(); msg.supports_stop = traits.get_supports_stop(); - return fill_and_encode_entity_info(valve, msg, ListEntitiesValveResponse::MESSAGE_TYPE, conn, remaining_size); + return fill_and_encode_entity_info_with_device_class(valve, msg, msg.device_class, + ListEntitiesValveResponse::MESSAGE_TYPE, conn, remaining_size); } void APIConnection::on_valve_command_request(const ValveCommandRequest &msg) { ENTITY_COMMAND_MAKE_CALL(valve::Valve, valve, valve) @@ -1434,9 +1474,9 @@ uint16_t APIConnection::try_send_event_response(event::Event *event, StringRef e uint16_t APIConnection::try_send_event_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) { auto *event = static_cast<event::Event *>(entity); ListEntitiesEventResponse msg; - msg.device_class = event->get_device_class_ref(); msg.event_types = &event->get_event_types(); - return fill_and_encode_entity_info(event, msg, ListEntitiesEventResponse::MESSAGE_TYPE, conn, remaining_size); + return fill_and_encode_entity_info_with_device_class(event, msg, msg.device_class, + ListEntitiesEventResponse::MESSAGE_TYPE, conn, remaining_size); } #endif @@ -1492,8 +1532,8 @@ uint16_t APIConnection::try_send_update_state(EntityBase *entity, APIConnection uint16_t APIConnection::try_send_update_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) { auto *update = static_cast<update::UpdateEntity *>(entity); ListEntitiesUpdateResponse msg; - msg.device_class = update->get_device_class_ref(); - return fill_and_encode_entity_info(update, msg, ListEntitiesUpdateResponse::MESSAGE_TYPE, conn, remaining_size); + return fill_and_encode_entity_info_with_device_class(update, msg, msg.device_class, + ListEntitiesUpdateResponse::MESSAGE_TYPE, conn, remaining_size); } void APIConnection::on_update_command_request(const UpdateCommandRequest &msg) { ENTITY_COMMAND_GET(update::UpdateEntity, update, update) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 88f0ef82d66..2c66a194a6b 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -334,36 +334,12 @@ class APIConnection final : public APIServerConnectionBase { // Helper to fill entity info base and encode message static uint16_t fill_and_encode_entity_info(EntityBase *entity, InfoResponseProtoMessage &msg, uint8_t message_type, - APIConnection *conn, uint32_t remaining_size) { - // Set common fields that are shared by all entity types - msg.key = entity->get_object_id_hash(); + APIConnection *conn, uint32_t remaining_size); - // API 1.14+ clients compute object_id client-side from the entity name - // For older clients, we must send object_id for backward compatibility - // See: https://github.com/esphome/backlog/issues/76 - // TODO: Remove this backward compat code before 2026.7.0 - all clients should support API 1.14 by then - // Buffer must remain in scope until encode_message_to_buffer is called - char object_id_buf[OBJECT_ID_MAX_LEN]; - if (!conn->client_supports_api_version(1, 14)) { - msg.object_id = entity->get_object_id_to(object_id_buf); - } - - if (entity->has_own_name()) { - msg.name = entity->get_name(); - } - - // Set common EntityBase properties -#ifdef USE_ENTITY_ICON - char icon_buf[MAX_ICON_LENGTH]; - msg.icon = StringRef(entity->get_icon_to(icon_buf)); -#endif - msg.disabled_by_default = entity->is_disabled_by_default(); - msg.entity_category = static_cast<enums::EntityCategory>(entity->get_entity_category()); -#ifdef USE_DEVICES - msg.device_id = entity->get_device_id(); -#endif - return encode_message_to_buffer(msg, message_type, conn, remaining_size); - } + // Wrapper for entity types that have a device_class field + static uint16_t fill_and_encode_entity_info_with_device_class(EntityBase *entity, InfoResponseProtoMessage &msg, + StringRef &device_class_field, uint8_t message_type, + APIConnection *conn, uint32_t remaining_size); #ifdef USE_VOICE_ASSISTANT // Helper to check voice assistant validity and connection ownership diff --git a/esphome/components/mqtt/mqtt_binary_sensor.cpp b/esphome/components/mqtt/mqtt_binary_sensor.cpp index 75995f61e06..ebb29db44f0 100644 --- a/esphome/components/mqtt/mqtt_binary_sensor.cpp +++ b/esphome/components/mqtt/mqtt_binary_sensor.cpp @@ -30,15 +30,11 @@ MQTTBinarySensorComponent::MQTTBinarySensorComponent(binary_sensor::BinarySensor void MQTTBinarySensorComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - const auto device_class = this->binary_sensor_->get_device_class_ref(); - if (!device_class.empty()) { - root[MQTT_DEVICE_CLASS] = device_class; - } - // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) if (this->binary_sensor_->is_status_binary_sensor()) root[MQTT_PAYLOAD_ON] = mqtt::global_mqtt_client->get_availability().payload_available; if (this->binary_sensor_->is_status_binary_sensor()) root[MQTT_PAYLOAD_OFF] = mqtt::global_mqtt_client->get_availability().payload_not_available; + // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) config.command_topic = false; } bool MQTTBinarySensorComponent::send_initial_state() { diff --git a/esphome/components/mqtt/mqtt_button.cpp b/esphome/components/mqtt/mqtt_button.cpp index 718fe930165..7e0ae7d06e1 100644 --- a/esphome/components/mqtt/mqtt_button.cpp +++ b/esphome/components/mqtt/mqtt_button.cpp @@ -30,13 +30,7 @@ void MQTTButtonComponent::dump_config() { } void MQTTButtonComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) { - // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson config.state_topic = false; - const auto device_class = this->button_->get_device_class_ref(); - if (!device_class.empty()) { - root[MQTT_DEVICE_CLASS] = device_class; - } - // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) } MQTT_COMPONENT_TYPE(MQTTButtonComponent, "button") diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index d31a78b0900..afc514609cc 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -214,6 +214,11 @@ bool MQTTComponent::send_discovery_() { if (icon[0] != '\0') { root[MQTT_ICON] = icon; } + char dc_buf[MAX_DEVICE_CLASS_LENGTH]; + const char *dc = this->get_entity()->get_device_class_to(dc_buf); + if (dc[0] != '\0') { + root[MQTT_DEVICE_CLASS] = dc; + } const auto entity_category = this->get_entity()->get_entity_category(); if (entity_category != ENTITY_CATEGORY_NONE) { diff --git a/esphome/components/mqtt/mqtt_cover.cpp b/esphome/components/mqtt/mqtt_cover.cpp index 97520040942..ddb4b2d69d2 100644 --- a/esphome/components/mqtt/mqtt_cover.cpp +++ b/esphome/components/mqtt/mqtt_cover.cpp @@ -91,12 +91,6 @@ void MQTTCoverComponent::dump_config() { } void MQTTCoverComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - const auto device_class = this->cover_->get_device_class_ref(); - if (!device_class.empty()) { - root[MQTT_DEVICE_CLASS] = device_class; - } - // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) - auto traits = this->cover_->get_traits(); if (traits.get_is_assumed_state()) { root[MQTT_OPTIMISTIC] = true; @@ -129,6 +123,7 @@ void MQTTCoverComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConf root[MQTT_TILT_COMMAND_TOPIC] = this->get_tilt_command_topic_to(topic_buf); } } + // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) if (traits.get_supports_tilt() && !traits.get_supports_position()) { config.command_topic = false; } diff --git a/esphome/components/mqtt/mqtt_event.cpp b/esphome/components/mqtt/mqtt_event.cpp index 37d5c2551a9..93ff6971b36 100644 --- a/esphome/components/mqtt/mqtt_event.cpp +++ b/esphome/components/mqtt/mqtt_event.cpp @@ -20,13 +20,6 @@ void MQTTEventComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConf for (const auto &event_type : this->event_->get_event_types()) event_types.add(event_type); - // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - const auto device_class = this->event_->get_device_class_ref(); - if (!device_class.empty()) { - root[MQTT_DEVICE_CLASS] = device_class; - } - // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) - config.command_topic = false; } diff --git a/esphome/components/mqtt/mqtt_number.cpp b/esphome/components/mqtt/mqtt_number.cpp index a2734f2beb0..b0bac8b3d71 100644 --- a/esphome/components/mqtt/mqtt_number.cpp +++ b/esphome/components/mqtt/mqtt_number.cpp @@ -57,10 +57,6 @@ void MQTTNumberComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCon root[MQTT_MODE] = NumberMqttModeStrings::get_progmem_str(static_cast<uint8_t>(mode), static_cast<uint8_t>(NUMBER_MODE_BOX)); } - const auto device_class = this->number_->get_device_class_ref(); - if (!device_class.empty()) { - root[MQTT_DEVICE_CLASS] = device_class; - } // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) config.command_topic = true; diff --git a/esphome/components/mqtt/mqtt_sensor.cpp b/esphome/components/mqtt/mqtt_sensor.cpp index a7d311d194a..c66465dd16f 100644 --- a/esphome/components/mqtt/mqtt_sensor.cpp +++ b/esphome/components/mqtt/mqtt_sensor.cpp @@ -44,11 +44,6 @@ void MQTTSensorComponent::disable_expire_after() { this->expire_after_ = 0; } void MQTTSensorComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - const auto device_class = this->sensor_->get_device_class_ref(); - if (!device_class.empty()) { - root[MQTT_DEVICE_CLASS] = device_class; - } - if (this->sensor_->has_accuracy_decimals()) { root[MQTT_SUGGESTED_DISPLAY_PRECISION] = this->sensor_->get_accuracy_decimals(); } diff --git a/esphome/components/mqtt/mqtt_text_sensor.cpp b/esphome/components/mqtt/mqtt_text_sensor.cpp index a6b9f90b683..3acd71b50d9 100644 --- a/esphome/components/mqtt/mqtt_text_sensor.cpp +++ b/esphome/components/mqtt/mqtt_text_sensor.cpp @@ -14,12 +14,6 @@ using namespace esphome::text_sensor; MQTTTextSensor::MQTTTextSensor(TextSensor *sensor) : sensor_(sensor) {} void MQTTTextSensor::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) { - // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - const auto device_class = this->sensor_->get_device_class_ref(); - if (!device_class.empty()) { - root[MQTT_DEVICE_CLASS] = device_class; - } - // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) config.command_topic = false; } void MQTTTextSensor::setup() { diff --git a/esphome/components/mqtt/mqtt_valve.cpp b/esphome/components/mqtt/mqtt_valve.cpp index 2b9f02858b5..b155a4c8972 100644 --- a/esphome/components/mqtt/mqtt_valve.cpp +++ b/esphome/components/mqtt/mqtt_valve.cpp @@ -64,12 +64,6 @@ void MQTTValveComponent::dump_config() { } void MQTTValveComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - const auto device_class = this->valve_->get_device_class_ref(); - if (!device_class.empty()) { - root[MQTT_DEVICE_CLASS] = device_class; - } - // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) - auto traits = this->valve_->get_traits(); if (traits.get_is_assumed_state()) { root[MQTT_OPTIMISTIC] = true; @@ -78,6 +72,7 @@ void MQTTValveComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConf root[MQTT_POSITION_TOPIC] = this->get_position_state_topic(); root[MQTT_SET_POSITION_TOPIC] = this->get_position_command_topic(); } + // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) } MQTT_COMPONENT_TYPE(MQTTValveComponent, "valve") diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index bc90c88e57f..5590e67b822 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -2137,7 +2137,8 @@ json::SerializationBuffer<> WebServer::event_json_(event::Event *obj, StringRef for (const char *event_type : obj->get_event_types()) { event_types.add(event_type); } - root[ESPHOME_F("device_class")] = obj->get_device_class_ref(); + char dc_buf[MAX_DEVICE_CLASS_LENGTH]; + root[ESPHOME_F("device_class")] = obj->get_device_class_to(dc_buf); this->add_sorting_info_(root, obj); } diff --git a/esphome/core/config.py b/esphome/core/config.py index 8631726a021..d4a839cb795 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -223,6 +223,12 @@ else: # Keep in sync with ESPHOME_FRIENDLY_NAME_MAX_LEN in esphome/core/entity_base.h FRIENDLY_NAME_MAX_LEN = 120 +# Max device class string length (47 chars + null = 48-byte PROGMEM buffer) +# Keep in sync with MAX_DEVICE_CLASS_LENGTH in esphome/core/entity_base.h: +# DEVICE_CLASS_MAX_LENGTH == MAX_DEVICE_CLASS_LENGTH - 1 (C++ includes the null) +DEVICE_CLASS_MAX_LENGTH = 47 + + # Max icon string length (63 chars + null = 64-byte PROGMEM buffer) # Keep in sync with MAX_ICON_LENGTH in esphome/core/entity_base.h ICON_MAX_LENGTH = 63 diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 37e7fcc9987..5c4e1c44459 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -51,7 +51,27 @@ __attribute__((weak)) const char *entity_device_class_lookup(uint8_t) { return " __attribute__((weak)) const char *entity_uom_lookup(uint8_t) { return ""; } __attribute__((weak)) const char *entity_icon_lookup(uint8_t) { return ""; } -// Entity device class (from index) +// Entity device class — buffer-based API for PROGMEM safety on ESP8266 +const char *EntityBase::get_device_class_to([[maybe_unused]] std::span<char, MAX_DEVICE_CLASS_LENGTH> buffer) const { +#ifdef USE_ENTITY_DEVICE_CLASS + const uint8_t idx = this->device_class_idx_; +#else + const uint8_t idx = 0; +#endif +#ifdef USE_ESP8266 + if (idx == 0) + return ""; + const char *dc = entity_device_class_lookup(idx); + ESPHOME_strncpy_P(buffer.data(), dc, buffer.size() - 1); + buffer[buffer.size() - 1] = '\0'; + return buffer.data(); +#else + return entity_device_class_lookup(idx); +#endif +} + +#ifndef USE_ESP8266 +// Deprecated device class accessors — not available on ESP8266 (rodata is RAM) StringRef EntityBase::get_device_class_ref() const { #ifdef USE_ENTITY_DEVICE_CLASS return StringRef(entity_device_class_lookup(this->device_class_idx_)); @@ -59,7 +79,14 @@ StringRef EntityBase::get_device_class_ref() const { return StringRef(entity_device_class_lookup(0)); #endif } -std::string EntityBase::get_device_class() const { return std::string(this->get_device_class_ref().c_str()); } +std::string EntityBase::get_device_class() const { +#ifdef USE_ENTITY_DEVICE_CLASS + return std::string(entity_device_class_lookup(this->device_class_idx_)); +#else + return std::string(entity_device_class_lookup(0)); +#endif +} +#endif // !USE_ESP8266 // Entity unit of measurement (from index) StringRef EntityBase::get_unit_of_measurement_ref() const { @@ -191,8 +218,10 @@ void log_entity_icon(const char *tag, const char *prefix, const EntityBase &obj) #endif void log_entity_device_class(const char *tag, const char *prefix, const EntityBase &obj) { - if (!obj.get_device_class_ref().empty()) { - ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj.get_device_class_ref().c_str()); + char dc_buf[MAX_DEVICE_CLASS_LENGTH]; + const char *dc = obj.get_device_class_to(dc_buf); + if (dc[0] != '\0') { + ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, dc); } } diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 1ce1e658e02..20eb68b67a7 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -36,6 +36,11 @@ static constexpr size_t OBJECT_ID_MAX_LEN = 128; // Maximum state length that Home Assistant will accept without raising ValueError static constexpr size_t MAX_STATE_LEN = 255; +// Maximum device class string buffer size (47 chars + null terminator) +// Longest standard device class: "volatile_organic_compounds_parts" (32 chars) +// Device classes are stored in PROGMEM; on ESP8266 they must be copied to a stack buffer. +static constexpr size_t MAX_DEVICE_CLASS_LENGTH = 48; + // Maximum icon string buffer size (63 chars + null terminator) // Icons are stored in PROGMEM; on ESP8266 they must be copied to a stack buffer. static constexpr size_t MAX_ICON_LENGTH = 64; @@ -113,13 +118,31 @@ class EntityBase { #endif } - // Get device class as StringRef (from packed index) + // Get this entity's device class into a stack buffer. + // On non-ESP8266: returns pointer to PROGMEM string directly (buffer unused). + // On ESP8266: copies from PROGMEM to buffer, returns buffer pointer. + const char *get_device_class_to(std::span<char, MAX_DEVICE_CLASS_LENGTH> buffer) const; + +#ifdef USE_ESP8266 + // On ESP8266, rodata is RAM. Device classes are in PROGMEM and cannot be accessed + // directly as const char*. Use get_device_class_to() with a stack buffer instead. + template<typename T = int> StringRef get_device_class_ref() const { + static_assert(sizeof(T) == 0, "get_device_class_ref() unavailable on ESP8266 (rodata is RAM). " + "Use get_device_class_to() with a stack buffer."); + return StringRef(""); + } + template<typename T = int> std::string get_device_class() const { + static_assert(sizeof(T) == 0, "get_device_class() unavailable on ESP8266 (rodata is RAM). " + "Use get_device_class_to() with a stack buffer."); + return ""; + } +#else + // Deprecated: use get_device_class_to() instead. Device classes are in PROGMEM. + ESPDEPRECATED("Use get_device_class_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0") StringRef get_device_class_ref() const; - /// Get the device class as std::string (deprecated, prefer get_device_class_ref()) - ESPDEPRECATED("Use get_device_class_ref() instead for better performance (avoids string copy). Will be removed in " - "ESPHome 2026.9.0", - "2026.3.0") + ESPDEPRECATED("Use get_device_class_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0") std::string get_device_class() const; +#endif // Get unit of measurement as StringRef (from packed index) StringRef get_unit_of_measurement_ref() const; /// Get the unit of measurement as std::string (deprecated, prefer get_unit_of_measurement_ref()) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 01fa27b833a..a46d2466fdf 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -17,7 +17,7 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority -from esphome.core.config import ICON_MAX_LENGTH +from esphome.core.config import DEVICE_CLASS_MAX_LENGTH, ICON_MAX_LENGTH from esphome.cpp_generator import MockObj, RawStatement, add, get_variable import esphome.final_validate as fv from esphome.helpers import cpp_string_escape, fnv1_hash_object_id, sanitize, snake_case @@ -132,7 +132,7 @@ def _generate_category_code( _CATEGORY_CONFIGS = ( - ("ENTITY_DC_TABLE", "entity_device_class_lookup", "device_classes", False), + ("ENTITY_DC_TABLE", "entity_device_class_lookup", "device_classes", True), ("ENTITY_UOM_TABLE", "entity_uom_lookup", "units", False), ("ENTITY_ICON_TABLE", "entity_icon_lookup", "icons", True), ) @@ -179,6 +179,10 @@ def _register_string( def register_device_class(value: str) -> int: """Register a device_class string and return its 1-based index.""" + if value and len(value) > DEVICE_CLASS_MAX_LENGTH: + raise ValueError( + f"Device class string too long ({len(value)} chars, max {DEVICE_CLASS_MAX_LENGTH}): '{value}'" + ) return _register_string( value, _get_pool().device_classes, _MAX_DEVICE_CLASSES, "device_class" ) diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 79bc3095b92..1392a1d0436 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -23,6 +23,7 @@ from esphome.core.entity_helpers import ( _setup_entity_impl, entity_duplicate_validator, get_base_entity_object_id, + register_device_class, register_icon, setup_entity, ) @@ -926,6 +927,22 @@ def test_register_icon_max_length() -> None: assert register_icon("") == 0 +def test_register_device_class_max_length() -> None: + """Test register_device_class rejects device classes exceeding 47 characters.""" + # 47 chars should succeed + max_dc = "a" * 47 + idx = register_device_class(max_dc) + assert idx > 0 + + # 48 chars should fail + too_long = "a" * 48 + with pytest.raises(ValueError, match="Device class string too long"): + register_device_class(too_long) + + # Empty string returns 0 + assert register_device_class("") == 0 + + @pytest.mark.asyncio async def test_setup_entity_with_entity_category( setup_test_environment: list[str],