From ffe459e6666795a157fac4a7aa6b46ebcf56d603 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 Dec 2025 15:30:24 -1000 Subject: [PATCH 1/4] [esp32_camera] Reduce loop overhead and improve frame latency with wake_loop_threadsafe --- esphome/components/esp32_camera/__init__.py | 5 ++- .../components/esp32_camera/esp32_camera.cpp | 37 ++++++++++++------- .../components/esp32_camera/esp32_camera.h | 5 ++- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index ca37cb392d6..db6244fb3f7 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -2,7 +2,7 @@ import logging from esphome import automation, pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import i2c, socket from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option from esphome.components.psram import DOMAIN as psram_domain import esphome.config_validation as cv @@ -27,7 +27,7 @@ import esphome.final_validate as fv _LOGGER = logging.getLogger(__name__) -AUTO_LOAD = ["camera"] +AUTO_LOAD = ["camera", "socket"] DEPENDENCIES = ["esp32"] esp32_camera_ns = cg.esphome_ns.namespace("esp32_camera") @@ -324,6 +324,7 @@ SETTERS = { 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 cg.register_component(var, config) diff --git a/esphome/components/esp32_camera/esp32_camera.cpp b/esphome/components/esp32_camera/esp32_camera.cpp index 45077894013..48df4f8db41 100644 --- a/esphome/components/esp32_camera/esp32_camera.cpp +++ b/esphome/components/esp32_camera/esp32_camera.cpp @@ -11,6 +11,7 @@ namespace esphome { namespace esp32_camera { static const char *const TAG = "esp32_camera"; +static constexpr size_t FRAMEBUFFER_TASK_STACK_SIZE = 1792; #if ESPHOME_LOG_LEVEL < ESPHOME_LOG_LEVEL_VERBOSE static constexpr uint32_t FRAME_LOG_INTERVAL_MS = 60000; #endif @@ -42,12 +43,12 @@ void ESP32Camera::setup() { this->framebuffer_get_queue_ = xQueueCreate(1, sizeof(camera_fb_t *)); this->framebuffer_return_queue_ = xQueueCreate(1, sizeof(camera_fb_t *)); xTaskCreatePinnedToCore(&ESP32Camera::framebuffer_task, - "framebuffer_task", // name - 1024, // stack size - this, // task pv params - 1, // priority - nullptr, // handle - 1 // core + "framebuffer_task", // name + FRAMEBUFFER_TASK_STACK_SIZE, // stack size + this, // task pv params + 1, // priority + nullptr, // handle + 1 // core ); } @@ -167,6 +168,19 @@ void ESP32Camera::dump_config() { } void ESP32Camera::loop() { + // Fast path: skip all work when truly idle + // (no current image, no pending requests, and not time for idle request yet) + const uint32_t now = App.get_loop_component_start_time(); + if (!this->current_image_ && !this->has_requested_image_()) { + // Only check idle interval when we're otherwise idle + if (this->idle_update_interval_ != 0 && now - this->last_idle_request_ > this->idle_update_interval_) { + this->last_idle_request_ = now; + this->request_image(camera::IDLE); + } else { + return; + } + } + // check if we can return the image if (this->can_return_image_()) { // return image @@ -175,13 +189,6 @@ void ESP32Camera::loop() { this->current_image_.reset(); } - // request idle image every idle_update_interval - const uint32_t now = App.get_loop_component_start_time(); - if (this->idle_update_interval_ != 0 && now - this->last_idle_request_ > this->idle_update_interval_) { - this->last_idle_request_ = now; - this->request_image(camera::IDLE); - } - // Check if we should fetch a new image if (!this->has_requested_image_()) return; @@ -421,6 +428,10 @@ void ESP32Camera::framebuffer_task(void *pv) { while (true) { camera_fb_t *framebuffer = esp_camera_fb_get(); xQueueSend(that->framebuffer_get_queue_, &framebuffer, portMAX_DELAY); + // Only wake the main loop if there's a pending request to consume the frame + if (that->has_requested_image_()) { + App.wake_loop_threadsafe(); + } // return is no-op for config with 1 fb xQueueReceive(that->framebuffer_return_queue_, &framebuffer, portMAX_DELAY); esp_camera_fb_return(framebuffer); diff --git a/esphome/components/esp32_camera/esp32_camera.h b/esphome/components/esp32_camera/esp32_camera.h index a49fca65111..e97eb27c705 100644 --- a/esphome/components/esp32_camera/esp32_camera.h +++ b/esphome/components/esp32_camera/esp32_camera.h @@ -2,6 +2,7 @@ #ifdef USE_ESP32 +#include #include #include #include @@ -205,8 +206,8 @@ class ESP32Camera : public camera::Camera { esp_err_t init_error_{ESP_OK}; std::shared_ptr current_image_; - uint8_t single_requesters_{0}; - uint8_t stream_requesters_{0}; + std::atomic single_requesters_{0}; + std::atomic stream_requesters_{0}; QueueHandle_t framebuffer_get_queue_; QueueHandle_t framebuffer_return_queue_; std::vector listeners_; From 9855d86616693df01e8969c6fe36586282b0e156 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 Dec 2025 21:10:22 -1000 Subject: [PATCH 2/4] try send right away --- esphome/components/api/api_connection.cpp | 49 ++++++++++++++--------- esphome/components/api/api_connection.h | 1 + 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 126d3cb220c..e883768a8a3 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -229,25 +229,7 @@ void APIConnection::loop() { } #ifdef USE_CAMERA - if (this->image_reader_ && this->image_reader_->available() && this->helper_->can_write_without_blocking()) { - uint32_t to_send = std::min((size_t) MAX_BATCH_PACKET_SIZE, this->image_reader_->available()); - bool done = this->image_reader_->available() == to_send; - - CameraImageResponse msg; - msg.key = camera::Camera::instance()->get_object_id_hash(); - msg.set_data(this->image_reader_->peek_data_buffer(), to_send); - msg.done = done; -#ifdef USE_DEVICES - msg.device_id = camera::Camera::instance()->get_device_id(); -#endif - - if (this->send_message_(msg, CameraImageResponse::MESSAGE_TYPE)) { - this->image_reader_->consume_data(to_send); - if (done) { - this->image_reader_->return_image(); - } - } - } + this->try_send_camera_image_(); #endif #ifdef USE_API_HOMEASSISTANT_STATES @@ -1057,6 +1039,30 @@ void APIConnection::media_player_command(const MediaPlayerCommandRequest &msg) { #endif #ifdef USE_CAMERA +void APIConnection::try_send_camera_image_() { + if (!this->image_reader_ || !this->image_reader_->available()) + return; + if (!this->helper_->can_write_without_blocking()) + return; + + uint32_t to_send = std::min((size_t) MAX_BATCH_PACKET_SIZE, this->image_reader_->available()); + bool done = this->image_reader_->available() == to_send; + + CameraImageResponse msg; + msg.key = camera::Camera::instance()->get_object_id_hash(); + msg.set_data(this->image_reader_->peek_data_buffer(), to_send); + msg.done = done; +#ifdef USE_DEVICES + msg.device_id = camera::Camera::instance()->get_device_id(); +#endif + + if (this->send_message_(msg, CameraImageResponse::MESSAGE_TYPE)) { + this->image_reader_->consume_data(to_send); + if (done) { + this->image_reader_->return_image(); + } + } +} void APIConnection::set_camera_state(std::shared_ptr image) { if (!this->flags_.state_subscription) return; @@ -1064,8 +1070,11 @@ void APIConnection::set_camera_state(std::shared_ptr image) return; if (this->image_reader_->available()) return; - if (image->was_requested_by(esphome::camera::API_REQUESTER) || image->was_requested_by(esphome::camera::IDLE)) + if (image->was_requested_by(esphome::camera::API_REQUESTER) || image->was_requested_by(esphome::camera::IDLE)) { this->image_reader_->set_image(std::move(image)); + // Try to send immediately to reduce latency + this->try_send_camera_image_(); + } } uint16_t APIConnection::try_send_camera_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single) { diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index b50be5d0d42..c9566c5d16c 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -77,6 +77,7 @@ class APIConnection final : public APIServerConnection { #endif #ifdef USE_CAMERA void set_camera_state(std::shared_ptr image); + void try_send_camera_image_(); void camera_image(const CameraImageRequest &msg) override; #endif #ifdef USE_CLIMATE From 6dd41a14c449e97539e88eefd98f3f01cb0c49c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 Dec 2025 21:17:19 -1000 Subject: [PATCH 3/4] try send right away --- esphome/components/api/api_connection.cpp | 28 ++++++++++++++--------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index e883768a8a3..7a02f084104 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1040,26 +1040,32 @@ void APIConnection::media_player_command(const MediaPlayerCommandRequest &msg) { #ifdef USE_CAMERA void APIConnection::try_send_camera_image_() { - if (!this->image_reader_ || !this->image_reader_->available()) - return; - if (!this->helper_->can_write_without_blocking()) + if (!this->image_reader_) return; - uint32_t to_send = std::min((size_t) MAX_BATCH_PACKET_SIZE, this->image_reader_->available()); - bool done = this->image_reader_->available() == to_send; + // Send as many chunks as possible without blocking + while (this->image_reader_->available()) { + if (!this->helper_->can_write_without_blocking()) + return; - CameraImageResponse msg; - msg.key = camera::Camera::instance()->get_object_id_hash(); - msg.set_data(this->image_reader_->peek_data_buffer(), to_send); - msg.done = done; + uint32_t to_send = std::min((size_t) MAX_BATCH_PACKET_SIZE, this->image_reader_->available()); + bool done = this->image_reader_->available() == to_send; + + CameraImageResponse msg; + msg.key = camera::Camera::instance()->get_object_id_hash(); + msg.set_data(this->image_reader_->peek_data_buffer(), to_send); + msg.done = done; #ifdef USE_DEVICES - msg.device_id = camera::Camera::instance()->get_device_id(); + msg.device_id = camera::Camera::instance()->get_device_id(); #endif - if (this->send_message_(msg, CameraImageResponse::MESSAGE_TYPE)) { + if (!this->send_message_(msg, CameraImageResponse::MESSAGE_TYPE)) { + return; // Send failed, try again later + } this->image_reader_->consume_data(to_send); if (done) { this->image_reader_->return_image(); + return; } } } From c1463a569c946f5b7a0938791cf51523e47e7015 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 Dec 2025 21:31:30 -1000 Subject: [PATCH 4/4] reorder --- esphome/components/api/api_connection.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 7a02f084104..d11de0505df 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -228,15 +228,17 @@ void APIConnection::loop() { } } -#ifdef USE_CAMERA - this->try_send_camera_image_(); -#endif - #ifdef USE_API_HOMEASSISTANT_STATES if (state_subs_at_ >= 0) { this->process_state_subscriptions_(); } #endif + +#ifdef USE_CAMERA + // Process camera last - state updates are higher priority + // (missing a frame is fine, missing a state update is not) + this->try_send_camera_image_(); +#endif } bool APIConnection::send_disconnect_response(const DisconnectRequest &msg) {