Reduce entity memory usage by eliminating field shadowing and bit-packing (#9076)

This commit is contained in:
J. Nick Koston 2025-06-14 22:21:55 -05:00 committed by Jesse Hills
parent fb12e4e66a
commit 4787e22f61
No known key found for this signature in database
GPG Key ID: BEAAE804EFD8E83A
25 changed files with 260 additions and 87 deletions

View File

@ -11,25 +11,25 @@ static const char *const TAG = "datetime.date_entity";
void DateEntity::publish_state() {
if (this->year_ == 0 || this->month_ == 0 || this->day_ == 0) {
this->has_state_ = false;
this->set_has_state(false);
return;
}
if (this->year_ < 1970 || this->year_ > 3000) {
this->has_state_ = false;
this->set_has_state(false);
ESP_LOGE(TAG, "Year must be between 1970 and 3000");
return;
}
if (this->month_ < 1 || this->month_ > 12) {
this->has_state_ = false;
this->set_has_state(false);
ESP_LOGE(TAG, "Month must be between 1 and 12");
return;
}
if (this->day_ > days_in_month(this->month_, this->year_)) {
this->has_state_ = false;
this->set_has_state(false);
ESP_LOGE(TAG, "Day must be between 1 and %d for month %d", days_in_month(this->month_, this->year_), this->month_);
return;
}
this->has_state_ = true;
this->set_has_state(true);
ESP_LOGD(TAG, "'%s': Sending date %d-%d-%d", this->get_name().c_str(), this->year_, this->month_, this->day_);
this->state_callback_.call();
}

View File

@ -13,9 +13,6 @@ namespace datetime {
class DateTimeBase : public EntityBase {
public:
/// Return whether this Datetime has gotten a full state yet.
bool has_state() const { return this->has_state_; }
virtual ESPTime state_as_esptime() const = 0;
void add_on_state_callback(std::function<void()> &&callback) { this->state_callback_.add(std::move(callback)); }
@ -31,8 +28,6 @@ class DateTimeBase : public EntityBase {
#ifdef USE_TIME
time::RealTimeClock *rtc_;
#endif
bool has_state_{false};
};
#ifdef USE_TIME

View File

@ -11,40 +11,40 @@ static const char *const TAG = "datetime.datetime_entity";
void DateTimeEntity::publish_state() {
if (this->year_ == 0 || this->month_ == 0 || this->day_ == 0) {
this->has_state_ = false;
this->set_has_state(false);
return;
}
if (this->year_ < 1970 || this->year_ > 3000) {
this->has_state_ = false;
this->set_has_state(false);
ESP_LOGE(TAG, "Year must be between 1970 and 3000");
return;
}
if (this->month_ < 1 || this->month_ > 12) {
this->has_state_ = false;
this->set_has_state(false);
ESP_LOGE(TAG, "Month must be between 1 and 12");
return;
}
if (this->day_ > days_in_month(this->month_, this->year_)) {
this->has_state_ = false;
this->set_has_state(false);
ESP_LOGE(TAG, "Day must be between 1 and %d for month %d", days_in_month(this->month_, this->year_), this->month_);
return;
}
if (this->hour_ > 23) {
this->has_state_ = false;
this->set_has_state(false);
ESP_LOGE(TAG, "Hour must be between 0 and 23");
return;
}
if (this->minute_ > 59) {
this->has_state_ = false;
this->set_has_state(false);
ESP_LOGE(TAG, "Minute must be between 0 and 59");
return;
}
if (this->second_ > 59) {
this->has_state_ = false;
this->set_has_state(false);
ESP_LOGE(TAG, "Second must be between 0 and 59");
return;
}
this->has_state_ = true;
this->set_has_state(true);
ESP_LOGD(TAG, "'%s': Sending datetime %04u-%02u-%02u %02d:%02d:%02d", this->get_name().c_str(), this->year_,
this->month_, this->day_, this->hour_, this->minute_, this->second_);
this->state_callback_.call();

View File

@ -11,21 +11,21 @@ static const char *const TAG = "datetime.time_entity";
void TimeEntity::publish_state() {
if (this->hour_ > 23) {
this->has_state_ = false;
this->set_has_state(false);
ESP_LOGE(TAG, "Hour must be between 0 and 23");
return;
}
if (this->minute_ > 59) {
this->has_state_ = false;
this->set_has_state(false);
ESP_LOGE(TAG, "Minute must be between 0 and 59");
return;
}
if (this->second_ > 59) {
this->has_state_ = false;
this->set_has_state(false);
ESP_LOGE(TAG, "Second must be between 0 and 59");
return;
}
this->has_state_ = true;
this->set_has_state(true);
ESP_LOGD(TAG, "'%s': Sending time %02d:%02d:%02d", this->get_name().c_str(), this->hour_, this->minute_,
this->second_);
this->state_callback_.call();

View File

@ -57,7 +57,7 @@ void ESP32Camera::dump_config() {
" External Clock: Pin:%d Frequency:%u\n"
" I2C Pins: SDA:%d SCL:%d\n"
" Reset Pin: %d",
this->name_.c_str(), YESNO(this->internal_), conf.pin_d0, conf.pin_d1, conf.pin_d2, conf.pin_d3,
this->name_.c_str(), YESNO(this->is_internal()), conf.pin_d0, conf.pin_d1, conf.pin_d2, conf.pin_d3,
conf.pin_d4, conf.pin_d5, conf.pin_d6, conf.pin_d7, conf.pin_vsync, conf.pin_href, conf.pin_pclk,
conf.pin_xclk, conf.xclk_freq_hz, conf.pin_sccb_sda, conf.pin_sccb_scl, conf.pin_reset);
switch (this->config_.frame_size) {

View File

@ -56,7 +56,7 @@ void NextionBinarySensor::set_state(bool state, bool publish, bool send_to_nexti
this->publish_state(state);
} else {
this->state = state;
this->has_state_ = true;
this->set_has_state(true);
}
this->update_component_settings();

View File

@ -88,7 +88,7 @@ void NextionSensor::set_state(float state, bool publish, bool send_to_nextion) {
} else {
this->raw_state = state;
this->state = state;
this->has_state_ = true;
this->set_has_state(true);
}
}
this->update_component_settings();

View File

@ -37,7 +37,7 @@ void NextionTextSensor::set_state(const std::string &state, bool publish, bool s
this->publish_state(state);
} else {
this->state = state;
this->has_state_ = true;
this->set_has_state(true);
}
this->update_component_settings();

View File

@ -7,7 +7,7 @@ namespace number {
static const char *const TAG = "number";
void Number::publish_state(float state) {
this->has_state_ = true;
this->set_has_state(true);
this->state = state;
ESP_LOGD(TAG, "'%s': Sending state %f", this->get_name().c_str(), state);
this->state_callback_.call(state);

View File

@ -48,9 +48,6 @@ class Number : public EntityBase {
NumberTraits traits;
/// Return whether this number has gotten a full state yet.
bool has_state() const { return has_state_; }
protected:
friend class NumberCall;
@ -63,7 +60,6 @@ class Number : public EntityBase {
virtual void control(float value) = 0;
CallbackManager<void(float)> state_callback_;
bool has_state_{false};
};
} // namespace number

View File

@ -10,7 +10,7 @@ void Select::publish_state(const std::string &state) {
auto index = this->index_of(state);
const auto *name = this->get_name().c_str();
if (index.has_value()) {
this->has_state_ = true;
this->set_has_state(true);
this->state = state;
ESP_LOGD(TAG, "'%s': Sending state %s (index %zu)", name, state.c_str(), index.value());
this->state_callback_.call(state, index.value());

View File

@ -35,9 +35,6 @@ class Select : public EntityBase {
void publish_state(const std::string &state);
/// Return whether this select component has gotten a full state yet.
bool has_state() const { return has_state_; }
/// Instantiate a SelectCall object to modify this select component's state.
SelectCall make_call() { return SelectCall(this); }
@ -73,7 +70,6 @@ class Select : public EntityBase {
virtual void control(const std::string &value) = 0;
CallbackManager<void(std::string, size_t)> state_callback_;
bool has_state_{false};
};
} // namespace select

View File

@ -88,13 +88,12 @@ float Sensor::get_raw_state() const { return this->raw_state; }
std::string Sensor::unique_id() { return ""; }
void Sensor::internal_send_state_to_frontend(float state) {
this->has_state_ = true;
this->set_has_state(true);
this->state = state;
ESP_LOGD(TAG, "'%s': Sending state %.5f %s with %d decimals of accuracy", this->get_name().c_str(), state,
this->get_unit_of_measurement().c_str(), this->get_accuracy_decimals());
this->callback_.call(state);
}
bool Sensor::has_state() const { return this->has_state_; }
} // namespace sensor
} // namespace esphome

View File

@ -140,9 +140,6 @@ class Sensor : public EntityBase, public EntityBase_DeviceClass, public EntityBa
*/
float raw_state;
/// Return whether this sensor has gotten a full state (that passed through all filters) yet.
bool has_state() const;
/** Override this method to set the unique ID of this sensor.
*
* @deprecated Do not use for new sensors, a suitable unique ID is automatically generated (2023.4).
@ -160,7 +157,6 @@ class Sensor : public EntityBase, public EntityBase_DeviceClass, public EntityBa
optional<int8_t> accuracy_decimals_; ///< Accuracy in decimals override
optional<StateClass> state_class_{STATE_CLASS_NONE}; ///< State class override
bool force_update_{false}; ///< Force update mode
bool has_state_{false};
};
} // namespace sensor

View File

@ -7,7 +7,7 @@ namespace text {
static const char *const TAG = "text";
void Text::publish_state(const std::string &state) {
this->has_state_ = true;
this->set_has_state(true);
this->state = state;
if (this->traits.get_mode() == TEXT_MODE_PASSWORD) {
ESP_LOGD(TAG, "'%s': Sending state " LOG_SECRET("'%s'"), this->get_name().c_str(), state.c_str());

View File

@ -28,9 +28,6 @@ class Text : public EntityBase {
void publish_state(const std::string &state);
/// Return whether this text input has gotten a full state yet.
bool has_state() const { return has_state_; }
/// Instantiate a TextCall object to modify this text component's state.
TextCall make_call() { return TextCall(this); }
@ -48,7 +45,6 @@ class Text : public EntityBase {
virtual void control(const std::string &value) = 0;
CallbackManager<void(std::string)> state_callback_;
bool has_state_{false};
};
} // namespace text

View File

@ -60,13 +60,12 @@ std::string TextSensor::get_state() const { return this->state; }
std::string TextSensor::get_raw_state() const { return this->raw_state; }
void TextSensor::internal_send_state_to_frontend(const std::string &state) {
this->state = state;
this->has_state_ = true;
this->set_has_state(true);
ESP_LOGD(TAG, "'%s': Sending state '%s'", this->name_.c_str(), state.c_str());
this->callback_.call(state);
}
std::string TextSensor::unique_id() { return ""; }
bool TextSensor::has_state() { return this->has_state_; }
} // namespace text_sensor
} // namespace esphome

View File

@ -67,8 +67,6 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass {
*/
virtual std::string unique_id();
bool has_state();
void internal_send_state_to_frontend(const std::string &state);
protected:
@ -76,8 +74,6 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass {
CallbackManager<void(std::string)> callback_; ///< Storage for filtered state callbacks.
Filter *filter_list_{nullptr}; ///< Store all active filters.
bool has_state_{false};
};
} // namespace text_sensor

View File

@ -30,7 +30,7 @@ void UpdateEntity::publish_state() {
ESP_LOGD(TAG, " Progress: %.0f%%", this->update_info_.progress);
}
this->has_state_ = true;
this->set_has_state(true);
this->state_callback_.call();
}

View File

@ -28,8 +28,6 @@ enum UpdateState : uint8_t {
class UpdateEntity : public EntityBase, public EntityBase_DeviceClass {
public:
bool has_state() const { return this->has_state_; }
void publish_state();
void perform() { this->perform(false); }
@ -44,7 +42,6 @@ class UpdateEntity : public EntityBase, public EntityBase_DeviceClass {
protected:
UpdateState state_{UPDATE_STATE_UNKNOWN};
UpdateInfo update_info_;
bool has_state_{false};
CallbackManager<void()> state_callback_{};
};

View File

@ -13,7 +13,7 @@ static const char *const TAG = "uptime.sensor";
void UptimeTimestampSensor::setup() {
this->time_->add_on_time_sync_callback([this]() {
if (this->has_state_)
if (this->has_state())
return; // No need to update the timestamp if it's already set
auto now = this->time_->now();

View File

@ -12,20 +12,12 @@ void EntityBase::set_name(const char *name) {
this->name_ = StringRef(name);
if (this->name_.empty()) {
this->name_ = StringRef(App.get_friendly_name());
this->has_own_name_ = false;
this->flags_.has_own_name = false;
} else {
this->has_own_name_ = true;
this->flags_.has_own_name = true;
}
}
// Entity Internal
bool EntityBase::is_internal() const { return this->internal_; }
void EntityBase::set_internal(bool internal) { this->internal_ = internal; }
// Entity Disabled by Default
bool EntityBase::is_disabled_by_default() const { return this->disabled_by_default_; }
void EntityBase::set_disabled_by_default(bool disabled_by_default) { this->disabled_by_default_ = disabled_by_default; }
// Entity Icon
std::string EntityBase::get_icon() const {
if (this->icon_c_str_ == nullptr) {
@ -35,14 +27,10 @@ std::string EntityBase::get_icon() const {
}
void EntityBase::set_icon(const char *icon) { this->icon_c_str_ = icon; }
// Entity Category
EntityCategory EntityBase::get_entity_category() const { return this->entity_category_; }
void EntityBase::set_entity_category(EntityCategory entity_category) { this->entity_category_ = entity_category; }
// Entity Object ID
std::string EntityBase::get_object_id() const {
// Check if `App.get_friendly_name()` is constant or dynamic.
if (!this->has_own_name_ && App.is_name_add_mac_suffix_enabled()) {
if (!this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled()) {
// `App.get_friendly_name()` is dynamic.
return str_sanitize(str_snake_case(App.get_friendly_name()));
} else {
@ -61,7 +49,7 @@ void EntityBase::set_object_id(const char *object_id) {
// Calculate Object ID Hash from Entity Name
void EntityBase::calc_object_id_() {
// Check if `App.get_friendly_name()` is constant or dynamic.
if (!this->has_own_name_ && App.is_name_add_mac_suffix_enabled()) {
if (!this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled()) {
// `App.get_friendly_name()` is dynamic.
const auto object_id = str_sanitize(str_snake_case(App.get_friendly_name()));
// FNV-1 hash

View File

@ -20,7 +20,7 @@ class EntityBase {
void set_name(const char *name);
// Get whether this Entity has its own name or it should use the device friendly_name.
bool has_own_name() const { return this->has_own_name_; }
bool has_own_name() const { return this->flags_.has_own_name; }
// Get the sanitized name of this Entity as an ID.
std::string get_object_id() const;
@ -29,24 +29,32 @@ class EntityBase {
// Get the unique Object ID of this Entity
uint32_t get_object_id_hash();
// Get/set whether this Entity should be hidden from outside of ESPHome
bool is_internal() const;
void set_internal(bool internal);
// Get/set whether this Entity should be hidden outside ESPHome
bool is_internal() const { return this->flags_.internal; }
void set_internal(bool internal) { this->flags_.internal = internal; }
// Check if this object is declared to be disabled by default.
// That means that when the device gets added to Home Assistant (or other clients) it should
// not be added to the default view by default, and a user action is necessary to manually add it.
bool is_disabled_by_default() const;
void set_disabled_by_default(bool disabled_by_default);
bool is_disabled_by_default() const { return this->flags_.disabled_by_default; }
void set_disabled_by_default(bool disabled_by_default) { this->flags_.disabled_by_default = disabled_by_default; }
// Get/set the entity category.
EntityCategory get_entity_category() const;
void set_entity_category(EntityCategory entity_category);
EntityCategory get_entity_category() const { return static_cast<EntityCategory>(this->flags_.entity_category); }
void set_entity_category(EntityCategory entity_category) {
this->flags_.entity_category = static_cast<uint8_t>(entity_category);
}
// Get/set this entity's icon
std::string get_icon() const;
void set_icon(const char *icon);
// Check if this entity has state
bool has_state() const { return this->flags_.has_state; }
// Set has_state - for components that need to manually set this
void set_has_state(bool state) { this->flags_.has_state = state; }
protected:
/// The hash_base() function has been deprecated. It is kept in this
/// class for now, to prevent external components from not compiling.
@ -56,11 +64,17 @@ class EntityBase {
StringRef name_;
const char *object_id_c_str_{nullptr};
const char *icon_c_str_{nullptr};
uint32_t object_id_hash_;
bool has_own_name_{false};
bool internal_{false};
bool disabled_by_default_{false};
EntityCategory entity_category_{ENTITY_CATEGORY_NONE};
uint32_t object_id_hash_{};
// Bit-packed flags to save memory (1 byte instead of 5)
struct EntityFlags {
uint8_t has_own_name : 1;
uint8_t internal : 1;
uint8_t disabled_by_default : 1;
uint8_t has_state : 1;
uint8_t entity_category : 2; // Supports up to 4 categories
uint8_t reserved : 2; // Reserved for future use
} flags_{};
};
class EntityBase_DeviceClass { // NOLINT(readability-identifier-naming)

View File

@ -0,0 +1,108 @@
esphome:
name: host-test
host:
api:
logger:
# Test various entity types with different flag combinations
sensor:
- platform: template
name: "Test Normal Sensor"
id: normal_sensor
update_interval: 1s
lambda: |-
return 42.0;
- platform: template
name: "Test Internal Sensor"
id: internal_sensor
internal: true
update_interval: 1s
lambda: |-
return 43.0;
- platform: template
name: "Test Disabled Sensor"
id: disabled_sensor
disabled_by_default: true
update_interval: 1s
lambda: |-
return 44.0;
- platform: template
name: "Test Mixed Flags Sensor"
id: mixed_flags_sensor
internal: true
entity_category: diagnostic
update_interval: 1s
lambda: |-
return 45.0;
- platform: template
name: "Test Diagnostic Sensor"
id: diagnostic_sensor
entity_category: diagnostic
update_interval: 1s
lambda: |-
return 46.0;
- platform: template
name: "Test All Flags Sensor"
id: all_flags_sensor
internal: true
disabled_by_default: true
entity_category: diagnostic
update_interval: 1s
lambda: |-
return 47.0;
# Also test other entity types to ensure bit-packing works across all
binary_sensor:
- platform: template
name: "Test Binary Sensor"
entity_category: config
lambda: |-
return true;
text_sensor:
- platform: template
name: "Test Text Sensor"
disabled_by_default: true
lambda: |-
return {"Hello"};
number:
- platform: template
name: "Test Number"
initial_value: 50
min_value: 0
max_value: 100
step: 1
optimistic: true
entity_category: diagnostic
select:
- platform: template
name: "Test Select"
options:
- "Option 1"
- "Option 2"
initial_option: "Option 1"
optimistic: true
internal: true
switch:
- platform: template
name: "Test Switch"
optimistic: true
disabled_by_default: true
entity_category: config
button:
- platform: template
name: "Test Button"
on_press:
- logger.log: "Button pressed"

View File

@ -0,0 +1,93 @@
"""Integration test for entity bit-packed fields."""
from __future__ import annotations
import asyncio
from aioesphomeapi import EntityCategory, EntityState
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_host_mode_entity_fields(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test entity bit-packed fields work correctly with all possible values."""
# Write, compile and run the ESPHome device, then connect to API
async with run_compiled(yaml_config), api_client_connected() as client:
# Get all entities
entities = await client.list_entities_services()
# Create a map of entity names to entity info
entity_map = {}
for entity in entities[0]:
if hasattr(entity, "name"):
entity_map[entity.name] = entity
# Test entities that should be visible via API (non-internal)
visible_test_cases = [
# (entity_name, expected_disabled_by_default, expected_entity_category)
("Test Normal Sensor", False, EntityCategory.NONE),
("Test Disabled Sensor", True, EntityCategory.NONE),
("Test Diagnostic Sensor", False, EntityCategory.DIAGNOSTIC),
("Test Switch", True, EntityCategory.CONFIG),
("Test Binary Sensor", False, EntityCategory.CONFIG),
("Test Number", False, EntityCategory.DIAGNOSTIC),
]
# Test entities that should NOT be visible via API (internal)
internal_entities = [
"Test Internal Sensor",
"Test Mixed Flags Sensor",
"Test All Flags Sensor",
"Test Select",
]
# Verify visible entities
for entity_name, expected_disabled, expected_category in visible_test_cases:
assert entity_name in entity_map, (
f"Entity '{entity_name}' not found - it should be visible via API"
)
entity = entity_map[entity_name]
# Check disabled_by_default flag
assert entity.disabled_by_default == expected_disabled, (
f"{entity_name}: disabled_by_default flag mismatch - "
f"expected {expected_disabled}, got {entity.disabled_by_default}"
)
# Check entity_category
assert entity.entity_category == expected_category, (
f"{entity_name}: entity_category mismatch - "
f"expected {expected_category}, got {entity.entity_category}"
)
# Verify internal entities are NOT visible
for entity_name in internal_entities:
assert entity_name not in entity_map, (
f"Entity '{entity_name}' found in API response - "
f"internal entities should not be exposed via API"
)
# Subscribe to states to verify has_state flag works
states: dict[int, EntityState] = {}
state_received = asyncio.Event()
def on_state(state: EntityState) -> None:
states[state.key] = state
state_received.set()
client.subscribe_states(on_state)
# Wait for at least one state
try:
await asyncio.wait_for(state_received.wait(), timeout=5.0)
except asyncio.TimeoutError:
pytest.fail("No states received within 5 seconds")
# Verify we received states (which means has_state flag is working)
assert len(states) > 0, "No states received - has_state flag may not be working"