From 6d0c6329ad426f2e86d8fe2e8602e255ce1a59c2 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 26 Jun 2025 08:45:14 +1000 Subject: [PATCH 1/3] [lvgl] Allow linear positioning of grid cells (#9196) --- esphome/components/lvgl/__init__.py | 2 +- esphome/components/lvgl/schemas.py | 64 +++++++++++++++++++++++-- tests/components/lvgl/lvgl-package.yaml | 4 +- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index dd49efd447..4a450375c4 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -466,7 +466,7 @@ LVGL_SCHEMA = cv.All( ): lvalid.lv_color, cv.Optional(df.CONF_THEME): cv.Schema( { - cv.Optional(name): obj_schema(w) + cv.Optional(name): obj_schema(w).extend(FULL_STYLE_SCHEMA) for name, w in WIDGET_TYPES.items() } ), diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index a0be65c928..959d203c41 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -21,7 +21,7 @@ from esphome.core.config import StartupTrigger from esphome.schema_extractors import SCHEMA_EXTRACT from . import defines as df, lv_validation as lvalid -from .defines import CONF_TIME_FORMAT, LV_GRAD_DIR +from .defines import CONF_TIME_FORMAT, LV_GRAD_DIR, TYPE_GRID from .helpers import add_lv_use, requires_component, validate_printf from .lv_validation import lv_color, lv_font, lv_gradient, lv_image, opacity from .lvcode import LvglComponent, lv_event_t_ptr @@ -349,7 +349,60 @@ def obj_schema(widget_type: WidgetType): ) +def _validate_grid_layout(config): + layout = config[df.CONF_LAYOUT] + rows = len(layout[df.CONF_GRID_ROWS]) + columns = len(layout[df.CONF_GRID_COLUMNS]) + used_cells = [[None] * columns for _ in range(rows)] + for index, widget in enumerate(config[df.CONF_WIDGETS]): + _, w = next(iter(widget.items())) + if (df.CONF_GRID_CELL_COLUMN_POS in w) != (df.CONF_GRID_CELL_ROW_POS in w): + # pylint: disable=raise-missing-from + raise cv.Invalid( + "Both row and column positions must be specified, or both omitted", + [df.CONF_WIDGETS, index], + ) + if df.CONF_GRID_CELL_ROW_POS in w: + row = w[df.CONF_GRID_CELL_ROW_POS] + column = w[df.CONF_GRID_CELL_COLUMN_POS] + else: + try: + row, column = next( + (r_idx, c_idx) + for r_idx, row in enumerate(used_cells) + for c_idx, value in enumerate(row) + if value is None + ) + except StopIteration: + # pylint: disable=raise-missing-from + raise cv.Invalid( + "No free cells available in grid layout", [df.CONF_WIDGETS, index] + ) + w[df.CONF_GRID_CELL_ROW_POS] = row + w[df.CONF_GRID_CELL_COLUMN_POS] = column + + for i in range(w[df.CONF_GRID_CELL_ROW_SPAN]): + for j in range(w[df.CONF_GRID_CELL_COLUMN_SPAN]): + if row + i >= rows or column + j >= columns: + # pylint: disable=raise-missing-from + raise cv.Invalid( + f"Cell at {row}/{column} span {w[df.CONF_GRID_CELL_ROW_SPAN]}x{w[df.CONF_GRID_CELL_COLUMN_SPAN]} " + f"exceeds grid size {rows}x{columns}", + [df.CONF_WIDGETS, index], + ) + if used_cells[row + i][column + j] is not None: + # pylint: disable=raise-missing-from + raise cv.Invalid( + f"Cell span {row + i}/{column + j} already occupied by widget at index {used_cells[row + i][column + j]}", + [df.CONF_WIDGETS, index], + ) + used_cells[row + i][column + j] = index + + return config + + LAYOUT_SCHEMAS = {} +LAYOUT_VALIDATORS = {TYPE_GRID: _validate_grid_layout} ALIGN_TO_SCHEMA = { cv.Optional(df.CONF_ALIGN_TO): cv.Schema( @@ -402,8 +455,8 @@ LAYOUT_SCHEMA = { } GRID_CELL_SCHEMA = { - cv.Required(df.CONF_GRID_CELL_ROW_POS): cv.positive_int, - cv.Required(df.CONF_GRID_CELL_COLUMN_POS): cv.positive_int, + cv.Optional(df.CONF_GRID_CELL_ROW_POS): cv.positive_int, + cv.Optional(df.CONF_GRID_CELL_COLUMN_POS): cv.positive_int, cv.Optional(df.CONF_GRID_CELL_ROW_SPAN, default=1): cv.positive_int, cv.Optional(df.CONF_GRID_CELL_COLUMN_SPAN, default=1): cv.positive_int, cv.Optional(df.CONF_GRID_CELL_X_ALIGN): grid_alignments, @@ -474,7 +527,10 @@ def container_validator(schema, widget_type: WidgetType): result = result.extend( LAYOUT_SCHEMAS.get(ltype.lower(), LAYOUT_SCHEMAS[df.TYPE_NONE]) ) - return result(value) + value = result(value) + if layout_validator := LAYOUT_VALIDATORS.get(ltype): + value = layout_validator(value) + return value return validator diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 212e30c1eb..2edc62b6a1 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -839,9 +839,7 @@ lvgl: styles: bdr_style grid_cell_x_align: center grid_cell_y_align: stretch - grid_cell_row_pos: 0 - grid_cell_column_pos: 1 - grid_cell_column_span: 1 + grid_cell_column_span: 2 text: "Grid cell 0/1" - label: grid_cell_x_align: end From 17497eec43819c989c5d77a34576d1c90a2c87cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Jun 2025 01:15:59 +0200 Subject: [PATCH 2/3] Reduce memory required for sensor entities (#9201) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/sensor/sensor.cpp | 18 ++++++++++------ esphome/components/sensor/sensor.h | 18 +++++++++++----- .../fixtures/host_mode_with_sensor.yaml | 3 +++ tests/integration/test_host_mode_sensor.py | 21 +++++++++++++++++++ 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index 6d6cff0400..7dab63b026 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -23,16 +23,22 @@ std::string state_class_to_string(StateClass state_class) { Sensor::Sensor() : state(NAN), raw_state(NAN) {} int8_t Sensor::get_accuracy_decimals() { - if (this->accuracy_decimals_.has_value()) - return *this->accuracy_decimals_; + if (this->sensor_flags_.has_accuracy_override) + return this->accuracy_decimals_; return 0; } -void Sensor::set_accuracy_decimals(int8_t accuracy_decimals) { this->accuracy_decimals_ = accuracy_decimals; } +void Sensor::set_accuracy_decimals(int8_t accuracy_decimals) { + this->accuracy_decimals_ = accuracy_decimals; + this->sensor_flags_.has_accuracy_override = true; +} -void Sensor::set_state_class(StateClass state_class) { this->state_class_ = state_class; } +void Sensor::set_state_class(StateClass state_class) { + this->state_class_ = state_class; + this->sensor_flags_.has_state_class_override = true; +} StateClass Sensor::get_state_class() { - if (this->state_class_.has_value()) - return *this->state_class_; + if (this->sensor_flags_.has_state_class_override) + return this->state_class_; return StateClass::STATE_CLASS_NONE; } diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index 456e876497..3fb6e5522b 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -80,9 +80,9 @@ class Sensor : public EntityBase, public EntityBase_DeviceClass, public EntityBa * state changes to the database when they are published, even if the state is the * same as before. */ - bool get_force_update() const { return force_update_; } + bool get_force_update() const { return sensor_flags_.force_update; } /// Set force update mode. - void set_force_update(bool force_update) { force_update_ = force_update; } + void set_force_update(bool force_update) { sensor_flags_.force_update = force_update; } /// Add a filter to the filter chain. Will be appended to the back. void add_filter(Filter *filter); @@ -155,9 +155,17 @@ class Sensor : public EntityBase, public EntityBase_DeviceClass, public EntityBa Filter *filter_list_{nullptr}; ///< Store all active filters. - optional accuracy_decimals_; ///< Accuracy in decimals override - optional state_class_{STATE_CLASS_NONE}; ///< State class override - bool force_update_{false}; ///< Force update mode + // Group small members together to avoid padding + int8_t accuracy_decimals_{-1}; ///< Accuracy in decimals (-1 = not set) + StateClass state_class_{STATE_CLASS_NONE}; ///< State class (STATE_CLASS_NONE = not set) + + // Bit-packed flags for sensor-specific settings + struct SensorFlags { + uint8_t has_accuracy_override : 1; + uint8_t has_state_class_override : 1; + uint8_t force_update : 1; + uint8_t reserved : 5; // Reserved for future use + } sensor_flags_{}; }; } // namespace sensor diff --git a/tests/integration/fixtures/host_mode_with_sensor.yaml b/tests/integration/fixtures/host_mode_with_sensor.yaml index fecd0b435b..0ac495f3b1 100644 --- a/tests/integration/fixtures/host_mode_with_sensor.yaml +++ b/tests/integration/fixtures/host_mode_with_sensor.yaml @@ -8,5 +8,8 @@ sensor: name: Test Sensor id: test_sensor unit_of_measurement: °C + accuracy_decimals: 2 + state_class: measurement + force_update: true lambda: return 42.0; update_interval: 0.1s diff --git a/tests/integration/test_host_mode_sensor.py b/tests/integration/test_host_mode_sensor.py index f0c938da1c..049f7db619 100644 --- a/tests/integration/test_host_mode_sensor.py +++ b/tests/integration/test_host_mode_sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio +import aioesphomeapi from aioesphomeapi import EntityState import pytest @@ -47,3 +48,23 @@ async def test_host_mode_with_sensor( # Verify the sensor state assert test_sensor_state.state == 42.0 assert len(states) > 0, "No states received" + + # Verify the optimized fields are working correctly + # Get entity info to check accuracy_decimals, state_class, etc. + entities, _ = await client.list_entities_services() + sensor_info: aioesphomeapi.SensorInfo | None = None + for entity in entities: + if isinstance(entity, aioesphomeapi.SensorInfo): + sensor_info = entity + break + + assert sensor_info is not None, "Sensor entity info not found" + assert sensor_info.accuracy_decimals == 2, ( + f"Expected accuracy_decimals=2, got {sensor_info.accuracy_decimals}" + ) + assert sensor_info.state_class == 1, ( + f"Expected state_class=1 (measurement), got {sensor_info.state_class}" + ) + assert sensor_info.force_update is True, ( + f"Expected force_update=True, got {sensor_info.force_update}" + ) From 2371ec1f9e750aeacf093ea21dab0dd055748ca0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Jun 2025 02:11:17 +0200 Subject: [PATCH 3/3] Replace ping retry timer with batch queue fallback --- esphome/components/api/api_connection.cpp | 36 +++++++++++------------ esphome/components/api/api_connection.h | 17 ++++++++--- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 634174ce0a..29eac240c0 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -60,10 +60,6 @@ uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_ void APIConnection::start() { this->last_traffic_ = App.get_loop_component_start_time(); - // Set next_ping_retry_ to prevent immediate ping - // This ensures the first ping happens after the keepalive period - this->next_ping_retry_ = this->last_traffic_ + KEEPALIVE_TIMEOUT_MS; - APIError err = this->helper_->init(); if (err != APIError::OK) { on_fatal_error(); @@ -161,30 +157,21 @@ void APIConnection::loop() { if (!this->initial_state_iterator_.completed() && this->list_entities_iterator_.completed()) this->initial_state_iterator_.advance(); - static uint8_t max_ping_retries = 60; - static uint16_t ping_retry_interval = 1000; if (this->sent_ping_) { // Disconnect if not responded within 2.5*keepalive if (now - this->last_traffic_ > (KEEPALIVE_TIMEOUT_MS * 5) / 2) { on_fatal_error(); ESP_LOGW(TAG, "%s is unresponsive; disconnecting", this->get_client_combined_info().c_str()); } - } else if (now - this->last_traffic_ > KEEPALIVE_TIMEOUT_MS && now > this->next_ping_retry_) { + } else if (now - this->last_traffic_ > KEEPALIVE_TIMEOUT_MS) { ESP_LOGVV(TAG, "Sending keepalive PING"); this->sent_ping_ = this->send_message(PingRequest()); if (!this->sent_ping_) { - this->next_ping_retry_ = now + ping_retry_interval; - this->ping_retries_++; - std::string warn_str = str_sprintf("%s: Sending keepalive failed %u time(s);", - this->get_client_combined_info().c_str(), this->ping_retries_); - if (this->ping_retries_ >= max_ping_retries) { - on_fatal_error(); - ESP_LOGE(TAG, "%s disconnecting", warn_str.c_str()); - } else if (this->ping_retries_ >= 10) { - ESP_LOGW(TAG, "%s retrying in %u ms", warn_str.c_str(), ping_retry_interval); - } else { - ESP_LOGD(TAG, "%s retrying in %u ms", warn_str.c_str(), ping_retry_interval); - } + // If we can't send the ping request directly (tx_buffer full), + // schedule it at the front of the batch so it will be sent with priority + ESP_LOGVV(TAG, "Failed to send ping directly, scheduling at front of batch"); + this->schedule_message_front_(nullptr, &APIConnection::try_send_ping_request, PingRequest::MESSAGE_TYPE); + this->sent_ping_ = true; // Mark as sent to avoid scheduling multiple pings } } @@ -1760,6 +1747,11 @@ void APIConnection::DeferredBatch::add_item(EntityBase *entity, MessageCreator c items.emplace_back(entity, std::move(creator), message_type); } +void APIConnection::DeferredBatch::add_item_front(EntityBase *entity, MessageCreator creator, uint16_t message_type) { + // Insert at front for high priority messages (no deduplication check) + items.insert(items.begin(), BatchItem(entity, std::move(creator), message_type)); +} + bool APIConnection::schedule_batch_() { if (!this->deferred_batch_.batch_scheduled) { this->deferred_batch_.batch_scheduled = true; @@ -1938,6 +1930,12 @@ uint16_t APIConnection::try_send_disconnect_request(EntityBase *entity, APIConne return encode_message_to_buffer(req, DisconnectRequest::MESSAGE_TYPE, conn, remaining_size, is_single); } +uint16_t APIConnection::try_send_ping_request(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, + bool is_single) { + PingRequest req; + return encode_message_to_buffer(req, PingRequest::MESSAGE_TYPE, conn, remaining_size, is_single); +} + uint16_t APIConnection::get_estimated_message_size(uint16_t message_type) { // Use generated ESTIMATED_SIZE constants from each message type switch (message_type) { diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index da12a3e449..5bfe421d6b 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -185,7 +185,6 @@ class APIConnection : public APIServerConnection { void on_disconnect_response(const DisconnectResponse &value) override; void on_ping_response(const PingResponse &value) override { // we initiated ping - this->ping_retries_ = 0; this->sent_ping_ = false; } void on_home_assistant_state_response(const HomeAssistantStateResponse &msg) override; @@ -441,13 +440,16 @@ class APIConnection : public APIServerConnection { // Helper function to get estimated message size for buffer pre-allocation static uint16_t get_estimated_message_size(uint16_t message_type); + // Batch message method for ping requests + static uint16_t try_send_ping_request(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, + bool is_single); + // Pointers first (4 bytes each, naturally aligned) std::unique_ptr helper_; APIServer *parent_; // 4-byte aligned types uint32_t last_traffic_; - uint32_t next_ping_retry_{0}; int state_subs_at_ = -1; // Strings (12 bytes each on 32-bit) @@ -470,8 +472,7 @@ class APIConnection : public APIServerConnection { bool sent_ping_{false}; bool service_call_subscription_{false}; bool next_close_ = false; - uint8_t ping_retries_{0}; - // 8 bytes used, no padding needed + // 7 bytes used, 1 byte padding // Larger objects at the end InitialStateIterator initial_state_iterator_; @@ -591,6 +592,8 @@ class APIConnection : public APIServerConnection { // Add item to the batch void add_item(EntityBase *entity, MessageCreator creator, uint16_t message_type); + // Add item to the front of the batch (for high priority messages like ping) + void add_item_front(EntityBase *entity, MessageCreator creator, uint16_t message_type); void clear() { items.clear(); batch_scheduled = false; @@ -630,6 +633,12 @@ class APIConnection : public APIServerConnection { bool schedule_message_(EntityBase *entity, MessageCreatorPtr function_ptr, uint16_t message_type) { return schedule_message_(entity, MessageCreator(function_ptr), message_type); } + + // Helper function to schedule a high priority message at the front of the batch + bool schedule_message_front_(EntityBase *entity, MessageCreatorPtr function_ptr, uint16_t message_type) { + this->deferred_batch_.add_item_front(entity, MessageCreator(function_ptr), message_type); + return this->schedule_batch_(); + } }; } // namespace api