From 29be1423f55a0d1502835d10ed2784b0f0839042 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 2 Dec 2025 08:59:50 -0500 Subject: [PATCH 01/14] [core] Filter noisy platformio log messages (#12218) Co-authored-by: Claude Co-authored-by: J. Nick Koston --- esphome/platformio_api.py | 28 ++++++- tests/unit_tests/test_platformio_api.py | 98 +++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index d59523a74a..4d795ea5d9 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -107,9 +107,24 @@ FILTER_PLATFORMIO_LINES = [ r"Warning: DEPRECATED: 'esptool.py' is deprecated. Please use 'esptool' instead. The '.py' suffix will be removed in a future major release.", r"Warning: esp-idf-size exited with code 2", r"esp_idf_size: error: unrecognized arguments: --ng", + r"Package configuration completed successfully", ] +class PlatformioLogFilter(logging.Filter): + """Filter to suppress noisy platformio log messages.""" + + _PATTERN = re.compile( + r"|".join(r"(?:" + pattern + r")" for pattern in FILTER_PLATFORMIO_LINES) + ) + + def filter(self, record: logging.LogRecord) -> bool: + # Only filter messages from platformio-related loggers + if "platformio" not in record.name.lower(): + return True + return self._PATTERN.match(record.getMessage()) is None + + def run_platformio_cli(*args, **kwargs) -> str | int: os.environ["PLATFORMIO_FORCE_COLOR"] = "true" os.environ["PLATFORMIO_BUILD_DIR"] = str(CORE.relative_pioenvs_path().absolute()) @@ -130,7 +145,18 @@ def run_platformio_cli(*args, **kwargs) -> str | int: patch_structhash() patch_file_downloader() - return run_external_command(platformio.__main__.main, *cmd, **kwargs) + + # Add log filter to suppress noisy platformio messages + log_filter = PlatformioLogFilter() if not CORE.verbose else None + if log_filter: + for handler in logging.getLogger().handlers: + handler.addFilter(log_filter) + try: + return run_external_command(platformio.__main__.main, *cmd, **kwargs) + finally: + if log_filter: + for handler in logging.getLogger().handlers: + handler.removeFilter(log_filter) def run_platformio_cli_run(config, verbose, *args, **kwargs) -> str | int: diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py index 13ef3516e4..4d7b635e59 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_api.py @@ -1,6 +1,7 @@ """Tests for platformio_api.py path functions.""" import json +import logging import os from pathlib import Path import shutil @@ -670,3 +671,100 @@ def test_process_stacktrace_bad_alloc( assert "Memory allocation of 512 bytes failed at 40201234" in caplog.text mock_decode_pc.assert_called_once_with(config, "40201234") assert state is False + + +def test_platformio_log_filter_allows_non_platformio_messages() -> None: + """Test that non-platformio logger messages are allowed through.""" + log_filter = platformio_api.PlatformioLogFilter() + record = logging.LogRecord( + name="esphome.core", + level=logging.INFO, + pathname="", + lineno=0, + msg="Some esphome message", + args=(), + exc_info=None, + ) + assert log_filter.filter(record) is True + + +@pytest.mark.parametrize( + "msg", + [ + "Verbose mode can be enabled via `-v, --verbose` option", + "Found 5 compatible libraries", + "Found 123 compatible libraries", + "Building in release mode", + "Building in debug mode", + "Merged 2 ELF section", + "esptool.py v4.7.0", + "esptool v4.8.1", + "PLATFORM: espressif32 @ 6.4.0", + "Using cache: /path/to/cache", + "Package configuration completed successfully", + "Scanning dependencies...", + "Installing dependencies", + "Library Manager: Already installed, built-in library", + "Memory Usage -> https://bit.ly/pio-memory-usage", + ], +) +def test_platformio_log_filter_blocks_noisy_messages(msg: str) -> None: + """Test that noisy platformio messages are filtered out.""" + log_filter = platformio_api.PlatformioLogFilter() + record = logging.LogRecord( + name="platformio.builder", + level=logging.INFO, + pathname="", + lineno=0, + msg=msg, + args=(), + exc_info=None, + ) + assert log_filter.filter(record) is False + + +@pytest.mark.parametrize( + "msg", + [ + "Compiling .pio/build/test/src/main.cpp.o", + "Linking .pio/build/test/firmware.elf", + "Error: something went wrong", + "warning: unused variable", + ], +) +def test_platformio_log_filter_allows_other_platformio_messages(msg: str) -> None: + """Test that non-noisy platformio messages are allowed through.""" + log_filter = platformio_api.PlatformioLogFilter() + record = logging.LogRecord( + name="platformio.builder", + level=logging.INFO, + pathname="", + lineno=0, + msg=msg, + args=(), + exc_info=None, + ) + assert log_filter.filter(record) is True + + +@pytest.mark.parametrize( + "logger_name", + [ + "PLATFORMIO.builder", + "PlatformIO.core", + "platformio.run", + ], +) +def test_platformio_log_filter_case_insensitive_logger_name(logger_name: str) -> None: + """Test that platformio logger name matching is case insensitive.""" + log_filter = platformio_api.PlatformioLogFilter() + record = logging.LogRecord( + name=logger_name, + level=logging.INFO, + pathname="", + lineno=0, + msg="Found 5 compatible libraries", + args=(), + exc_info=None, + ) + assert log_filter.filter(record) is False From deda7a1bf395a7b51ecafd86303cb31c3aa3179c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Dec 2025 09:59:05 -0600 Subject: [PATCH 02/14] [lock] Store lock state strings in flash on ESP8266 (#12163) --- esphome/components/lock/lock.cpp | 21 ++++++++++---------- esphome/components/lock/lock.h | 5 ++++- esphome/components/mqtt/mqtt_lock.cpp | 10 ++++++++-- esphome/components/web_server/web_server.cpp | 7 ++++--- 4 files changed, 26 insertions(+), 17 deletions(-) diff --git a/esphome/components/lock/lock.cpp b/esphome/components/lock/lock.cpp index b8f0fbe011..018f5113e3 100644 --- a/esphome/components/lock/lock.cpp +++ b/esphome/components/lock/lock.cpp @@ -7,21 +7,21 @@ namespace esphome::lock { static const char *const TAG = "lock"; -const char *lock_state_to_string(LockState state) { +const LogString *lock_state_to_string(LockState state) { switch (state) { case LOCK_STATE_LOCKED: - return "LOCKED"; + return LOG_STR("LOCKED"); case LOCK_STATE_UNLOCKED: - return "UNLOCKED"; + return LOG_STR("UNLOCKED"); case LOCK_STATE_JAMMED: - return "JAMMED"; + return LOG_STR("JAMMED"); case LOCK_STATE_LOCKING: - return "LOCKING"; + return LOG_STR("LOCKING"); case LOCK_STATE_UNLOCKING: - return "UNLOCKING"; + return LOG_STR("UNLOCKING"); case LOCK_STATE_NONE: default: - return "UNKNOWN"; + return LOG_STR("UNKNOWN"); } } @@ -52,7 +52,7 @@ void Lock::publish_state(LockState state) { this->state = state; this->rtc_.save(&this->state); - ESP_LOGD(TAG, "'%s': Sending state %s", this->name_.c_str(), lock_state_to_string(state)); + ESP_LOGD(TAG, "'%s': Sending state %s", this->name_.c_str(), LOG_STR_ARG(lock_state_to_string(state))); this->state_callback_.call(); #if defined(USE_LOCK) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_lock_update(this); @@ -65,8 +65,7 @@ void LockCall::perform() { ESP_LOGD(TAG, "'%s' - Setting", this->parent_->get_name().c_str()); this->validate_(); if (this->state_.has_value()) { - const char *state_s = lock_state_to_string(*this->state_); - ESP_LOGD(TAG, " State: %s", state_s); + ESP_LOGD(TAG, " State: %s", LOG_STR_ARG(lock_state_to_string(*this->state_))); } this->parent_->control(*this); } @@ -74,7 +73,7 @@ void LockCall::validate_() { if (this->state_.has_value()) { auto state = *this->state_; if (!this->parent_->traits.supports_state(state)) { - ESP_LOGW(TAG, " State %s is not supported by this device!", lock_state_to_string(*this->state_)); + ESP_LOGW(TAG, " State %s is not supported by this device!", LOG_STR_ARG(lock_state_to_string(*this->state_))); this->state_.reset(); } } diff --git a/esphome/components/lock/lock.h b/esphome/components/lock/lock.h index 8a906ef9fc..4001a182b8 100644 --- a/esphome/components/lock/lock.h +++ b/esphome/components/lock/lock.h @@ -30,7 +30,10 @@ enum LockState : uint8_t { LOCK_STATE_LOCKING = 4, LOCK_STATE_UNLOCKING = 5 }; -const char *lock_state_to_string(LockState state); +const LogString *lock_state_to_string(LockState state); + +/// Maximum length of lock state string (including null terminator): "UNLOCKING" = 10 +static constexpr size_t LOCK_STATE_STR_SIZE = 10; class LockTraits { public: diff --git a/esphome/components/mqtt/mqtt_lock.cpp b/esphome/components/mqtt/mqtt_lock.cpp index 0e15377ba4..95efbf60e1 100644 --- a/esphome/components/mqtt/mqtt_lock.cpp +++ b/esphome/components/mqtt/mqtt_lock.cpp @@ -48,8 +48,14 @@ void MQTTLockComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfi bool MQTTLockComponent::send_initial_state() { return this->publish_state(); } bool MQTTLockComponent::publish_state() { - std::string payload = lock_state_to_string(this->lock_->state); - return this->publish(this->get_state_topic_(), payload); +#ifdef USE_STORE_LOG_STR_IN_FLASH + char buf[LOCK_STATE_STR_SIZE]; + strncpy_P(buf, (PGM_P) lock_state_to_string(this->lock_->state), sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + return this->publish(this->get_state_topic_(), buf); +#else + return this->publish(this->get_state_topic_(), LOG_STR_ARG(lock_state_to_string(this->lock_->state))); +#endif } } // namespace mqtt diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index c752c00899..38fa54704a 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1334,7 +1334,7 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf const auto traits = obj->get_traits(); int8_t target_accuracy = traits.get_target_temperature_accuracy_decimals(); int8_t current_accuracy = traits.get_current_temperature_accuracy_decimals(); - char buf[16]; + char buf[PSTR_LOCAL_SIZE]; if (start_config == DETAIL_ALL) { JsonArray opt = root["modes"].to(); @@ -1484,7 +1484,8 @@ std::string WebServer::lock_json(lock::Lock *obj, lock::LockState value, JsonDet json::JsonBuilder builder; JsonObject root = builder.root(); - set_json_icon_state_value(root, obj, "lock", lock::lock_state_to_string(value), value, start_config); + char buf[PSTR_LOCAL_SIZE]; + set_json_icon_state_value(root, obj, "lock", PSTR_LOCAL(lock::lock_state_to_string(value)), value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1645,7 +1646,7 @@ std::string WebServer::alarm_control_panel_json(alarm_control_panel::AlarmContro json::JsonBuilder builder; JsonObject root = builder.root(); - char buf[16]; + char buf[PSTR_LOCAL_SIZE]; set_json_icon_state_value(root, obj, "alarm-control-panel", PSTR_LOCAL(alarm_control_panel_state_to_string(value)), value, start_config); if (start_config == DETAIL_ALL) { From f9ad832e7bef3c685273ccc971942814b4eda2dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Dec 2025 09:59:32 -0600 Subject: [PATCH 03/14] [esp32_camera] Replace std::function callbacks with CameraListener interface (#12165) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/api/api_server.cpp | 16 ++++--- esphome/components/api/api_server.h | 10 +++++ esphome/components/camera/camera.h | 25 ++++++++--- .../components/esp32_camera/esp32_camera.cpp | 21 ++++----- .../components/esp32_camera/esp32_camera.h | 43 ++++++++----------- .../camera_web_server.cpp | 14 +++--- .../camera_web_server.h | 5 ++- 7 files changed, 78 insertions(+), 56 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index de0c4b24c9..4168761c74 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -107,12 +107,7 @@ void APIServer::setup() { #ifdef USE_CAMERA if (camera::Camera::instance() != nullptr && !camera::Camera::instance()->is_internal()) { - camera::Camera::instance()->add_image_callback([this](const std::shared_ptr &image) { - for (auto &c : this->clients_) { - if (!c->flags_.remove) - c->set_camera_state(image); - } - }); + camera::Camera::instance()->add_listener(this); } #endif } @@ -544,6 +539,15 @@ void APIServer::on_log(uint8_t level, const char *tag, const char *message, size } #endif +#ifdef USE_CAMERA +void APIServer::on_camera_image(const std::shared_ptr &image) { + for (auto &c : this->clients_) { + if (!c->flags_.remove) + c->set_camera_state(image); + } +} +#endif + void APIServer::on_shutdown() { this->shutting_down_ = true; diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 57aea6ad0e..3089bb1d35 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -18,6 +18,9 @@ #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif +#ifdef USE_CAMERA +#include "esphome/components/camera/camera.h" +#endif #include #include @@ -36,6 +39,10 @@ class APIServer : public Component, , public logger::LogListener #endif +#ifdef USE_CAMERA + , + public camera::CameraListener +#endif { public: APIServer(); @@ -49,6 +56,9 @@ class APIServer : public Component, #ifdef USE_LOGGER void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) override; #endif +#ifdef USE_CAMERA + void on_camera_image(const std::shared_ptr &image) override; +#endif #ifdef USE_API_PASSWORD bool check_password(const uint8_t *password_data, size_t password_len) const; void set_password(const std::string &password); diff --git a/esphome/components/camera/camera.h b/esphome/components/camera/camera.h index c28a756a06..6e1fc8cc06 100644 --- a/esphome/components/camera/camera.h +++ b/esphome/components/camera/camera.h @@ -35,6 +35,21 @@ inline const char *to_string(PixelFormat format) { return "PIXEL_FORMAT_UNKNOWN"; } +// Forward declaration +class CameraImage; + +/** Listener interface for camera events. + * + * Components can implement this interface to receive camera notifications + * (new images, stream start/stop) without the overhead of std::function callbacks. + */ +class CameraListener { + public: + virtual void on_camera_image(const std::shared_ptr &image) {} + virtual void on_stream_start() {} + virtual void on_stream_stop() {} +}; + /** Abstract camera image base class. * Encapsulates the JPEG encoded data and it is shared among * all connected clients. @@ -87,12 +102,12 @@ struct CameraImageSpec { }; /** Abstract camera base class. Collaborates with API. - * 1) API server starts and installs callback (add_image_callback) - * which is called by the camera when a new image is available. + * 1) API server starts and registers as a listener (add_listener) + * to receive new images from the camera. * 2) New API client connects and creates a new image reader (create_image_reader). * 3) API connection receives protobuf CameraImageRequest and calls request_image. * 3.a) API connection receives protobuf CameraImageRequest and calls start_stream. - * 4) Camera implementation provides JPEG data in the CameraImage and calls callback. + * 4) Camera implementation provides JPEG data in the CameraImage and notifies listeners. * 5) API connection sets the image in the image reader. * 6) API connection consumes data from the image reader and returns the image when finished. * 7.a) Camera captures a new image and continues with 4) until start_stream is called. @@ -100,8 +115,8 @@ struct CameraImageSpec { class Camera : public EntityBase, public Component { public: Camera(); - // Camera implementation invokes callback to publish a new image. - virtual void add_image_callback(std::function)> &&callback) = 0; + /// Add a listener to receive camera events + virtual void add_listener(CameraListener *listener) = 0; /// Returns a new camera image reader that keeps track of the JPEG data in the camera image. virtual CameraImageReader *create_image_reader() = 0; // Connection, camera or web server requests one new JPEG image. diff --git a/esphome/components/esp32_camera/esp32_camera.cpp b/esphome/components/esp32_camera/esp32_camera.cpp index 38bd8d5822..5080a6f32d 100644 --- a/esphome/components/esp32_camera/esp32_camera.cpp +++ b/esphome/components/esp32_camera/esp32_camera.cpp @@ -205,7 +205,9 @@ void ESP32Camera::loop() { this->current_image_ = std::make_shared(fb, this->single_requesters_ | this->stream_requesters_); ESP_LOGD(TAG, "Got Image: len=%u", fb->len); - this->new_image_callback_.call(this->current_image_); + for (auto *listener : this->listeners_) { + listener->on_camera_image(this->current_image_); + } this->last_update_ = now; this->single_requesters_ = 0; } @@ -357,21 +359,16 @@ void ESP32Camera::set_frame_buffer_location(camera_fb_location_t fb_location) { } /* ---------------- public API (specific) ---------------- */ -void ESP32Camera::add_image_callback(std::function)> &&callback) { - this->new_image_callback_.add(std::move(callback)); -} -void ESP32Camera::add_stream_start_callback(std::function &&callback) { - this->stream_start_callback_.add(std::move(callback)); -} -void ESP32Camera::add_stream_stop_callback(std::function &&callback) { - this->stream_stop_callback_.add(std::move(callback)); -} void ESP32Camera::start_stream(camera::CameraRequester requester) { - this->stream_start_callback_.call(); + for (auto *listener : this->listeners_) { + listener->on_stream_start(); + } this->stream_requesters_ |= (1U << requester); } void ESP32Camera::stop_stream(camera::CameraRequester requester) { - this->stream_stop_callback_.call(); + for (auto *listener : this->listeners_) { + listener->on_stream_stop(); + } this->stream_requesters_ &= ~(1U << requester); } void ESP32Camera::request_image(camera::CameraRequester requester) { this->single_requesters_ |= (1U << requester); } diff --git a/esphome/components/esp32_camera/esp32_camera.h b/esphome/components/esp32_camera/esp32_camera.h index 0e7f7c0ea6..54a7d6064a 100644 --- a/esphome/components/esp32_camera/esp32_camera.h +++ b/esphome/components/esp32_camera/esp32_camera.h @@ -165,9 +165,8 @@ class ESP32Camera : public camera::Camera { void request_image(camera::CameraRequester requester) override; void update_camera_parameters(); - void add_image_callback(std::function)> &&callback) override; - void add_stream_start_callback(std::function &&callback); - void add_stream_stop_callback(std::function &&callback); + /// Add a listener to receive camera events + void add_listener(camera::CameraListener *listener) override { this->listeners_.push_back(listener); } camera::CameraImageReader *create_image_reader() override; protected: @@ -210,9 +209,7 @@ class ESP32Camera : public camera::Camera { uint8_t stream_requesters_{0}; QueueHandle_t framebuffer_get_queue_; QueueHandle_t framebuffer_return_queue_; - CallbackManager)> new_image_callback_{}; - CallbackManager stream_start_callback_{}; - CallbackManager stream_stop_callback_{}; + std::vector listeners_; uint32_t last_idle_request_{0}; uint32_t last_update_{0}; @@ -221,33 +218,27 @@ class ESP32Camera : public camera::Camera { #endif // USE_I2C }; -class ESP32CameraImageTrigger : public Trigger { +class ESP32CameraImageTrigger : public Trigger, public camera::CameraListener { public: - explicit ESP32CameraImageTrigger(ESP32Camera *parent) { - parent->add_image_callback([this](const std::shared_ptr &image) { - CameraImageData camera_image_data{}; - camera_image_data.length = image->get_data_length(); - camera_image_data.data = image->get_data_buffer(); - this->trigger(camera_image_data); - }); + explicit ESP32CameraImageTrigger(ESP32Camera *parent) { parent->add_listener(this); } + void on_camera_image(const std::shared_ptr &image) override { + CameraImageData camera_image_data{}; + camera_image_data.length = image->get_data_length(); + camera_image_data.data = image->get_data_buffer(); + this->trigger(camera_image_data); } }; -class ESP32CameraStreamStartTrigger : public Trigger<> { +class ESP32CameraStreamStartTrigger : public Trigger<>, public camera::CameraListener { public: - explicit ESP32CameraStreamStartTrigger(ESP32Camera *parent) { - parent->add_stream_start_callback([this]() { this->trigger(); }); - } - - protected: + explicit ESP32CameraStreamStartTrigger(ESP32Camera *parent) { parent->add_listener(this); } + void on_stream_start() override { this->trigger(); } }; -class ESP32CameraStreamStopTrigger : public Trigger<> { - public: - explicit ESP32CameraStreamStopTrigger(ESP32Camera *parent) { - parent->add_stream_stop_callback([this]() { this->trigger(); }); - } - protected: +class ESP32CameraStreamStopTrigger : public Trigger<>, public camera::CameraListener { + public: + explicit ESP32CameraStreamStopTrigger(ESP32Camera *parent) { parent->add_listener(this); } + void on_stream_stop() override { this->trigger(); } }; } // namespace esp32_camera diff --git a/esphome/components/esp32_camera_web_server/camera_web_server.cpp b/esphome/components/esp32_camera_web_server/camera_web_server.cpp index 1b81989296..f49578c425 100644 --- a/esphome/components/esp32_camera_web_server/camera_web_server.cpp +++ b/esphome/components/esp32_camera_web_server/camera_web_server.cpp @@ -67,12 +67,14 @@ void CameraWebServer::setup() { httpd_register_uri_handler(this->httpd_, &uri); - camera::Camera::instance()->add_image_callback([this](std::shared_ptr image) { - if (this->running_ && image->was_requested_by(camera::WEB_REQUESTER)) { - this->image_ = std::move(image); - xSemaphoreGive(this->semaphore_); - } - }); + camera::Camera::instance()->add_listener(this); +} + +void CameraWebServer::on_camera_image(const std::shared_ptr &image) { + if (this->running_ && image->was_requested_by(camera::WEB_REQUESTER)) { + this->image_ = image; + xSemaphoreGive(this->semaphore_); + } } void CameraWebServer::on_shutdown() { diff --git a/esphome/components/esp32_camera_web_server/camera_web_server.h b/esphome/components/esp32_camera_web_server/camera_web_server.h index e70246745c..ad7b29fb11 100644 --- a/esphome/components/esp32_camera_web_server/camera_web_server.h +++ b/esphome/components/esp32_camera_web_server/camera_web_server.h @@ -18,7 +18,7 @@ namespace esp32_camera_web_server { enum Mode { STREAM, SNAPSHOT }; -class CameraWebServer : public Component { +class CameraWebServer : public Component, public camera::CameraListener { public: CameraWebServer(); ~CameraWebServer(); @@ -31,6 +31,9 @@ class CameraWebServer : public Component { void set_mode(Mode mode) { this->mode_ = mode; } void loop() override; + /// CameraListener interface + void on_camera_image(const std::shared_ptr &image) override; + protected: std::shared_ptr wait_for_image_(); esp_err_t handler_(struct httpd_req *req); From 5142ff372bb389c3df9d73830930db325272037c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Dec 2025 10:01:54 -0600 Subject: [PATCH 04/14] [light] Use listener pattern for state callbacks with lazy allocation (#12166) --- esphome/components/light/automation.h | 62 ++++++----- esphome/components/light/light_call.cpp | 6 +- esphome/components/light/light_state.cpp | 26 +++-- esphome/components/light/light_state.h | 58 +++++++--- esphome/components/mqtt/mqtt_light.cpp | 7 +- esphome/components/mqtt/mqtt_light.h | 5 +- .../fixtures/light_automations.yaml | 26 +++++ tests/integration/test_light_automations.py | 101 ++++++++++++++++++ 8 files changed, 236 insertions(+), 55 deletions(-) create mode 100644 tests/integration/fixtures/light_automations.yaml create mode 100644 tests/integration/test_light_automations.py diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 9893c15e0c..c90d71c5df 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -120,46 +120,54 @@ template class LightIsOffCondition : public Condition { LightState *state_; }; -class LightTurnOnTrigger : public Trigger<> { +class LightTurnOnTrigger : public Trigger<>, public LightRemoteValuesListener { public: - LightTurnOnTrigger(LightState *a_light) { - a_light->add_new_remote_values_callback([this, a_light]() { - // using the remote value because of transitions we need to trigger as early as possible - auto is_on = a_light->remote_values.is_on(); - // only trigger when going from off to on - auto should_trigger = is_on && !this->last_on_; - // Set new state immediately so that trigger() doesn't devolve - // into infinite loop - this->last_on_ = is_on; - if (should_trigger) { - this->trigger(); - } - }); + explicit LightTurnOnTrigger(LightState *a_light) : light_(a_light) { + a_light->add_remote_values_listener(this); this->last_on_ = a_light->current_values.is_on(); } + void on_light_remote_values_update() override { + // using the remote value because of transitions we need to trigger as early as possible + auto is_on = this->light_->remote_values.is_on(); + // only trigger when going from off to on + auto should_trigger = is_on && !this->last_on_; + // Set new state immediately so that trigger() doesn't devolve + // into infinite loop + this->last_on_ = is_on; + if (should_trigger) { + this->trigger(); + } + } + protected: + LightState *light_; bool last_on_; }; -class LightTurnOffTrigger : public Trigger<> { +class LightTurnOffTrigger : public Trigger<>, public LightTargetStateReachedListener { public: - LightTurnOffTrigger(LightState *a_light) { - a_light->add_new_target_state_reached_callback([this, a_light]() { - auto is_on = a_light->current_values.is_on(); - // only trigger when going from on to off - if (!is_on) { - this->trigger(); - } - }); + explicit LightTurnOffTrigger(LightState *a_light) : light_(a_light) { + a_light->add_target_state_reached_listener(this); } + + void on_light_target_state_reached() override { + auto is_on = this->light_->current_values.is_on(); + // only trigger when going from on to off + if (!is_on) { + this->trigger(); + } + } + + protected: + LightState *light_; }; -class LightStateTrigger : public Trigger<> { +class LightStateTrigger : public Trigger<>, public LightRemoteValuesListener { public: - LightStateTrigger(LightState *a_light) { - a_light->add_new_remote_values_callback([this]() { this->trigger(); }); - } + explicit LightStateTrigger(LightState *a_light) { a_light->add_remote_values_listener(this); } + + void on_light_remote_values_update() override { this->trigger(); } }; // This is slightly ugly, but we can't log in headers, and can't make this a static method on AddressableSet diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index f523b4451b..dca5861734 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -174,8 +174,10 @@ void LightCall::perform() { this->parent_->set_immediately_(v, publish); } - if (!this->has_transition_()) { - this->parent_->target_state_reached_callback_.call(); + if (!this->has_transition_() && this->parent_->target_state_reached_listeners_) { + for (auto *listener : *this->parent_->target_state_reached_listeners_) { + listener->on_light_target_state_reached(); + } } if (publish) { this->parent_->publish_state(); diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 9cde9077da..af619a426a 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -127,7 +127,11 @@ void LightState::loop() { this->transformer_->stop(); this->is_transformer_active_ = false; this->transformer_ = nullptr; - this->target_state_reached_callback_.call(); + if (this->target_state_reached_listeners_) { + for (auto *listener : *this->target_state_reached_listeners_) { + listener->on_light_target_state_reached(); + } + } // Disable loop if idle (no transformer and no effect) this->disable_loop_if_idle_(); @@ -146,7 +150,11 @@ void LightState::loop() { float LightState::get_setup_priority() const { return setup_priority::HARDWARE - 1.0f; } void LightState::publish_state() { - this->remote_values_callback_.call(); + if (this->remote_values_listeners_) { + for (auto *listener : *this->remote_values_listeners_) { + listener->on_light_remote_values_update(); + } + } #if defined(USE_LIGHT) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_light_update(this); #endif @@ -171,11 +179,17 @@ StringRef LightState::get_effect_name_ref() { return EFFECT_NONE_REF; } -void LightState::add_new_remote_values_callback(std::function &&send_callback) { - this->remote_values_callback_.add(std::move(send_callback)); +void LightState::add_remote_values_listener(LightRemoteValuesListener *listener) { + if (!this->remote_values_listeners_) { + this->remote_values_listeners_ = make_unique>(); + } + this->remote_values_listeners_->push_back(listener); } -void LightState::add_new_target_state_reached_callback(std::function &&send_callback) { - this->target_state_reached_callback_.add(std::move(send_callback)); +void LightState::add_target_state_reached_listener(LightTargetStateReachedListener *listener) { + if (!this->target_state_reached_listeners_) { + this->target_state_reached_listeners_ = make_unique>(); + } + this->target_state_reached_listeners_->push_back(listener); } void LightState::set_default_transition_length(uint32_t default_transition_length) { diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index ad8922b46f..7ea72306f9 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -18,6 +18,29 @@ namespace esphome::light { class LightOutput; +class LightState; + +/** Listener interface for light remote value changes. + * + * Components can implement this interface to receive notifications + * when the light's remote values change (state, brightness, color, etc.) + * without the overhead of std::function callbacks. + */ +class LightRemoteValuesListener { + public: + virtual void on_light_remote_values_update() = 0; +}; + +/** Listener interface for light target state reached. + * + * Components can implement this interface to receive notifications + * when the light finishes a transition and reaches its target state + * without the overhead of std::function callbacks. + */ +class LightTargetStateReachedListener { + public: + virtual void on_light_target_state_reached() = 0; +}; enum LightRestoreMode : uint8_t { LIGHT_RESTORE_DEFAULT_OFF, @@ -121,21 +144,17 @@ class LightState : public EntityBase, public Component { /// Return the name of the current effect as StringRef (for API usage) StringRef get_effect_name_ref(); - /** - * This lets front-end components subscribe to light change events. This callback is called once - * when the remote color values are changed. - * - * @param send_callback The callback. + /** Add a listener for remote values changes. + * Listener is notified when the light's remote values change (state, brightness, color, etc.) + * Lazily allocates the listener vector on first registration. */ - void add_new_remote_values_callback(std::function &&send_callback); + void add_remote_values_listener(LightRemoteValuesListener *listener); - /** - * The callback is called once the state of current_values and remote_values are equal (when the - * transition is finished). - * - * @param send_callback + /** Add a listener for target state reached. + * Listener is notified when the light finishes a transition and reaches its target state. + * Lazily allocates the listener vector on first registration. */ - void add_new_target_state_reached_callback(std::function &&send_callback); + void add_target_state_reached_listener(LightTargetStateReachedListener *listener); /// Set the default transition length, i.e. the transition length when no transition is provided. void set_default_transition_length(uint32_t default_transition_length); @@ -279,19 +298,24 @@ class LightState : public EntityBase, public Component { // for effects, true if a transformer (transition) is active. bool is_transformer_active_ = false; - /** Callback to call when new values for the frontend are available. + /** Listeners for remote values changes. * * "Remote values" are light color values that are reported to the frontend and have a lower * publish frequency than the "real" color values. For example, during transitions the current * color value may change continuously, but the remote values will be reported as the target values * starting with the beginning of the transition. + * + * Lazily allocated - only created when a listener is actually registered. */ - CallbackManager remote_values_callback_{}; + std::unique_ptr> remote_values_listeners_; - /** Callback to call when the state of current_values and remote_values are equal - * This should be called once the state of current_values changed and equals the state of remote_values + /** Listeners for target state reached. + * Notified when the state of current_values and remote_values are equal + * (when the transition is finished). + * + * Lazily allocated - only created when a listener is actually registered. */ - CallbackManager target_state_reached_callback_{}; + std::unique_ptr> target_state_reached_listeners_; /// Initial state of the light. optional initial_state_{}; diff --git a/esphome/components/mqtt/mqtt_light.cpp b/esphome/components/mqtt/mqtt_light.cpp index 883b67ffc6..fe911bfba2 100644 --- a/esphome/components/mqtt/mqtt_light.cpp +++ b/esphome/components/mqtt/mqtt_light.cpp @@ -25,8 +25,11 @@ void MQTTJSONLightComponent::setup() { call.perform(); }); - auto f = std::bind(&MQTTJSONLightComponent::publish_state_, this); - this->state_->add_new_remote_values_callback([this, f]() { this->defer("send", f); }); + this->state_->add_remote_values_listener(this); +} + +void MQTTJSONLightComponent::on_light_remote_values_update() { + this->defer("send", [this]() { this->publish_state_(); }); } MQTTJSONLightComponent::MQTTJSONLightComponent(LightState *state) : state_(state) {} diff --git a/esphome/components/mqtt/mqtt_light.h b/esphome/components/mqtt/mqtt_light.h index 3d1e770d4d..a105f3d7b8 100644 --- a/esphome/components/mqtt/mqtt_light.h +++ b/esphome/components/mqtt/mqtt_light.h @@ -11,7 +11,7 @@ namespace esphome { namespace mqtt { -class MQTTJSONLightComponent : public mqtt::MQTTComponent { +class MQTTJSONLightComponent : public mqtt::MQTTComponent, public light::LightRemoteValuesListener { public: explicit MQTTJSONLightComponent(light::LightState *state); @@ -25,6 +25,9 @@ class MQTTJSONLightComponent : public mqtt::MQTTComponent { bool send_initial_state() override; + // LightRemoteValuesListener interface + void on_light_remote_values_update() override; + protected: std::string component_type() const override; const EntityBase *get_entity() const override; diff --git a/tests/integration/fixtures/light_automations.yaml b/tests/integration/fixtures/light_automations.yaml new file mode 100644 index 0000000000..b5b88d95e7 --- /dev/null +++ b/tests/integration/fixtures/light_automations.yaml @@ -0,0 +1,26 @@ +esphome: + name: light-automations-test + +host: +api: # Port will be automatically injected +logger: + level: DEBUG + +output: + - platform: template + id: test_output + type: binary + write_action: + - lambda: "" + +light: + - platform: binary + id: test_light + name: "Test Light" + output: test_output + on_turn_on: + - logger.log: "TRIGGER: on_turn_on fired" + on_turn_off: + - logger.log: "TRIGGER: on_turn_off fired" + on_state: + - logger.log: "TRIGGER: on_state fired" diff --git a/tests/integration/test_light_automations.py b/tests/integration/test_light_automations.py new file mode 100644 index 0000000000..9ff334548a --- /dev/null +++ b/tests/integration/test_light_automations.py @@ -0,0 +1,101 @@ +"""Integration test for light automation triggers. + +Tests that on_turn_on, on_turn_off, and on_state triggers work correctly +with the listener interface pattern. +""" + +import asyncio + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_light_automations( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test light on_turn_on, on_turn_off, and on_state triggers.""" + loop = asyncio.get_running_loop() + + # Futures for log line detection + on_turn_on_future: asyncio.Future[bool] = loop.create_future() + on_turn_off_future: asyncio.Future[bool] = loop.create_future() + on_state_count = 0 + counting_enabled = False + on_state_futures: list[asyncio.Future[bool]] = [] + + def create_on_state_future() -> asyncio.Future[bool]: + """Create a new future for on_state trigger.""" + future: asyncio.Future[bool] = loop.create_future() + on_state_futures.append(future) + return future + + def check_output(line: str) -> None: + """Check log output for trigger messages.""" + nonlocal on_state_count + if "TRIGGER: on_turn_on fired" in line: + if not on_turn_on_future.done(): + on_turn_on_future.set_result(True) + elif "TRIGGER: on_turn_off fired" in line: + if not on_turn_off_future.done(): + on_turn_off_future.set_result(True) + elif "TRIGGER: on_state fired" in line: + # Only count on_state after we start testing + if counting_enabled: + on_state_count += 1 + # Complete any pending on_state futures + for future in on_state_futures: + if not future.done(): + future.set_result(True) + break + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Get entities + entities = await client.list_entities_services() + light = next(e for e in entities[0] if e.object_id == "test_light") + + # Start counting on_state events now + counting_enabled = True + + # Test 1: Turn light on - should trigger on_turn_on and on_state + on_state_future_1 = create_on_state_future() + client.light_command(key=light.key, state=True) + + # Wait for on_turn_on trigger + try: + await asyncio.wait_for(on_turn_on_future, timeout=5.0) + except TimeoutError: + pytest.fail("on_turn_on trigger did not fire") + + # Wait for on_state trigger + try: + await asyncio.wait_for(on_state_future_1, timeout=5.0) + except TimeoutError: + pytest.fail("on_state trigger did not fire after turn on") + + # Test 2: Turn light off - should trigger on_turn_off and on_state + on_state_future_2 = create_on_state_future() + client.light_command(key=light.key, state=False) + + # Wait for on_turn_off trigger + try: + await asyncio.wait_for(on_turn_off_future, timeout=5.0) + except TimeoutError: + pytest.fail("on_turn_off trigger did not fire") + + # Wait for on_state trigger + try: + await asyncio.wait_for(on_state_future_2, timeout=5.0) + except TimeoutError: + pytest.fail("on_state trigger did not fire after turn off") + + # Verify on_state fired exactly twice (once for on, once for off) + assert on_state_count == 2, ( + f"on_state should have triggered exactly twice, got {on_state_count}" + ) From 101103c66639bba53309fab3caec4992128b89e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Dec 2025 10:02:09 -0600 Subject: [PATCH 05/14] [core] Add RAM strings and symbols analysis to analyze-memory command (#12161) --- esphome/__main__.py | 22 +- esphome/analyze_memory/__init__.py | 173 +-------- esphome/analyze_memory/demangle.py | 182 ++++++++++ esphome/analyze_memory/ram_strings.py | 493 ++++++++++++++++++++++++++ esphome/analyze_memory/toolchain.py | 57 +++ tests/unit_tests/test_main.py | 25 +- 6 files changed, 779 insertions(+), 173 deletions(-) create mode 100644 esphome/analyze_memory/demangle.py create mode 100644 esphome/analyze_memory/ram_strings.py create mode 100644 esphome/analyze_memory/toolchain.py diff --git a/esphome/__main__.py b/esphome/__main__.py index f8fb678cb2..55fbbc6c8a 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -944,6 +944,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: """ from esphome import platformio_api from esphome.analyze_memory.cli import MemoryAnalyzerCLI + from esphome.analyze_memory.ram_strings import RamStringsAnalyzer # Always compile to ensure fresh data (fast if no changes - just relinks) exit_code = write_cpp(config) @@ -966,7 +967,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: external_components = detect_external_components(config) _LOGGER.debug("Detected external components: %s", external_components) - # Perform memory analysis + # Perform component memory analysis _LOGGER.info("Analyzing memory usage...") analyzer = MemoryAnalyzerCLI( str(firmware_elf), @@ -976,11 +977,28 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: ) analyzer.analyze() - # Generate and display report + # Generate and display component report report = analyzer.generate_report() print() print(report) + # Perform RAM strings analysis + _LOGGER.info("Analyzing RAM strings...") + try: + ram_analyzer = RamStringsAnalyzer( + str(firmware_elf), + objdump_path=idedata.objdump_path, + platform=CORE.target_platform, + ) + ram_analyzer.analyze() + + # Generate and display RAM strings report + ram_report = ram_analyzer.generate_report() + print() + print(ram_report) + except Exception as e: # pylint: disable=broad-except + _LOGGER.warning("RAM strings analysis failed: %s", e) + return 0 diff --git a/esphome/analyze_memory/__init__.py b/esphome/analyze_memory/__init__.py index 71e86e3788..9632a68913 100644 --- a/esphome/analyze_memory/__init__.py +++ b/esphome/analyze_memory/__init__.py @@ -15,6 +15,7 @@ from .const import ( SECTION_TO_ATTR, SYMBOL_PATTERNS, ) +from .demangle import batch_demangle from .helpers import ( get_component_class_patterns, get_esphome_components, @@ -27,15 +28,6 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -# GCC global constructor/destructor prefix annotations -_GCC_PREFIX_ANNOTATIONS = { - "_GLOBAL__sub_I_": "global constructor for", - "_GLOBAL__sub_D_": "global destructor for", -} - -# GCC optimization suffix pattern (e.g., $isra$0, $part$1, $constprop$2) -_GCC_OPTIMIZATION_SUFFIX_PATTERN = re.compile(r"(\$(?:isra|part|constprop)\$\d+)") - # C++ runtime patterns for categorization _CPP_RUNTIME_PATTERNS = frozenset(["vtable", "typeinfo", "thunk"]) @@ -312,168 +304,9 @@ class MemoryAnalyzer: if not symbols: return - # Try to find the appropriate c++filt for the platform - cppfilt_cmd = "c++filt" - _LOGGER.info("Demangling %d symbols", len(symbols)) - _LOGGER.debug("objdump_path = %s", self.objdump_path) - - # Check if we have a toolchain-specific c++filt - if self.objdump_path and self.objdump_path != "objdump": - # Replace objdump with c++filt in the path - potential_cppfilt = self.objdump_path.replace("objdump", "c++filt") - _LOGGER.info("Checking for toolchain c++filt at: %s", potential_cppfilt) - if Path(potential_cppfilt).exists(): - cppfilt_cmd = potential_cppfilt - _LOGGER.info("✓ Using toolchain c++filt: %s", cppfilt_cmd) - else: - _LOGGER.info( - "✗ Toolchain c++filt not found at %s, using system c++filt", - potential_cppfilt, - ) - else: - _LOGGER.info("✗ Using system c++filt (objdump_path=%s)", self.objdump_path) - - # Strip GCC optimization suffixes and prefixes before demangling - # Suffixes like $isra$0, $part$0, $constprop$0 confuse c++filt - # Prefixes like _GLOBAL__sub_I_ need to be removed and tracked - symbols_stripped: list[str] = [] - symbols_prefixes: list[str] = [] # Track removed prefixes - for symbol in symbols: - # Remove GCC optimization markers - stripped = _GCC_OPTIMIZATION_SUFFIX_PATTERN.sub("", symbol) - - # Handle GCC global constructor/initializer prefixes - # _GLOBAL__sub_I_ -> extract for demangling - prefix = "" - for gcc_prefix in _GCC_PREFIX_ANNOTATIONS: - if stripped.startswith(gcc_prefix): - prefix = gcc_prefix - stripped = stripped[len(prefix) :] - break - - symbols_stripped.append(stripped) - symbols_prefixes.append(prefix) - - try: - # Send all symbols to c++filt at once - result = subprocess.run( - [cppfilt_cmd], - input="\n".join(symbols_stripped), - capture_output=True, - text=True, - check=False, - ) - except (subprocess.SubprocessError, OSError, UnicodeDecodeError) as e: - # On error, cache originals - _LOGGER.warning("Failed to batch demangle symbols: %s", e) - for symbol in symbols: - self._demangle_cache[symbol] = symbol - return - - if result.returncode != 0: - _LOGGER.warning( - "c++filt exited with code %d: %s", - result.returncode, - result.stderr[:200] if result.stderr else "(no error output)", - ) - # Cache originals on failure - for symbol in symbols: - self._demangle_cache[symbol] = symbol - return - - # Process demangled output - self._process_demangled_output( - symbols, symbols_stripped, symbols_prefixes, result.stdout, cppfilt_cmd - ) - - def _process_demangled_output( - self, - symbols: list[str], - symbols_stripped: list[str], - symbols_prefixes: list[str], - demangled_output: str, - cppfilt_cmd: str, - ) -> None: - """Process demangled symbol output and populate cache. - - Args: - symbols: Original symbol names - symbols_stripped: Stripped symbol names sent to c++filt - symbols_prefixes: Removed prefixes to restore - demangled_output: Output from c++filt - cppfilt_cmd: Path to c++filt command (for logging) - """ - demangled_lines = demangled_output.strip().split("\n") - failed_count = 0 - - for original, stripped, prefix, demangled in zip( - symbols, symbols_stripped, symbols_prefixes, demangled_lines - ): - # Add back any prefix that was removed - demangled = self._restore_symbol_prefix(prefix, stripped, demangled) - - # If we stripped a suffix, add it back to the demangled name for clarity - if original != stripped and not prefix: - demangled = self._restore_symbol_suffix(original, demangled) - - self._demangle_cache[original] = demangled - - # Log symbols that failed to demangle (stayed the same as stripped version) - if stripped == demangled and stripped.startswith("_Z"): - failed_count += 1 - if failed_count <= 5: # Only log first 5 failures - _LOGGER.warning("Failed to demangle: %s", original) - - if failed_count == 0: - _LOGGER.info("Successfully demangled all %d symbols", len(symbols)) - return - - _LOGGER.warning( - "Failed to demangle %d/%d symbols using %s", - failed_count, - len(symbols), - cppfilt_cmd, - ) - - @staticmethod - def _restore_symbol_prefix(prefix: str, stripped: str, demangled: str) -> str: - """Restore prefix that was removed before demangling. - - Args: - prefix: Prefix that was removed (e.g., "_GLOBAL__sub_I_") - stripped: Stripped symbol name - demangled: Demangled symbol name - - Returns: - Demangled name with prefix restored/annotated - """ - if not prefix: - return demangled - - # Successfully demangled - add descriptive prefix - if demangled != stripped and ( - annotation := _GCC_PREFIX_ANNOTATIONS.get(prefix) - ): - return f"[{annotation}: {demangled}]" - - # Failed to demangle - restore original prefix - return prefix + demangled - - @staticmethod - def _restore_symbol_suffix(original: str, demangled: str) -> str: - """Restore GCC optimization suffix that was removed before demangling. - - Args: - original: Original symbol name with suffix - demangled: Demangled symbol name without suffix - - Returns: - Demangled name with suffix annotation - """ - if suffix_match := _GCC_OPTIMIZATION_SUFFIX_PATTERN.search(original): - return f"{demangled} [{suffix_match.group(1)}]" - return demangled + self._demangle_cache = batch_demangle(symbols, objdump_path=self.objdump_path) + _LOGGER.info("Successfully demangled %d symbols", len(self._demangle_cache)) def _demangle_symbol(self, symbol: str) -> str: """Get demangled C++ symbol name from cache.""" diff --git a/esphome/analyze_memory/demangle.py b/esphome/analyze_memory/demangle.py new file mode 100644 index 0000000000..8999108b51 --- /dev/null +++ b/esphome/analyze_memory/demangle.py @@ -0,0 +1,182 @@ +"""Symbol demangling utilities for memory analysis. + +This module provides functions for demangling C++ symbol names using c++filt. +""" + +from __future__ import annotations + +import logging +import re +import subprocess + +from .toolchain import find_tool + +_LOGGER = logging.getLogger(__name__) + +# GCC global constructor/destructor prefix annotations +GCC_PREFIX_ANNOTATIONS = { + "_GLOBAL__sub_I_": "global constructor for", + "_GLOBAL__sub_D_": "global destructor for", +} + +# GCC optimization suffix pattern (e.g., $isra$0, $part$1, $constprop$2) +GCC_OPTIMIZATION_SUFFIX_PATTERN = re.compile(r"(\$(?:isra|part|constprop)\$\d+)") + + +def _strip_gcc_annotations(symbol: str) -> tuple[str, str]: + """Strip GCC optimization suffixes and prefixes from a symbol. + + Args: + symbol: The mangled symbol name + + Returns: + Tuple of (stripped_symbol, removed_prefix) + """ + # Remove GCC optimization markers + stripped = GCC_OPTIMIZATION_SUFFIX_PATTERN.sub("", symbol) + + # Handle GCC global constructor/initializer prefixes + prefix = "" + for gcc_prefix in GCC_PREFIX_ANNOTATIONS: + if stripped.startswith(gcc_prefix): + prefix = gcc_prefix + stripped = stripped[len(prefix) :] + break + + return stripped, prefix + + +def _restore_symbol_prefix(prefix: str, stripped: str, demangled: str) -> str: + """Restore prefix that was removed before demangling. + + Args: + prefix: Prefix that was removed (e.g., "_GLOBAL__sub_I_") + stripped: Stripped symbol name + demangled: Demangled symbol name + + Returns: + Demangled name with prefix restored/annotated + """ + if not prefix: + return demangled + + # Successfully demangled - add descriptive prefix + if demangled != stripped and (annotation := GCC_PREFIX_ANNOTATIONS.get(prefix)): + return f"[{annotation}: {demangled}]" + + # Failed to demangle - restore original prefix + return prefix + demangled + + +def _restore_symbol_suffix(original: str, demangled: str) -> str: + """Restore GCC optimization suffix that was removed before demangling. + + Args: + original: Original symbol name with suffix + demangled: Demangled symbol name without suffix + + Returns: + Demangled name with suffix annotation + """ + if suffix_match := GCC_OPTIMIZATION_SUFFIX_PATTERN.search(original): + return f"{demangled} [{suffix_match.group(1)}]" + return demangled + + +def batch_demangle( + symbols: list[str], + cppfilt_path: str | None = None, + objdump_path: str | None = None, +) -> dict[str, str]: + """Batch demangle C++ symbol names. + + Args: + symbols: List of symbol names to demangle + cppfilt_path: Path to c++filt binary (auto-detected if not provided) + objdump_path: Path to objdump binary to derive c++filt path from + + Returns: + Dictionary mapping original symbol names to demangled names + """ + cache: dict[str, str] = {} + + if not symbols: + return cache + + # Find c++filt tool + cppfilt_cmd = cppfilt_path or find_tool("c++filt", objdump_path) + if not cppfilt_cmd: + _LOGGER.warning("Could not find c++filt, symbols will not be demangled") + return {s: s for s in symbols} + + _LOGGER.debug("Demangling %d symbols using %s", len(symbols), cppfilt_cmd) + + # Strip GCC optimization suffixes and prefixes before demangling + symbols_stripped: list[str] = [] + symbols_prefixes: list[str] = [] + for symbol in symbols: + stripped, prefix = _strip_gcc_annotations(symbol) + symbols_stripped.append(stripped) + symbols_prefixes.append(prefix) + + try: + result = subprocess.run( + [cppfilt_cmd], + input="\n".join(symbols_stripped), + capture_output=True, + text=True, + check=False, + ) + except (subprocess.SubprocessError, OSError, UnicodeDecodeError) as e: + _LOGGER.warning("Failed to batch demangle symbols: %s", e) + return {s: s for s in symbols} + + if result.returncode != 0: + _LOGGER.warning( + "c++filt exited with code %d: %s", + result.returncode, + result.stderr[:200] if result.stderr else "(no error output)", + ) + return {s: s for s in symbols} + + # Process demangled output + demangled_lines = result.stdout.strip().split("\n") + + # Check for output length mismatch + if len(demangled_lines) != len(symbols): + _LOGGER.warning( + "c++filt output mismatch: expected %d lines, got %d", + len(symbols), + len(demangled_lines), + ) + return {s: s for s in symbols} + + failed_count = 0 + + for original, stripped, prefix, demangled in zip( + symbols, symbols_stripped, symbols_prefixes, demangled_lines + ): + # Add back any prefix that was removed + demangled = _restore_symbol_prefix(prefix, stripped, demangled) + + # If we stripped a suffix, add it back to the demangled name for clarity + if original != stripped and not prefix: + demangled = _restore_symbol_suffix(original, demangled) + + cache[original] = demangled + + # Count symbols that failed to demangle + if stripped == demangled and stripped.startswith("_Z"): + failed_count += 1 + if failed_count <= 5: + _LOGGER.debug("Failed to demangle: %s", original) + + if failed_count > 0: + _LOGGER.debug( + "Failed to demangle %d/%d symbols using %s", + failed_count, + len(symbols), + cppfilt_cmd, + ) + + return cache diff --git a/esphome/analyze_memory/ram_strings.py b/esphome/analyze_memory/ram_strings.py new file mode 100644 index 0000000000..fbcbeeca61 --- /dev/null +++ b/esphome/analyze_memory/ram_strings.py @@ -0,0 +1,493 @@ +"""Analyzer for RAM-stored strings in ESP8266/ESP32 firmware ELF files. + +This module identifies strings that are stored in RAM sections (.data, .bss, .rodata) +rather than in flash sections (.irom0.text, .irom.text), which is important for +memory-constrained platforms like ESP8266. +""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +import logging +from pathlib import Path +import re +import subprocess + +from .demangle import batch_demangle +from .toolchain import find_tool + +_LOGGER = logging.getLogger(__name__) + +# ESP8266: .rodata is in RAM (DRAM), not flash +# ESP32: .rodata is in flash, mapped to data bus +ESP8266_RAM_SECTIONS = frozenset([".data", ".rodata", ".bss"]) +ESP8266_FLASH_SECTIONS = frozenset([".irom0.text", ".irom.text", ".text"]) + +# ESP32: .rodata is memory-mapped from flash +ESP32_RAM_SECTIONS = frozenset([".data", ".bss", ".dram0.data", ".dram0.bss"]) +ESP32_FLASH_SECTIONS = frozenset([".text", ".rodata", ".flash.text", ".flash.rodata"]) + +# nm symbol types for data symbols (D=global data, d=local data, R=rodata, B=bss) +DATA_SYMBOL_TYPES = frozenset(["D", "d", "R", "r", "B", "b"]) + + +@dataclass +class SectionInfo: + """Information about an ELF section.""" + + name: str + address: int + size: int + + +@dataclass +class RamString: + """A string found in RAM.""" + + section: str + address: int + content: str + + @property + def size(self) -> int: + """Size in bytes including null terminator.""" + return len(self.content) + 1 + + +@dataclass +class RamSymbol: + """A symbol found in RAM.""" + + name: str + sym_type: str + address: int + size: int + section: str + demangled: str = "" # Demangled name, set after batch demangling + + +class RamStringsAnalyzer: + """Analyzes ELF files to find strings stored in RAM.""" + + def __init__( + self, + elf_path: str, + objdump_path: str | None = None, + min_length: int = 8, + platform: str = "esp32", + ) -> None: + """Initialize the RAM strings analyzer. + + Args: + elf_path: Path to the ELF file to analyze + objdump_path: Path to objdump binary (used to find other tools) + min_length: Minimum string length to report (default: 8) + platform: Platform name ("esp8266", "esp32", etc.) for section mapping + """ + self.elf_path = Path(elf_path) + if not self.elf_path.exists(): + raise FileNotFoundError(f"ELF file not found: {elf_path}") + + self.objdump_path = objdump_path + self.min_length = min_length + self.platform = platform + + # Set RAM/flash sections based on platform + if self.platform == "esp8266": + self.ram_sections = ESP8266_RAM_SECTIONS + self.flash_sections = ESP8266_FLASH_SECTIONS + else: + # ESP32 and other platforms + self.ram_sections = ESP32_RAM_SECTIONS + self.flash_sections = ESP32_FLASH_SECTIONS + + self.sections: dict[str, SectionInfo] = {} + self.ram_strings: list[RamString] = [] + self.ram_symbols: list[RamSymbol] = [] + + def _run_command(self, cmd: list[str]) -> str: + """Run a command and return its output.""" + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + return result.stdout + except subprocess.CalledProcessError as e: + _LOGGER.debug("Command failed: %s - %s", " ".join(cmd), e.stderr) + raise + except FileNotFoundError: + _LOGGER.warning("Command not found: %s", cmd[0]) + raise + + def analyze(self) -> None: + """Perform the full RAM analysis.""" + self._parse_sections() + self._extract_strings() + self._analyze_symbols() + self._demangle_symbols() + + def _parse_sections(self) -> None: + """Parse section headers from ELF file.""" + objdump = find_tool("objdump", self.objdump_path) + if not objdump: + _LOGGER.error("Could not find objdump command") + return + + try: + output = self._run_command([objdump, "-h", str(self.elf_path)]) + except (subprocess.CalledProcessError, FileNotFoundError): + return + + # Parse section headers + # Format: Idx Name Size VMA LMA File off Algn + section_pattern = re.compile( + r"^\s*\d+\s+(\S+)\s+([0-9a-fA-F]+)\s+([0-9a-fA-F]+)" + ) + + for line in output.split("\n"): + if match := section_pattern.match(line): + name = match.group(1) + size = int(match.group(2), 16) + vma = int(match.group(3), 16) + self.sections[name] = SectionInfo(name, vma, size) + + def _extract_strings(self) -> None: + """Extract strings from RAM sections.""" + objdump = find_tool("objdump", self.objdump_path) + if not objdump: + return + + for section_name in self.ram_sections: + if section_name not in self.sections: + continue + + try: + output = self._run_command( + [objdump, "-s", "-j", section_name, str(self.elf_path)] + ) + except subprocess.CalledProcessError: + # Section may exist but have no content (e.g., .bss) + continue + except FileNotFoundError: + continue + + strings = self._parse_hex_dump(output, section_name) + self.ram_strings.extend(strings) + + def _parse_hex_dump(self, output: str, section_name: str) -> list[RamString]: + """Parse hex dump output to extract strings. + + Args: + output: Output from objdump -s + section_name: Name of the section being parsed + + Returns: + List of RamString objects + """ + strings: list[RamString] = [] + current_string = bytearray() + string_start_addr = 0 + + for line in output.split("\n"): + # Lines look like: " 3ffef8a0 00000000 00000000 00000000 00000000 ................" + match = re.match(r"^\s+([0-9a-fA-F]+)\s+((?:[0-9a-fA-F]{2,8}\s*)+)", line) + if not match: + continue + + addr = int(match.group(1), 16) + hex_data = match.group(2).strip() + + # Convert hex to bytes + hex_bytes = hex_data.split() + byte_offset = 0 + for hex_chunk in hex_bytes: + # Handle both byte-by-byte and word formats + for i in range(0, len(hex_chunk), 2): + byte_val = int(hex_chunk[i : i + 2], 16) + if 0x20 <= byte_val <= 0x7E: # Printable ASCII + if not current_string: + string_start_addr = addr + byte_offset + current_string.append(byte_val) + else: + if byte_val == 0 and len(current_string) >= self.min_length: + # Found null terminator + strings.append( + RamString( + section=section_name, + address=string_start_addr, + content=current_string.decode( + "ascii", errors="ignore" + ), + ) + ) + current_string = bytearray() + byte_offset += 1 + + return strings + + def _analyze_symbols(self) -> None: + """Analyze symbols in RAM sections.""" + nm = find_tool("nm", self.objdump_path) + if not nm: + return + + try: + output = self._run_command([nm, "-S", "--size-sort", str(self.elf_path)]) + except (subprocess.CalledProcessError, FileNotFoundError): + return + + for line in output.split("\n"): + parts = line.split() + if len(parts) < 4: + continue + + try: + addr = int(parts[0], 16) + size = int(parts[1], 16) if parts[1] != "?" else 0 + except ValueError: + continue + + sym_type = parts[2] + name = " ".join(parts[3:]) + + # Filter for data symbols + if sym_type not in DATA_SYMBOL_TYPES: + continue + + # Check if symbol is in a RAM section + for section_name in self.ram_sections: + if section_name not in self.sections: + continue + + section = self.sections[section_name] + if section.address <= addr < section.address + section.size: + self.ram_symbols.append( + RamSymbol( + name=name, + sym_type=sym_type, + address=addr, + size=size, + section=section_name, + ) + ) + break + + def _demangle_symbols(self) -> None: + """Batch demangle all RAM symbol names.""" + if not self.ram_symbols: + return + + # Collect all symbol names and demangle them + symbol_names = [s.name for s in self.ram_symbols] + demangle_cache = batch_demangle(symbol_names, objdump_path=self.objdump_path) + + # Assign demangled names to symbols + for symbol in self.ram_symbols: + symbol.demangled = demangle_cache.get(symbol.name, symbol.name) + + def _get_sections_size(self, section_names: frozenset[str]) -> int: + """Get total size of specified sections.""" + return sum( + section.size + for name, section in self.sections.items() + if name in section_names + ) + + def get_total_ram_usage(self) -> int: + """Get total RAM usage from RAM sections.""" + return self._get_sections_size(self.ram_sections) + + def get_total_flash_usage(self) -> int: + """Get total flash usage from flash sections.""" + return self._get_sections_size(self.flash_sections) + + def get_total_string_bytes(self) -> int: + """Get total bytes used by strings in RAM.""" + return sum(s.size for s in self.ram_strings) + + def get_repeated_strings(self) -> list[tuple[str, int]]: + """Find strings that appear multiple times. + + Returns: + List of (string, count) tuples sorted by potential savings + """ + string_counts: dict[str, int] = defaultdict(int) + for ram_string in self.ram_strings: + string_counts[ram_string.content] += 1 + + return sorted( + [(s, c) for s, c in string_counts.items() if c > 1], + key=lambda x: x[1] * (len(x[0]) + 1), + reverse=True, + ) + + def get_long_strings(self, min_len: int = 20) -> list[RamString]: + """Get strings longer than the specified length. + + Args: + min_len: Minimum string length + + Returns: + List of RamString objects sorted by length + """ + return sorted( + [s for s in self.ram_strings if len(s.content) >= min_len], + key=lambda x: len(x.content), + reverse=True, + ) + + def get_largest_symbols(self, min_size: int = 100) -> list[RamSymbol]: + """Get RAM symbols larger than the specified size. + + Args: + min_size: Minimum symbol size in bytes + + Returns: + List of RamSymbol objects sorted by size + """ + return sorted( + [s for s in self.ram_symbols if s.size >= min_size], + key=lambda x: x.size, + reverse=True, + ) + + def generate_report(self, show_all_sections: bool = False) -> str: + """Generate a formatted RAM strings analysis report. + + Args: + show_all_sections: If True, show all sections, not just RAM + + Returns: + Formatted report string + """ + lines: list[str] = [] + table_width = 80 + + lines.append("=" * table_width) + lines.append( + f"RAM Strings Analysis ({self.platform.upper()})".center(table_width) + ) + lines.append("=" * table_width) + lines.append("") + + # Section Analysis + lines.append("SECTION ANALYSIS") + lines.append("-" * table_width) + lines.append(f"{'Section':<20} {'Address':<12} {'Size':<12} {'Location'}") + lines.append("-" * table_width) + + total_ram_usage = 0 + total_flash_usage = 0 + + for name, section in sorted(self.sections.items(), key=lambda x: x[1].address): + if name in self.ram_sections: + location = "RAM" + total_ram_usage += section.size + elif name in self.flash_sections: + location = "FLASH" + total_flash_usage += section.size + else: + location = "OTHER" + + if show_all_sections or name in self.ram_sections: + lines.append( + f"{name:<20} 0x{section.address:08x} {section.size:>8} B {location}" + ) + + lines.append("-" * table_width) + lines.append(f"Total RAM sections size: {total_ram_usage:,} bytes") + lines.append(f"Total Flash sections size: {total_flash_usage:,} bytes") + + # Strings in RAM + lines.append("") + lines.append("=" * table_width) + lines.append("STRINGS IN RAM SECTIONS") + lines.append("=" * table_width) + lines.append( + "Note: .bss sections contain uninitialized data (no strings to extract)" + ) + + # Group strings by section + strings_by_section: dict[str, list[RamString]] = defaultdict(list) + for ram_string in self.ram_strings: + strings_by_section[ram_string.section].append(ram_string) + + for section_name in sorted(strings_by_section.keys()): + section_strings = strings_by_section[section_name] + lines.append(f"\nSection: {section_name}") + lines.append("-" * 40) + for ram_string in sorted(section_strings, key=lambda x: x.address): + clean_string = ram_string.content[:100] + ( + "..." if len(ram_string.content) > 100 else "" + ) + lines.append( + f' 0x{ram_string.address:08x}: "{clean_string}" (len={len(ram_string.content)})' + ) + + # Large RAM symbols + lines.append("") + lines.append("=" * table_width) + lines.append("LARGE DATA SYMBOLS IN RAM (>= 50 bytes)") + lines.append("=" * table_width) + + largest_symbols = self.get_largest_symbols(50) + lines.append(f"\n{'Symbol':<50} {'Type':<6} {'Size':<10} {'Section'}") + lines.append("-" * table_width) + + for symbol in largest_symbols: + # Use demangled name if available, otherwise raw name + display_name = symbol.demangled or symbol.name + name_display = display_name[:49] if len(display_name) > 49 else display_name + lines.append( + f"{name_display:<50} {symbol.sym_type:<6} {symbol.size:>8} B {symbol.section}" + ) + + # Summary + lines.append("") + lines.append("=" * table_width) + lines.append("SUMMARY") + lines.append("=" * table_width) + lines.append(f"Total strings found in RAM: {len(self.ram_strings)}") + total_string_bytes = self.get_total_string_bytes() + lines.append(f"Total bytes used by strings: {total_string_bytes:,}") + + # Optimization targets + lines.append("") + lines.append("=" * table_width) + lines.append("POTENTIAL OPTIMIZATION TARGETS") + lines.append("=" * table_width) + + # Repeated strings + repeated = self.get_repeated_strings()[:10] + if repeated: + lines.append("\nRepeated strings (could be deduplicated):") + for string, count in repeated: + savings = (count - 1) * (len(string) + 1) + clean_string = string[:50] + ("..." if len(string) > 50 else "") + lines.append( + f' "{clean_string}" - appears {count} times (potential savings: {savings} bytes)' + ) + + # Long strings - platform-specific advice + long_strings = self.get_long_strings(20)[:10] + if long_strings: + if self.platform == "esp8266": + lines.append( + "\nLong strings that could be moved to PROGMEM (>= 20 chars):" + ) + else: + # ESP32: strings in DRAM are typically there for a reason + # (interrupt handlers, pre-flash-init code, etc.) + lines.append("\nLong strings in DRAM (>= 20 chars):") + lines.append( + "Note: ESP32 DRAM strings may be required for interrupt/early-boot contexts" + ) + for ram_string in long_strings: + clean_string = ram_string.content[:60] + ( + "..." if len(ram_string.content) > 60 else "" + ) + lines.append( + f' {ram_string.section} @ 0x{ram_string.address:08x}: "{clean_string}" ({len(ram_string.content)} bytes)' + ) + + lines.append("") + return "\n".join(lines) diff --git a/esphome/analyze_memory/toolchain.py b/esphome/analyze_memory/toolchain.py new file mode 100644 index 0000000000..e766252412 --- /dev/null +++ b/esphome/analyze_memory/toolchain.py @@ -0,0 +1,57 @@ +"""Toolchain utilities for memory analysis.""" + +from __future__ import annotations + +import logging +from pathlib import Path +import subprocess + +_LOGGER = logging.getLogger(__name__) + +# Platform-specific toolchain prefixes +TOOLCHAIN_PREFIXES = [ + "xtensa-lx106-elf-", # ESP8266 + "xtensa-esp32-elf-", # ESP32 + "xtensa-esp-elf-", # ESP32 (newer IDF) + "", # System default (no prefix) +] + + +def find_tool( + tool_name: str, + objdump_path: str | None = None, +) -> str | None: + """Find a toolchain tool by name. + + First tries to derive the tool path from objdump_path (if provided), + then falls back to searching for platform-specific tools. + + Args: + tool_name: Name of the tool (e.g., "objdump", "nm", "c++filt") + objdump_path: Path to objdump binary to derive other tool paths from + + Returns: + Path to the tool or None if not found + """ + # Try to derive from objdump path first (most reliable) + if objdump_path and objdump_path != "objdump": + objdump_file = Path(objdump_path) + # Replace just the filename portion, preserving any prefix (e.g., xtensa-esp32-elf-) + new_name = objdump_file.name.replace("objdump", tool_name) + potential_path = str(objdump_file.with_name(new_name)) + if Path(potential_path).exists(): + _LOGGER.debug("Found %s at: %s", tool_name, potential_path) + return potential_path + + # Try platform-specific tools + for prefix in TOOLCHAIN_PREFIXES: + cmd = f"{prefix}{tool_name}" + try: + subprocess.run([cmd, "--version"], capture_output=True, check=True) + _LOGGER.debug("Found %s: %s", tool_name, cmd) + return cmd + except (subprocess.CalledProcessError, FileNotFoundError): + continue + + _LOGGER.warning("Could not find %s tool", tool_name) + return None diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index ccbc5a1306..670d6c16fc 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -269,6 +269,16 @@ def mock_memory_analyzer_cli() -> Generator[Mock]: yield mock_class +@pytest.fixture +def mock_ram_strings_analyzer() -> Generator[Mock]: + """Mock RamStringsAnalyzer for testing.""" + with patch("esphome.analyze_memory.ram_strings.RamStringsAnalyzer") as mock_class: + mock_analyzer = MagicMock() + mock_analyzer.generate_report.return_value = "Mock RAM Strings Report" + mock_class.return_value = mock_analyzer + yield mock_class + + def test_choose_upload_log_host_with_string_default() -> None: """Test with a single string default device.""" setup_core() @@ -2424,6 +2434,7 @@ def test_command_analyze_memory_success( mock_get_idedata: Mock, mock_get_esphome_components: Mock, mock_memory_analyzer_cli: Mock, + mock_ram_strings_analyzer: Mock, ) -> None: """Test command_analyze_memory with successful compilation and analysis.""" setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device") @@ -2471,9 +2482,20 @@ def test_command_analyze_memory_success( mock_analyzer.analyze.assert_called_once() mock_analyzer.generate_report.assert_called_once() - # Verify report was printed + # Verify RAM strings analyzer was created and run + mock_ram_strings_analyzer.assert_called_once_with( + str(firmware_elf), + objdump_path="/path/to/objdump", + platform="esp32", + ) + mock_ram_analyzer = mock_ram_strings_analyzer.return_value + mock_ram_analyzer.analyze.assert_called_once() + mock_ram_analyzer.generate_report.assert_called_once() + + # Verify reports were printed captured = capfd.readouterr() assert "Mock Memory Report" in captured.out + assert "Mock RAM Strings Report" in captured.out def test_command_analyze_memory_with_external_components( @@ -2483,6 +2505,7 @@ def test_command_analyze_memory_with_external_components( mock_get_idedata: Mock, mock_get_esphome_components: Mock, mock_memory_analyzer_cli: Mock, + mock_ram_strings_analyzer: Mock, ) -> None: """Test command_analyze_memory detects external components.""" setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device") From d1583456e97af1a00ec80350223d685addac2eb0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Dec 2025 10:02:29 -0600 Subject: [PATCH 06/14] [web_server] Store update state strings in flash on ESP8266 (#12204) --- esphome/components/web_server/web_server.cpp | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 38fa54704a..b56d9ce698 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -41,8 +41,8 @@ namespace web_server { static const char *const TAG = "web_server"; -// Longest: HORIZONTAL (10 chars + null terminator, rounded up) -static constexpr size_t PSTR_LOCAL_SIZE = 16; +// Longest: UPDATE AVAILABLE (16 chars + null terminator, rounded up) +static constexpr size_t PSTR_LOCAL_SIZE = 18; #define PSTR_LOCAL(mode_s) ESPHOME_strncpy_P(buf, (ESPHOME_PGM_P) ((mode_s)), PSTR_LOCAL_SIZE - 1) #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS @@ -1717,16 +1717,16 @@ std::string WebServer::event_json(event::Event *obj, const std::string &event_ty #endif #ifdef USE_UPDATE -static const char *update_state_to_string(update::UpdateState state) { +static const LogString *update_state_to_string(update::UpdateState state) { switch (state) { case update::UPDATE_STATE_NO_UPDATE: - return "NO UPDATE"; + return LOG_STR("NO UPDATE"); case update::UPDATE_STATE_AVAILABLE: - return "UPDATE AVAILABLE"; + return LOG_STR("UPDATE AVAILABLE"); case update::UPDATE_STATE_INSTALLING: - return "INSTALLING"; + return LOG_STR("INSTALLING"); default: - return "UNKNOWN"; + return LOG_STR("UNKNOWN"); } } @@ -1769,8 +1769,9 @@ std::string WebServer::update_json(update::UpdateEntity *obj, JsonDetail start_c json::JsonBuilder builder; JsonObject root = builder.root(); - set_json_icon_state_value(root, obj, "update", update_state_to_string(obj->state), obj->update_info.latest_version, - start_config); + char buf[PSTR_LOCAL_SIZE]; + set_json_icon_state_value(root, obj, "update", PSTR_LOCAL(update_state_to_string(obj->state)), + obj->update_info.latest_version, start_config); if (start_config == DETAIL_ALL) { root["current_version"] = obj->update_info.current_version; root["title"] = obj->update_info.title; From 3f08cacf71e32e582b9c6612c445488a9372423c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Dec 2025 10:02:51 -0600 Subject: [PATCH 07/14] [valve] Store valve state strings in flash on ESP8266 (#12202) --- .../prometheus/prometheus_handler.cpp | 6 ++++- esphome/components/valve/valve.cpp | 22 +++++++++---------- esphome/components/valve/valve.h | 3 ++- esphome/components/web_server/web_server.cpp | 3 ++- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 252b477400..4b5d834ebf 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -895,7 +895,11 @@ void PrometheusHandler::valve_row_(AsyncResponseStream *stream, valve::Valve *ob stream->print(ESPHOME_F("\",name=\"")); stream->print(relabel_name_(obj).c_str()); stream->print(ESPHOME_F("\",operation=\"")); - stream->print(valve::valve_operation_to_str(obj->current_operation)); +#ifdef USE_STORE_LOG_STR_IN_FLASH + stream->print((const __FlashStringHelper *) valve::valve_operation_to_str(obj->current_operation)); +#else + stream->print((const char *) valve::valve_operation_to_str(obj->current_operation)); +#endif stream->print(ESPHOME_F("\"} ")); stream->print(ESPHOME_F("1.0")); stream->print(ESPHOME_F("\n")); diff --git a/esphome/components/valve/valve.cpp b/esphome/components/valve/valve.cpp index 381d9061de..fed113afc2 100644 --- a/esphome/components/valve/valve.cpp +++ b/esphome/components/valve/valve.cpp @@ -12,25 +12,25 @@ static const char *const TAG = "valve"; const float VALVE_OPEN = 1.0f; const float VALVE_CLOSED = 0.0f; -const char *valve_command_to_str(float pos) { +const LogString *valve_command_to_str(float pos) { if (pos == VALVE_OPEN) { - return "OPEN"; + return LOG_STR("OPEN"); } else if (pos == VALVE_CLOSED) { - return "CLOSE"; + return LOG_STR("CLOSE"); } else { - return "UNKNOWN"; + return LOG_STR("UNKNOWN"); } } -const char *valve_operation_to_str(ValveOperation op) { +const LogString *valve_operation_to_str(ValveOperation op) { switch (op) { case VALVE_OPERATION_IDLE: - return "IDLE"; + return LOG_STR("IDLE"); case VALVE_OPERATION_OPENING: - return "OPENING"; + return LOG_STR("OPENING"); case VALVE_OPERATION_CLOSING: - return "CLOSING"; + return LOG_STR("CLOSING"); default: - return "UNKNOWN"; + return LOG_STR("UNKNOWN"); } } @@ -82,7 +82,7 @@ void ValveCall::perform() { if (traits.get_supports_position()) { ESP_LOGD(TAG, " Position: %.0f%%", *this->position_ * 100.0f); } else { - ESP_LOGD(TAG, " Command: %s", valve_command_to_str(*this->position_)); + ESP_LOGD(TAG, " Command: %s", LOG_STR_ARG(valve_command_to_str(*this->position_))); } } if (this->toggle_.has_value()) { @@ -146,7 +146,7 @@ void Valve::publish_state(bool save) { ESP_LOGD(TAG, " State: UNKNOWN"); } } - ESP_LOGD(TAG, " Current Operation: %s", valve_operation_to_str(this->current_operation)); + ESP_LOGD(TAG, " Current Operation: %s", LOG_STR_ARG(valve_operation_to_str(this->current_operation))); this->state_callback_.call(); #if defined(USE_VALVE) && defined(USE_CONTROLLER_REGISTRY) diff --git a/esphome/components/valve/valve.h b/esphome/components/valve/valve.h index ab7ff5abe1..2cb28e4b2f 100644 --- a/esphome/components/valve/valve.h +++ b/esphome/components/valve/valve.h @@ -3,6 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/entity_base.h" #include "esphome/core/helpers.h" +#include "esphome/core/log.h" #include "esphome/core/preferences.h" #include "valve_traits.h" @@ -81,7 +82,7 @@ enum ValveOperation : uint8_t { VALVE_OPERATION_CLOSING, }; -const char *valve_operation_to_str(ValveOperation op); +const LogString *valve_operation_to_str(ValveOperation op); /** Base class for all valve devices. * diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index b56d9ce698..35f20f8609 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1565,7 +1565,8 @@ std::string WebServer::valve_json(valve::Valve *obj, JsonDetail start_config) { set_json_icon_state_value(root, obj, "valve", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); - root["current_operation"] = valve::valve_operation_to_str(obj->current_operation); + char buf[PSTR_LOCAL_SIZE]; + root["current_operation"] = PSTR_LOCAL(valve::valve_operation_to_str(obj->current_operation)); if (obj->get_traits().get_supports_position()) root["position"] = obj->position; From 77477bd3300827055b3574c79fe55aa630cabd17 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Dec 2025 10:03:29 -0600 Subject: [PATCH 08/14] [web_server_idf] Fix SSE multi-line message formatting (#12247) --- .../web_server_idf/web_server_idf.cpp | 87 +++++++++++++++++-- 1 file changed, 81 insertions(+), 6 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index c910ed06c5..af99b85e53 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -664,17 +664,92 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char event_buffer_.append(CRLF_STR, CRLF_LEN); } - if (message && *message) { - event_buffer_.append("data: ", sizeof("data: ") - 1); - event_buffer_.append(message); - event_buffer_.append(CRLF_STR, CRLF_LEN); + // Match ESPAsyncWebServer: null message means no data lines and no terminating blank line + if (message) { + // SSE spec requires each line of a multi-line message to have its own "data:" prefix + // Handle \n, \r, and \r\n line endings (matching ESPAsyncWebServer behavior) + + // Fast path: check if message contains any newlines at all + // Most SSE messages (JSON state updates) have no newlines + const char *first_n = strchr(message, '\n'); + const char *first_r = strchr(message, '\r'); + + if (first_n == nullptr && first_r == nullptr) { + // No newlines - fast path (most common case) + event_buffer_.append("data: ", sizeof("data: ") - 1); + event_buffer_.append(message); + event_buffer_.append(CRLF_STR CRLF_STR, CRLF_LEN * 2); // data line + blank line terminator + } else { + // Has newlines - handle multi-line message + const char *line_start = message; + size_t msg_len = strlen(message); + const char *msg_end = message + msg_len; + + // Reuse the first search results + const char *next_n = first_n; + const char *next_r = first_r; + + while (line_start <= msg_end) { + const char *line_end; + const char *next_line; + + if (next_n == nullptr && next_r == nullptr) { + // No more line breaks - output remaining text as final line + event_buffer_.append("data: ", sizeof("data: ") - 1); + event_buffer_.append(line_start); + event_buffer_.append(CRLF_STR, CRLF_LEN); + break; + } + + // Determine line ending type and next line start + if (next_n != nullptr && next_r != nullptr) { + if (next_r + 1 == next_n) { + // \r\n sequence + line_end = next_r; + next_line = next_n + 1; + } else { + // Mixed \n and \r - use whichever comes first + line_end = (next_r < next_n) ? next_r : next_n; + next_line = line_end + 1; + } + } else if (next_n != nullptr) { + // Unix LF + line_end = next_n; + next_line = next_n + 1; + } else { + // Old Mac CR + line_end = next_r; + next_line = next_r + 1; + } + + // Output this line + event_buffer_.append("data: ", sizeof("data: ") - 1); + event_buffer_.append(line_start, line_end - line_start); + event_buffer_.append(CRLF_STR, CRLF_LEN); + + line_start = next_line; + + // Check if we've consumed all content + if (line_start >= msg_end) { + break; + } + + // Search for next newlines only in remaining string + next_n = strchr(line_start, '\n'); + next_r = strchr(line_start, '\r'); + } + + // Terminate message with blank line + event_buffer_.append(CRLF_STR, CRLF_LEN); + } } - if (event_buffer_.empty()) { + if (event_buffer_.size() == static_cast(chunk_len_header_len)) { + // Nothing was added, reset buffer + event_buffer_.resize(0); return true; } - event_buffer_.append(CRLF_STR, CRLF_LEN); event_buffer_.append(CRLF_STR, CRLF_LEN); // chunk length header itself and the final chunk terminating CRLF are not counted as part of the chunk From 6ce2a456915e16968eb0275bc7d6531dd03b7ca4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Dec 2025 10:03:58 -0600 Subject: [PATCH 09/14] [text_sensor] Add deprecation warning for raw_state member access (#12246) --- esphome/components/text_sensor/text_sensor.cpp | 18 ++++++++++-------- esphome/components/text_sensor/text_sensor.h | 11 +++++++---- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index d984e78b2a..51923ebd96 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -25,11 +25,11 @@ void log_text_sensor(const char *tag, const char *prefix, const char *type, Text } void TextSensor::publish_state(const std::string &state) { - // Only store raw_state_ separately when filters exist - // When no filters, raw_state == state, so we avoid the duplicate storage - if (this->filter_list_ != nullptr) { - this->raw_state_ = state; - } +// Suppress deprecation warning - we need to populate raw_state for backwards compatibility +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + this->raw_state = state; +#pragma GCC diagnostic pop if (this->raw_callback_) { this->raw_callback_->call(state); } @@ -85,9 +85,11 @@ void TextSensor::add_on_raw_state_callback(std::function call std::string TextSensor::get_state() const { return this->state; } std::string TextSensor::get_raw_state() const { - // When no filters exist, raw_state == state, so return state to avoid - // requiring separate storage - return this->filter_list_ != nullptr ? this->raw_state_ : this->state; +// Suppress deprecation warning - get_raw_state() is the replacement API +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + return this->raw_state; +#pragma GCC diagnostic pop } void TextSensor::internal_send_state_to_frontend(const std::string &state) { this->state = state; diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index fcfbed2fbc..7217806a55 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -51,6 +51,13 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass { std::string state; +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + /// @deprecated Use get_raw_state() instead. This member will be removed in ESPHome 2026.6.0. + ESPDEPRECATED("Use get_raw_state() instead of .raw_state. Will be removed in 2026.6.0", "2025.12.0") + std::string raw_state; +#pragma GCC diagnostic pop + // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) @@ -62,10 +69,6 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass { CallbackManager callback_; ///< Storage for filtered state callbacks. Filter *filter_list_{nullptr}; ///< Store all active filters. - - /// Raw state (before filters). Only populated when filters are configured. - /// When no filters exist, get_raw_state() returns state directly. - std::string raw_state_; }; } // namespace text_sensor From 8f97f3b81f397c5204200bc4046c0ff7d634dec5 Mon Sep 17 00:00:00 2001 From: Flo Date: Tue, 2 Dec 2025 17:12:27 +0100 Subject: [PATCH 10/14] [wifi] Fix ap_active condition (#12227) --- esphome/components/wifi/wifi_component.cpp | 2 +- esphome/components/wifi/wifi_component.h | 1 + esphome/components/wifi/wifi_component_esp8266.cpp | 3 +++ esphome/components/wifi/wifi_component_esp_idf.cpp | 5 ++--- esphome/components/wifi/wifi_component_libretiny.cpp | 3 +++ esphome/components/wifi/wifi_component_pico_w.cpp | 4 ++++ 6 files changed, 14 insertions(+), 4 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index e67493aa4d..317507f242 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -580,7 +580,7 @@ void WiFiComponent::loop() { WiFiComponent::WiFiComponent() { global_wifi_component = this; } bool WiFiComponent::has_ap() const { return this->has_ap_; } -bool WiFiComponent::is_ap_active() const { return this->state_ == WIFI_COMPONENT_STATE_AP; } +bool WiFiComponent::is_ap_active() const { return this->ap_started_; } bool WiFiComponent::has_sta() const { return !this->sta_.empty(); } #ifdef USE_WIFI_11KV_SUPPORT void WiFiComponent::set_btm(bool btm) { this->btm_ = btm; } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 97cc3961fe..2148f2d4c7 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -616,6 +616,7 @@ class WiFiComponent : public Component { bool error_from_callback_{false}; bool scan_done_{false}; bool ap_setup_{false}; + bool ap_started_{false}; bool passive_scan_{false}; bool has_saved_wifi_settings_{false}; #ifdef USE_WIFI_11KV_SUPPORT diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 701cae5f7c..c1c0dd470f 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -82,8 +82,11 @@ bool WiFiComponent::wifi_mode_(optional sta, optional ap) { if (!ret) { ESP_LOGW(TAG, "Set mode failed"); + return false; } + this->ap_started_ = target_ap; + return ret; } bool WiFiComponent::wifi_apply_power_save_() { diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 3d25d2890f..e1f8108892 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -53,7 +53,6 @@ static esp_netif_t *s_ap_netif = nullptr; // NOLINT(cppcoreguidelines-avoid- #endif // USE_WIFI_AP static bool s_sta_started = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool s_sta_connected = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static bool s_ap_started = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool s_sta_connect_not_found = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool s_sta_connect_error = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool s_sta_connecting = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -831,11 +830,11 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_START) { ESP_LOGV(TAG, "AP start"); - s_ap_started = true; + this->ap_started_ = true; } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STOP) { ESP_LOGV(TAG, "AP stop"); - s_ap_started = false; + this->ap_started_ = false; } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_PROBEREQRECVED) { const auto &it = data->data.ap_probe_req_rx; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index f1405d3bef..0de7003899 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -50,8 +50,11 @@ bool WiFiComponent::wifi_mode_(optional sta, optional ap) { if (!ret) { ESP_LOGW(TAG, "Setting mode failed"); + return false; } + this->ap_started_ = enable_ap; + return ret; } bool WiFiComponent::wifi_apply_output_power_(float output_power) { diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 1a8b75213c..c7dc4120dd 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -28,11 +28,15 @@ bool WiFiComponent::wifi_mode_(optional sta, optional ap) { cyw43_wifi_set_up(&cyw43_state, CYW43_ITF_STA, true, CYW43_COUNTRY_WORLDWIDE); } } + + bool ap_state = false; if (ap.has_value()) { if (ap.value()) { cyw43_wifi_set_up(&cyw43_state, CYW43_ITF_AP, true, CYW43_COUNTRY_WORLDWIDE); + ap_state = true; } } + this->ap_started_ = ap_state; return true; } From 638c59e162cd7ae46b6ddb24f9738cb332ea51ae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 10:13:20 -0600 Subject: [PATCH 11/14] Bump pylint from 4.0.3 to 4.0.4 (#12239) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 3aec877126..9d55d23272 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -pylint==4.0.3 +pylint==4.0.4 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.14.7 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating From a6a6f482e6661c5747ba475d3a170268f9a88e45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Dec 2025 10:51:05 -0600 Subject: [PATCH 12/14] [core] Add PROGMEM macros and move web_server JSON keys to flash (#12214) --- .../components/light/light_json_schema.cpp | 94 ++++++------ esphome/components/web_server/web_server.cpp | 142 +++++++++--------- .../web_server_base/web_server_base.h | 15 +- esphome/core/progmem.h | 16 ++ 4 files changed, 137 insertions(+), 130 deletions(-) create mode 100644 esphome/core/progmem.h diff --git a/esphome/components/light/light_json_schema.cpp b/esphome/components/light/light_json_schema.cpp index 41cb855630..3365d1f417 100644 --- a/esphome/components/light/light_json_schema.cpp +++ b/esphome/components/light/light_json_schema.cpp @@ -1,5 +1,6 @@ #include "light_json_schema.h" #include "light_output.h" +#include "esphome/core/progmem.h" #ifdef USE_JSON @@ -35,9 +36,9 @@ static const char *get_color_mode_json_str(ColorMode mode) { void LightJSONSchema::dump_json(LightState &state, JsonObject root) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson if (state.supports_effects()) { - root["effect"] = state.get_effect_name(); - root["effect_index"] = state.get_current_effect_index(); - root["effect_count"] = state.get_effect_count(); + root[ESPHOME_F("effect")] = state.get_effect_name(); + root[ESPHOME_F("effect_index")] = state.get_current_effect_index(); + root[ESPHOME_F("effect_count")] = state.get_effect_count(); } auto values = state.remote_values; @@ -45,39 +46,39 @@ void LightJSONSchema::dump_json(LightState &state, JsonObject root) { const auto color_mode = values.get_color_mode(); const char *mode_str = get_color_mode_json_str(color_mode); if (mode_str != nullptr) { - root["color_mode"] = mode_str; + root[ESPHOME_F("color_mode")] = mode_str; } if (color_mode & ColorCapability::ON_OFF) - root["state"] = (values.get_state() != 0.0f) ? "ON" : "OFF"; + root[ESPHOME_F("state")] = (values.get_state() != 0.0f) ? "ON" : "OFF"; if (color_mode & ColorCapability::BRIGHTNESS) - root["brightness"] = to_uint8_scale(values.get_brightness()); + root[ESPHOME_F("brightness")] = to_uint8_scale(values.get_brightness()); - JsonObject color = root["color"].to(); + JsonObject color = root[ESPHOME_F("color")].to(); if (color_mode & ColorCapability::RGB) { float color_brightness = values.get_color_brightness(); - color["r"] = to_uint8_scale(color_brightness * values.get_red()); - color["g"] = to_uint8_scale(color_brightness * values.get_green()); - color["b"] = to_uint8_scale(color_brightness * values.get_blue()); + color[ESPHOME_F("r")] = to_uint8_scale(color_brightness * values.get_red()); + color[ESPHOME_F("g")] = to_uint8_scale(color_brightness * values.get_green()); + color[ESPHOME_F("b")] = to_uint8_scale(color_brightness * values.get_blue()); } if (color_mode & ColorCapability::WHITE) { uint8_t white_val = to_uint8_scale(values.get_white()); - color["w"] = white_val; - root["white_value"] = white_val; // legacy API + color[ESPHOME_F("w")] = white_val; + root[ESPHOME_F("white_value")] = white_val; // legacy API } if (color_mode & ColorCapability::COLOR_TEMPERATURE) { // this one isn't under the color subkey for some reason - root["color_temp"] = uint32_t(values.get_color_temperature()); + root[ESPHOME_F("color_temp")] = uint32_t(values.get_color_temperature()); } if (color_mode & ColorCapability::COLD_WARM_WHITE) { - color["c"] = to_uint8_scale(values.get_cold_white()); - color["w"] = to_uint8_scale(values.get_warm_white()); + color[ESPHOME_F("c")] = to_uint8_scale(values.get_cold_white()); + color[ESPHOME_F("w")] = to_uint8_scale(values.get_warm_white()); } } void LightJSONSchema::parse_color_json(LightState &state, LightCall &call, JsonObject root) { - if (root["state"].is()) { - auto val = parse_on_off(root["state"]); + if (root[ESPHOME_F("state")].is()) { + auto val = parse_on_off(root[ESPHOME_F("state")]); switch (val) { case PARSE_ON: call.set_state(true); @@ -93,76 +94,77 @@ void LightJSONSchema::parse_color_json(LightState &state, LightCall &call, JsonO } } - if (root["brightness"].is()) { - call.set_brightness(float(root["brightness"]) / 255.0f); + if (root[ESPHOME_F("brightness")].is()) { + call.set_brightness(float(root[ESPHOME_F("brightness")]) / 255.0f); } - if (root["color"].is()) { - JsonObject color = root["color"]; + if (root[ESPHOME_F("color")].is()) { + JsonObject color = root[ESPHOME_F("color")]; // HA also encodes brightness information in the r, g, b values, so extract that and set it as color brightness. float max_rgb = 0.0f; - if (color["r"].is()) { - float r = float(color["r"]) / 255.0f; + if (color[ESPHOME_F("r")].is()) { + float r = float(color[ESPHOME_F("r")]) / 255.0f; max_rgb = fmaxf(max_rgb, r); call.set_red(r); } - if (color["g"].is()) { - float g = float(color["g"]) / 255.0f; + if (color[ESPHOME_F("g")].is()) { + float g = float(color[ESPHOME_F("g")]) / 255.0f; max_rgb = fmaxf(max_rgb, g); call.set_green(g); } - if (color["b"].is()) { - float b = float(color["b"]) / 255.0f; + if (color[ESPHOME_F("b")].is()) { + float b = float(color[ESPHOME_F("b")]) / 255.0f; max_rgb = fmaxf(max_rgb, b); call.set_blue(b); } - if (color["r"].is() || color["g"].is() || color["b"].is()) { + if (color[ESPHOME_F("r")].is() || color[ESPHOME_F("g")].is() || + color[ESPHOME_F("b")].is()) { call.set_color_brightness(max_rgb); } - if (color["c"].is()) { - call.set_cold_white(float(color["c"]) / 255.0f); + if (color[ESPHOME_F("c")].is()) { + call.set_cold_white(float(color[ESPHOME_F("c")]) / 255.0f); } - if (color["w"].is()) { + if (color[ESPHOME_F("w")].is()) { // the HA scheme is ambiguous here, the same key is used for white channel in RGBW and warm // white channel in RGBWW. - if (color["c"].is()) { - call.set_warm_white(float(color["w"]) / 255.0f); + if (color[ESPHOME_F("c")].is()) { + call.set_warm_white(float(color[ESPHOME_F("w")]) / 255.0f); } else { - call.set_white(float(color["w"]) / 255.0f); + call.set_white(float(color[ESPHOME_F("w")]) / 255.0f); } } } - if (root["white_value"].is()) { // legacy API - call.set_white(float(root["white_value"]) / 255.0f); + if (root[ESPHOME_F("white_value")].is()) { // legacy API + call.set_white(float(root[ESPHOME_F("white_value")]) / 255.0f); } - if (root["color_temp"].is()) { - call.set_color_temperature(float(root["color_temp"])); + if (root[ESPHOME_F("color_temp")].is()) { + call.set_color_temperature(float(root[ESPHOME_F("color_temp")])); } } void LightJSONSchema::parse_json(LightState &state, LightCall &call, JsonObject root) { LightJSONSchema::parse_color_json(state, call, root); - if (root["flash"].is()) { - auto length = uint32_t(float(root["flash"]) * 1000); + if (root[ESPHOME_F("flash")].is()) { + auto length = uint32_t(float(root[ESPHOME_F("flash")]) * 1000); call.set_flash_length(length); } - if (root["transition"].is()) { - auto length = uint32_t(float(root["transition"]) * 1000); + if (root[ESPHOME_F("transition")].is()) { + auto length = uint32_t(float(root[ESPHOME_F("transition")]) * 1000); call.set_transition_length(length); } - if (root["effect"].is()) { - const char *effect = root["effect"]; + if (root[ESPHOME_F("effect")].is()) { + const char *effect = root[ESPHOME_F("effect")]; call.set_effect(effect); } - if (root["effect_index"].is()) { - uint32_t effect_index = root["effect_index"]; + if (root[ESPHOME_F("effect_index")].is()) { + uint32_t effect_index = root[ESPHOME_F("effect_index")]; call.set_effect(effect_index); } } diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 35f20f8609..1f3605a082 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -244,8 +244,8 @@ void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource for (auto &group : ws->sorting_groups_) { json::JsonBuilder builder; JsonObject root = builder.root(); - root["name"] = group.second.name; - root["sorting_weight"] = group.second.weight; + root[ESPHOME_F("name")] = group.second.name; + root[ESPHOME_F("sorting_weight")] = group.second.weight; message = builder.serialize(); // up to 31 groups should be able to be queued initially without defer @@ -286,15 +286,15 @@ std::string WebServer::get_config_json() { json::JsonBuilder builder; JsonObject root = builder.root(); - root["title"] = App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name(); - root["comment"] = App.get_comment(); + root[ESPHOME_F("title")] = App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name(); + root[ESPHOME_F("comment")] = App.get_comment(); #if defined(USE_WEBSERVER_OTA_DISABLED) || !defined(USE_WEBSERVER_OTA) - root["ota"] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal + root[ESPHOME_F("ota")] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal #else - root["ota"] = true; + root[ESPHOME_F("ota")] = true; #endif - root["log"] = this->expose_log_; - root["lang"] = "en"; + root[ESPHOME_F("log")] = this->expose_log_; + root[ESPHOME_F("lang")] = "en"; return builder.serialize(); } @@ -407,14 +407,14 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J char id_buf[160]; // object_id can be up to 128 chars + prefix + dash + null const auto &object_id = obj->get_object_id(); snprintf(id_buf, sizeof(id_buf), "%s-%s", prefix, object_id.c_str()); - root["id"] = id_buf; + root[ESPHOME_F("id")] = id_buf; if (start_config == DETAIL_ALL) { - root["name"] = obj->get_name(); - root["icon"] = obj->get_icon_ref(); - root["entity_category"] = obj->get_entity_category(); + root[ESPHOME_F("name")] = obj->get_name(); + root[ESPHOME_F("icon")] = obj->get_icon_ref(); + root[ESPHOME_F("entity_category")] = obj->get_entity_category(); bool is_disabled = obj->is_disabled_by_default(); if (is_disabled) - root["is_disabled_by_default"] = is_disabled; + root[ESPHOME_F("is_disabled_by_default")] = is_disabled; } } @@ -424,14 +424,14 @@ template static void set_json_value(JsonObject &root, EntityBase *obj, const char *prefix, const T &value, JsonDetail start_config) { set_json_id(root, obj, prefix, start_config); - root["value"] = value; + root[ESPHOME_F("value")] = value; } template static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, const std::string &state, const T &value, JsonDetail start_config) { set_json_value(root, obj, prefix, value, start_config); - root["state"] = state; + root[ESPHOME_F("state")] = state; } // Helper to get request detail parameter @@ -478,7 +478,7 @@ std::string WebServer::sensor_json(sensor::Sensor *obj, float value, JsonDetail if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); if (!uom_ref.empty()) - root["uom"] = uom_ref; + root[ESPHOME_F("uom")] = uom_ref; } return builder.serialize(); @@ -593,7 +593,7 @@ std::string WebServer::switch_json(switch_::Switch *obj, bool value, JsonDetail set_json_icon_state_value(root, obj, "switch", value ? "ON" : "OFF", value, start_config); if (start_config == DETAIL_ALL) { - root["assumed_state"] = obj->assumed_state(); + root[ESPHOME_F("assumed_state")] = obj->assumed_state(); this->add_sorting_info_(root, obj); } @@ -748,11 +748,11 @@ std::string WebServer::fan_json(fan::Fan *obj, JsonDetail start_config) { set_json_icon_state_value(root, obj, "fan", obj->state ? "ON" : "OFF", obj->state, start_config); const auto traits = obj->get_traits(); if (traits.supports_speed()) { - root["speed_level"] = obj->speed; - root["speed_count"] = traits.supported_speed_count(); + root[ESPHOME_F("speed_level")] = obj->speed; + root[ESPHOME_F("speed_count")] = traits.supported_speed_count(); } if (obj->get_traits().supports_oscillation()) - root["oscillation"] = obj->oscillating; + root[ESPHOME_F("oscillation")] = obj->oscillating; if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -827,7 +827,7 @@ std::string WebServer::light_json(light::LightState *obj, JsonDetail start_confi light::LightJSONSchema::dump_json(*obj, root); if (start_config == DETAIL_ALL) { - JsonArray opt = root["effects"].to(); + JsonArray opt = root[ESPHOME_F("effects")].to(); opt.add("None"); for (auto const &option : obj->get_effects()) { opt.add(option->get_name()); @@ -913,12 +913,12 @@ std::string WebServer::cover_json(cover::Cover *obj, JsonDetail start_config) { set_json_icon_state_value(root, obj, "cover", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); char buf[PSTR_LOCAL_SIZE]; - root["current_operation"] = PSTR_LOCAL(cover::cover_operation_to_str(obj->current_operation)); + root[ESPHOME_F("current_operation")] = PSTR_LOCAL(cover::cover_operation_to_str(obj->current_operation)); if (obj->get_traits().get_supports_position()) - root["position"] = obj->position; + root[ESPHOME_F("position")] = obj->position; if (obj->get_traits().get_supports_tilt()) - root["tilt"] = obj->tilt; + root[ESPHOME_F("tilt")] = obj->tilt; if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -979,14 +979,15 @@ std::string WebServer::number_json(number::Number *obj, float value, JsonDetail value, step_to_accuracy_decimals(obj->traits.get_step()), uom_ref); set_json_icon_state_value(root, obj, "number", state_str, val_str, start_config); if (start_config == DETAIL_ALL) { - root["min_value"] = + root[ESPHOME_F("min_value")] = value_accuracy_to_string(obj->traits.get_min_value(), step_to_accuracy_decimals(obj->traits.get_step())); - root["max_value"] = + root[ESPHOME_F("max_value")] = value_accuracy_to_string(obj->traits.get_max_value(), step_to_accuracy_decimals(obj->traits.get_step())); - root["step"] = value_accuracy_to_string(obj->traits.get_step(), step_to_accuracy_decimals(obj->traits.get_step())); - root["mode"] = (int) obj->traits.get_mode(); + root[ESPHOME_F("step")] = + value_accuracy_to_string(obj->traits.get_step(), step_to_accuracy_decimals(obj->traits.get_step())); + root[ESPHOME_F("mode")] = (int) obj->traits.get_mode(); if (!uom_ref.empty()) - root["uom"] = uom_ref; + root[ESPHOME_F("uom")] = uom_ref; this->add_sorting_info_(root, obj); } @@ -1208,11 +1209,11 @@ std::string WebServer::text_json(text::Text *obj, const std::string &value, Json std::string state = obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD ? "********" : value; set_json_icon_state_value(root, obj, "text", state, value, start_config); - root["min_length"] = obj->traits.get_min_length(); - root["max_length"] = obj->traits.get_max_length(); - root["pattern"] = obj->traits.get_pattern(); + root[ESPHOME_F("min_length")] = obj->traits.get_min_length(); + root[ESPHOME_F("max_length")] = obj->traits.get_max_length(); + root[ESPHOME_F("pattern")] = obj->traits.get_pattern(); if (start_config == DETAIL_ALL) { - root["mode"] = (int) obj->traits.get_mode(); + root[ESPHOME_F("mode")] = (int) obj->traits.get_mode(); this->add_sorting_info_(root, obj); } @@ -1266,7 +1267,7 @@ std::string WebServer::select_json(select::Select *obj, const char *value, JsonD set_json_icon_state_value(root, obj, "select", value, value, start_config); if (start_config == DETAIL_ALL) { - JsonArray opt = root["option"].to(); + JsonArray opt = root[ESPHOME_F("option")].to(); for (auto &option : obj->traits.get_options()) { opt.add(option); } @@ -1337,32 +1338,32 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf char buf[PSTR_LOCAL_SIZE]; if (start_config == DETAIL_ALL) { - JsonArray opt = root["modes"].to(); + JsonArray opt = root[ESPHOME_F("modes")].to(); for (climate::ClimateMode m : traits.get_supported_modes()) opt.add(PSTR_LOCAL(climate::climate_mode_to_string(m))); if (!traits.get_supported_custom_fan_modes().empty()) { - JsonArray opt = root["fan_modes"].to(); + JsonArray opt = root[ESPHOME_F("fan_modes")].to(); for (climate::ClimateFanMode m : traits.get_supported_fan_modes()) opt.add(PSTR_LOCAL(climate::climate_fan_mode_to_string(m))); } if (!traits.get_supported_custom_fan_modes().empty()) { - JsonArray opt = root["custom_fan_modes"].to(); + JsonArray opt = root[ESPHOME_F("custom_fan_modes")].to(); for (auto const &custom_fan_mode : traits.get_supported_custom_fan_modes()) opt.add(custom_fan_mode); } if (traits.get_supports_swing_modes()) { - JsonArray opt = root["swing_modes"].to(); + JsonArray opt = root[ESPHOME_F("swing_modes")].to(); for (auto swing_mode : traits.get_supported_swing_modes()) opt.add(PSTR_LOCAL(climate::climate_swing_mode_to_string(swing_mode))); } if (traits.get_supports_presets() && obj->preset.has_value()) { - JsonArray opt = root["presets"].to(); + JsonArray opt = root[ESPHOME_F("presets")].to(); for (climate::ClimatePreset m : traits.get_supported_presets()) opt.add(PSTR_LOCAL(climate::climate_preset_to_string(m))); } if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) { - JsonArray opt = root["custom_presets"].to(); + JsonArray opt = root[ESPHOME_F("custom_presets")].to(); for (auto const &custom_preset : traits.get_supported_custom_presets()) opt.add(custom_preset); } @@ -1370,49 +1371,50 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf } bool has_state = false; - root["mode"] = PSTR_LOCAL(climate_mode_to_string(obj->mode)); - root["max_temp"] = value_accuracy_to_string(traits.get_visual_max_temperature(), target_accuracy); - root["min_temp"] = value_accuracy_to_string(traits.get_visual_min_temperature(), target_accuracy); - root["step"] = traits.get_visual_target_temperature_step(); + root[ESPHOME_F("mode")] = PSTR_LOCAL(climate_mode_to_string(obj->mode)); + root[ESPHOME_F("max_temp")] = value_accuracy_to_string(traits.get_visual_max_temperature(), target_accuracy); + root[ESPHOME_F("min_temp")] = value_accuracy_to_string(traits.get_visual_min_temperature(), target_accuracy); + root[ESPHOME_F("step")] = traits.get_visual_target_temperature_step(); if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) { - root["action"] = PSTR_LOCAL(climate_action_to_string(obj->action)); - root["state"] = root["action"]; + root[ESPHOME_F("action")] = PSTR_LOCAL(climate_action_to_string(obj->action)); + root[ESPHOME_F("state")] = root[ESPHOME_F("action")]; has_state = true; } if (traits.get_supports_fan_modes() && obj->fan_mode.has_value()) { - root["fan_mode"] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())); + root[ESPHOME_F("fan_mode")] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())); } if (!traits.get_supported_custom_fan_modes().empty() && obj->has_custom_fan_mode()) { - root["custom_fan_mode"] = obj->get_custom_fan_mode(); + root[ESPHOME_F("custom_fan_mode")] = obj->get_custom_fan_mode(); } if (traits.get_supports_presets() && obj->preset.has_value()) { - root["preset"] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value())); + root[ESPHOME_F("preset")] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value())); } if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) { - root["custom_preset"] = obj->get_custom_preset(); + root[ESPHOME_F("custom_preset")] = obj->get_custom_preset(); } if (traits.get_supports_swing_modes()) { - root["swing_mode"] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode)); + root[ESPHOME_F("swing_mode")] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode)); } if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) { if (!std::isnan(obj->current_temperature)) { - root["current_temperature"] = value_accuracy_to_string(obj->current_temperature, current_accuracy); + root[ESPHOME_F("current_temperature")] = value_accuracy_to_string(obj->current_temperature, current_accuracy); } else { - root["current_temperature"] = "NA"; + root[ESPHOME_F("current_temperature")] = "NA"; } } if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { - root["target_temperature_low"] = value_accuracy_to_string(obj->target_temperature_low, target_accuracy); - root["target_temperature_high"] = value_accuracy_to_string(obj->target_temperature_high, target_accuracy); + root[ESPHOME_F("target_temperature_low")] = value_accuracy_to_string(obj->target_temperature_low, target_accuracy); + root[ESPHOME_F("target_temperature_high")] = + value_accuracy_to_string(obj->target_temperature_high, target_accuracy); if (!has_state) { - root["state"] = value_accuracy_to_string((obj->target_temperature_high + obj->target_temperature_low) / 2.0f, - target_accuracy); + root[ESPHOME_F("state")] = value_accuracy_to_string( + (obj->target_temperature_high + obj->target_temperature_low) / 2.0f, target_accuracy); } } else { - root["target_temperature"] = value_accuracy_to_string(obj->target_temperature, target_accuracy); + root[ESPHOME_F("target_temperature")] = value_accuracy_to_string(obj->target_temperature, target_accuracy); if (!has_state) - root["state"] = root["target_temperature"]; + root[ESPHOME_F("state")] = root[ESPHOME_F("target_temperature")]; } return builder.serialize(); @@ -1566,10 +1568,10 @@ std::string WebServer::valve_json(valve::Valve *obj, JsonDetail start_config) { set_json_icon_state_value(root, obj, "valve", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); char buf[PSTR_LOCAL_SIZE]; - root["current_operation"] = PSTR_LOCAL(valve::valve_operation_to_str(obj->current_operation)); + root[ESPHOME_F("current_operation")] = PSTR_LOCAL(valve::valve_operation_to_str(obj->current_operation)); if (obj->get_traits().get_supports_position()) - root["position"] = obj->position; + root[ESPHOME_F("position")] = obj->position; if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1701,14 +1703,14 @@ std::string WebServer::event_json(event::Event *obj, const std::string &event_ty set_json_id(root, obj, "event", start_config); if (!event_type.empty()) { - root["event_type"] = event_type; + root[ESPHOME_F("event_type")] = event_type; } if (start_config == DETAIL_ALL) { - JsonArray event_types = root["event_types"].to(); + JsonArray event_types = root[ESPHOME_F("event_types")].to(); for (const char *event_type : obj->get_event_types()) { event_types.add(event_type); } - root["device_class"] = obj->get_device_class_ref(); + root[ESPHOME_F("device_class")] = obj->get_device_class_ref(); this->add_sorting_info_(root, obj); } @@ -1774,10 +1776,10 @@ std::string WebServer::update_json(update::UpdateEntity *obj, JsonDetail start_c set_json_icon_state_value(root, obj, "update", PSTR_LOCAL(update_state_to_string(obj->state)), obj->update_info.latest_version, start_config); if (start_config == DETAIL_ALL) { - root["current_version"] = obj->update_info.current_version; - root["title"] = obj->update_info.title; - root["summary"] = obj->update_info.summary; - root["release_url"] = obj->update_info.release_url; + root[ESPHOME_F("current_version")] = obj->update_info.current_version; + root[ESPHOME_F("title")] = obj->update_info.title; + root[ESPHOME_F("summary")] = obj->update_info.summary; + root[ESPHOME_F("release_url")] = obj->update_info.release_url; this->add_sorting_info_(root, obj); } @@ -2063,9 +2065,9 @@ bool WebServer::isRequestHandlerTrivial() const { return false; } void WebServer::add_sorting_info_(JsonObject &root, EntityBase *entity) { #ifdef USE_WEBSERVER_SORTING if (this->sorting_entitys_.find(entity) != this->sorting_entitys_.end()) { - root["sorting_weight"] = this->sorting_entitys_[entity].weight; + root[ESPHOME_F("sorting_weight")] = this->sorting_entitys_[entity].weight; if (this->sorting_groups_.find(this->sorting_entitys_[entity].group_id) != this->sorting_groups_.end()) { - root["sorting_group"] = this->sorting_groups_[this->sorting_entitys_[entity].group_id].name; + root[ESPHOME_F("sorting_group")] = this->sorting_groups_[this->sorting_entitys_[entity].group_id].name; } } #endif diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index fbf0d00c06..54ec997671 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -6,20 +6,7 @@ #include #include "esphome/core/component.h" - -// Platform-agnostic macros for web server components -// On ESP32 (both Arduino and IDF): Use plain strings (no PROGMEM) -// On ESP8266: Use Arduino's F() macro for PROGMEM strings -#ifdef USE_ESP32 -#define ESPHOME_F(string_literal) (string_literal) -#define ESPHOME_PGM_P const char * -#define ESPHOME_strncpy_P strncpy -#else -// ESP8266 uses Arduino macros -#define ESPHOME_F(string_literal) F(string_literal) -#define ESPHOME_PGM_P PGM_P -#define ESPHOME_strncpy_P strncpy_P -#endif +#include "esphome/core/progmem.h" #if USE_ESP32 #include "esphome/core/hal.h" diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h new file mode 100644 index 0000000000..67131fd113 --- /dev/null +++ b/esphome/core/progmem.h @@ -0,0 +1,16 @@ +#pragma once + +// Platform-agnostic macros for PROGMEM string handling +// On ESP32 (both Arduino and IDF): Use plain strings (no PROGMEM) +// On ESP8266/Arduino: Use Arduino's F() macro for PROGMEM strings + +#ifdef USE_ESP32 +#define ESPHOME_F(string_literal) (string_literal) +#define ESPHOME_PGM_P const char * +#define ESPHOME_strncpy_P strncpy +#else +// ESP8266 and other Arduino platforms use Arduino macros +#define ESPHOME_F(string_literal) F(string_literal) +#define ESPHOME_PGM_P PGM_P +#define ESPHOME_strncpy_P strncpy_P +#endif From 2f75962b19ed472604b1b4f938f8e70f5a879ea0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 2 Dec 2025 13:40:46 -0500 Subject: [PATCH 13/14] [analog_threshold] Fix oscillation when using invert filter (#12251) Co-authored-by: Claude --- .../analog_threshold_binary_sensor.cpp | 11 +++++++---- .../analog_threshold/analog_threshold_binary_sensor.h | 1 + 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp b/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp index f83f2aff08..0b3bd0e472 100644 --- a/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp +++ b/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp @@ -12,10 +12,11 @@ void AnalogThresholdBinarySensor::setup() { // TRUE state is defined to be when sensor is >= threshold // so when undefined sensor value initialize to FALSE if (std::isnan(sensor_value)) { + this->raw_state_ = false; this->publish_initial_state(false); } else { - this->publish_initial_state(sensor_value >= - (this->lower_threshold_.value() + this->upper_threshold_.value()) / 2.0f); + this->raw_state_ = sensor_value >= (this->lower_threshold_.value() + this->upper_threshold_.value()) / 2.0f; + this->publish_initial_state(this->raw_state_); } } @@ -25,8 +26,10 @@ void AnalogThresholdBinarySensor::set_sensor(sensor::Sensor *analog_sensor) { this->sensor_->add_on_state_callback([this](float sensor_value) { // if there is an invalid sensor reading, ignore the change and keep the current state if (!std::isnan(sensor_value)) { - this->publish_state(sensor_value >= - (this->state ? this->lower_threshold_.value() : this->upper_threshold_.value())); + // Use raw_state_ for hysteresis logic, not this->state which is post-filter + this->raw_state_ = + sensor_value >= (this->raw_state_ ? this->lower_threshold_.value() : this->upper_threshold_.value()); + this->publish_state(this->raw_state_); } }); } diff --git a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h index 55d6b15c36..9ea95d8570 100644 --- a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h +++ b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h @@ -20,6 +20,7 @@ class AnalogThresholdBinarySensor : public Component, public binary_sensor::Bina sensor::Sensor *sensor_{nullptr}; TemplatableValue upper_threshold_{}; TemplatableValue lower_threshold_{}; + bool raw_state_{false}; // Pre-filter state for hysteresis logic }; } // namespace analog_threshold From 708496c10116dae0e6268983bcf368da7e6ad09e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 13:45:38 -0600 Subject: [PATCH 14/14] Bump actions/checkout from 6.0.0 to 6.0.1 (#12259) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/auto-label-pr.yml | 2 +- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-clang-tidy-hash.yml | 2 +- .github/workflows/ci-docker.yml | 2 +- .../workflows/ci-memory-impact-comment.yml | 2 +- .github/workflows/ci.yml | 30 +++++++++---------- .github/workflows/codeql.yml | 2 +- .github/workflows/release.yml | 8 ++--- .github/workflows/sync-device-classes.yml | 4 +-- 9 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index d09072d814..39164fc2ea 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -22,7 +22,7 @@ jobs: if: github.event.action != 'labeled' || github.event.sender.type != 'Bot' steps: - name: Checkout - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Generate a token id: generate-token diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 2bee5ed211..a0c6568345 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Python uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml index 1826ed27cf..94068c19d6 100644 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ b/.github/workflows/ci-clang-tidy-hash.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Python uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index c76d9cf2a5..bf7fa0c262 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -43,7 +43,7 @@ jobs: - "docker" # - "lint" steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Python uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml index 6ca58e252e..7e81e1184d 100644 --- a/.github/workflows/ci-memory-impact-comment.yml +++ b/.github/workflows/ci-memory-impact-comment.yml @@ -49,7 +49,7 @@ jobs: - name: Check out code from base repository if: steps.pr.outputs.skip != 'true' - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Always check out from the base repository (esphome/esphome), never from forks # Use the PR's target branch to ensure we run trusted code from the main repo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cfc02d5cf..9ef6b4341c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: cache-key: ${{ steps.cache-key.outputs.key }} steps: - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Generate cache-key id: cache-key run: echo key="${{ hashFiles('requirements.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT @@ -70,7 +70,7 @@ jobs: if: needs.determine-jobs.outputs.python-linters == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -91,7 +91,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -132,7 +132,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python id: restore-python uses: ./.github/actions/restore-python @@ -183,7 +183,7 @@ jobs: component-test-batches: ${{ steps.determine.outputs.component-test-batches }} steps: - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Fetch enough history to find the merge base fetch-depth: 2 @@ -237,7 +237,7 @@ jobs: if: needs.determine-jobs.outputs.integration-tests == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Python 3.13 id: python uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 @@ -273,7 +273,7 @@ jobs: if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]') steps: - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python uses: ./.github/actions/restore-python @@ -321,7 +321,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -400,7 +400,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -489,7 +489,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -577,7 +577,7 @@ jobs: version: 1.0 - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -662,7 +662,7 @@ jobs: if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') steps: - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -688,7 +688,7 @@ jobs: skip: ${{ steps.check-script.outputs.skip }} steps: - name: Check out target branch - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.base_ref }} @@ -840,7 +840,7 @@ jobs: flash_usage: ${{ steps.extract.outputs.flash_usage }} steps: - name: Check out PR branch - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -908,7 +908,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 33f587a748..d9b6bcdcca 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -54,7 +54,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ff810d869..d52595bbb3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: branch_build: ${{ steps.tag.outputs.branch_build }} deploy_env: ${{ steps.tag.outputs.deploy_env }} steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Get tag id: tag # yamllint disable rule:line-length @@ -60,7 +60,7 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Python uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: @@ -92,7 +92,7 @@ jobs: os: "ubuntu-24.04-arm" steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Python uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: @@ -168,7 +168,7 @@ jobs: - ghcr - dockerhub steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Download digests uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index baaa29df2c..ea81a1e013 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -13,10 +13,10 @@ jobs: if: github.repository == 'esphome/esphome' steps: - name: Checkout - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Checkout Home Assistant - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: repository: home-assistant/core path: lib/home-assistant