From 4eac63293969bbf75861f6790bb8e706fda48a39 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Feb 2026 22:22:48 -0600 Subject: [PATCH 01/12] pack_entity_strings --- .../alarm_control_panel/__init__.py | 2 +- esphome/components/api/api_connection.cpp | 4 +- esphome/components/binary_sensor/__init__.py | 12 +- .../components/binary_sensor/binary_sensor.h | 2 +- esphome/components/button/__init__.py | 12 +- esphome/components/button/button.h | 2 +- esphome/components/climate/__init__.py | 3 +- esphome/components/cover/__init__.py | 12 +- esphome/components/cover/cover.h | 2 +- esphome/components/datetime/__init__.py | 3 +- esphome/components/esp32/core.cpp | 1 + esphome/components/esp32_camera/__init__.py | 7 +- esphome/components/esp8266/core.cpp | 3 + esphome/components/event/__init__.py | 12 +- esphome/components/event/event.h | 2 +- esphome/components/fan/__init__.py | 3 +- esphome/components/host/core.cpp | 1 + esphome/components/infrared/__init__.py | 2 +- esphome/components/libretiny/core.cpp | 1 + esphome/components/light/__init__.py | 7 +- esphome/components/lock/__init__.py | 3 +- esphome/components/media_player/__init__.py | 2 +- esphome/components/mqtt/mqtt_number.cpp | 4 +- esphome/components/number/__init__.py | 16 +- esphome/components/number/number.cpp | 17 +- esphome/components/number/number_traits.h | 12 +- esphome/components/rp2040/core.cpp | 3 + esphome/components/select/__init__.py | 3 +- esphome/components/sensor/__init__.py | 16 +- esphome/components/sensor/sensor.h | 2 +- esphome/components/sprinkler/sprinkler.cpp | 4 +- esphome/components/switch/__init__.py | 12 +- esphome/components/switch/switch.h | 2 +- esphome/components/text/__init__.py | 3 +- esphome/components/text_sensor/__init__.py | 12 +- esphome/components/text_sensor/text_sensor.h | 2 +- esphome/components/update/__init__.py | 12 +- esphome/components/update/update_entity.h | 2 +- esphome/components/valve/__init__.py | 12 +- esphome/components/valve/valve.h | 2 +- esphome/components/water_heater/__init__.py | 3 +- esphome/components/web_server/web_server.cpp | 2 +- esphome/components/zephyr/core.cpp | 1 + esphome/core/defines.h | 2 + esphome/core/entity_base.cpp | 72 +++--- esphome/core/entity_base.h | 88 ++++--- esphome/core/entity_helpers.py | 219 +++++++++++++++++- esphome/core/hal.h | 1 + esphome/cpp_generator.py | 17 ++ tests/component_tests/sensor/test_sensor.py | 2 +- .../text_sensor/test_text_sensor.py | 4 +- tests/unit_tests/core/test_entity_helpers.py | 34 ++- 52 files changed, 473 insertions(+), 206 deletions(-) diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index b1e2252ce74..b8555861527 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -186,8 +186,8 @@ ALARM_CONTROL_PANEL_CONDITION_SCHEMA = maybe_simple_id( ) +@setup_entity("alarm_control_panel") async def setup_alarm_control_panel_core_(var, config): - await setup_entity(var, config, "alarm_control_panel") for conf in config.get(CONF_ON_STATE, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 5b02bee5371..0b425ff6182 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -770,9 +770,9 @@ uint16_t APIConnection::try_send_number_state(EntityBase *entity, APIConnection uint16_t APIConnection::try_send_number_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) { auto *number = static_cast(entity); ListEntitiesNumberResponse msg; - msg.unit_of_measurement = number->traits.get_unit_of_measurement_ref(); + msg.unit_of_measurement = number->get_unit_of_measurement_ref(); msg.mode = static_cast(number->traits.get_mode()); - msg.device_class = number->traits.get_device_class_ref(); + 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(); diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index c38d6b78d39..54caaff9cc6 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -60,7 +60,11 @@ from esphome.const import ( DEVICE_CLASS_WINDOW, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObjClass from esphome.util import Registry @@ -550,11 +554,9 @@ def binary_sensor_schema( return _BINARY_SENSOR_SCHEMA.extend(schema) +@setup_entity("binary_sensor") async def setup_binary_sensor_core_(var, config): - await setup_entity(var, config, "binary_sensor") - - if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: - cg.add(var.set_device_class(device_class)) + setup_device_class(config) trigger = config.get(CONF_TRIGGER_ON_INITIAL_STATE, False) or config.get( CONF_PUBLISH_INITIAL_STATE, False ) diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index 83c992bfede..af05f379911 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -28,7 +28,7 @@ void log_binary_sensor(const char *tag, const char *prefix, const char *type, Bi * The sub classes should notify the front-end of new states via the publish_state() method which * handles inverted inputs for you. */ -class BinarySensor : public StatefulEntityBase, public EntityBase_DeviceClass { +class BinarySensor : public StatefulEntityBase { public: explicit BinarySensor(){}; diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index d2f143b97ef..48197324add 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -18,7 +18,11 @@ from esphome.const import ( DEVICE_CLASS_UPDATE, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@esphome/core"] @@ -84,15 +88,13 @@ def button_schema( return _BUTTON_SCHEMA.extend(schema) +@setup_entity("button") async def setup_button_core_(var, config): - await setup_entity(var, config, "button") - for conf in config.get(CONF_ON_PRESS, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) - if device_class := config.get(CONF_DEVICE_CLASS): - cg.add(var.set_device_class(device_class)) + setup_device_class(config) if mqtt_id := config.get(CONF_MQTT_ID): mqtt_ = cg.new_Pvariable(mqtt_id, var) diff --git a/esphome/components/button/button.h b/esphome/components/button/button.h index be6e080917b..0f7576a419f 100644 --- a/esphome/components/button/button.h +++ b/esphome/components/button/button.h @@ -22,7 +22,7 @@ void log_button(const char *tag, const char *prefix, const char *type, Button *o * * A button is just a momentary switch that does not have a state, only a trigger. */ -class Button : public EntityBase, public EntityBase_DeviceClass { +class Button : public EntityBase { public: /** Press this button. This is called by the front-end. * diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 2150a30c3e4..1f449ad2a4b 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -268,9 +268,8 @@ def climate_schema( return _CLIMATE_SCHEMA.extend(schema) +@setup_entity("climate") async def setup_climate_core_(var, config): - await setup_entity(var, config, "climate") - visual = config[CONF_VISUAL] if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None: cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 648fe7decff..d4440fab90a 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -37,7 +37,11 @@ from esphome.const import ( DEVICE_CLASS_WINDOW, ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType, TemplateArgsType @@ -190,11 +194,9 @@ def cover_schema( return _COVER_SCHEMA.extend(schema) +@setup_entity("cover") async def setup_cover_core_(var, config): - await setup_entity(var, config, "cover") - - if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: - cg.add(var.set_device_class(device_class)) + setup_device_class(config) if CONF_ON_OPEN in config: _LOGGER.warning( diff --git a/esphome/components/cover/cover.h b/esphome/components/cover/cover.h index 0af48f75de8..8cf9aa092aa 100644 --- a/esphome/components/cover/cover.h +++ b/esphome/components/cover/cover.h @@ -107,7 +107,7 @@ const LogString *cover_operation_to_str(CoverOperation op); * to control all values of the cover. Also implement get_traits() to return what operations * the cover supports. */ -class Cover : public EntityBase, public EntityBase_DeviceClass { +class Cover : public EntityBase { public: explicit Cover(); diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 602db3827ad..74c9d594f75 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -134,9 +134,8 @@ def datetime_schema(class_: MockObjClass) -> cv.Schema: return _DATETIME_SCHEMA.extend(schema) +@setup_entity("datetime") async def setup_datetime_core_(var, config): - await setup_entity(var, config, "datetime") - if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: mqtt_ = cg.new_Pvariable(mqtt_id, var) await mqtt.register_mqtt_component(mqtt_, config) diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 202d929ab93..ad6d584245f 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -47,6 +47,7 @@ void arch_init() { void HOT arch_feed_wdt() { esp_task_wdt_reset(); } uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } +uint16_t progmem_read_word(const uint16_t *addr) { return *addr; } uint32_t arch_get_cpu_cycle_count() { return esp_cpu_get_cycle_count(); } uint32_t arch_get_cpu_freq_hz() { uint32_t freq = 0; diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 3a5d87792b6..22e14ae9ee8 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -368,11 +368,16 @@ SETTERS = { } +@setup_entity("camera") +async def _setup_esp32_camera(var, config): + pass + + async def to_code(config): cg.add_define("USE_CAMERA") socket.require_wake_loop_threadsafe() var = cg.new_Pvariable(config[CONF_ID]) - await setup_entity(var, config, "camera") + await _setup_esp32_camera(var, config) await cg.register_component(var, config) for key, setter in SETTERS.items(): diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 236d3022be3..50345f8bc3a 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -32,6 +32,9 @@ void HOT arch_feed_wdt() { system_soft_wdt_feed(); } uint8_t progmem_read_byte(const uint8_t *addr) { return pgm_read_byte(addr); // NOLINT } +uint16_t progmem_read_word(const uint16_t *addr) { + return pgm_read_word(addr); // NOLINT +} uint32_t IRAM_ATTR HOT arch_get_cpu_cycle_count() { return esp_get_cycle_count(); } uint32_t arch_get_cpu_freq_hz() { return F_CPU; } diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 8fac7a279c4..14cc1505ad8 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -18,7 +18,11 @@ from esphome.const import ( DEVICE_CLASS_MOTION, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@nohat"] @@ -85,17 +89,15 @@ def event_schema( return _EVENT_SCHEMA.extend(schema) +@setup_entity("event") async def setup_event_core_(var, config, *, event_types: list[str]): - await setup_entity(var, config, "event") - for conf in config.get(CONF_ON_EVENT, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [(cg.StringRef, "event_type")], conf) cg.add(var.set_event_types(event_types)) - if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: - cg.add(var.set_device_class(device_class)) + setup_device_class(config) if mqtt_id := config.get(CONF_MQTT_ID): mqtt_ = cg.new_Pvariable(mqtt_id, var) diff --git a/esphome/components/event/event.h b/esphome/components/event/event.h index a7451407bba..5b6a94b47c0 100644 --- a/esphome/components/event/event.h +++ b/esphome/components/event/event.h @@ -20,7 +20,7 @@ namespace event { LOG_ENTITY_DEVICE_CLASS(TAG, prefix, *(obj)); \ } -class Event : public EntityBase, public EntityBase_DeviceClass { +class Event : public EntityBase { public: void trigger(const std::string &event_type); diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index 6010aa8ed46..9c0c118830e 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -222,9 +222,8 @@ def validate_preset_modes(value): return value +@setup_entity("fan") async def setup_fan_core_(var, config): - await setup_entity(var, config, "fan") - cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index c20a33fa378..296ada2c3c4 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -53,6 +53,7 @@ void HOT arch_feed_wdt() { } uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } +uint16_t progmem_read_word(const uint16_t *addr) { return *addr; } uint32_t arch_get_cpu_cycle_count() { struct timespec spec; clock_gettime(CLOCK_MONOTONIC, &spec); diff --git a/esphome/components/infrared/__init__.py b/esphome/components/infrared/__init__.py index 5c759d6fd9c..6a2a72fa5d7 100644 --- a/esphome/components/infrared/__init__.py +++ b/esphome/components/infrared/__init__.py @@ -45,9 +45,9 @@ def infrared_schema(class_: type[cg.MockObjClass]) -> cv.Schema: ) +@setup_entity("infrared") async def setup_infrared_core_(var: cg.Pvariable, config: ConfigType) -> None: """Set up core infrared configuration.""" - await setup_entity(var, config, "infrared") async def register_infrared(var: cg.Pvariable, config: ConfigType) -> None: diff --git a/esphome/components/libretiny/core.cpp b/esphome/components/libretiny/core.cpp index 4dda7c38563..a1a7e7745e7 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -34,6 +34,7 @@ void HOT arch_feed_wdt() { lt_wdt_feed(); } uint32_t arch_get_cpu_cycle_count() { return lt_cpu_get_cycle_count(); } uint32_t arch_get_cpu_freq_hz() { return lt_cpu_get_freq(); } uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } +uint16_t progmem_read_word(const uint16_t *addr) { return *addr; } } // namespace esphome diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index f1089ad64f2..0eb883dba61 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -208,9 +208,8 @@ def validate_color_temperature_channels(value): return value -async def setup_light_core_(light_var, output_var, config): - await setup_entity(light_var, config, "light") - +@setup_entity("light") +async def setup_light_core_(light_var, config, output_var): cg.add(light_var.set_restore_mode(config[CONF_RESTORE_MODE])) if (initial_state_config := config.get(CONF_INITIAL_STATE)) is not None: @@ -274,7 +273,7 @@ async def register_light(output_var, config): cg.add(cg.App.register_light(light_var)) CORE.register_platform_component("light", light_var) await cg.register_component(light_var, config) - await setup_light_core_(light_var, output_var, config) + await setup_light_core_(light_var, config, output_var) async def new_light(config, *args): diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index 9d893d3ad9d..e37092756fb 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -91,9 +91,8 @@ def lock_schema( return _LOCK_SCHEMA.extend(schema) +@setup_entity("lock") async def _setup_lock_core(var, config): - await setup_entity(var, config, "lock") - for conf in config.get(CONF_ON_LOCK, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index b2afbe5e587..051e386eaf7 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -96,8 +96,8 @@ VolumeSetAction = media_player_ns.class_( ) +@setup_entity("media_player") async def setup_media_player_core_(var, config): - await setup_entity(var, config, "media_player") for conf_key, _ in _STATE_TRIGGERS: for conf in config.get(conf_key, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) diff --git a/esphome/components/mqtt/mqtt_number.cpp b/esphome/components/mqtt/mqtt_number.cpp index fdc909fcc92..a2734f2beb0 100644 --- a/esphome/components/mqtt/mqtt_number.cpp +++ b/esphome/components/mqtt/mqtt_number.cpp @@ -48,7 +48,7 @@ void MQTTNumberComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCon root[MQTT_MAX] = traits.get_max_value(); root[MQTT_STEP] = traits.get_step(); // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - const auto unit_of_measurement = this->number_->traits.get_unit_of_measurement_ref(); + const auto unit_of_measurement = this->number_->get_unit_of_measurement_ref(); if (!unit_of_measurement.empty()) { root[MQTT_UNIT_OF_MEASUREMENT] = unit_of_measurement; } @@ -57,7 +57,7 @@ void MQTTNumberComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCon root[MQTT_MODE] = NumberMqttModeStrings::get_progmem_str(static_cast(mode), static_cast(NUMBER_MODE_BOX)); } - const auto device_class = this->number_->traits.get_device_class_ref(); + const auto device_class = this->number_->get_device_class_ref(); if (!device_class.empty()) { root[MQTT_DEVICE_CLASS] = device_class; } diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index b23da7799f1..078cc317126 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -79,7 +79,12 @@ from esphome.const import ( DEVICE_CLASS_WIND_SPEED, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, + setup_unit_of_measurement, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@esphome/core"] @@ -240,11 +245,10 @@ def number_schema( return _NUMBER_SCHEMA.extend(schema) +@setup_entity("number") async def setup_number_core_( var, config, *, min_value: float, max_value: float, step: float ): - await setup_entity(var, config, "number") - cg.add(var.traits.set_min_value(min_value)) cg.add(var.traits.set_max_value(max_value)) cg.add(var.traits.set_step(step)) @@ -268,10 +272,8 @@ async def setup_number_core_( cg.add(trigger.set_max(template_)) await automation.build_automation(trigger, [(float, "x")], conf) - if (unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)) is not None: - cg.add(var.traits.set_unit_of_measurement(unit_of_measurement)) - if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: - cg.add(var.traits.set_device_class(device_class)) + setup_device_class(config) + setup_unit_of_measurement(config) if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: mqtt_ = cg.new_Pvariable(mqtt_id, var) diff --git a/esphome/components/number/number.cpp b/esphome/components/number/number.cpp index 1c4126496c5..91d5a260ad2 100644 --- a/esphome/components/number/number.cpp +++ b/esphome/components/number/number.cpp @@ -1,4 +1,5 @@ #include "number.h" +#include #include "esphome/core/defines.h" #include "esphome/core/controller_registry.h" #include "esphome/core/log.h" @@ -15,8 +16,20 @@ void log_number(const char *tag, const char *prefix, const char *type, Number *o ESP_LOGCONFIG(tag, "%s%s '%s'", prefix, type, obj->get_name().c_str()); LOG_ENTITY_ICON(tag, prefix, *obj); - LOG_ENTITY_UNIT_OF_MEASUREMENT(tag, prefix, obj->traits); - LOG_ENTITY_DEVICE_CLASS(tag, prefix, obj->traits); + LOG_ENTITY_UNIT_OF_MEASUREMENT(tag, prefix, *obj); + LOG_ENTITY_DEVICE_CLASS(tag, prefix, *obj); +} + +// Deprecated backward-compat: delegate to parent Number's EntityBase methods +StringRef NumberTraits::get_device_class_ref() const { + const auto *number = + reinterpret_cast(reinterpret_cast(this) - offsetof(Number, traits)); + return number->get_device_class_ref(); +} +StringRef NumberTraits::get_unit_of_measurement_ref() const { + const auto *number = + reinterpret_cast(reinterpret_cast(this) - offsetof(Number, traits)); + return number->get_unit_of_measurement_ref(); } void Number::publish_state(float state) { diff --git a/esphome/components/number/number_traits.h b/esphome/components/number/number_traits.h index 5ccbb9ba489..642768ea8f1 100644 --- a/esphome/components/number/number_traits.h +++ b/esphome/components/number/number_traits.h @@ -11,7 +11,7 @@ enum NumberMode : uint8_t { NUMBER_MODE_SLIDER = 2, }; -class NumberTraits : public EntityBase_DeviceClass, public EntityBase_UnitOfMeasurement { +class NumberTraits { public: // Set/get the number value boundaries. void set_min_value(float min_value) { min_value_ = min_value; } @@ -27,6 +27,16 @@ class NumberTraits : public EntityBase_DeviceClass, public EntityBase_UnitOfMeas void set_mode(NumberMode mode) { this->mode_ = mode; } NumberMode get_mode() const { return this->mode_; } + // Deprecated: use Number::get_device_class_ref() instead. + // Delegates to parent Number's EntityBase via offsetof. + ESPDEPRECATED("Use number->get_device_class_ref() instead. Removed in 2027.2.0", "2026.8.0") + StringRef get_device_class_ref() const; + + // Deprecated: use Number::get_unit_of_measurement_ref() instead. + // Delegates to parent Number's EntityBase via offsetof. + ESPDEPRECATED("Use number->get_unit_of_measurement_ref() instead. Removed in 2027.2.0", "2026.8.0") + StringRef get_unit_of_measurement_ref() const; + protected: float min_value_ = NAN; float max_value_ = NAN; diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp index 37378d88bb1..ad8c3a414ad 100644 --- a/esphome/components/rp2040/core.cpp +++ b/esphome/components/rp2040/core.cpp @@ -32,6 +32,9 @@ void HOT arch_feed_wdt() { watchdog_update(); } uint8_t progmem_read_byte(const uint8_t *addr) { return pgm_read_byte(addr); // NOLINT } +uint16_t progmem_read_word(const uint16_t *addr) { + return pgm_read_word(addr); // NOLINT +} uint32_t IRAM_ATTR HOT arch_get_cpu_cycle_count() { return ulMainGetRunTimeCounterValue(); } uint32_t arch_get_cpu_freq_hz() { return RP2040::f_cpu(); } diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index 84ad591ba13..75d91cd7943 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -92,9 +92,8 @@ def select_schema( return _SELECT_SCHEMA.extend(schema) +@setup_entity("select") async def setup_select_core_(var, config, *, options: list[str]): - await setup_entity(var, config, "select") - cg.add(var.traits.set_options(options)) for conf in config.get(CONF_ON_VALUE, []): diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index ebbe0fbccca..34f42f976b4 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -106,7 +106,12 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, + setup_unit_of_measurement, +) from esphome.cpp_generator import MockObj, MockObjClass from esphome.util import Registry @@ -888,15 +893,12 @@ async def build_filters(config): return await cg.build_registry_list(FILTER_REGISTRY, config) +@setup_entity("sensor") async def setup_sensor_core_(var, config): - await setup_entity(var, config, "sensor") - - if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: - cg.add(var.set_device_class(device_class)) + setup_device_class(config) + setup_unit_of_measurement(config) if (state_class := config.get(CONF_STATE_CLASS)) is not None: cg.add(var.set_state_class(state_class)) - if (unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)) is not None: - cg.add(var.set_unit_of_measurement(unit_of_measurement)) if (accuracy_decimals := config.get(CONF_ACCURACY_DECIMALS)) is not None: cg.add(var.set_accuracy_decimals(accuracy_decimals)) # Only set force_update if True (default is False) diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index f9a45cb1d09..9728179bd7e 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -40,7 +40,7 @@ const LogString *state_class_to_string(StateClass state_class); * * A sensor has unit of measurement and can use publish_state to send out a new value with the specified accuracy. */ -class Sensor : public EntityBase, public EntityBase_DeviceClass, public EntityBase_UnitOfMeasurement { +class Sensor : public EntityBase { public: explicit Sensor(); diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index 9e423c17609..cc70cf033dd 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -566,7 +566,7 @@ void Sprinkler::set_valve_run_duration(const optional valve_number, cons return; } auto call = this->valve_[valve_number.value()].run_duration_number->make_call(); - if (this->valve_[valve_number.value()].run_duration_number->traits.get_unit_of_measurement_ref() == MIN_STR) { + if (this->valve_[valve_number.value()].run_duration_number->get_unit_of_measurement_ref() == MIN_STR) { call.set_value(run_duration.value() / 60.0); } else { call.set_value(run_duration.value()); @@ -648,7 +648,7 @@ uint32_t Sprinkler::valve_run_duration(const size_t valve_number) { return 0; } if (this->valve_[valve_number].run_duration_number != nullptr) { - if (this->valve_[valve_number].run_duration_number->traits.get_unit_of_measurement_ref() == MIN_STR) { + if (this->valve_[valve_number].run_duration_number->get_unit_of_measurement_ref() == MIN_STR) { return static_cast(roundf(this->valve_[valve_number].run_duration_number->state * 60)); } else { return static_cast(roundf(this->valve_[valve_number].run_duration_number->state)); diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 7424d7c92fe..52aa1efaa78 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -22,7 +22,11 @@ from esphome.const import ( DEVICE_CLASS_SWITCH, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@esphome/core"] @@ -141,9 +145,8 @@ def switch_schema( return _SWITCH_SCHEMA.extend(schema) +@setup_entity("switch") async def setup_switch_core_(var, config): - await setup_entity(var, config, "switch") - if (inverted := config.get(CONF_INVERTED)) is not None: cg.add(var.set_inverted(inverted)) for conf in config.get(CONF_ON_STATE, []): @@ -163,8 +166,7 @@ async def setup_switch_core_(var, config): if web_server_config := config.get(CONF_WEB_SERVER): await web_server.add_entity_config(var, web_server_config) - if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: - cg.add(var.set_device_class(device_class)) + setup_device_class(config) cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) await zigbee.setup_switch(var, config) diff --git a/esphome/components/switch/switch.h b/esphome/components/switch/switch.h index 9319adf9ed7..10e5df0a363 100644 --- a/esphome/components/switch/switch.h +++ b/esphome/components/switch/switch.h @@ -36,7 +36,7 @@ enum SwitchRestoreMode : uint8_t { * A switch is basically just a combination of a binary sensor (for reporting switch values) * and a write_state method that writes a state to the hardware. */ -class Switch : public EntityBase, public EntityBase_DeviceClass { +class Switch : public EntityBase { public: explicit Switch(); diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index 9ceea0dfdfc..92b5bf670f4 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -84,6 +84,7 @@ def text_schema( return _TEXT_SCHEMA.extend(schema) +@setup_entity("text") async def setup_text_core_( var, config, @@ -92,8 +93,6 @@ async def setup_text_core_( max_length: int | None, pattern: str | None, ): - await setup_entity(var, config, "text") - cg.add(var.traits.set_min_length(min_length)) cg.add(var.traits.set_max_length(max_length)) if pattern is not None: diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 0d22400a8eb..a4dac8c28fb 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -21,7 +21,11 @@ from esphome.const import ( DEVICE_CLASS_TIMESTAMP, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObjClass from esphome.util import Registry @@ -197,11 +201,9 @@ async def build_filters(config): return await cg.build_registry_list(FILTER_REGISTRY, config) +@setup_entity("text_sensor") async def setup_text_sensor_core_(var, config): - await setup_entity(var, config, "text_sensor") - - if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: - cg.add(var.set_device_class(device_class)) + setup_device_class(config) if config.get(CONF_FILTERS): # must exist and not be empty filters = await build_filters(config[CONF_FILTERS]) diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index 1352a8c1e48..43c3a195dc4 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -22,7 +22,7 @@ void log_text_sensor(const char *tag, const char *prefix, const char *type, Text public: \ void set_##name##_text_sensor(text_sensor::TextSensor *text_sensor) { this->name##_text_sensor_ = text_sensor; } -class TextSensor : public EntityBase, public EntityBase_DeviceClass { +class TextSensor : public EntityBase { public: std::string state; diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index e146f7e6857..c36a4ab769d 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -15,7 +15,11 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@jesserockz"] @@ -87,11 +91,9 @@ def update_schema( return _UPDATE_SCHEMA.extend(schema) +@setup_entity("update") async def setup_update_core_(var, config): - await setup_entity(var, config, "update") - - if device_class_config := config.get(CONF_DEVICE_CLASS): - cg.add(var.set_device_class(device_class_config)) + setup_device_class(config) if on_update_available := config.get(CONF_ON_UPDATE_AVAILABLE): await automation.build_automation( diff --git a/esphome/components/update/update_entity.h b/esphome/components/update/update_entity.h index 405346bee4f..82eaacaf76d 100644 --- a/esphome/components/update/update_entity.h +++ b/esphome/components/update/update_entity.h @@ -29,7 +29,7 @@ enum UpdateState : uint8_t { const LogString *update_state_to_string(UpdateState state); -class UpdateEntity : public EntityBase, public EntityBase_DeviceClass { +class UpdateEntity : public EntityBase { public: void publish_state(); diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 73e907eb0f9..22cd01988d8 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -22,7 +22,11 @@ from esphome.const import ( DEVICE_CLASS_WATER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObjClass IS_PLATFORM_COMPONENT = True @@ -129,11 +133,9 @@ def valve_schema( return _VALVE_SCHEMA.extend(schema) +@setup_entity("valve") async def _setup_valve_core(var, config): - await setup_entity(var, config, "valve") - - if device_class_config := config.get(CONF_DEVICE_CLASS): - cg.add(var.set_device_class(device_class_config)) + setup_device_class(config) for conf in config.get(CONF_ON_OPEN, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) diff --git a/esphome/components/valve/valve.h b/esphome/components/valve/valve.h index cd461443727..aab819a7788 100644 --- a/esphome/components/valve/valve.h +++ b/esphome/components/valve/valve.h @@ -101,7 +101,7 @@ const LogString *valve_operation_to_str(ValveOperation op); * to control all values of the valve. Also implement get_traits() to return what operations * the valve supports. */ -class Valve : public EntityBase, public EntityBase_DeviceClass { +class Valve : public EntityBase { public: explicit Valve(); diff --git a/esphome/components/water_heater/__init__.py b/esphome/components/water_heater/__init__.py index db32c2d9193..58cf5a4054e 100644 --- a/esphome/components/water_heater/__init__.py +++ b/esphome/components/water_heater/__init__.py @@ -69,10 +69,9 @@ def water_heater_schema( return _WATER_HEATER_SCHEMA.extend(schema) +@setup_entity("water_heater") async def setup_water_heater_core_(var: cg.Pvariable, config: ConfigType) -> None: """Set up the core water heater properties in C++.""" - await setup_entity(var, config, "water_heater") - visual = config[CONF_VISUAL] if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None: cg.add_define("USE_WATER_HEATER_VISUAL_OVERRIDES") diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 4b572417c1d..b2054d57bf2 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1131,7 +1131,7 @@ json::SerializationBuffer<> WebServer::number_json_(number::Number *obj, float v json::JsonBuilder builder; JsonObject root = builder.root(); - const auto uom_ref = obj->traits.get_unit_of_measurement_ref(); + const auto uom_ref = obj->get_unit_of_measurement_ref(); const int8_t accuracy = step_to_accuracy_decimals(obj->traits.get_step()); // Need two buffers: one for value, one for state with UOM diff --git a/esphome/components/zephyr/core.cpp b/esphome/components/zephyr/core.cpp index d7027b33f57..7b41fa30cd1 100644 --- a/esphome/components/zephyr/core.cpp +++ b/esphome/components/zephyr/core.cpp @@ -59,6 +59,7 @@ void arch_restart() { sys_reboot(SYS_REBOOT_COLD); } uint32_t arch_get_cpu_cycle_count() { return k_cycle_get_32(); } uint32_t arch_get_cpu_freq_hz() { return sys_clock_hw_cycles_per_sec(); } uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } +uint16_t progmem_read_word(const uint16_t *addr) { return *addr; } Mutex::Mutex() { auto *mutex = new k_mutex(); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 5109dd36f43..9a66d0b4e2c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -42,7 +42,9 @@ #define USE_DEEP_SLEEP #define USE_DEVICES #define USE_DISPLAY +#define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON +#define USE_ENTITY_UNIT_OF_MEASUREMENT #define USE_ESP32_CAMERA_JPEG_CONVERSION #define USE_ESP32_HOSTED #define USE_ESP32_IMPROV_STATE_CALLBACK diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index f6a7ec1dfdd..05f545c861d 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -45,24 +45,46 @@ void EntityBase::set_name(const char *name, uint32_t object_id_hash) { } } -// Entity Icon -std::string EntityBase::get_icon() const { +// Weak default lookup functions — overridden by generated code in main.cpp +__attribute__((weak)) const char *entity_device_class_lookup(uint16_t) { return ""; } +__attribute__((weak)) const char *entity_uom_lookup(uint16_t) { return ""; } +__attribute__((weak)) const char *entity_icon_lookup(uint16_t) { return ""; } + +// Entity device class (from packed index) +StringRef EntityBase::get_device_class_ref() const { + static constexpr auto EMPTY = StringRef::from_lit(""); + uint16_t idx = (this->entity_string_packed_ >> ENTITY_STR_DC_SHIFT) & ENTITY_STR_DC_MASK; + if (idx == 0) + return EMPTY; + return StringRef(entity_device_class_lookup(idx)); +} +std::string EntityBase::get_device_class() const { return std::string(this->get_device_class_ref().c_str()); } + +// Entity unit of measurement (from packed index) +StringRef EntityBase::get_unit_of_measurement_ref() const { + static constexpr auto EMPTY = StringRef::from_lit(""); + uint16_t idx = (this->entity_string_packed_ >> ENTITY_STR_UOM_SHIFT) & ENTITY_STR_UOM_MASK; + if (idx == 0) + return EMPTY; + return StringRef(entity_uom_lookup(idx)); +} +std::string EntityBase::get_unit_of_measurement() const { + return std::string(this->get_unit_of_measurement_ref().c_str()); +} + +// Entity icon (from packed index) +StringRef EntityBase::get_icon_ref() const { + static constexpr auto EMPTY = StringRef::from_lit(""); #ifdef USE_ENTITY_ICON - if (this->icon_c_str_ == nullptr) { - return ""; - } - return this->icon_c_str_; + uint16_t idx = (this->entity_string_packed_ >> ENTITY_STR_ICON_SHIFT) & ENTITY_STR_ICON_MASK; + if (idx == 0) + return EMPTY; + return StringRef(entity_icon_lookup(idx)); #else - return ""; -#endif -} -void EntityBase::set_icon(const char *icon) { -#ifdef USE_ENTITY_ICON - this->icon_c_str_ = icon; -#else - // No-op when USE_ENTITY_ICON is not defined + return EMPTY; #endif } +std::string EntityBase::get_icon() const { return std::string(this->get_icon_ref().c_str()); } // Entity Object ID - computed on-demand from name std::string EntityBase::get_object_id() const { @@ -134,24 +156,6 @@ ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t ve return global_preferences->make_preference(size, key); } -std::string EntityBase_DeviceClass::get_device_class() { - if (this->device_class_ == nullptr) { - return ""; - } - return this->device_class_; -} - -void EntityBase_DeviceClass::set_device_class(const char *device_class) { this->device_class_ = device_class; } - -std::string EntityBase_UnitOfMeasurement::get_unit_of_measurement() { - if (this->unit_of_measurement_ == nullptr) - return ""; - return this->unit_of_measurement_; -} -void EntityBase_UnitOfMeasurement::set_unit_of_measurement(const char *unit_of_measurement) { - this->unit_of_measurement_ = unit_of_measurement; -} - #ifdef USE_ENTITY_ICON void log_entity_icon(const char *tag, const char *prefix, const EntityBase &obj) { if (!obj.get_icon_ref().empty()) { @@ -160,13 +164,13 @@ 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_DeviceClass &obj) { +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()); } } -void log_entity_unit_of_measurement(const char *tag, const char *prefix, const EntityBase_UnitOfMeasurement &obj) { +void log_entity_unit_of_measurement(const char *tag, const char *prefix, const EntityBase &obj) { if (!obj.get_unit_of_measurement_ref().empty()) { ESP_LOGCONFIG(tag, "%s Unit of Measurement: '%s'", prefix, obj.get_unit_of_measurement_ref().c_str()); } diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index cbc07cc44c0..d85f6ee4353 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -14,6 +14,21 @@ namespace esphome { +// Extern lookup functions for packed entity string tables. +// Generated code provides strong definitions; weak defaults return "". +extern const char *entity_device_class_lookup(uint16_t index); +extern const char *entity_uom_lookup(uint16_t index); +extern const char *entity_icon_lookup(uint16_t index); + +// Bit layout for entity_string_packed_: +// [31..20] icon (12 bits) | [19..10] UoM (10 bits) | [9..0] device_class (10 bits) +static constexpr uint8_t ENTITY_STR_DC_SHIFT = 0; +static constexpr uint8_t ENTITY_STR_UOM_SHIFT = 10; +static constexpr uint8_t ENTITY_STR_ICON_SHIFT = 20; +static constexpr uint16_t ENTITY_STR_DC_MASK = 0x3FF; +static constexpr uint16_t ENTITY_STR_UOM_MASK = 0x3FF; +static constexpr uint16_t ENTITY_STR_ICON_MASK = 0xFFF; + // Maximum device name length - keep in sync with validate_hostname() in esphome/core/config.py static constexpr size_t ESPHOME_DEVICE_NAME_MAX_LEN = 31; @@ -89,20 +104,31 @@ class EntityBase { this->flags_.entity_category = static_cast(entity_category); } + // Set packed entity string indices — one call per entity from codegen. + // Bit layout: [31..20] icon (12 bits) | [19..10] UoM (10 bits) | [9..0] device_class (10 bits) + void set_entity_strings(uint32_t packed) { this->entity_string_packed_ = packed; } + + // Get device class as StringRef (from packed index) + 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") + std::string get_device_class() const; + // 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()) + ESPDEPRECATED("Use get_unit_of_measurement_ref() instead for better performance (avoids string copy). Will be " + "removed in ESPHome 2026.9.0", + "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; - void set_icon(const char *icon); - StringRef get_icon_ref() const { - static constexpr auto EMPTY_STRING = StringRef::from_lit(""); -#ifdef USE_ENTITY_ICON - return this->icon_c_str_ == nullptr ? EMPTY_STRING : StringRef(this->icon_c_str_); -#else - return EMPTY_STRING; -#endif - } + StringRef get_icon_ref() const; #ifdef USE_DEVICES // Get/set this entity's device id @@ -173,9 +199,7 @@ class EntityBase { void calc_object_id_(); StringRef name_; -#ifdef USE_ENTITY_ICON - const char *icon_c_str_{nullptr}; -#endif + uint32_t entity_string_packed_{0}; // bits 0-9: device_class, 10-19: uom, 20-31: icon uint32_t object_id_hash_{}; #ifdef USE_DEVICES Device *device_{}; @@ -192,42 +216,14 @@ class EntityBase { } flags_{}; }; +// Empty shell — methods moved to EntityBase. Kept for backward compatibility. +// TODO: Remove in 2027.2.0 class EntityBase_DeviceClass { // NOLINT(readability-identifier-naming) - public: - /// Get the device class, using the manual override if set. - ESPDEPRECATED("Use get_device_class_ref() instead for better performance (avoids string copy). Will be removed in " - "ESPHome 2026.5.0", - "2025.11.0") - std::string get_device_class(); - /// Manually set the device class. - void set_device_class(const char *device_class); - /// Get the device class as StringRef - StringRef get_device_class_ref() const { - static constexpr auto EMPTY_STRING = StringRef::from_lit(""); - return this->device_class_ == nullptr ? EMPTY_STRING : StringRef(this->device_class_); - } - - protected: - const char *device_class_{nullptr}; ///< Device class override }; +// Empty shell — methods moved to EntityBase. Kept for backward compatibility. +// TODO: Remove in 2027.2.0 class EntityBase_UnitOfMeasurement { // NOLINT(readability-identifier-naming) - public: - /// Get the unit of measurement, using the manual override if set. - ESPDEPRECATED("Use get_unit_of_measurement_ref() instead for better performance (avoids string copy). Will be " - "removed in ESPHome 2026.5.0", - "2025.11.0") - std::string get_unit_of_measurement(); - /// Manually set the unit of measurement. - void set_unit_of_measurement(const char *unit_of_measurement); - /// Get the unit of measurement as StringRef - StringRef get_unit_of_measurement_ref() const { - static constexpr auto EMPTY_STRING = StringRef::from_lit(""); - return this->unit_of_measurement_ == nullptr ? EMPTY_STRING : StringRef(this->unit_of_measurement_); - } - - protected: - const char *unit_of_measurement_{nullptr}; ///< Unit of measurement override }; /// Log entity icon if set (for use in dump_config) @@ -240,10 +236,10 @@ inline void log_entity_icon(const char *, const char *, const EntityBase &) {} #endif /// Log entity device class if set (for use in dump_config) #define LOG_ENTITY_DEVICE_CLASS(tag, prefix, obj) log_entity_device_class(tag, prefix, obj) -void log_entity_device_class(const char *tag, const char *prefix, const EntityBase_DeviceClass &obj); +void log_entity_device_class(const char *tag, const char *prefix, const EntityBase &obj); /// Log entity unit of measurement if set (for use in dump_config) #define LOG_ENTITY_UNIT_OF_MEASUREMENT(tag, prefix, obj) log_entity_unit_of_measurement(tag, prefix, obj) -void log_entity_unit_of_measurement(const char *tag, const char *prefix, const EntityBase_UnitOfMeasurement &obj); +void log_entity_unit_of_measurement(const char *tag, const char *prefix, const EntityBase &obj); /** * An entity that has a state. diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index c1801c0bdaa..da64699260a 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -1,9 +1,12 @@ from collections.abc import Callable +from dataclasses import dataclass, field +import functools import logging import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( + CONF_DEVICE_CLASS, CONF_DEVICE_ID, CONF_DISABLED_BY_DEFAULT, CONF_ENTITY_CATEGORY, @@ -11,15 +14,184 @@ from esphome.const import ( CONF_ID, CONF_INTERNAL, CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, ) from esphome.core import CORE, ID -from esphome.cpp_generator import MockObj, add, get_variable +from esphome.cpp_generator import DeferredStatement, MockObj, add, get_variable import esphome.final_validate as fv -from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case +from esphome.helpers import cpp_string_escape, fnv1_hash_object_id, sanitize, snake_case from esphome.types import ConfigType, EntityMetadata _LOGGER = logging.getLogger(__name__) +DOMAIN = "entity_string_pool" + +# Private config keys for storing registered string indices +_KEY_DC_IDX = "_entity_dc_idx" +_KEY_UOM_IDX = "_entity_uom_idx" +_KEY_ICON_IDX = "_entity_icon_idx" + + +@dataclass +class EntityStringPool: + """Pool of entity string properties packed into contiguous blobs. + + Strings are registered during to_code() and assigned 1-based indices. + Index 0 means "not set" (empty string). At render time, the pool + generates C++ blob + PROGMEM offset table + lookup function per category. + """ + + device_classes: dict[str, int] = field(default_factory=dict) + units: dict[str, int] = field(default_factory=dict) + icons: dict[str, int] = field(default_factory=dict) + _tables_registered: bool = False + + +def _get_pool() -> EntityStringPool: + """Get or create the entity string pool from CORE.data.""" + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = EntityStringPool() + return CORE.data[DOMAIN] + + +def _ensure_tables_registered() -> None: + """Register the deferred global statement for table generation (once).""" + pool = _get_pool() + if pool._tables_registered: + return + pool._tables_registered = True + cg.add_global(DeferredStatement(_generate_tables)) + + +def _generate_blob_and_offsets( + strings: dict[str, int], +) -> tuple[bytes, list[int]]: + """Build a packed blob and offset list from a string dict. + + Returns (blob_bytes, offsets) where offsets[0] points to "" (empty) + and offsets[i] points to the i-th string. + """ + # Sort by assigned index to ensure deterministic output + sorted_strings = sorted(strings.items(), key=lambda x: x[1]) + blob = bytearray(b"\x00") # Index 0 = offset 0 = empty string + offsets = [0] # offset for index 0 (empty string) + for s, _idx in sorted_strings: + offsets.append(len(blob)) + blob.extend(s.encode("utf-8")) + blob.append(0) # null terminator + return bytes(blob), offsets + + +def _generate_category_code( + blob_var: str, + offsets_var: str, + lookup_fn: str, + strings: dict[str, int], +) -> str: + """Generate C++ code for one string category (blob + offsets + lookup).""" + if not strings: + return "" + + blob_bytes, offsets = _generate_blob_and_offsets(strings) + use_uint16 = len(blob_bytes) > 255 + offset_type = "uint16_t" if use_uint16 else "uint8_t" + read_fn = "progmem_read_word" if use_uint16 else "progmem_read_byte" + count = len(offsets) + blob_escaped = cpp_string_escape(blob_bytes) + offsets_str = ", ".join(str(o) for o in offsets) + + return ( + f"static const char {blob_var}[] = {blob_escaped};\n" + f"static const {offset_type} {offsets_var}[] PROGMEM = {{{offsets_str}}};\n" + f"const char *{lookup_fn}(uint16_t index) {{\n" + f" if (index >= {count}) return {blob_var};\n" + f" return &{blob_var}[{read_fn}(&{offsets_var}[index])];\n" + f"}}\n" + ) + + +_CATEGORY_CONFIGS = ( + ("ENTITY_DC", "entity_device_class_lookup", "device_classes"), + ("ENTITY_UOM", "entity_uom_lookup", "units"), + ("ENTITY_ICON", "entity_icon_lookup", "icons"), +) + + +def _generate_tables() -> str: + """Generate all entity string table C++ code. Called at render time.""" + pool = _get_pool() + parts = ["namespace esphome {"] + for prefix, lookup_fn, attr in _CATEGORY_CONFIGS: + code = _generate_category_code( + f"{prefix}_BLOB", f"{prefix}_OFFSETS", lookup_fn, getattr(pool, attr) + ) + if code: + parts.append(code) + parts.append("} // namespace esphome") + return "\n".join(parts) + + +def _register_string( + value: str, category: dict[str, int], max_count: int, category_name: str +) -> int: + """Register a string in a category dict and return its 1-based index. + + Returns 0 if value is empty/None (meaning "not set"). + """ + if not value: + return 0 + if value in category: + return category[value] + idx = len(category) + 1 + if idx > max_count: + raise ValueError( + f"Too many unique {category_name} values (max {max_count}), got {idx}: '{value}'" + ) + category[value] = idx + _ensure_tables_registered() + return idx + + +def register_device_class(value: str) -> int: + """Register a device_class string and return its 1-based index.""" + return _register_string(value, _get_pool().device_classes, 1023, "device_class") + + +def register_unit_of_measurement(value: str) -> int: + """Register a unit_of_measurement string and return its 1-based index.""" + return _register_string(value, _get_pool().units, 1023, "unit_of_measurement") + + +def register_icon(value: str) -> int: + """Register an icon string and return its 1-based index.""" + return _register_string(value, _get_pool().icons, 4095, "icon") + + +def setup_device_class(config: ConfigType) -> None: + """Register config's device_class and store its index for finalize_entity_strings.""" + config[_KEY_DC_IDX] = register_device_class(config.get(CONF_DEVICE_CLASS, "")) + + +def setup_unit_of_measurement(config: ConfigType) -> None: + """Register config's unit_of_measurement and store its index for finalize_entity_strings.""" + config[_KEY_UOM_IDX] = register_unit_of_measurement( + config.get(CONF_UNIT_OF_MEASUREMENT, "") + ) + + +def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: + """Emit a single set_entity_strings() call with all packed 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. + """ + dc_idx = config.get(_KEY_DC_IDX, 0) + uom_idx = config.get(_KEY_UOM_IDX, 0) + icon_idx = config.get(_KEY_ICON_IDX, 0) + packed = dc_idx | (uom_idx << 10) | (icon_idx << 20) + if packed != 0: + add(var.set_entity_strings(packed)) + def get_base_entity_object_id( name: str, friendly_name: str | None, device_name: str | None = None @@ -64,16 +236,40 @@ def get_base_entity_object_id( return sanitize(snake_case(base_str)) -async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: - """Set up generic properties of an Entity. +def setup_entity(platform: str) -> Callable: + """Decorator for component setup functions. + + Wraps the function to: + 1. Set up common entity properties (name, icon, etc.) + 2. Run the wrapped function (which may call setup_device_class, etc.) + 3. Finalize entity strings (pack dc/uom/icon indices into uint32_t) + + Usage:: + + @setup_entity("sensor") + async def setup_sensor_core_(var, config): + setup_device_class(config) + setup_unit_of_measurement(config) + ... + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + async def wrapper(var: MockObj, config: ConfigType, *args, **kwargs) -> None: + await _setup_entity_impl(var, config, platform) + await func(var, config, *args, **kwargs) + finalize_entity_strings(var, config) + + return wrapper + + return decorator + + +async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> None: + """Set up generic properties of an Entity (internal implementation). This function sets up the common entity properties like name, icon, entity category, etc. - - Args: - var: The entity variable to set up - config: Configuration dictionary containing entity settings - platform: The platform name (e.g., "sensor", "binary_sensor") """ # Get device info if configured if device_id_obj := config.get(CONF_DEVICE_ID): @@ -92,12 +288,15 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: add(var.set_disabled_by_default(True)) if CONF_INTERNAL in config: add(var.set_internal(config[CONF_INTERNAL])) + icon_idx = 0 if CONF_ICON in config: # Add USE_ENTITY_ICON define when icons are used cg.add_define("USE_ENTITY_ICON") - add(var.set_icon(config[CONF_ICON])) + icon_idx = register_icon(config[CONF_ICON]) if CONF_ENTITY_CATEGORY in config: add(var.set_entity_category(config[CONF_ENTITY_CATEGORY])) + # Store icon index for finalize_entity_strings + config[_KEY_ICON_IDX] = icon_idx def inherit_property_from(property_to_inherit, parent_id_property, transform=None): diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 1a4230e4215..25c327a5dd3 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -41,5 +41,6 @@ void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); uint32_t arch_get_cpu_freq_hz(); uint8_t progmem_read_byte(const uint8_t *addr); +uint16_t progmem_read_word(const uint16_t *addr); } // namespace esphome diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index fe666bdd6e1..9e4f13e0e61 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -393,6 +393,23 @@ class RawStatement(Statement): return self.text +class DeferredStatement(Statement): + """Statement evaluated lazily at render time (after all to_code() calls). + + Use this to generate code that depends on state accumulated across + multiple components' to_code() calls. The resolver callback is only + called when the statement is converted to a string during code generation. + """ + + __slots__ = ("_resolver",) + + def __init__(self, resolver: Callable[[], str]): + self._resolver = resolver + + def __str__(self): + return self._resolver() + + class ExpressionStatement(Statement): __slots__ = ("expression",) diff --git a/tests/component_tests/sensor/test_sensor.py b/tests/component_tests/sensor/test_sensor.py index 35ce1f4e11b..221e7edf2c3 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->set_device_class("voltage");' in main_cpp + assert "s_1->set_entity_strings(" in main_cpp diff --git a/tests/component_tests/text_sensor/test_text_sensor.py b/tests/component_tests/text_sensor/test_text_sensor.py index 1593d0b6d8e..4aaebe04d1c 100644 --- a/tests/component_tests/text_sensor/test_text_sensor.py +++ b/tests/component_tests/text_sensor/test_text_sensor.py @@ -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->set_device_class("timestamp");' in main_cpp - assert 'ts_3->set_device_class("date");' in main_cpp + assert "ts_2->set_entity_strings(" in main_cpp + assert "ts_3->set_entity_strings(" in main_cpp diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index a58d4784cee..b937b2fceed 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -18,9 +18,9 @@ from esphome.const import ( ) from esphome.core import CORE, ID, entity_helpers from esphome.core.entity_helpers import ( + _setup_entity_impl, entity_duplicate_validator, get_base_entity_object_id, - setup_entity, ) from esphome.cpp_generator import MockObj from esphome.helpers import sanitize, snake_case @@ -305,7 +305,7 @@ async def test_setup_entity_no_duplicates(setup_test_environment: list[str]) -> CONF_NAME: "Temperature", CONF_DISABLED_BY_DEFAULT: False, } - await setup_entity(var1, config1, "sensor") + await _setup_entity_impl(var1, config1, "sensor") # Get object ID from first entity object_id1 = extract_object_id_from_expressions(added_expressions) @@ -319,7 +319,7 @@ async def test_setup_entity_no_duplicates(setup_test_environment: list[str]) -> CONF_NAME: "Humidity", CONF_DISABLED_BY_DEFAULT: False, } - await setup_entity(var2, config2, "sensor") + await _setup_entity_impl(var2, config2, "sensor") # Get object ID from second entity object_id2 = extract_object_id_from_expressions(added_expressions) @@ -354,7 +354,7 @@ async def test_setup_entity_different_platforms( object_ids: list[str] = [] for var, platform in platforms: added_expressions.clear() - await setup_entity(var, config, platform) + await _setup_entity_impl(var, config, platform) object_id = extract_object_id_from_expressions(added_expressions) object_ids.append(object_id) @@ -416,7 +416,7 @@ async def test_setup_entity_with_devices( object_ids: list[str] = [] for var, config in [(sensor1, config1), (sensor2, config2)]: added_expressions.clear() - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") object_id = extract_object_id_from_expressions(added_expressions) object_ids.append(object_id) @@ -438,7 +438,7 @@ async def test_setup_entity_empty_name(setup_test_environment: list[str]) -> Non CONF_DISABLED_BY_DEFAULT: False, } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") object_id = extract_object_id_from_expressions(added_expressions) # Should use friendly name @@ -460,7 +460,7 @@ async def test_setup_entity_special_characters( CONF_DISABLED_BY_DEFAULT: False, } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") object_id = extract_object_id_from_expressions(added_expressions) # Special characters should be sanitized @@ -471,7 +471,7 @@ async def test_setup_entity_special_characters( async def test_setup_entity_with_icon(setup_test_environment: list[str]) -> None: """Test setup_entity sets icon correctly.""" - added_expressions = setup_test_environment + setup_test_environment # noqa: F841 - fixture initializes CORE state var = MockObj("sensor1") @@ -481,12 +481,10 @@ async def test_setup_entity_with_icon(setup_test_environment: list[str]) -> None CONF_ICON: "mdi:thermometer", } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") - # Check icon was set - assert any( - 'sensor1.set_icon("mdi:thermometer")' in expr for expr in added_expressions - ) + # Check icon index was stored in config for finalize_entity_strings + assert config.get("_entity_icon_idx", 0) > 0 @pytest.mark.asyncio @@ -504,7 +502,7 @@ async def test_setup_entity_disabled_by_default( CONF_DISABLED_BY_DEFAULT: True, } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") # Check disabled_by_default was set assert any( @@ -790,7 +788,7 @@ async def test_setup_entity_empty_name_with_device( CONF_DEVICE_ID: device_id, } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") entity_helpers.get_variable = original_get_variable @@ -826,7 +824,7 @@ async def test_setup_entity_empty_name_with_mac_suffix( CONF_DISABLED_BY_DEFAULT: False, } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") # For empty-name entities, Python passes 0 - C++ calculates hash at runtime assert any('set_name("", 0)' in expr for expr in added_expressions), ( @@ -858,7 +856,7 @@ async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name( CONF_DISABLED_BY_DEFAULT: False, } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") # For empty-name entities, Python passes 0 - C++ calculates hash at runtime assert any('set_name("", 0)' in expr for expr in added_expressions), ( @@ -891,7 +889,7 @@ async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name( CONF_DISABLED_BY_DEFAULT: False, } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") # For empty-name entities, Python passes 0 - C++ calculates hash at runtime assert any('set_name("", 0)' in expr for expr in added_expressions), ( From e0f67da7b6093538554397b6aca13523e1e34364 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Feb 2026 22:38:25 -0600 Subject: [PATCH 02/12] Use named constants and coroutine_with_priority for table generation - Replace DeferredStatement with FINAL-priority coroutine job - Add named constants for bit shifts and max values - Use _DC_SHIFT in packed calculation for consistency - Restore Args docstring on _setup_entity_impl --- esphome/core/entity_helpers.py | 49 ++++++++++++++++++++++++++-------- esphome/cpp_generator.py | 17 ------------ 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index da64699260a..367da3d9ec0 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -16,8 +16,8 @@ from esphome.const import ( CONF_NAME, CONF_UNIT_OF_MEASUREMENT, ) -from esphome.core import CORE, ID -from esphome.cpp_generator import DeferredStatement, MockObj, add, get_variable +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +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 from esphome.types import ConfigType, EntityMetadata @@ -31,6 +31,22 @@ _KEY_DC_IDX = "_entity_dc_idx" _KEY_UOM_IDX = "_entity_uom_idx" _KEY_ICON_IDX = "_entity_icon_idx" +# Bit layout for entity_string_packed_ (must match C++ constants in entity_base.h): +# +# uint32_t entity_string_packed_: +# [31 .......... 20][19 ......... 10][9 ........... 0] +# icon (12 bits) UoM (10 bits) device_class (10 bits) +# max 4095 max 1023 max 1023 +# +_DC_SHIFT = 0 +_UOM_SHIFT = 10 +_ICON_SHIFT = 20 + +# Maximum unique strings per category (must match bit widths above) +_MAX_DEVICE_CLASSES = 0x3FF # 10 bits → 1023 +_MAX_UNITS = 0x3FF # 10 bits → 1023 +_MAX_ICONS = 0xFFF # 12 bits → 4095 + @dataclass class EntityStringPool: @@ -55,12 +71,12 @@ def _get_pool() -> EntityStringPool: def _ensure_tables_registered() -> None: - """Register the deferred global statement for table generation (once).""" + """Schedule the table generation job (once).""" pool = _get_pool() if pool._tables_registered: return pool._tables_registered = True - cg.add_global(DeferredStatement(_generate_tables)) + CORE.add_job(_generate_tables_job) def _generate_blob_and_offsets( @@ -117,8 +133,12 @@ _CATEGORY_CONFIGS = ( ) -def _generate_tables() -> str: - """Generate all entity string table C++ code. Called at render time.""" +@coroutine_with_priority(CoroPriority.FINAL) +async def _generate_tables_job() -> None: + """Generate all entity string table C++ code as a FINAL-priority job. + + Runs after all component to_code() calls have registered their strings. + """ pool = _get_pool() parts = ["namespace esphome {"] for prefix, lookup_fn, attr in _CATEGORY_CONFIGS: @@ -128,7 +148,7 @@ def _generate_tables() -> str: if code: parts.append(code) parts.append("} // namespace esphome") - return "\n".join(parts) + cg.add_global(RawStatement("\n".join(parts))) def _register_string( @@ -154,17 +174,19 @@ def _register_string( def register_device_class(value: str) -> int: """Register a device_class string and return its 1-based index.""" - return _register_string(value, _get_pool().device_classes, 1023, "device_class") + return _register_string( + value, _get_pool().device_classes, _MAX_DEVICE_CLASSES, "device_class" + ) def register_unit_of_measurement(value: str) -> int: """Register a unit_of_measurement string and return its 1-based index.""" - return _register_string(value, _get_pool().units, 1023, "unit_of_measurement") + return _register_string(value, _get_pool().units, _MAX_UNITS, "unit_of_measurement") def register_icon(value: str) -> int: """Register an icon string and return its 1-based index.""" - return _register_string(value, _get_pool().icons, 4095, "icon") + return _register_string(value, _get_pool().icons, _MAX_ICONS, "icon") def setup_device_class(config: ConfigType) -> None: @@ -188,7 +210,7 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: dc_idx = config.get(_KEY_DC_IDX, 0) uom_idx = config.get(_KEY_UOM_IDX, 0) icon_idx = config.get(_KEY_ICON_IDX, 0) - packed = dc_idx | (uom_idx << 10) | (icon_idx << 20) + packed = (dc_idx << _DC_SHIFT) | (uom_idx << _UOM_SHIFT) | (icon_idx << _ICON_SHIFT) if packed != 0: add(var.set_entity_strings(packed)) @@ -270,6 +292,11 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> This function sets up the common entity properties like name, icon, entity category, etc. + + Args: + var: The entity variable to set up + config: Configuration dictionary containing entity settings + platform: The platform name (e.g., "sensor", "binary_sensor") """ # Get device info if configured if device_id_obj := config.get(CONF_DEVICE_ID): diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 9e4f13e0e61..fe666bdd6e1 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -393,23 +393,6 @@ class RawStatement(Statement): return self.text -class DeferredStatement(Statement): - """Statement evaluated lazily at render time (after all to_code() calls). - - Use this to generate code that depends on state accumulated across - multiple components' to_code() calls. The resolver callback is only - called when the statement is converted to a string during code generation. - """ - - __slots__ = ("_resolver",) - - def __init__(self, resolver: Callable[[], str]): - self._resolver = resolver - - def __str__(self): - return self._resolver() - - class ExpressionStatement(Statement): __slots__ = ("expression",) From f1578351aa0e01656bfbe4751a3da3d766afcec1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Feb 2026 22:46:59 -0600 Subject: [PATCH 03/12] empty --- esphome/core/entity_base.cpp | 13 ++++----- esphome/core/entity_helpers.py | 6 ++-- tests/unit_tests/core/test_entity_helpers.py | 29 ++++++++++++++++++++ 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 05f545c861d..d869026a85a 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -50,22 +50,22 @@ __attribute__((weak)) const char *entity_device_class_lookup(uint16_t) { return __attribute__((weak)) const char *entity_uom_lookup(uint16_t) { return ""; } __attribute__((weak)) const char *entity_icon_lookup(uint16_t) { return ""; } +static constexpr auto EMPTY_REF = StringRef::from_lit(""); + // Entity device class (from packed index) StringRef EntityBase::get_device_class_ref() const { - static constexpr auto EMPTY = StringRef::from_lit(""); uint16_t idx = (this->entity_string_packed_ >> ENTITY_STR_DC_SHIFT) & ENTITY_STR_DC_MASK; if (idx == 0) - return EMPTY; + return EMPTY_REF; return StringRef(entity_device_class_lookup(idx)); } std::string EntityBase::get_device_class() const { return std::string(this->get_device_class_ref().c_str()); } // Entity unit of measurement (from packed index) StringRef EntityBase::get_unit_of_measurement_ref() const { - static constexpr auto EMPTY = StringRef::from_lit(""); uint16_t idx = (this->entity_string_packed_ >> ENTITY_STR_UOM_SHIFT) & ENTITY_STR_UOM_MASK; if (idx == 0) - return EMPTY; + return EMPTY_REF; return StringRef(entity_uom_lookup(idx)); } std::string EntityBase::get_unit_of_measurement() const { @@ -74,14 +74,13 @@ std::string EntityBase::get_unit_of_measurement() const { // Entity icon (from packed index) StringRef EntityBase::get_icon_ref() const { - static constexpr auto EMPTY = StringRef::from_lit(""); #ifdef USE_ENTITY_ICON uint16_t idx = (this->entity_string_packed_ >> ENTITY_STR_ICON_SHIFT) & ENTITY_STR_ICON_MASK; if (idx == 0) - return EMPTY; + return EMPTY_REF; return StringRef(entity_icon_lookup(idx)); #else - return EMPTY; + return EMPTY_REF; #endif } std::string EntityBase::get_icon() const { return std::string(this->get_icon_ref().c_str()); } diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 367da3d9ec0..55d14d5d191 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -60,7 +60,7 @@ class EntityStringPool: device_classes: dict[str, int] = field(default_factory=dict) units: dict[str, int] = field(default_factory=dict) icons: dict[str, int] = field(default_factory=dict) - _tables_registered: bool = False + tables_registered: bool = False def _get_pool() -> EntityStringPool: @@ -73,9 +73,9 @@ def _get_pool() -> EntityStringPool: def _ensure_tables_registered() -> None: """Schedule the table generation job (once).""" pool = _get_pool() - if pool._tables_registered: + if pool.tables_registered: return - pool._tables_registered = True + pool.tables_registered = True CORE.add_job(_generate_tables_job) diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index b937b2fceed..5dc017a5ed4 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -11,6 +11,7 @@ from esphome.config_validation import Invalid from esphome.const import ( CONF_DEVICE_ID, CONF_DISABLED_BY_DEFAULT, + CONF_ENTITY_CATEGORY, CONF_ICON, CONF_ID, CONF_INTERNAL, @@ -18,6 +19,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, entity_helpers from esphome.core.entity_helpers import ( + _register_string, _setup_entity_impl, entity_duplicate_validator, get_base_entity_object_id, @@ -895,3 +897,30 @@ async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name( assert any('set_name("", 0)' in expr for expr in added_expressions), ( f"Expected set_name with hash 0, got {added_expressions}" ) + + +def test_register_string_overflow() -> None: + """Test _register_string raises ValueError when max count is exceeded.""" + category: dict[str, int] = {} + for i in range(3): + _register_string(f"val_{i}", category, 3, "test") + with pytest.raises(ValueError, match="Too many unique test values"): + _register_string("overflow", category, 3, "test") + + +@pytest.mark.asyncio +async def test_setup_entity_with_entity_category( + setup_test_environment: list[str], +) -> None: + """Test setup_entity sets entity_category correctly.""" + added_expressions = setup_test_environment + var = MockObj("sensor1") + config = { + CONF_NAME: "Temperature", + CONF_DISABLED_BY_DEFAULT: False, + CONF_ENTITY_CATEGORY: "diagnostic", + } + await _setup_entity_impl(var, config, "sensor") + assert any( + 'set_entity_category("diagnostic")' in expr for expr in added_expressions + ) From 70fb8cc7c46e3dd9d350a99b8a660624d0cf92ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Feb 2026 22:48:46 -0600 Subject: [PATCH 04/12] Remove empty EntityBase_DeviceClass/EntityBase_UnitOfMeasurement shells These empty classes were only used internally by entity base classes, not exposed to external components. No need for backward compat shells. --- esphome/core/entity_base.h | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index d85f6ee4353..70949289fd1 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -216,16 +216,6 @@ class EntityBase { } flags_{}; }; -// Empty shell — methods moved to EntityBase. Kept for backward compatibility. -// TODO: Remove in 2027.2.0 -class EntityBase_DeviceClass { // NOLINT(readability-identifier-naming) -}; - -// Empty shell — methods moved to EntityBase. Kept for backward compatibility. -// TODO: Remove in 2027.2.0 -class EntityBase_UnitOfMeasurement { // NOLINT(readability-identifier-naming) -}; - /// Log entity icon if set (for use in dump_config) #ifdef USE_ENTITY_ICON #define LOG_ENTITY_ICON(tag, prefix, obj) log_entity_icon(tag, prefix, obj) From 6100d1c1d7b6ed5016f409d3e938a2df8fafb5f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Feb 2026 23:09:30 -0600 Subject: [PATCH 05/12] Remove leading null from blobs and EMPTY_REF from getters - Blobs now start directly with first string (no wasted \0 prefix) - Lookup functions handle index 0 by pointing to trailing null of blob - get_*_ref() simplified: just calls lookup, no separate empty check - Removes EMPTY_REF constant and per-getter branch --- esphome/core/entity_base.cpp | 20 +++++--------------- esphome/core/entity_helpers.py | 23 ++++++++++++++++------- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index d869026a85a..ba5529a29ed 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -50,23 +50,16 @@ __attribute__((weak)) const char *entity_device_class_lookup(uint16_t) { return __attribute__((weak)) const char *entity_uom_lookup(uint16_t) { return ""; } __attribute__((weak)) const char *entity_icon_lookup(uint16_t) { return ""; } -static constexpr auto EMPTY_REF = StringRef::from_lit(""); - // Entity device class (from packed index) StringRef EntityBase::get_device_class_ref() const { - uint16_t idx = (this->entity_string_packed_ >> ENTITY_STR_DC_SHIFT) & ENTITY_STR_DC_MASK; - if (idx == 0) - return EMPTY_REF; - return StringRef(entity_device_class_lookup(idx)); + return StringRef( + entity_device_class_lookup((this->entity_string_packed_ >> ENTITY_STR_DC_SHIFT) & ENTITY_STR_DC_MASK)); } std::string EntityBase::get_device_class() const { return std::string(this->get_device_class_ref().c_str()); } // Entity unit of measurement (from packed index) StringRef EntityBase::get_unit_of_measurement_ref() const { - uint16_t idx = (this->entity_string_packed_ >> ENTITY_STR_UOM_SHIFT) & ENTITY_STR_UOM_MASK; - if (idx == 0) - return EMPTY_REF; - return StringRef(entity_uom_lookup(idx)); + return StringRef(entity_uom_lookup((this->entity_string_packed_ >> ENTITY_STR_UOM_SHIFT) & ENTITY_STR_UOM_MASK)); } std::string EntityBase::get_unit_of_measurement() const { return std::string(this->get_unit_of_measurement_ref().c_str()); @@ -75,12 +68,9 @@ std::string EntityBase::get_unit_of_measurement() const { // Entity icon (from packed index) StringRef EntityBase::get_icon_ref() const { #ifdef USE_ENTITY_ICON - uint16_t idx = (this->entity_string_packed_ >> ENTITY_STR_ICON_SHIFT) & ENTITY_STR_ICON_MASK; - if (idx == 0) - return EMPTY_REF; - return StringRef(entity_icon_lookup(idx)); + return StringRef(entity_icon_lookup((this->entity_string_packed_ >> ENTITY_STR_ICON_SHIFT) & ENTITY_STR_ICON_MASK)); #else - return EMPTY_REF; + return StringRef(entity_icon_lookup(0)); #endif } std::string EntityBase::get_icon() const { return std::string(this->get_icon_ref().c_str()); } diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 55d14d5d191..e521f2e6583 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -84,13 +84,14 @@ def _generate_blob_and_offsets( ) -> tuple[bytes, list[int]]: """Build a packed blob and offset list from a string dict. - Returns (blob_bytes, offsets) where offsets[0] points to "" (empty) - and offsets[i] points to the i-th string. + Returns (blob_bytes, offsets) where offsets are 1-based (matching the + 1-based indices returned by _register_string). Index 0 means "not set" + and is handled by the caller before reaching the lookup function. """ # Sort by assigned index to ensure deterministic output sorted_strings = sorted(strings.items(), key=lambda x: x[1]) - blob = bytearray(b"\x00") # Index 0 = offset 0 = empty string - offsets = [0] # offset for index 0 (empty string) + blob = bytearray() + offsets = [] for s, _idx in sorted_strings: offsets.append(len(blob)) blob.extend(s.encode("utf-8")) @@ -104,7 +105,11 @@ def _generate_category_code( lookup_fn: str, strings: dict[str, int], ) -> str: - """Generate C++ code for one string category (blob + offsets + lookup).""" + """Generate C++ code for one string category (blob + offsets + lookup). + + Index 0 means "not set" and is handled by get_*_ref() before calling + the lookup function. The lookup uses 1-based indexing directly. + """ if not strings: return "" @@ -116,12 +121,16 @@ def _generate_category_code( blob_escaped = cpp_string_escape(blob_bytes) offsets_str = ", ".join(str(o) for o in offsets) + # The blob's last byte is always '\0' (null terminator of last string). + # Point there for index 0 ("not set") and out-of-range to return "". + empty_offset = len(blob_bytes) - 1 + return ( f"static const char {blob_var}[] = {blob_escaped};\n" f"static const {offset_type} {offsets_var}[] PROGMEM = {{{offsets_str}}};\n" f"const char *{lookup_fn}(uint16_t index) {{\n" - f" if (index >= {count}) return {blob_var};\n" - f" return &{blob_var}[{read_fn}(&{offsets_var}[index])];\n" + f" if (index == 0 || index > {count}) return &{blob_var}[{empty_offset}];\n" + f" return &{blob_var}[{read_fn}(&{offsets_var}[index - 1])];\n" f"}}\n" ) From 7a9a0f9e75baf9c401a11050f3c15095dfa24262 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Feb 2026 23:21:53 -0600 Subject: [PATCH 06/12] Remove deprecated offsetof hack from NumberTraits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NumberTraits no longer needs get_device_class_ref() or get_unit_of_measurement_ref() — no internal callers exist. Callers should use number->get_device_class_ref() directly. Eliminates offsetof warning on non-standard-layout type. --- esphome/components/number/number.cpp | 13 ------------- esphome/components/number/number_traits.h | 14 ++------------ 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/esphome/components/number/number.cpp b/esphome/components/number/number.cpp index 91d5a260ad2..c0653c3b304 100644 --- a/esphome/components/number/number.cpp +++ b/esphome/components/number/number.cpp @@ -1,5 +1,4 @@ #include "number.h" -#include #include "esphome/core/defines.h" #include "esphome/core/controller_registry.h" #include "esphome/core/log.h" @@ -20,18 +19,6 @@ void log_number(const char *tag, const char *prefix, const char *type, Number *o LOG_ENTITY_DEVICE_CLASS(tag, prefix, *obj); } -// Deprecated backward-compat: delegate to parent Number's EntityBase methods -StringRef NumberTraits::get_device_class_ref() const { - const auto *number = - reinterpret_cast(reinterpret_cast(this) - offsetof(Number, traits)); - return number->get_device_class_ref(); -} -StringRef NumberTraits::get_unit_of_measurement_ref() const { - const auto *number = - reinterpret_cast(reinterpret_cast(this) - offsetof(Number, traits)); - return number->get_unit_of_measurement_ref(); -} - void Number::publish_state(float state) { this->set_has_state(true); this->state = state; diff --git a/esphome/components/number/number_traits.h b/esphome/components/number/number_traits.h index 642768ea8f1..f855813c9bf 100644 --- a/esphome/components/number/number_traits.h +++ b/esphome/components/number/number_traits.h @@ -1,7 +1,7 @@ #pragma once -#include "esphome/core/entity_base.h" -#include "esphome/core/helpers.h" +#include +#include namespace esphome::number { @@ -27,16 +27,6 @@ class NumberTraits { void set_mode(NumberMode mode) { this->mode_ = mode; } NumberMode get_mode() const { return this->mode_; } - // Deprecated: use Number::get_device_class_ref() instead. - // Delegates to parent Number's EntityBase via offsetof. - ESPDEPRECATED("Use number->get_device_class_ref() instead. Removed in 2027.2.0", "2026.8.0") - StringRef get_device_class_ref() const; - - // Deprecated: use Number::get_unit_of_measurement_ref() instead. - // Delegates to parent Number's EntityBase via offsetof. - ESPDEPRECATED("Use number->get_unit_of_measurement_ref() instead. Removed in 2027.2.0", "2026.8.0") - StringRef get_unit_of_measurement_ref() const; - protected: float min_value_ = NAN; float max_value_ = NAN; From 2906f56b3fd02eb3262a655bf5c7ac43418eb0e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Feb 2026 23:32:28 -0600 Subject: [PATCH 07/12] tweaks --- esphome/components/esp32/core.cpp | 1 + esphome/components/esp8266/core.cpp | 3 ++ esphome/components/host/core.cpp | 1 + esphome/components/libretiny/core.cpp | 1 + esphome/components/rp2040/core.cpp | 3 ++ esphome/components/zephyr/core.cpp | 1 + esphome/core/entity_helpers.py | 70 ++++++++------------------- esphome/core/hal.h | 1 + script/clang-tidy | 1 + 9 files changed, 32 insertions(+), 50 deletions(-) diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index ad6d584245f..f399cf8172b 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -48,6 +48,7 @@ void HOT arch_feed_wdt() { esp_task_wdt_reset(); } uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } uint16_t progmem_read_word(const uint16_t *addr) { return *addr; } +const char *progmem_read_ptr(const char *const *addr) { return *addr; } uint32_t arch_get_cpu_cycle_count() { return esp_cpu_get_cycle_count(); } uint32_t arch_get_cpu_freq_hz() { uint32_t freq = 0; diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 50345f8bc3a..db56dcbcaed 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -35,6 +35,9 @@ uint8_t progmem_read_byte(const uint8_t *addr) { uint16_t progmem_read_word(const uint16_t *addr) { return pgm_read_word(addr); // NOLINT } +const char *progmem_read_ptr(const char *const *addr) { + return reinterpret_cast(pgm_read_ptr(addr)); // NOLINT +} uint32_t IRAM_ATTR HOT arch_get_cpu_cycle_count() { return esp_get_cycle_count(); } uint32_t arch_get_cpu_freq_hz() { return F_CPU; } diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index 296ada2c3c4..85d9f0b5305 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -54,6 +54,7 @@ void HOT arch_feed_wdt() { uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } uint16_t progmem_read_word(const uint16_t *addr) { return *addr; } +const char *progmem_read_ptr(const char *const *addr) { return *addr; } uint32_t arch_get_cpu_cycle_count() { struct timespec spec; clock_gettime(CLOCK_MONOTONIC, &spec); diff --git a/esphome/components/libretiny/core.cpp b/esphome/components/libretiny/core.cpp index a1a7e7745e7..ae48d5fc339 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -35,6 +35,7 @@ uint32_t arch_get_cpu_cycle_count() { return lt_cpu_get_cycle_count(); } uint32_t arch_get_cpu_freq_hz() { return lt_cpu_get_freq(); } uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } uint16_t progmem_read_word(const uint16_t *addr) { return *addr; } +const char *progmem_read_ptr(const char *const *addr) { return *addr; } } // namespace esphome diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp index ad8c3a414ad..feda41a9629 100644 --- a/esphome/components/rp2040/core.cpp +++ b/esphome/components/rp2040/core.cpp @@ -35,6 +35,9 @@ uint8_t progmem_read_byte(const uint8_t *addr) { uint16_t progmem_read_word(const uint16_t *addr) { return pgm_read_word(addr); // NOLINT } +const char *progmem_read_ptr(const char *const *addr) { + return reinterpret_cast(pgm_read_ptr(addr)); // NOLINT +} uint32_t IRAM_ATTR HOT arch_get_cpu_cycle_count() { return ulMainGetRunTimeCounterValue(); } uint32_t arch_get_cpu_freq_hz() { return RP2040::f_cpu(); } diff --git a/esphome/components/zephyr/core.cpp b/esphome/components/zephyr/core.cpp index 7b41fa30cd1..1eef66aa736 100644 --- a/esphome/components/zephyr/core.cpp +++ b/esphome/components/zephyr/core.cpp @@ -60,6 +60,7 @@ uint32_t arch_get_cpu_cycle_count() { return k_cycle_get_32(); } uint32_t arch_get_cpu_freq_hz() { return sys_clock_hw_cycles_per_sec(); } uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } uint16_t progmem_read_word(const uint16_t *addr) { return *addr; } +const char *progmem_read_ptr(const char *const *addr) { return *addr; } Mutex::Mutex() { auto *mutex = new k_mutex(); diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index e521f2e6583..d269aecafc1 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -19,7 +19,7 @@ from esphome.const import ( from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority 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 +from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case from esphome.types import ConfigType, EntityMetadata _LOGGER = logging.getLogger(__name__) @@ -50,11 +50,11 @@ _MAX_ICONS = 0xFFF # 12 bits → 4095 @dataclass class EntityStringPool: - """Pool of entity string properties packed into contiguous blobs. + """Pool of entity string properties for PROGMEM pointer tables. Strings are registered during to_code() and assigned 1-based indices. Index 0 means "not set" (empty string). At render time, the pool - generates C++ blob + PROGMEM offset table + lookup function per category. + generates C++ PROGMEM pointer table + lookup function per category. """ device_classes: dict[str, int] = field(default_factory=dict) @@ -79,66 +79,38 @@ def _ensure_tables_registered() -> None: CORE.add_job(_generate_tables_job) -def _generate_blob_and_offsets( - strings: dict[str, int], -) -> tuple[bytes, list[int]]: - """Build a packed blob and offset list from a string dict. - - Returns (blob_bytes, offsets) where offsets are 1-based (matching the - 1-based indices returned by _register_string). Index 0 means "not set" - and is handled by the caller before reaching the lookup function. - """ - # Sort by assigned index to ensure deterministic output - sorted_strings = sorted(strings.items(), key=lambda x: x[1]) - blob = bytearray() - offsets = [] - for s, _idx in sorted_strings: - offsets.append(len(blob)) - blob.extend(s.encode("utf-8")) - blob.append(0) # null terminator - return bytes(blob), offsets - - def _generate_category_code( - blob_var: str, - offsets_var: str, + table_var: str, lookup_fn: str, strings: dict[str, int], ) -> str: - """Generate C++ code for one string category (blob + offsets + lookup). + """Generate C++ code for one string category (PROGMEM pointer table + lookup). - Index 0 means "not set" and is handled by get_*_ref() before calling - the lookup function. The lookup uses 1-based indexing directly. + Uses a PROGMEM array of string pointers. On ESP8266, pointers are stored + 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. """ if not strings: return "" - blob_bytes, offsets = _generate_blob_and_offsets(strings) - use_uint16 = len(blob_bytes) > 255 - offset_type = "uint16_t" if use_uint16 else "uint8_t" - read_fn = "progmem_read_word" if use_uint16 else "progmem_read_byte" - count = len(offsets) - blob_escaped = cpp_string_escape(blob_bytes) - offsets_str = ", ".join(str(o) for o in offsets) - - # The blob's last byte is always '\0' (null terminator of last string). - # Point there for index 0 ("not set") and out-of-range to return "". - empty_offset = len(blob_bytes) - 1 + sorted_strings = sorted(strings.items(), key=lambda x: x[1]) + entries = ", ".join(f'"{s}"' for s, _ in sorted_strings) + count = len(sorted_strings) return ( - f"static const char {blob_var}[] = {blob_escaped};\n" - f"static const {offset_type} {offsets_var}[] PROGMEM = {{{offsets_str}}};\n" + f"static const char *const {table_var}[] PROGMEM = {{{entries}}};\n" f"const char *{lookup_fn}(uint16_t index) {{\n" - f" if (index == 0 || index > {count}) return &{blob_var}[{empty_offset}];\n" - f" return &{blob_var}[{read_fn}(&{offsets_var}[index - 1])];\n" + f' if (index == 0 || index > {count}) return "";\n' + f" return progmem_read_ptr(&{table_var}[index - 1]);\n" f"}}\n" ) _CATEGORY_CONFIGS = ( - ("ENTITY_DC", "entity_device_class_lookup", "device_classes"), - ("ENTITY_UOM", "entity_uom_lookup", "units"), - ("ENTITY_ICON", "entity_icon_lookup", "icons"), + ("ENTITY_DC_TABLE", "entity_device_class_lookup", "device_classes"), + ("ENTITY_UOM_TABLE", "entity_uom_lookup", "units"), + ("ENTITY_ICON_TABLE", "entity_icon_lookup", "icons"), ) @@ -150,10 +122,8 @@ async def _generate_tables_job() -> None: """ pool = _get_pool() parts = ["namespace esphome {"] - for prefix, lookup_fn, attr in _CATEGORY_CONFIGS: - code = _generate_category_code( - f"{prefix}_BLOB", f"{prefix}_OFFSETS", lookup_fn, getattr(pool, attr) - ) + for table_var, lookup_fn, attr in _CATEGORY_CONFIGS: + code = _generate_category_code(table_var, lookup_fn, getattr(pool, attr)) if code: parts.append(code) parts.append("} // namespace esphome") diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 25c327a5dd3..d6c7695895d 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -42,5 +42,6 @@ uint32_t arch_get_cpu_cycle_count(); uint32_t arch_get_cpu_freq_hz(); uint8_t progmem_read_byte(const uint8_t *addr); uint16_t progmem_read_word(const uint16_t *addr); +const char *progmem_read_ptr(const char *const *addr); } // namespace esphome diff --git a/script/clang-tidy b/script/clang-tidy index 17bcafacc79..9c2899026d2 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -79,6 +79,7 @@ def clang_options(idedata): "-Dpgm_read_byte_near(s)=(*(const uint8_t *)(s))", "-Dpgm_read_word(s)=(*(const uint16_t *)(s))", "-Dpgm_read_dword(s)=(*(const uint32_t *)(s))", + "-Dpgm_read_ptr(s)=(*(const void *const *)(s))", "-DPROGMEM=", "-DPGM_P=const char *", "-DPSTR(s)=(s)", From 6c92e284c7ad1317fe963fd09a42071cdda6f8cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Feb 2026 23:35:40 -0600 Subject: [PATCH 08/12] tweaks --- esphome/components/esp32/core.cpp | 1 - esphome/components/esp8266/core.cpp | 3 --- esphome/components/host/core.cpp | 1 - esphome/components/libretiny/core.cpp | 1 - esphome/components/rp2040/core.cpp | 3 --- esphome/components/zephyr/core.cpp | 1 - esphome/core/hal.h | 1 - 7 files changed, 11 deletions(-) diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index f399cf8172b..9105b74c5d2 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -47,7 +47,6 @@ void arch_init() { void HOT arch_feed_wdt() { esp_task_wdt_reset(); } uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } -uint16_t progmem_read_word(const uint16_t *addr) { return *addr; } const char *progmem_read_ptr(const char *const *addr) { return *addr; } uint32_t arch_get_cpu_cycle_count() { return esp_cpu_get_cycle_count(); } uint32_t arch_get_cpu_freq_hz() { diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index db56dcbcaed..12d0536dc19 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -32,9 +32,6 @@ void HOT arch_feed_wdt() { system_soft_wdt_feed(); } uint8_t progmem_read_byte(const uint8_t *addr) { return pgm_read_byte(addr); // NOLINT } -uint16_t progmem_read_word(const uint16_t *addr) { - return pgm_read_word(addr); // NOLINT -} const char *progmem_read_ptr(const char *const *addr) { return reinterpret_cast(pgm_read_ptr(addr)); // NOLINT } diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index 85d9f0b5305..7a9ff120eb9 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -53,7 +53,6 @@ void HOT arch_feed_wdt() { } uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } -uint16_t progmem_read_word(const uint16_t *addr) { return *addr; } const char *progmem_read_ptr(const char *const *addr) { return *addr; } uint32_t arch_get_cpu_cycle_count() { struct timespec spec; diff --git a/esphome/components/libretiny/core.cpp b/esphome/components/libretiny/core.cpp index ae48d5fc339..d5e0937cba8 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -34,7 +34,6 @@ void HOT arch_feed_wdt() { lt_wdt_feed(); } uint32_t arch_get_cpu_cycle_count() { return lt_cpu_get_cycle_count(); } uint32_t arch_get_cpu_freq_hz() { return lt_cpu_get_freq(); } uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } -uint16_t progmem_read_word(const uint16_t *addr) { return *addr; } const char *progmem_read_ptr(const char *const *addr) { return *addr; } } // namespace esphome diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp index feda41a9629..65b43904725 100644 --- a/esphome/components/rp2040/core.cpp +++ b/esphome/components/rp2040/core.cpp @@ -32,9 +32,6 @@ void HOT arch_feed_wdt() { watchdog_update(); } uint8_t progmem_read_byte(const uint8_t *addr) { return pgm_read_byte(addr); // NOLINT } -uint16_t progmem_read_word(const uint16_t *addr) { - return pgm_read_word(addr); // NOLINT -} const char *progmem_read_ptr(const char *const *addr) { return reinterpret_cast(pgm_read_ptr(addr)); // NOLINT } diff --git a/esphome/components/zephyr/core.cpp b/esphome/components/zephyr/core.cpp index 1eef66aa736..e80f3d0c9cb 100644 --- a/esphome/components/zephyr/core.cpp +++ b/esphome/components/zephyr/core.cpp @@ -59,7 +59,6 @@ void arch_restart() { sys_reboot(SYS_REBOOT_COLD); } uint32_t arch_get_cpu_cycle_count() { return k_cycle_get_32(); } uint32_t arch_get_cpu_freq_hz() { return sys_clock_hw_cycles_per_sec(); } uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } -uint16_t progmem_read_word(const uint16_t *addr) { return *addr; } const char *progmem_read_ptr(const char *const *addr) { return *addr; } Mutex::Mutex() { diff --git a/esphome/core/hal.h b/esphome/core/hal.h index d6c7695895d..b328528f3a4 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -41,7 +41,6 @@ void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); uint32_t arch_get_cpu_freq_hz(); uint8_t progmem_read_byte(const uint8_t *addr); -uint16_t progmem_read_word(const uint16_t *addr); const char *progmem_read_ptr(const char *const *addr); } // namespace esphome From 2e93a7db78654a918ab10cdcc06bc315b10344f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 00:18:44 -0600 Subject: [PATCH 09/12] Narrow packed fields to 8 bits each, reserve 8 bits for future use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 bits per field (max 255) is more than sufficient — even large configs use fewer than 40 unique icons, 14 device classes, and 11 units of measurement. This frees up 8 bits in the packed uint32_t for future use. --- esphome/core/entity_base.h | 16 ++++++++-------- esphome/core/entity_helpers.py | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 70949289fd1..ccb07a4ed0b 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -21,13 +21,13 @@ extern const char *entity_uom_lookup(uint16_t index); extern const char *entity_icon_lookup(uint16_t index); // Bit layout for entity_string_packed_: -// [31..20] icon (12 bits) | [19..10] UoM (10 bits) | [9..0] device_class (10 bits) +// [31..24] reserved | [23..16] icon (8 bits) | [15..8] UoM (8 bits) | [7..0] device_class (8 bits) static constexpr uint8_t ENTITY_STR_DC_SHIFT = 0; -static constexpr uint8_t ENTITY_STR_UOM_SHIFT = 10; -static constexpr uint8_t ENTITY_STR_ICON_SHIFT = 20; -static constexpr uint16_t ENTITY_STR_DC_MASK = 0x3FF; -static constexpr uint16_t ENTITY_STR_UOM_MASK = 0x3FF; -static constexpr uint16_t ENTITY_STR_ICON_MASK = 0xFFF; +static constexpr uint8_t ENTITY_STR_UOM_SHIFT = 8; +static constexpr uint8_t ENTITY_STR_ICON_SHIFT = 16; +static constexpr uint8_t ENTITY_STR_DC_MASK = 0xFF; +static constexpr uint8_t ENTITY_STR_UOM_MASK = 0xFF; +static constexpr uint8_t ENTITY_STR_ICON_MASK = 0xFF; // Maximum device name length - keep in sync with validate_hostname() in esphome/core/config.py static constexpr size_t ESPHOME_DEVICE_NAME_MAX_LEN = 31; @@ -105,7 +105,7 @@ class EntityBase { } // Set packed entity string indices — one call per entity from codegen. - // Bit layout: [31..20] icon (12 bits) | [19..10] UoM (10 bits) | [9..0] device_class (10 bits) + // Bit layout: [23..16] icon (8 bits) | [15..8] UoM (8 bits) | [7..0] device_class (8 bits) void set_entity_strings(uint32_t packed) { this->entity_string_packed_ = packed; } // Get device class as StringRef (from packed index) @@ -199,7 +199,7 @@ class EntityBase { void calc_object_id_(); StringRef name_; - uint32_t entity_string_packed_{0}; // bits 0-9: device_class, 10-19: uom, 20-31: icon + uint32_t entity_string_packed_{0}; // bits 0-7: device_class, 8-15: uom, 16-23: icon, 24-31: reserved uint32_t object_id_hash_{}; #ifdef USE_DEVICES Device *device_{}; diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index d269aecafc1..5623b5b5023 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -34,18 +34,18 @@ _KEY_ICON_IDX = "_entity_icon_idx" # Bit layout for entity_string_packed_ (must match C++ constants in entity_base.h): # # uint32_t entity_string_packed_: -# [31 .......... 20][19 ......... 10][9 ........... 0] -# icon (12 bits) UoM (10 bits) device_class (10 bits) -# max 4095 max 1023 max 1023 +# [31 ...... 24][23 ...... 16][15 ....... 8][7 ........ 0] +# reserved (8) icon (8) UoM (8) device_class (8) +# max 255 max 255 max 255 # _DC_SHIFT = 0 -_UOM_SHIFT = 10 -_ICON_SHIFT = 20 +_UOM_SHIFT = 8 +_ICON_SHIFT = 16 # Maximum unique strings per category (must match bit widths above) -_MAX_DEVICE_CLASSES = 0x3FF # 10 bits → 1023 -_MAX_UNITS = 0x3FF # 10 bits → 1023 -_MAX_ICONS = 0xFFF # 12 bits → 4095 +_MAX_DEVICE_CLASSES = 0xFF # 8 bits → 255 +_MAX_UNITS = 0xFF # 8 bits → 255 +_MAX_ICONS = 0xFF # 8 bits → 255 @dataclass From 95ee73e07fa902b0ff821d79472755f458e1185c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 00:28:16 -0600 Subject: [PATCH 10/12] Store entity string indices as 3 uint8_t fields in struct padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace uint32_t entity_string_packed_ with 3 separate uint8_t fields (device_class_idx_, uom_idx_, icon_idx_) placed after the 1-byte EntityFlags struct. These fill the 3 padding bytes that the compiler would insert anyway for alignment, saving 4 bytes per entity with zero layout overhead. The set_entity_strings(uint32_t) API is preserved — it unpacks into the individual fields. --- esphome/core/entity_base.cpp | 15 ++++++--------- esphome/core/entity_base.h | 26 ++++++++++++-------------- esphome/core/entity_helpers.py | 17 ++++++----------- 3 files changed, 24 insertions(+), 34 deletions(-) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index ba5529a29ed..a6c702448e2 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -50,25 +50,22 @@ __attribute__((weak)) const char *entity_device_class_lookup(uint16_t) { return __attribute__((weak)) const char *entity_uom_lookup(uint16_t) { return ""; } __attribute__((weak)) const char *entity_icon_lookup(uint16_t) { return ""; } -// Entity device class (from packed index) +// Entity device class (from index) StringRef EntityBase::get_device_class_ref() const { - return StringRef( - entity_device_class_lookup((this->entity_string_packed_ >> ENTITY_STR_DC_SHIFT) & ENTITY_STR_DC_MASK)); + return StringRef(entity_device_class_lookup(this->device_class_idx_)); } std::string EntityBase::get_device_class() const { return std::string(this->get_device_class_ref().c_str()); } -// Entity unit of measurement (from packed index) -StringRef EntityBase::get_unit_of_measurement_ref() const { - return StringRef(entity_uom_lookup((this->entity_string_packed_ >> ENTITY_STR_UOM_SHIFT) & ENTITY_STR_UOM_MASK)); -} +// Entity unit of measurement (from index) +StringRef EntityBase::get_unit_of_measurement_ref() const { return StringRef(entity_uom_lookup(this->uom_idx_)); } std::string EntityBase::get_unit_of_measurement() const { return std::string(this->get_unit_of_measurement_ref().c_str()); } -// Entity icon (from packed index) +// Entity icon (from index) StringRef EntityBase::get_icon_ref() const { #ifdef USE_ENTITY_ICON - return StringRef(entity_icon_lookup((this->entity_string_packed_ >> ENTITY_STR_ICON_SHIFT) & ENTITY_STR_ICON_MASK)); + return StringRef(entity_icon_lookup(this->icon_idx_)); #else return StringRef(entity_icon_lookup(0)); #endif diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index ccb07a4ed0b..e381db2f1b3 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -14,21 +14,12 @@ namespace esphome { -// Extern lookup functions for packed entity string tables. +// Extern lookup functions for entity string tables. // Generated code provides strong definitions; weak defaults return "". extern const char *entity_device_class_lookup(uint16_t index); extern const char *entity_uom_lookup(uint16_t index); extern const char *entity_icon_lookup(uint16_t index); -// Bit layout for entity_string_packed_: -// [31..24] reserved | [23..16] icon (8 bits) | [15..8] UoM (8 bits) | [7..0] device_class (8 bits) -static constexpr uint8_t ENTITY_STR_DC_SHIFT = 0; -static constexpr uint8_t ENTITY_STR_UOM_SHIFT = 8; -static constexpr uint8_t ENTITY_STR_ICON_SHIFT = 16; -static constexpr uint8_t ENTITY_STR_DC_MASK = 0xFF; -static constexpr uint8_t ENTITY_STR_UOM_MASK = 0xFF; -static constexpr uint8_t ENTITY_STR_ICON_MASK = 0xFF; - // Maximum device name length - keep in sync with validate_hostname() in esphome/core/config.py static constexpr size_t ESPHOME_DEVICE_NAME_MAX_LEN = 31; @@ -104,9 +95,13 @@ class EntityBase { this->flags_.entity_category = static_cast(entity_category); } - // Set packed entity string indices — one call per entity from codegen. - // Bit layout: [23..16] icon (8 bits) | [15..8] UoM (8 bits) | [7..0] device_class (8 bits) - void set_entity_strings(uint32_t packed) { this->entity_string_packed_ = packed; } + // Set entity string table indices — one call per entity from codegen. + // Packed: [23..16] icon | [15..8] UoM | [7..0] device_class (each 8 bits) + void set_entity_strings(uint32_t packed) { + this->device_class_idx_ = packed & 0xFF; + this->uom_idx_ = (packed >> 8) & 0xFF; + this->icon_idx_ = (packed >> 16) & 0xFF; + } // Get device class as StringRef (from packed index) StringRef get_device_class_ref() const; @@ -199,7 +194,6 @@ class EntityBase { void calc_object_id_(); StringRef name_; - uint32_t entity_string_packed_{0}; // bits 0-7: device_class, 8-15: uom, 16-23: icon, 24-31: reserved uint32_t object_id_hash_{}; #ifdef USE_DEVICES Device *device_{}; @@ -214,6 +208,10 @@ class EntityBase { uint8_t entity_category : 2; // Supports up to 4 categories uint8_t reserved : 2; // Reserved for future use } flags_{}; + // String table indices — packed into the 3 padding bytes after flags_ + uint8_t device_class_idx_{}; + uint8_t uom_idx_{}; + uint8_t icon_idx_{}; }; /// Log entity icon if set (for use in dump_config) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 5623b5b5023..c0bb1b1c086 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -31,21 +31,16 @@ _KEY_DC_IDX = "_entity_dc_idx" _KEY_UOM_IDX = "_entity_uom_idx" _KEY_ICON_IDX = "_entity_icon_idx" -# Bit layout for entity_string_packed_ (must match C++ constants in entity_base.h): -# -# uint32_t entity_string_packed_: -# [31 ...... 24][23 ...... 16][15 ....... 8][7 ........ 0] -# reserved (8) icon (8) UoM (8) device_class (8) -# max 255 max 255 max 255 -# +# Bit layout for set_entity_strings(packed) — must match C++ setter 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 _ICON_SHIFT = 16 -# Maximum unique strings per category (must match bit widths above) -_MAX_DEVICE_CLASSES = 0xFF # 8 bits → 255 -_MAX_UNITS = 0xFF # 8 bits → 255 -_MAX_ICONS = 0xFF # 8 bits → 255 +# Maximum unique strings per category (8-bit index, 0 = not set) +_MAX_DEVICE_CLASSES = 0xFF # 255 +_MAX_UNITS = 0xFF # 255 +_MAX_ICONS = 0xFF # 255 @dataclass From 1d547642e34693cb30ffdbde73738f3149578c16 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 01:11:06 -0600 Subject: [PATCH 11/12] Use uint8_t for lookup indices and escape strings in codegen - Change lookup function signatures from uint16_t to uint8_t since indices are stored in uint8_t fields (max 255 per category) - Use cpp_string_escape() for string table entries to prevent compilation errors from special characters in user-provided strings --- esphome/core/entity_base.cpp | 6 +++--- esphome/core/entity_base.h | 6 +++--- esphome/core/entity_helpers.py | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index a6c702448e2..31e96186409 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -46,9 +46,9 @@ void EntityBase::set_name(const char *name, uint32_t object_id_hash) { } // Weak default lookup functions — overridden by generated code in main.cpp -__attribute__((weak)) const char *entity_device_class_lookup(uint16_t) { return ""; } -__attribute__((weak)) const char *entity_uom_lookup(uint16_t) { return ""; } -__attribute__((weak)) const char *entity_icon_lookup(uint16_t) { return ""; } +__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) StringRef EntityBase::get_device_class_ref() const { diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index e381db2f1b3..e67f16422a1 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -16,9 +16,9 @@ namespace esphome { // Extern lookup functions for entity string tables. // Generated code provides strong definitions; weak defaults return "". -extern const char *entity_device_class_lookup(uint16_t index); -extern const char *entity_uom_lookup(uint16_t index); -extern const char *entity_icon_lookup(uint16_t index); +extern const char *entity_device_class_lookup(uint8_t index); +extern const char *entity_uom_lookup(uint8_t index); +extern const char *entity_icon_lookup(uint8_t index); // Maximum device name length - keep in sync with validate_hostname() in esphome/core/config.py static constexpr size_t ESPHOME_DEVICE_NAME_MAX_LEN = 31; diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index c0bb1b1c086..cc435e7c47c 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -19,7 +19,7 @@ from esphome.const import ( from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.cpp_generator import MockObj, RawStatement, add, get_variable import esphome.final_validate as fv -from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case +from esphome.helpers import cpp_string_escape, fnv1_hash_object_id, sanitize, snake_case from esphome.types import ConfigType, EntityMetadata _LOGGER = logging.getLogger(__name__) @@ -90,12 +90,12 @@ def _generate_category_code( return "" sorted_strings = sorted(strings.items(), key=lambda x: x[1]) - entries = ", ".join(f'"{s}"' for s, _ in sorted_strings) + entries = ", ".join(cpp_string_escape(s) for s, _ in sorted_strings) count = len(sorted_strings) return ( f"static const char *const {table_var}[] PROGMEM = {{{entries}}};\n" - f"const char *{lookup_fn}(uint16_t index) {{\n" + f"const char *{lookup_fn}(uint8_t index) {{\n" f' if (index == 0 || index > {count}) return "";\n' f" return progmem_read_ptr(&{table_var}[index - 1]);\n" f"}}\n" From d01d2189b9266c4c2dc745fac67ce073abb4de89 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 01:22:18 -0600 Subject: [PATCH 12/12] Guard device_class/UoM fields and lookups with #ifdef like icon --- esphome/core/entity_base.cpp | 12 +++++++++++- esphome/core/entity_base.h | 14 +++++++++++++- esphome/core/entity_helpers.py | 12 ++++++++---- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 31e96186409..eafc04f92a4 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -52,12 +52,22 @@ __attribute__((weak)) const char *entity_icon_lookup(uint8_t) { return ""; } // Entity device class (from index) StringRef EntityBase::get_device_class_ref() const { +#ifdef USE_ENTITY_DEVICE_CLASS return StringRef(entity_device_class_lookup(this->device_class_idx_)); +#else + 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()); } // Entity unit of measurement (from index) -StringRef EntityBase::get_unit_of_measurement_ref() const { return StringRef(entity_uom_lookup(this->uom_idx_)); } +StringRef EntityBase::get_unit_of_measurement_ref() const { +#ifdef USE_ENTITY_UNIT_OF_MEASUREMENT + return StringRef(entity_uom_lookup(this->uom_idx_)); +#else + return StringRef(entity_uom_lookup(0)); +#endif +} std::string EntityBase::get_unit_of_measurement() const { return std::string(this->get_unit_of_measurement_ref().c_str()); } diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index e67f16422a1..042eebb40f3 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -97,10 +97,16 @@ class EntityBase { // Set entity string table indices — one call per entity from codegen. // Packed: [23..16] icon | [15..8] UoM | [7..0] device_class (each 8 bits) - void set_entity_strings(uint32_t packed) { + void set_entity_strings([[maybe_unused]] uint32_t packed) { +#ifdef USE_ENTITY_DEVICE_CLASS this->device_class_idx_ = packed & 0xFF; +#endif +#ifdef USE_ENTITY_UNIT_OF_MEASUREMENT this->uom_idx_ = (packed >> 8) & 0xFF; +#endif +#ifdef USE_ENTITY_ICON this->icon_idx_ = (packed >> 16) & 0xFF; +#endif } // Get device class as StringRef (from packed index) @@ -209,9 +215,15 @@ class EntityBase { uint8_t reserved : 2; // Reserved for future use } flags_{}; // String table indices — packed into the 3 padding bytes after flags_ +#ifdef USE_ENTITY_DEVICE_CLASS uint8_t device_class_idx_{}; +#endif +#ifdef USE_ENTITY_UNIT_OF_MEASUREMENT uint8_t uom_idx_{}; +#endif +#ifdef USE_ENTITY_ICON uint8_t icon_idx_{}; +#endif }; /// Log entity icon if set (for use in dump_config) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index cc435e7c47c..fd151d82d9f 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -165,14 +165,18 @@ def register_icon(value: str) -> int: def setup_device_class(config: ConfigType) -> None: """Register config's device_class and store its index for finalize_entity_strings.""" - config[_KEY_DC_IDX] = register_device_class(config.get(CONF_DEVICE_CLASS, "")) + idx = register_device_class(config.get(CONF_DEVICE_CLASS, "")) + if idx: + cg.add_define("USE_ENTITY_DEVICE_CLASS") + config[_KEY_DC_IDX] = idx def setup_unit_of_measurement(config: ConfigType) -> None: """Register config's unit_of_measurement and store its index for finalize_entity_strings.""" - config[_KEY_UOM_IDX] = register_unit_of_measurement( - config.get(CONF_UNIT_OF_MEASUREMENT, "") - ) + idx = register_unit_of_measurement(config.get(CONF_UNIT_OF_MEASUREMENT, "")) + if idx: + cg.add_define("USE_ENTITY_UNIT_OF_MEASUREMENT") + config[_KEY_UOM_IDX] = idx def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: