From 9de91539e6d1990593400db003ad05a7c55795d3 Mon Sep 17 00:00:00 2001 From: schrob <83939986+schdro@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:24:57 +0100 Subject: [PATCH 01/16] [epaper_spi] Add Waveshare 1.54-G (#13758) --- esphome/components/epaper_spi/colorconv.h | 67 ++++++ .../epaper_spi/epaper_spi_jd79660.cpp | 227 ++++++++++++++++++ .../epaper_spi/epaper_spi_jd79660.h | 145 +++++++++++ .../components/epaper_spi/models/jd79660.py | 86 +++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 16 ++ 5 files changed, 541 insertions(+) create mode 100644 esphome/components/epaper_spi/colorconv.h create mode 100644 esphome/components/epaper_spi/epaper_spi_jd79660.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_jd79660.h create mode 100644 esphome/components/epaper_spi/models/jd79660.py diff --git a/esphome/components/epaper_spi/colorconv.h b/esphome/components/epaper_spi/colorconv.h new file mode 100644 index 0000000000..a2ea28f4b6 --- /dev/null +++ b/esphome/components/epaper_spi/colorconv.h @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include "esphome/core/color.h" + +/* Utility for converting internal \a Color RGB representation to supported IC hardware color keys + * + * Focus in driver layer is on efficiency. + * For optimum output quality on RGB inputs consider offline color keying/dithering. + * Also see e.g. Image component. + */ + +namespace esphome::epaper_spi { + +/** Delta for when to regard as gray */ +static constexpr uint8_t COLORCONV_GRAY_THRESHOLD = 50; + +/** Map RGB color to discrete BWYR hex 4 color key + * + * @tparam NATIVE_COLOR Type of native hardware color values + * @param color RGB color to convert from + * @param hw_black Native value for black + * @param hw_white Native value for white + * @param hw_yellow Native value for yellow + * @param hw_red Native value for red + * @return Converted native hardware color value + * @internal Constexpr. Does not depend on side effects ("pure"). + */ +template +constexpr NATIVE_COLOR color_to_bwyr(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, NATIVE_COLOR hw_yellow, + NATIVE_COLOR hw_red) { + // --- Step 1: Check for Grayscale (Black or White) --- + // We define "grayscale" as a color where the min and max components + // are close to each other. + + const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b}); + + if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) { + // It's a shade of gray. Map to BLACK or WHITE. + // We split the luminance at the halfway point (382 = (255*3)/2) + if ((static_cast(color.r) + color.g + color.b) > 382) { + return hw_white; + } + return hw_black; + } + + // --- Step 2: Check for Primary/Secondary Colors --- + // If it's not gray, it's a color. We check which components are + // "on" (over 128) vs "off". This divides the RGB cube into 8 corners. + const bool r_on = (color.r > 128); + const bool g_on = (color.g > 128); + const bool b_on = (color.b > 128); + + if (r_on) { + if (!b_on) { + return g_on ? hw_yellow : hw_red; + } + + // At least red+blue high (but not gray) -> White + return hw_white; + } else { + return (b_on && g_on) ? hw_white : hw_black; + } +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_jd79660.cpp b/esphome/components/epaper_spi/epaper_spi_jd79660.cpp new file mode 100644 index 0000000000..1cd1087c6b --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_jd79660.cpp @@ -0,0 +1,227 @@ +#include "epaper_spi_jd79660.h" +#include "colorconv.h" + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { +static constexpr const char *const TAG = "epaper_spi.jd79660"; + +/** Pixel color as 2bpp. Must match IC LUT values. */ +enum JD79660Color : uint8_t { + BLACK = 0b00, + WHITE = 0b01, + YELLOW = 0b10, + RED = 0b11, +}; + +/** Map RGB color to JD79660 BWYR hex color keys */ +static JD79660Color HOT color_to_hex(Color color) { + return color_to_bwyr(color, JD79660Color::BLACK, JD79660Color::WHITE, JD79660Color::YELLOW, JD79660Color::RED); +} + +void EPaperJD79660::fill(Color color) { + // If clipping is active, fall back to base implementation + if (this->get_clipping().is_set()) { + EPaperBase::fill(color); + return; + } + + const auto pixel_color = color_to_hex(color); + + // We store 4 pixels per byte + this->buffer_.fill(pixel_color | (pixel_color << 2) | (pixel_color << 4) | (pixel_color << 6)); +} + +void HOT EPaperJD79660::draw_pixel_at(int x, int y, Color color) { + if (!this->rotate_coordinates_(x, y)) + return; + const auto pixel_bits = color_to_hex(color); + const uint32_t pixel_position = x + y * this->get_width_internal(); + // We store 4 pixels per byte at LSB offsets 6, 4, 2, 0 + const uint32_t byte_position = pixel_position / 4; + const uint32_t bit_offset = 6 - ((pixel_position % 4) * 2); + const auto original = this->buffer_[byte_position]; + + this->buffer_[byte_position] = (original & (~(0b11 << bit_offset))) | // mask old 2bpp + (pixel_bits << bit_offset); // add new 2bpp +} + +bool EPaperJD79660::reset() { + // On entry state RESET set step, next state will be RESET_END + if (this->state_ == EPaperState::RESET) { + this->step_ = FSMState::RESET_STEP0_H; + } + + switch (this->step_) { + case FSMState::RESET_STEP0_H: + // Step #0: Reset H for some settle time. + + ESP_LOGVV(TAG, "reset #0"); + this->reset_pin_->digital_write(true); + + this->reset_duration_ = SLEEP_MS_RESET0; + this->step_ = FSMState::RESET_STEP1_L; + return false; // another loop: step #1 below + + case FSMState::RESET_STEP1_L: + // Step #1: Reset L pulse for slightly >1.5ms. + // This is actual reset trigger. + + ESP_LOGVV(TAG, "reset #1"); + + // As commented on SLEEP_MS_RESET1: Reset pulse must happen within time window. + // So do not use FSM loop, and avoid other calls/logs during pulse below. + this->reset_pin_->digital_write(false); + delay(SLEEP_MS_RESET1); + this->reset_pin_->digital_write(true); + + this->reset_duration_ = SLEEP_MS_RESET2; + this->step_ = FSMState::RESET_STEP2_IDLECHECK; + return false; // another loop: step #2 below + + case FSMState::RESET_STEP2_IDLECHECK: + // Step #2: Basically finished. Check sanity, and move FSM to INITIALISE state + ESP_LOGVV(TAG, "reset #2"); + + if (!this->is_idle_()) { + // Expectation: Idle after reset + settle time. + // Improperly connected/unexpected hardware? + // Error path reproducable e.g. with disconnected VDD/... pins + // (optimally while busy_pin configured with local pulldown). + // -> Mark failed to avoid followup problems. + this->mark_failed(LOG_STR("Busy after reset")); + } + break; // End state loop below + + default: + // Unexpected step = bug? + this->mark_failed(); + } + + this->step_ = FSMState::INIT_STEP0_REGULARINIT; // reset for initialize state + return true; +} + +bool EPaperJD79660::initialise(bool partial) { + switch (this->step_) { + case FSMState::INIT_STEP0_REGULARINIT: + // Step #0: Regular init sequence + ESP_LOGVV(TAG, "init #0"); + if (!EPaperBase::initialise(partial)) { // Call parent impl + return false; // If parent should request another loop, do so + } + + // Fast init requested + supported? + if (partial && (this->fast_update_length_ > 0)) { + this->step_ = FSMState::INIT_STEP1_FASTINIT; + this->wait_for_idle_(true); // Must wait for idle before fastinit sequence in next loop + return false; // another loop: step #1 below + } + + break; // End state loop below + + case FSMState::INIT_STEP1_FASTINIT: + // Step #1: Fast init sequence + ESP_LOGVV(TAG, "init #1"); + this->write_fastinit_(); + break; // End state loop below + + default: + // Unexpected step = bug? + this->mark_failed(); + } + + this->step_ = FSMState::NONE; + return true; // Finished: State transition waits for idle +} + +bool EPaperJD79660::transfer_buffer_chunks_() { + size_t buf_idx = 0; + uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; + const uint32_t start_time = App.get_loop_component_start_time(); + const auto buffer_length = this->buffer_length_; + while (this->current_data_index_ != buffer_length) { + bytes_to_send[buf_idx++] = this->buffer_[this->current_data_index_++]; + + if (buf_idx == sizeof bytes_to_send) { + this->start_data_(); + this->write_array(bytes_to_send, buf_idx); + this->disable(); + ESP_LOGVV(TAG, "Wrote %zu bytes at %ums", buf_idx, (unsigned) millis()); + buf_idx = 0; + + if (millis() - start_time > MAX_TRANSFER_TIME) { + // Let the main loop run and come back next loop + return false; + } + } + } + + // Finished the entire dataset + if (buf_idx != 0) { + this->start_data_(); + this->write_array(bytes_to_send, buf_idx); + this->disable(); + ESP_LOGVV(TAG, "Wrote %zu bytes at %ums", buf_idx, (unsigned) millis()); + } + // Cleanup for next transfer + this->current_data_index_ = 0; + + // Finished with all buffer chunks + return true; +} + +void EPaperJD79660::write_fastinit_() { + // Undocumented register sequence in vendor register range. + // Related to Fast Init/Update. + // Should likely happen after regular init seq and power on, but before refresh. + // Might only work for some models with certain factory MTP. + // Please do not change without knowledge to avoid breakage. + + this->send_init_sequence_(this->fast_update_, this->fast_update_length_); +} + +bool EPaperJD79660::transfer_data() { + // For now always send full frame buffer in chunks. + // JD79660 might support partial window transfers. But sample code missing. + // And likely minimal impact, solely on SPI transfer time into RAM. + + if (this->current_data_index_ == 0) { + this->command(CMD_TRANSFER); + } + + return this->transfer_buffer_chunks_(); +} + +void EPaperJD79660::refresh_screen([[maybe_unused]] bool partial) { + ESP_LOGV(TAG, "Refresh"); + this->cmd_data(CMD_REFRESH, {(uint8_t) 0x00}); +} + +void EPaperJD79660::power_off() { + ESP_LOGV(TAG, "Power off"); + this->cmd_data(CMD_POWEROFF, {(uint8_t) 0x00}); +} + +void EPaperJD79660::deep_sleep() { + ESP_LOGV(TAG, "Deep sleep"); + // "Deepsleep between update": Ensure EPD sleep to avoid early hardware wearout! + this->cmd_data(CMD_DEEPSLEEP, {(uint8_t) 0xA5}); + + // Notes: + // - VDD: Some boards (Waveshare) with "clever reset logic" would allow switching off + // EPD VDD by pulling reset pin low for longer time. + // However, a) not all boards have this, b) reliable sequence timing is difficult, + // c) saving is not worth it after deepsleep command above. + // If needed: Better option is to drive VDD via MOSFET with separate enable pin. + // + // - Possible safe shutdown: + // EPaperBase::on_safe_shutdown() may also trigger deep_sleep() again. + // Regularly, in IDLE state, this does not make sense for this "deepsleep between update" model, + // but SPI sequence should simply be ignored by sleeping receiver. + // But if triggering during lengthy update, this quick SPI sleep sequence may have benefit. + // Optimally, EPDs should even be set all white for longer storage. + // But full sequence (>15s) not possible w/o app logic. +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_jd79660.h b/esphome/components/epaper_spi/epaper_spi_jd79660.h new file mode 100644 index 0000000000..4e488fe93e --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_jd79660.h @@ -0,0 +1,145 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +/** + * JD7966x IC driver implementation + * + * Currently tested with: + * - JD79660 (max res: 200x200) + * + * May also work for other JD7966x chipset family members with minimal adaptations. + * + * Capabilities: + * - HW frame buffer layout: + * 4 colors (gray0..3, commonly BWYR). Bytes consist of 4px/2bpp. + * Width must be rounded to multiple of 4. + * - Fast init/update (shorter wave forms): Yes. Controlled by CONF_FULL_UPDATE_EVERY. + * Needs undocumented fastinit sequence, based on likely vendor specific MTP content. + * - Partial transfer (transfer only changed window): No. Maybe possible by HW. + * - Partial refresh (refresh only changed window): No. Likely HW limit. + * + * @internal \c final saves few bytes by devirtualization. Remove \c final when subclassing. + */ +class EPaperJD79660 final : public EPaperBase { + public: + EPaperJD79660(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length, const uint8_t *fast_update, uint16_t fast_update_length) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR), + fast_update_(fast_update), + fast_update_length_(fast_update_length) { + this->row_width_ = (width + 3) / 4; // Fix base class calc (2bpp instead of 1bpp) + this->buffer_length_ = this->row_width_ * height; + } + + void fill(Color color) override; + + protected: + /** Draw colored pixel into frame buffer */ + void draw_pixel_at(int x, int y, Color color) override; + + /** Reset (multistep sequence) + * @pre this->reset_pin_ != nullptr // cv.Required check + * @post Should be idle on successful reset. Can mark failures. + */ + bool reset() override; + + /** Initialise (multistep sequence) */ + bool initialise(bool partial) override; + + /** Buffer transfer */ + bool transfer_data() override; + + /** Power on: Already part of init sequence (likely needed there before transferring buffers). + * So nothing to do in FSM state. + */ + void power_on() override {} + + /** Refresh screen + * @param partial Ignored: Needed earlier in \a ::initialize + * @pre Must be idle. + * @post Should return to idle later after processing. + */ + void refresh_screen([[maybe_unused]] bool partial) override; + + /** Power off + * @pre Must be idle. + * @post Should return to idle later after processing. + * (latter will take long period like ~15-20s on actual refresh!) + */ + void power_off() override; + + /** Deepsleep: Must be used to avoid hardware wearout! + * @pre Must be idle. + * @post Will go busy, and not return idle till ::reset! + */ + void deep_sleep() override; + + /** Internal: Send fast init sequence via undocumented vendor registers + * @pre Must be directly after regular ::initialise sequence, before ::transfer_data + * @pre Must be idle. + * @post Should return to idle later after processing. + */ + void write_fastinit_(); + + /** Internal: Send raw buffer in chunks + * \retval true Finished + * \retval false Loop time elapsed. Need to call again next loop. + */ + bool transfer_buffer_chunks_(); + + /** @name IC commands @{ */ + static constexpr uint8_t CMD_POWEROFF = 0x02; + static constexpr uint8_t CMD_DEEPSLEEP = 0x07; + static constexpr uint8_t CMD_TRANSFER = 0x10; + static constexpr uint8_t CMD_REFRESH = 0x12; + /** @} */ + + /** State machine constants for \a step_ */ + enum class FSMState : uint8_t { + NONE = 0, //!< Initial/default value: Unused + + /* Reset state steps */ + RESET_STEP0_H, + RESET_STEP1_L, + RESET_STEP2_IDLECHECK, + + /* Init state steps */ + INIT_STEP0_REGULARINIT, + INIT_STEP1_FASTINIT, + }; + + /** Wait time (millisec) for first reset phase: High + * + * Wait via FSM loop. + */ + static constexpr uint16_t SLEEP_MS_RESET0 = 200; + + /** Wait time (millisec) for second reset phase: Low + * + * Holding Reset Low too long may trigger "clever reset" logic + * of e.g. Waveshare Rev2 boards: VDD is shut down via MOSFET, and IC + * will not report idle anymore! + * FSM loop may spuriously increase delay, e.g. >16ms. + * Therefore, sync wait below, as allowed (code rule "delays > 10ms not permitted"), + * yet only slightly exceeding known IC min req of >1.5ms. + */ + static constexpr uint16_t SLEEP_MS_RESET1 = 2; + + /** Wait time (millisec) for third reset phase: High + * + * Wait via FSM loop. + */ + static constexpr uint16_t SLEEP_MS_RESET2 = 200; + + // properties initialised in the constructor + const uint8_t *const fast_update_{}; + const uint16_t fast_update_length_{}; + + /** Counter for tracking substeps within FSM state */ + FSMState step_{FSMState::NONE}; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/jd79660.py b/esphome/components/epaper_spi/models/jd79660.py new file mode 100644 index 0000000000..2d8830ebd2 --- /dev/null +++ b/esphome/components/epaper_spi/models/jd79660.py @@ -0,0 +1,86 @@ +import esphome.codegen as cg +from esphome.components.mipi import flatten_sequence +import esphome.config_validation as cv +from esphome.const import CONF_BUSY_PIN, CONF_RESET_PIN +from esphome.core import ID + +from ..display import CONF_INIT_SEQUENCE_ID +from . import EpaperModel + + +class JD79660(EpaperModel): + def __init__(self, name, class_name="EPaperJD79660", fast_update=None, **kwargs): + super().__init__(name, class_name, **kwargs) + self.fast_update = fast_update + + def option(self, name, fallback=cv.UNDEFINED) -> cv.Optional | cv.Required: + # Validate required pins, as C++ code will assume existence + if name in (CONF_RESET_PIN, CONF_BUSY_PIN): + return cv.Required(name) + + # Delegate to parent + return super().option(name, fallback) + + def get_constructor_args(self, config) -> tuple: + # Resembles init_sequence handling for fast_update config + if self.fast_update is None: + fast_update = cg.nullptr, 0 + else: + flat_fast_update = flatten_sequence(self.fast_update) + fast_update = ( + cg.static_const_array( + ID( + config[CONF_INIT_SEQUENCE_ID].id + "_fast_update", type=cg.uint8 + ), + flat_fast_update, + ), + len(flat_fast_update), + ) + return (*fast_update,) + + +jd79660 = JD79660( + "jd79660", + # Specified refresh times are ~20s (full) or ~15s (fast) due to BWRY. + # So disallow low update intervals (with safety margin), to avoid e.g. FSM update loops. + # Even less frequent intervals (min/h) highly recommended to optimize lifetime! + minimum_update_interval="30s", + # SPI rate: From spec comparisons, IC should allow SCL write cycles up to 10MHz rate. + # Existing code samples also prefer 10MHz. So justifies as default. + # Decrease value further in user config if needed (e.g. poor cabling). + data_rate="10MHz", + # No need to set optional reset_duration: + # Code requires multistep reset sequence with precise timings + # according to data sheet or samples. +) + +# Waveshare 1.54-G +# +# Device may have specific factory provisioned MTP content to facilitate vendor register features like fast init. +# Vendor specific init derived from vendor sample code +# +# Compatible MIT license, see esphome/LICENSE file. +# +# fmt: off +jd79660.extend( + "Waveshare-1.54in-G", + width=200, + height=200, + + initsequence=( + (0x4D, 0x78,), + (0x00, 0x0F, 0x29,), + (0x06, 0x0d, 0x12, 0x30, 0x20, 0x19, 0x2a, 0x22,), + (0x50, 0x37,), + (0x61, 200 // 256, 200 % 256, 200 // 256, 200 % 256,), # RES: 200x200 fixed + (0xE9, 0x01,), + (0x30, 0x08,), + # Power On (0x04): Must be early part of init seq = Disabled later! + (0x04,), + ), + fast_update=( + (0xE0, 0x02,), + (0xE6, 0x5D,), + (0xA5, 0x00,), + ), +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 621a819c3c..333ab567cd 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -25,6 +25,22 @@ display: lambda: |- it.circle(64, 64, 50, Color::BLACK); + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-1.54in-G + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + - platform: epaper_spi spi_id: spi_bus model: waveshare-2.13in-v3 From a43e3e59483a239467d1db8201522db25291371d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 7 Feb 2026 22:19:20 +0100 Subject: [PATCH 02/16] [dashboard] Close WebSocket after process exit to prevent zombie connections (#13834) --- esphome/dashboard/web_server.py | 1 + tests/dashboard/test_web_server.py | 24 +++++++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index f94d8eea22..da50279864 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -317,6 +317,7 @@ class EsphomeCommandWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandl # Check if the proc was not forcibly closed _LOGGER.info("Process exited with return code %s", returncode) self.write_message({"event": "exit", "code": returncode}) + self.close() def on_close(self) -> None: # Check if proc exists (if 'start' has been run) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 10ca6061e6..7642876ee5 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -29,7 +29,7 @@ from esphome.dashboard.entries import ( bool_to_entry_state, ) from esphome.dashboard.models import build_importable_device_dict -from esphome.dashboard.web_server import DashboardSubscriber +from esphome.dashboard.web_server import DashboardSubscriber, EsphomeCommandWebSocket from esphome.zeroconf import DiscoveredImport from .common import get_fixture_path @@ -1654,3 +1654,25 @@ async def test_websocket_check_origin_multiple_trusted_domains( assert data["event"] == "initial_state" finally: ws.close() + + +def test_proc_on_exit_calls_close() -> None: + """Test _proc_on_exit sends exit event and closes the WebSocket.""" + handler = Mock(spec=EsphomeCommandWebSocket) + handler._is_closed = False + + EsphomeCommandWebSocket._proc_on_exit(handler, 0) + + handler.write_message.assert_called_once_with({"event": "exit", "code": 0}) + handler.close.assert_called_once() + + +def test_proc_on_exit_skips_when_already_closed() -> None: + """Test _proc_on_exit does nothing when WebSocket is already closed.""" + handler = Mock(spec=EsphomeCommandWebSocket) + handler._is_closed = True + + EsphomeCommandWebSocket._proc_on_exit(handler, 0) + + handler.write_message.assert_not_called() + handler.close.assert_not_called() From 7b40e8afcb5c60ec15436da1e5b4588bd028df81 Mon Sep 17 00:00:00 2001 From: schrob <83939986+schdro@users.noreply.github.com> Date: Sun, 8 Feb 2026 02:21:37 +0100 Subject: [PATCH 03/16] [epaper_spi] Declare leaf classes final (#13776) --- esphome/components/epaper_spi/epaper_spi_spectra_e6.h | 2 +- esphome/components/epaper_spi/epaper_waveshare.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/epaper_spi/epaper_spi_spectra_e6.h b/esphome/components/epaper_spi/epaper_spi_spectra_e6.h index b8dbf0b0c5..9c251068af 100644 --- a/esphome/components/epaper_spi/epaper_spi_spectra_e6.h +++ b/esphome/components/epaper_spi/epaper_spi_spectra_e6.h @@ -4,7 +4,7 @@ namespace esphome::epaper_spi { -class EPaperSpectraE6 : public EPaperBase { +class EPaperSpectraE6 final : public EPaperBase { public: EPaperSpectraE6(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) diff --git a/esphome/components/epaper_spi/epaper_waveshare.h b/esphome/components/epaper_spi/epaper_waveshare.h index 15fb575ca0..d3ad313d92 100644 --- a/esphome/components/epaper_spi/epaper_waveshare.h +++ b/esphome/components/epaper_spi/epaper_waveshare.h @@ -6,7 +6,7 @@ namespace esphome::epaper_spi { /** * An epaper display that needs LUTs to be sent to it. */ -class EpaperWaveshare : public EPaperMono { +class EpaperWaveshare final : public EPaperMono { public: EpaperWaveshare(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length, const uint8_t *lut, size_t lut_length, const uint8_t *partial_lut, From 41fedaedb3aea2a93621f7715e5d6588414eb696 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Feb 2026 08:26:47 -0600 Subject: [PATCH 04/16] [udp] Eliminate per-loop heap allocation using std::span (#13838) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- esphome/codegen.py | 1 + .../packet_transport/packet_transport.cpp | 4 +- .../packet_transport/packet_transport.h | 5 ++- esphome/components/udp/__init__.py | 16 ++++++-- .../udp/packet_transport/udp_transport.cpp | 2 +- esphome/components/udp/udp_component.cpp | 8 ++-- esphome/components/udp/udp_component.h | 6 ++- esphome/cpp_types.py | 1 + tests/integration/test_udp.py | 39 ++++++++++++++++--- 9 files changed, 62 insertions(+), 20 deletions(-) diff --git a/esphome/codegen.py b/esphome/codegen.py index 4a2a5975c6..c5283f4967 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -87,6 +87,7 @@ from esphome.cpp_types import ( # noqa: F401 size_t, std_ns, std_shared_ptr, + std_span, std_string, std_string_ref, std_vector, diff --git a/esphome/components/packet_transport/packet_transport.cpp b/esphome/components/packet_transport/packet_transport.cpp index cefe9a604e..365a5f2ec7 100644 --- a/esphome/components/packet_transport/packet_transport.cpp +++ b/esphome/components/packet_transport/packet_transport.cpp @@ -396,9 +396,9 @@ static bool process_rolling_code(Provider &provider, PacketDecoder &decoder) { /** * Process a received packet */ -void PacketTransport::process_(const std::vector &data) { +void PacketTransport::process_(std::span data) { auto ping_key_seen = !this->ping_pong_enable_; - PacketDecoder decoder((data.data()), data.size()); + PacketDecoder decoder(data.data(), data.size()); char namebuf[256]{}; uint8_t byte; FuData rdata{}; diff --git a/esphome/components/packet_transport/packet_transport.h b/esphome/components/packet_transport/packet_transport.h index 86ec564fce..57f40874b5 100644 --- a/esphome/components/packet_transport/packet_transport.h +++ b/esphome/components/packet_transport/packet_transport.h @@ -9,8 +9,9 @@ #include "esphome/components/binary_sensor/binary_sensor.h" #endif -#include #include +#include +#include /** * Providing packet encoding functions for exchanging data with a remote host. @@ -113,7 +114,7 @@ class PacketTransport : public PollingComponent { virtual bool should_send() { return true; } // to be called by child classes when a data packet is received. - void process_(const std::vector &data); + void process_(std::span data); void send_data_(bool all); void flush_(); void add_data_(uint8_t key, const char *id, float data); diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index 8252e35023..bfaa5f2516 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -13,7 +13,7 @@ from esphome.components.packet_transport import ( import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_ID, CONF_PORT, CONF_TRIGGER_ID from esphome.core import ID -from esphome.cpp_generator import literal +from esphome.cpp_generator import MockObj CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["network"] @@ -23,8 +23,12 @@ MULTI_CONF = True udp_ns = cg.esphome_ns.namespace("udp") UDPComponent = udp_ns.class_("UDPComponent", cg.Component) UDPWriteAction = udp_ns.class_("UDPWriteAction", automation.Action) -trigger_args = cg.std_vector.template(cg.uint8) trigger_argname = "data" +# Listener callback type (non-owning span from UDP component) +listener_args = cg.std_span.template(cg.uint8.operator("const")) +listener_argtype = [(listener_args, trigger_argname)] +# Automation/trigger type (owned vector, safe for deferred actions like delay) +trigger_args = cg.std_vector.template(cg.uint8) trigger_argtype = [(trigger_args, trigger_argname)] CONF_ADDRESSES = "addresses" @@ -118,7 +122,13 @@ async def to_code(config): trigger_id, trigger_argtype, on_receive ) trigger_lambda = await cg.process_lambda( - trigger.trigger(literal(trigger_argname)), trigger_argtype + trigger.trigger( + cg.std_vector.template(cg.uint8)( + MockObj(trigger_argname).begin(), + MockObj(trigger_argname).end(), + ) + ), + listener_argtype, ) cg.add(var.add_listener(trigger_lambda)) cg.add(var.set_should_listen()) diff --git a/esphome/components/udp/packet_transport/udp_transport.cpp b/esphome/components/udp/packet_transport/udp_transport.cpp index f3e33573a5..b5e73af777 100644 --- a/esphome/components/udp/packet_transport/udp_transport.cpp +++ b/esphome/components/udp/packet_transport/udp_transport.cpp @@ -12,7 +12,7 @@ bool UDPTransport::should_send() { return network::is_connected(); } void UDPTransport::setup() { PacketTransport::setup(); if (!this->providers_.empty() || this->is_encrypted_()) { - this->parent_->add_listener([this](std::vector &buf) { this->process_(buf); }); + this->parent_->add_listener([this](std::span data) { this->process_(data); }); } } diff --git a/esphome/components/udp/udp_component.cpp b/esphome/components/udp/udp_component.cpp index 947a59dfa9..c144212ecf 100644 --- a/esphome/components/udp/udp_component.cpp +++ b/esphome/components/udp/udp_component.cpp @@ -103,8 +103,8 @@ void UDPComponent::setup() { } void UDPComponent::loop() { - auto buf = std::vector(MAX_PACKET_SIZE); if (this->should_listen_) { + std::array buf; for (;;) { #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) auto len = this->listen_socket_->read(buf.data(), buf.size()); @@ -116,9 +116,9 @@ void UDPComponent::loop() { #endif if (len <= 0) break; - buf.resize(len); - ESP_LOGV(TAG, "Received packet of length %zu", len); - this->packet_listeners_.call(buf); + size_t packet_len = static_cast(len); + ESP_LOGV(TAG, "Received packet of length %zu", packet_len); + this->packet_listeners_.call(std::span(buf.data(), packet_len)); } } } diff --git a/esphome/components/udp/udp_component.h b/esphome/components/udp/udp_component.h index 9967e4dbbb..7fd6308065 100644 --- a/esphome/components/udp/udp_component.h +++ b/esphome/components/udp/udp_component.h @@ -10,7 +10,9 @@ #ifdef USE_SOCKET_IMPL_LWIP_TCP #include #endif +#include #include +#include #include namespace esphome::udp { @@ -26,7 +28,7 @@ class UDPComponent : public Component { void set_broadcast_port(uint16_t port) { this->broadcast_port_ = port; } void set_should_broadcast() { this->should_broadcast_ = true; } void set_should_listen() { this->should_listen_ = true; } - void add_listener(std::function &)> &&listener) { + void add_listener(std::function)> &&listener) { this->packet_listeners_.add(std::move(listener)); } void setup() override; @@ -41,7 +43,7 @@ class UDPComponent : public Component { uint16_t broadcast_port_{}; bool should_broadcast_{}; bool should_listen_{}; - CallbackManager &)> packet_listeners_{}; + CallbackManager)> packet_listeners_{}; #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) std::unique_ptr broadcast_socket_ = nullptr; diff --git a/esphome/cpp_types.py b/esphome/cpp_types.py index 7001c38857..6d255bc0be 100644 --- a/esphome/cpp_types.py +++ b/esphome/cpp_types.py @@ -12,6 +12,7 @@ std_shared_ptr = std_ns.class_("shared_ptr") std_string = std_ns.class_("string") std_string_ref = std_ns.namespace("string &") std_vector = std_ns.class_("vector") +std_span = std_ns.class_("span") uint8 = global_ns.namespace("uint8_t") uint16 = global_ns.namespace("uint16_t") uint32 = global_ns.namespace("uint32_t") diff --git a/tests/integration/test_udp.py b/tests/integration/test_udp.py index 74c7ef60e3..2187d13814 100644 --- a/tests/integration/test_udp.py +++ b/tests/integration/test_udp.py @@ -93,23 +93,34 @@ async def udp_listener(port: int = 0) -> AsyncGenerator[tuple[int, UDPReceiver]] sock.close() +def _get_free_udp_port() -> int: + """Get a free UDP port by binding to port 0 and releasing.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + @pytest.mark.asyncio async def test_udp_send_receive( yaml_config: str, run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - """Test UDP component can send messages with multiple addresses configured.""" - # Track log lines to verify dump_config output + """Test UDP component can send and receive messages.""" log_lines: list[str] = [] + receive_event = asyncio.Event() def on_log_line(line: str) -> None: log_lines.append(line) + if "Received UDP:" in line: + receive_event.set() - async with udp_listener() as (udp_port, receiver): - # Replace placeholders in the config - config = yaml_config.replace("UDP_LISTEN_PORT_PLACEHOLDER", str(udp_port + 1)) - config = config.replace("UDP_BROADCAST_PORT_PLACEHOLDER", str(udp_port)) + async with udp_listener() as (broadcast_port, receiver): + listen_port = _get_free_udp_port() + config = yaml_config.replace("UDP_LISTEN_PORT_PLACEHOLDER", str(listen_port)) + config = config.replace("UDP_BROADCAST_PORT_PLACEHOLDER", str(broadcast_port)) async with ( run_compiled(config, line_callback=on_log_line), @@ -169,3 +180,19 @@ async def test_udp_send_receive( assert "Address: 127.0.0.2" in log_text, ( f"Address 127.0.0.2 not found in dump_config. Log: {log_text[-2000:]}" ) + + # Test receiving a UDP packet (exercises on_receive with std::span) + test_payload = b"TEST_RECEIVE_UDP" + send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + send_sock.sendto(test_payload, ("127.0.0.1", listen_port)) + finally: + send_sock.close() + + try: + await asyncio.wait_for(receive_event.wait(), timeout=5.0) + except TimeoutError: + pytest.fail( + f"on_receive did not fire. Expected 'Received UDP:' in logs. " + f"Last log lines: {log_lines[-20:]}" + ) From 28b9487b25043dcbecb12068d617bc1909acb22f Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sun, 8 Feb 2026 18:52:05 +0100 Subject: [PATCH 05/16] [nrf52,logger] fix printk (#13874) --- esphome/components/logger/logger_zephyr.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index ef1702c5c1..1fc0acd573 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -68,7 +68,7 @@ void HOT Logger::write_msg_(const char *msg, uint16_t len) { #ifdef CONFIG_PRINTK // Requires the debug component and an active SWD connection. // It is used for pyocd rtt -t nrf52840 - k_str_out(const_cast(msg), len); + printk("%.*s", static_cast(len), msg); #endif if (this->uart_dev_ == nullptr) { return; From 756f1c6b7e8162752af7ca8191e2eff71a122e3a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:53:43 +1100 Subject: [PATCH 06/16] [lvgl] Fix crash with unconfigured `top_layer` (#13846) --- esphome/components/lvgl/schemas.py | 1 + tests/components/lvgl/test.host.yaml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 45d933c00e..2aeeedbd10 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -436,6 +436,7 @@ def container_schema(widget_type: WidgetType, extras=None): schema = schema.extend(widget_type.schema) def validator(value): + value = value or {} return append_layout_schema(schema, value)(value) return validator diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index 00a8cd8c01..f84156c9d8 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -20,6 +20,8 @@ lvgl: - id: lvgl_0 default_font: space16 displays: sdl0 + top_layer: + - id: lvgl_1 displays: sdl1 on_idle: From 140ec0639ca3f0e8cac0c839d5e93f67cbee2621 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 03:24:45 -0600 Subject: [PATCH 07/16] [api] Elide empty message construction in protobuf dispatch (#13871) --- esphome/components/api/api_connection.cpp | 17 ++-- esphome/components/api/api_connection.h | 24 +++-- esphome/components/api/api_pb2_service.cpp | 106 ++++++++------------- esphome/components/api/api_pb2_service.h | 62 ++++++------ script/api_protobuf/api_protobuf.py | 53 ++++++----- 5 files changed, 117 insertions(+), 145 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 2aa5956f24..efc3d210b4 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -283,7 +283,7 @@ void APIConnection::loop() { #endif } -bool APIConnection::send_disconnect_response(const DisconnectRequest &msg) { +bool APIConnection::send_disconnect_response() { // remote initiated disconnect_client // don't close yet, we still need to send the disconnect response // close will happen on next loop @@ -292,7 +292,7 @@ bool APIConnection::send_disconnect_response(const DisconnectRequest &msg) { DisconnectResponse resp; return this->send_message(resp, DisconnectResponse::MESSAGE_TYPE); } -void APIConnection::on_disconnect_response(const DisconnectResponse &value) { +void APIConnection::on_disconnect_response() { this->helper_->close(); this->flags_.remove = true; } @@ -1095,7 +1095,7 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) { void APIConnection::subscribe_bluetooth_le_advertisements(const SubscribeBluetoothLEAdvertisementsRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->subscribe_api_connection(this, msg.flags); } -void APIConnection::unsubscribe_bluetooth_le_advertisements(const UnsubscribeBluetoothLEAdvertisementsRequest &msg) { +void APIConnection::unsubscribe_bluetooth_le_advertisements() { bluetooth_proxy::global_bluetooth_proxy->unsubscribe_api_connection(this); } void APIConnection::bluetooth_device_request(const BluetoothDeviceRequest &msg) { @@ -1121,8 +1121,7 @@ void APIConnection::bluetooth_gatt_notify(const BluetoothGATTNotifyRequest &msg) bluetooth_proxy::global_bluetooth_proxy->bluetooth_gatt_notify(msg); } -bool APIConnection::send_subscribe_bluetooth_connections_free_response( - const SubscribeBluetoothConnectionsFreeRequest &msg) { +bool APIConnection::send_subscribe_bluetooth_connections_free_response() { bluetooth_proxy::global_bluetooth_proxy->send_connections_free(this); return true; } @@ -1491,12 +1490,12 @@ bool APIConnection::send_hello_response(const HelloRequest &msg) { return this->send_message(resp, HelloResponse::MESSAGE_TYPE); } -bool APIConnection::send_ping_response(const PingRequest &msg) { +bool APIConnection::send_ping_response() { PingResponse resp; return this->send_message(resp, PingResponse::MESSAGE_TYPE); } -bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { +bool APIConnection::send_device_info_response() { DeviceInfoResponse resp{}; resp.name = StringRef(App.get_name()); resp.friendly_name = StringRef(App.get_friendly_name()); @@ -1746,9 +1745,7 @@ bool APIConnection::send_noise_encryption_set_key_response(const NoiseEncryption } #endif #ifdef USE_API_HOMEASSISTANT_STATES -void APIConnection::subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) { - state_subs_at_ = 0; -} +void APIConnection::subscribe_home_assistant_states() { state_subs_at_ = 0; } #endif bool APIConnection::try_to_clear_buffer(bool log_out_of_space) { if (this->flags_.remove) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 40e4fd61c1..935393b2da 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -127,7 +127,7 @@ class APIConnection final : public APIServerConnection { #endif // USE_API_HOMEASSISTANT_SERVICES #ifdef USE_BLUETOOTH_PROXY void subscribe_bluetooth_le_advertisements(const SubscribeBluetoothLEAdvertisementsRequest &msg) override; - void unsubscribe_bluetooth_le_advertisements(const UnsubscribeBluetoothLEAdvertisementsRequest &msg) override; + void unsubscribe_bluetooth_le_advertisements() override; void bluetooth_device_request(const BluetoothDeviceRequest &msg) override; void bluetooth_gatt_read(const BluetoothGATTReadRequest &msg) override; @@ -136,7 +136,7 @@ class APIConnection final : public APIServerConnection { void bluetooth_gatt_write_descriptor(const BluetoothGATTWriteDescriptorRequest &msg) override; void bluetooth_gatt_get_services(const BluetoothGATTGetServicesRequest &msg) override; void bluetooth_gatt_notify(const BluetoothGATTNotifyRequest &msg) override; - bool send_subscribe_bluetooth_connections_free_response(const SubscribeBluetoothConnectionsFreeRequest &msg) override; + bool send_subscribe_bluetooth_connections_free_response() override; void bluetooth_scanner_set_mode(const BluetoothScannerSetModeRequest &msg) override; #endif @@ -187,8 +187,8 @@ class APIConnection final : public APIServerConnection { void update_command(const UpdateCommandRequest &msg) override; #endif - void on_disconnect_response(const DisconnectResponse &value) override; - void on_ping_response(const PingResponse &value) override { + void on_disconnect_response() override; + void on_ping_response() override { // we initiated ping this->flags_.sent_ping = false; } @@ -199,11 +199,11 @@ class APIConnection final : public APIServerConnection { void on_get_time_response(const GetTimeResponse &value) override; #endif bool send_hello_response(const HelloRequest &msg) override; - bool send_disconnect_response(const DisconnectRequest &msg) override; - bool send_ping_response(const PingRequest &msg) override; - bool send_device_info_response(const DeviceInfoRequest &msg) override; - void list_entities(const ListEntitiesRequest &msg) override { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); } - void subscribe_states(const SubscribeStatesRequest &msg) override { + bool send_disconnect_response() override; + bool send_ping_response() override; + bool send_device_info_response() override; + void list_entities() override { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); } + void subscribe_states() override { this->flags_.state_subscription = true; // Start initial state iterator only if no iterator is active // If list_entities is running, we'll start initial_state when it completes @@ -217,12 +217,10 @@ class APIConnection final : public APIServerConnection { App.schedule_dump_config(); } #ifdef USE_API_HOMEASSISTANT_SERVICES - void subscribe_homeassistant_services(const SubscribeHomeassistantServicesRequest &msg) override { - this->flags_.service_call_subscription = true; - } + void subscribe_homeassistant_services() override { this->flags_.service_call_subscription = true; } #endif #ifdef USE_API_HOMEASSISTANT_STATES - void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) override; + void subscribe_home_assistant_states() override; #endif #ifdef USE_API_USER_DEFINED_ACTIONS void execute_service(const ExecuteServiceRequest &msg) override; diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index af0a2d0ca2..df66b6eb83 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -15,6 +15,9 @@ void APIServerConnectionBase::log_receive_message_(const LogString *name, const DumpBuffer dump_buf; ESP_LOGVV(TAG, "%s: %s", LOG_STR_ARG(name), msg.dump_to(dump_buf)); } +void APIServerConnectionBase::log_receive_message_(const LogString *name) { + ESP_LOGVV(TAG, "%s: {}", LOG_STR_ARG(name)); +} #endif void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) { @@ -29,66 +32,52 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, break; } case DisconnectRequest::MESSAGE_TYPE: { - DisconnectRequest msg; - // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_disconnect_request"), msg); + this->log_receive_message_(LOG_STR("on_disconnect_request")); #endif - this->on_disconnect_request(msg); + this->on_disconnect_request(); break; } case DisconnectResponse::MESSAGE_TYPE: { - DisconnectResponse msg; - // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_disconnect_response"), msg); + this->log_receive_message_(LOG_STR("on_disconnect_response")); #endif - this->on_disconnect_response(msg); + this->on_disconnect_response(); break; } case PingRequest::MESSAGE_TYPE: { - PingRequest msg; - // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_ping_request"), msg); + this->log_receive_message_(LOG_STR("on_ping_request")); #endif - this->on_ping_request(msg); + this->on_ping_request(); break; } case PingResponse::MESSAGE_TYPE: { - PingResponse msg; - // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_ping_response"), msg); + this->log_receive_message_(LOG_STR("on_ping_response")); #endif - this->on_ping_response(msg); + this->on_ping_response(); break; } case DeviceInfoRequest::MESSAGE_TYPE: { - DeviceInfoRequest msg; - // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_device_info_request"), msg); + this->log_receive_message_(LOG_STR("on_device_info_request")); #endif - this->on_device_info_request(msg); + this->on_device_info_request(); break; } case ListEntitiesRequest::MESSAGE_TYPE: { - ListEntitiesRequest msg; - // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_list_entities_request"), msg); + this->log_receive_message_(LOG_STR("on_list_entities_request")); #endif - this->on_list_entities_request(msg); + this->on_list_entities_request(); break; } case SubscribeStatesRequest::MESSAGE_TYPE: { - SubscribeStatesRequest msg; - // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_subscribe_states_request"), msg); + this->log_receive_message_(LOG_STR("on_subscribe_states_request")); #endif - this->on_subscribe_states_request(msg); + this->on_subscribe_states_request(); break; } case SubscribeLogsRequest::MESSAGE_TYPE: { @@ -146,12 +135,10 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, #endif #ifdef USE_API_HOMEASSISTANT_SERVICES case SubscribeHomeassistantServicesRequest::MESSAGE_TYPE: { - SubscribeHomeassistantServicesRequest msg; - // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_subscribe_homeassistant_services_request"), msg); + this->log_receive_message_(LOG_STR("on_subscribe_homeassistant_services_request")); #endif - this->on_subscribe_homeassistant_services_request(msg); + this->on_subscribe_homeassistant_services_request(); break; } #endif @@ -166,12 +153,10 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } #ifdef USE_API_HOMEASSISTANT_STATES case SubscribeHomeAssistantStatesRequest::MESSAGE_TYPE: { - SubscribeHomeAssistantStatesRequest msg; - // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_subscribe_home_assistant_states_request"), msg); + this->log_receive_message_(LOG_STR("on_subscribe_home_assistant_states_request")); #endif - this->on_subscribe_home_assistant_states_request(msg); + this->on_subscribe_home_assistant_states_request(); break; } #endif @@ -375,23 +360,19 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, #endif #ifdef USE_BLUETOOTH_PROXY case SubscribeBluetoothConnectionsFreeRequest::MESSAGE_TYPE: { - SubscribeBluetoothConnectionsFreeRequest msg; - // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_subscribe_bluetooth_connections_free_request"), msg); + this->log_receive_message_(LOG_STR("on_subscribe_bluetooth_connections_free_request")); #endif - this->on_subscribe_bluetooth_connections_free_request(msg); + this->on_subscribe_bluetooth_connections_free_request(); break; } #endif #ifdef USE_BLUETOOTH_PROXY case UnsubscribeBluetoothLEAdvertisementsRequest::MESSAGE_TYPE: { - UnsubscribeBluetoothLEAdvertisementsRequest msg; - // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_unsubscribe_bluetooth_le_advertisements_request"), msg); + this->log_receive_message_(LOG_STR("on_unsubscribe_bluetooth_le_advertisements_request")); #endif - this->on_unsubscribe_bluetooth_le_advertisements_request(msg); + this->on_unsubscribe_bluetooth_le_advertisements_request(); break; } #endif @@ -647,36 +628,29 @@ void APIServerConnection::on_hello_request(const HelloRequest &msg) { this->on_fatal_error(); } } -void APIServerConnection::on_disconnect_request(const DisconnectRequest &msg) { - if (!this->send_disconnect_response(msg)) { +void APIServerConnection::on_disconnect_request() { + if (!this->send_disconnect_response()) { this->on_fatal_error(); } } -void APIServerConnection::on_ping_request(const PingRequest &msg) { - if (!this->send_ping_response(msg)) { +void APIServerConnection::on_ping_request() { + if (!this->send_ping_response()) { this->on_fatal_error(); } } -void APIServerConnection::on_device_info_request(const DeviceInfoRequest &msg) { - if (!this->send_device_info_response(msg)) { +void APIServerConnection::on_device_info_request() { + if (!this->send_device_info_response()) { this->on_fatal_error(); } } -void APIServerConnection::on_list_entities_request(const ListEntitiesRequest &msg) { this->list_entities(msg); } -void APIServerConnection::on_subscribe_states_request(const SubscribeStatesRequest &msg) { - this->subscribe_states(msg); -} +void APIServerConnection::on_list_entities_request() { this->list_entities(); } +void APIServerConnection::on_subscribe_states_request() { this->subscribe_states(); } void APIServerConnection::on_subscribe_logs_request(const SubscribeLogsRequest &msg) { this->subscribe_logs(msg); } #ifdef USE_API_HOMEASSISTANT_SERVICES -void APIServerConnection::on_subscribe_homeassistant_services_request( - const SubscribeHomeassistantServicesRequest &msg) { - this->subscribe_homeassistant_services(msg); -} +void APIServerConnection::on_subscribe_homeassistant_services_request() { this->subscribe_homeassistant_services(); } #endif #ifdef USE_API_HOMEASSISTANT_STATES -void APIServerConnection::on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) { - this->subscribe_home_assistant_states(msg); -} +void APIServerConnection::on_subscribe_home_assistant_states_request() { this->subscribe_home_assistant_states(); } #endif #ifdef USE_API_USER_DEFINED_ACTIONS void APIServerConnection::on_execute_service_request(const ExecuteServiceRequest &msg) { this->execute_service(msg); } @@ -793,17 +767,15 @@ void APIServerConnection::on_bluetooth_gatt_notify_request(const BluetoothGATTNo } #endif #ifdef USE_BLUETOOTH_PROXY -void APIServerConnection::on_subscribe_bluetooth_connections_free_request( - const SubscribeBluetoothConnectionsFreeRequest &msg) { - if (!this->send_subscribe_bluetooth_connections_free_response(msg)) { +void APIServerConnection::on_subscribe_bluetooth_connections_free_request() { + if (!this->send_subscribe_bluetooth_connections_free_response()) { this->on_fatal_error(); } } #endif #ifdef USE_BLUETOOTH_PROXY -void APIServerConnection::on_unsubscribe_bluetooth_le_advertisements_request( - const UnsubscribeBluetoothLEAdvertisementsRequest &msg) { - this->unsubscribe_bluetooth_le_advertisements(msg); +void APIServerConnection::on_unsubscribe_bluetooth_le_advertisements_request() { + this->unsubscribe_bluetooth_le_advertisements(); } #endif #ifdef USE_BLUETOOTH_PROXY diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index e2bc1609ed..b8c9e4da6f 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -14,6 +14,7 @@ class APIServerConnectionBase : public ProtoService { protected: void log_send_message_(const char *name, const char *dump); void log_receive_message_(const LogString *name, const ProtoMessage &msg); + void log_receive_message_(const LogString *name); public: #endif @@ -28,15 +29,15 @@ class APIServerConnectionBase : public ProtoService { virtual void on_hello_request(const HelloRequest &value){}; - virtual void on_disconnect_request(const DisconnectRequest &value){}; - virtual void on_disconnect_response(const DisconnectResponse &value){}; - virtual void on_ping_request(const PingRequest &value){}; - virtual void on_ping_response(const PingResponse &value){}; - virtual void on_device_info_request(const DeviceInfoRequest &value){}; + virtual void on_disconnect_request(){}; + virtual void on_disconnect_response(){}; + virtual void on_ping_request(){}; + virtual void on_ping_response(){}; + virtual void on_device_info_request(){}; - virtual void on_list_entities_request(const ListEntitiesRequest &value){}; + virtual void on_list_entities_request(){}; - virtual void on_subscribe_states_request(const SubscribeStatesRequest &value){}; + virtual void on_subscribe_states_request(){}; #ifdef USE_COVER virtual void on_cover_command_request(const CoverCommandRequest &value){}; @@ -61,14 +62,14 @@ class APIServerConnectionBase : public ProtoService { #endif #ifdef USE_API_HOMEASSISTANT_SERVICES - virtual void on_subscribe_homeassistant_services_request(const SubscribeHomeassistantServicesRequest &value){}; + virtual void on_subscribe_homeassistant_services_request(){}; #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES virtual void on_homeassistant_action_response(const HomeassistantActionResponse &value){}; #endif #ifdef USE_API_HOMEASSISTANT_STATES - virtual void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &value){}; + virtual void on_subscribe_home_assistant_states_request(){}; #endif #ifdef USE_API_HOMEASSISTANT_STATES @@ -147,12 +148,11 @@ class APIServerConnectionBase : public ProtoService { #endif #ifdef USE_BLUETOOTH_PROXY - virtual void on_subscribe_bluetooth_connections_free_request(const SubscribeBluetoothConnectionsFreeRequest &value){}; + virtual void on_subscribe_bluetooth_connections_free_request(){}; #endif #ifdef USE_BLUETOOTH_PROXY - virtual void on_unsubscribe_bluetooth_le_advertisements_request( - const UnsubscribeBluetoothLEAdvertisementsRequest &value){}; + virtual void on_unsubscribe_bluetooth_le_advertisements_request(){}; #endif #ifdef USE_BLUETOOTH_PROXY @@ -231,17 +231,17 @@ class APIServerConnectionBase : public ProtoService { class APIServerConnection : public APIServerConnectionBase { public: virtual bool send_hello_response(const HelloRequest &msg) = 0; - virtual bool send_disconnect_response(const DisconnectRequest &msg) = 0; - virtual bool send_ping_response(const PingRequest &msg) = 0; - virtual bool send_device_info_response(const DeviceInfoRequest &msg) = 0; - virtual void list_entities(const ListEntitiesRequest &msg) = 0; - virtual void subscribe_states(const SubscribeStatesRequest &msg) = 0; + virtual bool send_disconnect_response() = 0; + virtual bool send_ping_response() = 0; + virtual bool send_device_info_response() = 0; + virtual void list_entities() = 0; + virtual void subscribe_states() = 0; virtual void subscribe_logs(const SubscribeLogsRequest &msg) = 0; #ifdef USE_API_HOMEASSISTANT_SERVICES - virtual void subscribe_homeassistant_services(const SubscribeHomeassistantServicesRequest &msg) = 0; + virtual void subscribe_homeassistant_services() = 0; #endif #ifdef USE_API_HOMEASSISTANT_STATES - virtual void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) = 0; + virtual void subscribe_home_assistant_states() = 0; #endif #ifdef USE_API_USER_DEFINED_ACTIONS virtual void execute_service(const ExecuteServiceRequest &msg) = 0; @@ -331,11 +331,10 @@ class APIServerConnection : public APIServerConnectionBase { virtual void bluetooth_gatt_notify(const BluetoothGATTNotifyRequest &msg) = 0; #endif #ifdef USE_BLUETOOTH_PROXY - virtual bool send_subscribe_bluetooth_connections_free_response( - const SubscribeBluetoothConnectionsFreeRequest &msg) = 0; + virtual bool send_subscribe_bluetooth_connections_free_response() = 0; #endif #ifdef USE_BLUETOOTH_PROXY - virtual void unsubscribe_bluetooth_le_advertisements(const UnsubscribeBluetoothLEAdvertisementsRequest &msg) = 0; + virtual void unsubscribe_bluetooth_le_advertisements() = 0; #endif #ifdef USE_BLUETOOTH_PROXY virtual void bluetooth_scanner_set_mode(const BluetoothScannerSetModeRequest &msg) = 0; @@ -363,17 +362,17 @@ class APIServerConnection : public APIServerConnectionBase { #endif protected: void on_hello_request(const HelloRequest &msg) override; - void on_disconnect_request(const DisconnectRequest &msg) override; - void on_ping_request(const PingRequest &msg) override; - void on_device_info_request(const DeviceInfoRequest &msg) override; - void on_list_entities_request(const ListEntitiesRequest &msg) override; - void on_subscribe_states_request(const SubscribeStatesRequest &msg) override; + void on_disconnect_request() override; + void on_ping_request() override; + void on_device_info_request() override; + void on_list_entities_request() override; + void on_subscribe_states_request() override; void on_subscribe_logs_request(const SubscribeLogsRequest &msg) override; #ifdef USE_API_HOMEASSISTANT_SERVICES - void on_subscribe_homeassistant_services_request(const SubscribeHomeassistantServicesRequest &msg) override; + void on_subscribe_homeassistant_services_request() override; #endif #ifdef USE_API_HOMEASSISTANT_STATES - void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) override; + void on_subscribe_home_assistant_states_request() override; #endif #ifdef USE_API_USER_DEFINED_ACTIONS void on_execute_service_request(const ExecuteServiceRequest &msg) override; @@ -463,11 +462,10 @@ class APIServerConnection : public APIServerConnectionBase { void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg) override; #endif #ifdef USE_BLUETOOTH_PROXY - void on_subscribe_bluetooth_connections_free_request(const SubscribeBluetoothConnectionsFreeRequest &msg) override; + void on_subscribe_bluetooth_connections_free_request() override; #endif #ifdef USE_BLUETOOTH_PROXY - void on_unsubscribe_bluetooth_le_advertisements_request( - const UnsubscribeBluetoothLEAdvertisementsRequest &msg) override; + void on_unsubscribe_bluetooth_le_advertisements_request() override; #endif #ifdef USE_BLUETOOTH_PROXY void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) override; diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 4021a062ca..5fbc1137a8 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2270,10 +2270,13 @@ SOURCE_NAMES = { SOURCE_CLIENT: "SOURCE_CLIENT", } -RECEIVE_CASES: dict[int, tuple[str, str | None]] = {} +RECEIVE_CASES: dict[int, tuple[str, str | None, str]] = {} ifdefs: dict[str, str] = {} +# Track messages with no fields (empty messages) for parameter elision +EMPTY_MESSAGES: set[str] = set() + def get_opt( desc: descriptor.DescriptorProto, @@ -2504,26 +2507,26 @@ def build_service_message_type( # Only add ifdef when we're actually generating content if ifdef is not None: hout += f"#ifdef {ifdef}\n" - # Generate receive + # Generate receive handler and switch case func = f"on_{snake}" - hout += f"virtual void {func}(const {mt.name} &value){{}};\n" - case = "" - case += f"{mt.name} msg;\n" - # Check if this message has any fields (excluding deprecated ones) has_fields = any(not field.options.deprecated for field in mt.field) - if has_fields: - # Normal case: decode the message + is_empty = not has_fields + if is_empty: + EMPTY_MESSAGES.add(mt.name) + hout += f"virtual void {func}({'' if is_empty else f'const {mt.name} &value'}){{}};\n" + case = "" + if not is_empty: + case += f"{mt.name} msg;\n" case += "msg.decode(msg_data, msg_size);\n" - else: - # Empty message optimization: skip decode since there are no fields - case += "// Empty message: no decode needed\n" if log: case += "#ifdef HAS_PROTO_MESSAGE_DUMP\n" - case += f'this->log_receive_message_(LOG_STR("{func}"), msg);\n' + if is_empty: + case += f'this->log_receive_message_(LOG_STR("{func}"));\n' + else: + case += f'this->log_receive_message_(LOG_STR("{func}"), msg);\n' case += "#endif\n" - case += f"this->{func}(msg);\n" + case += f"this->{func}({'msg' if not is_empty else ''});\n" case += "break;" - # Store the message name and ifdef with the case for later use RECEIVE_CASES[id_] = (case, ifdef, mt.name) # Only close ifdef if we opened it @@ -2839,6 +2842,7 @@ static const char *const TAG = "api.service"; hpp += ( " void log_receive_message_(const LogString *name, const ProtoMessage &msg);\n" ) + hpp += " void log_receive_message_(const LogString *name);\n" hpp += " public:\n" hpp += "#endif\n\n" @@ -2862,6 +2866,9 @@ static const char *const TAG = "api.service"; cpp += " DumpBuffer dump_buf;\n" cpp += ' ESP_LOGVV(TAG, "%s: %s", LOG_STR_ARG(name), msg.dump_to(dump_buf));\n' cpp += "}\n" + cpp += f"void {class_name}::log_receive_message_(const LogString *name) {{\n" + cpp += ' ESP_LOGVV(TAG, "%s: {}", LOG_STR_ARG(name));\n' + cpp += "}\n" cpp += "#endif\n\n" for mt in file.message_type: @@ -2929,22 +2936,22 @@ static const char *const TAG = "api.service"; hpp_protected += f"#ifdef {ifdef}\n" cpp += f"#ifdef {ifdef}\n" - hpp_protected += f" void {on_func}(const {inp} &msg) override;\n" + is_empty = inp in EMPTY_MESSAGES + param = "" if is_empty else f"const {inp} &msg" + arg = "" if is_empty else "msg" - # For non-void methods, generate a send_ method instead of return-by-value + hpp_protected += f" void {on_func}({param}) override;\n" if is_void: - hpp += f" virtual void {func}(const {inp} &msg) = 0;\n" + hpp += f" virtual void {func}({param}) = 0;\n" else: - hpp += f" virtual bool send_{func}_response(const {inp} &msg) = 0;\n" + hpp += f" virtual bool send_{func}_response({param}) = 0;\n" - cpp += f"void {class_name}::{on_func}(const {inp} &msg) {{\n" - - # No authentication check here - it's done in read_message + cpp += f"void {class_name}::{on_func}({param}) {{\n" body = "" if is_void: - body += f"this->{func}(msg);\n" + body += f"this->{func}({arg});\n" else: - body += f"if (!this->send_{func}_response(msg)) {{\n" + body += f"if (!this->send_{func}_response({arg})) {{\n" body += " this->on_fatal_error();\n" body += "}\n" From eb6a6f8d0d6a408138d0dd2fd2143a8af9b2ee8c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 03:25:05 -0600 Subject: [PATCH 08/16] [web_server_idf] Remove unused host() method (#13869) --- esphome/components/web_server_idf/web_server_idf.cpp | 2 -- esphome/components/web_server_idf/web_server_idf.h | 1 - 2 files changed, 3 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 9860810452..39e6b7a790 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -258,8 +258,6 @@ StringRef AsyncWebServerRequest::url_to(std::span buffer) co return StringRef(buffer.data(), decoded_len); } -std::string AsyncWebServerRequest::host() const { return this->get_header("Host").value(); } - void AsyncWebServerRequest::send(AsyncWebServerResponse *response) { httpd_resp_send(*this, response->get_content_data(), response->get_content_size()); } diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 817f47da79..1760544963 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -121,7 +121,6 @@ class AsyncWebServerRequest { char buffer[URL_BUF_SIZE]; return std::string(this->url_to(buffer)); } - std::string host() const; // NOLINTNEXTLINE(readability-identifier-naming) size_t contentLength() const { return this->req_->content_len; } From 6ee185c58aa047dde7e933c4d5a1b47c42f6572a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 03:25:23 -0600 Subject: [PATCH 09/16] [dashboard] Use resolve/relative_to for download path validation (#13867) --- esphome/dashboard/web_server.py | 17 ++++-- tests/dashboard/test_web_server.py | 93 +++++++++++++++++++++++++----- 2 files changed, 92 insertions(+), 18 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index da50279864..00974bf460 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -1054,17 +1054,26 @@ class DownloadBinaryRequestHandler(BaseHandler): # fallback to type=, but prioritize file= file_name = self.get_argument("type", None) file_name = self.get_argument("file", file_name) - if file_name is None: + if file_name is None or not file_name.strip(): self.send_error(400) return - file_name = file_name.replace("..", "").lstrip("/") # get requested download name, or build it based on filename download_name = self.get_argument( "download", f"{storage_json.name}-{file_name}", ) - path = storage_json.firmware_bin_path.parent.joinpath(file_name) + if storage_json.firmware_bin_path is None: + self.send_error(404) + return + + base_dir = storage_json.firmware_bin_path.parent.resolve() + path = base_dir.joinpath(file_name).resolve() + try: + path.relative_to(base_dir) + except ValueError: + self.send_error(403) + return if not path.is_file(): args = ["esphome", "idedata", settings.rel_path(configuration)] @@ -1078,7 +1087,7 @@ class DownloadBinaryRequestHandler(BaseHandler): found = False for image in idedata.extra_flash_images: - if image.path.endswith(file_name): + if image.path.as_posix().endswith(file_name): path = image.path download_name = file_name found = True diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 7642876ee5..9ea7a5164b 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -8,6 +8,7 @@ import gzip import json import os from pathlib import Path +import sys from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest @@ -421,7 +422,7 @@ async def test_download_binary_handler_idedata_fallback( # Mock idedata response mock_image = Mock() - mock_image.path = str(bootloader_file) + mock_image.path = bootloader_file mock_idedata_instance = Mock() mock_idedata_instance.extra_flash_images = [mock_image] mock_idedata.return_value = mock_idedata_instance @@ -528,14 +529,22 @@ async def test_download_binary_handler_subdirectory_file_url_encoded( @pytest.mark.asyncio @pytest.mark.usefixtures("mock_ext_storage_path") @pytest.mark.parametrize( - "attack_path", + ("attack_path", "expected_code"), [ - pytest.param("../../../secrets.yaml", id="basic_traversal"), - pytest.param("..%2F..%2F..%2Fsecrets.yaml", id="url_encoded"), - pytest.param("zephyr/../../../secrets.yaml", id="traversal_with_prefix"), - pytest.param("/etc/passwd", id="absolute_path"), - pytest.param("//etc/passwd", id="double_slash_absolute"), - pytest.param("....//secrets.yaml", id="multiple_dots"), + pytest.param("../../../secrets.yaml", 403, id="basic_traversal"), + pytest.param("..%2F..%2F..%2Fsecrets.yaml", 403, id="url_encoded"), + pytest.param("zephyr/../../../secrets.yaml", 403, id="traversal_with_prefix"), + pytest.param("/etc/passwd", 403, id="absolute_path"), + pytest.param("//etc/passwd", 403, id="double_slash_absolute"), + pytest.param( + "....//secrets.yaml", + # On Windows, Path.resolve() treats "..." and "...." as parent + # traversal (like ".."), so the path escapes base_dir -> 403. + # On Unix, "...." is a literal directory name that stays inside + # base_dir but doesn't exist -> 404. + 403 if sys.platform == "win32" else 404, + id="multiple_dots", + ), ], ) async def test_download_binary_handler_path_traversal_protection( @@ -543,11 +552,14 @@ async def test_download_binary_handler_path_traversal_protection( tmp_path: Path, mock_storage_json: MagicMock, attack_path: str, + expected_code: int, ) -> None: """Test that DownloadBinaryRequestHandler prevents path traversal attacks. - Verifies that attempts to use '..' in file paths are sanitized to prevent - accessing files outside the build directory. Tests multiple attack vectors. + Verifies that attempts to escape the build directory via '..' are rejected + using resolve()/relative_to() validation. Tests multiple attack vectors. + Real traversals that escape the base directory get 403. Paths like '....' + that resolve inside the base directory but don't exist get 404. """ # Create build structure build_dir = get_build_path(tmp_path, "test") @@ -565,14 +577,67 @@ async def test_download_binary_handler_path_traversal_protection( mock_storage.firmware_bin_path = firmware_file mock_storage_json.load.return_value = mock_storage - # Attempt path traversal attack - should be blocked - with pytest.raises(HTTPClientError) as exc_info: + # Mock async_run_system_command so paths that pass validation but don't exist + # return 404 deterministically without spawning a real subprocess. + with ( + patch( + "esphome.dashboard.web_server.async_run_system_command", + new_callable=AsyncMock, + return_value=(2, "", ""), + ), + pytest.raises(HTTPClientError) as exc_info, + ): await dashboard.fetch( f"/download.bin?configuration=test.yaml&file={attack_path}", method="GET", ) - # Should get 404 (file not found after sanitization) or 500 (idedata fails) - assert exc_info.value.code in (404, 500) + assert exc_info.value.code == expected_code + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +async def test_download_binary_handler_no_firmware_bin_path( + dashboard: DashboardTestHelper, + mock_storage_json: MagicMock, +) -> None: + """Test that download returns 404 when firmware_bin_path is None. + + This covers configs created by StorageJSON.from_wizard() where no + firmware has been compiled yet. + """ + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = None + mock_storage_json.load.return_value = mock_storage + + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + "/download.bin?configuration=test.yaml&file=firmware.bin", + method="GET", + ) + assert exc_info.value.code == 404 + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +@pytest.mark.parametrize("file_value", ["", "%20%20", "%20"]) +async def test_download_binary_handler_empty_file_name( + dashboard: DashboardTestHelper, + mock_storage_json: MagicMock, + file_value: str, +) -> None: + """Test that download returns 400 for empty or whitespace-only file names.""" + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = Path("/fake/firmware.bin") + mock_storage_json.load.return_value = mock_storage + + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + f"/download.bin?configuration=test.yaml&file={file_value}", + method="GET", + ) + assert exc_info.value.code == 400 @pytest.mark.asyncio From 5370687001745b2dfd590426eb3008158469a56b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 03:25:41 -0600 Subject: [PATCH 10/16] [wizard] Use secrets module for fallback AP password generation (#13864) --- esphome/wizard.py | 3 +-- tests/unit_tests/test_wizard.py | 13 ++++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/esphome/wizard.py b/esphome/wizard.py index 4b74847996..f83342cc6a 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -1,6 +1,5 @@ import base64 from pathlib import Path -import random import secrets import string from typing import Literal, NotRequired, TypedDict, Unpack @@ -130,7 +129,7 @@ def wizard_file(**kwargs: Unpack[WizardFileKwargs]) -> str: if len(ap_name) > 32: ap_name = ap_name_base kwargs["fallback_name"] = ap_name - kwargs["fallback_psk"] = "".join(random.choice(letters) for _ in range(12)) + kwargs["fallback_psk"] = "".join(secrets.choice(letters) for _ in range(12)) base = BASE_CONFIG_FRIENDLY if kwargs.get("friendly_name") else BASE_CONFIG diff --git a/tests/unit_tests/test_wizard.py b/tests/unit_tests/test_wizard.py index eb44c1c20f..0ce89230d8 100644 --- a/tests/unit_tests/test_wizard.py +++ b/tests/unit_tests/test_wizard.py @@ -2,7 +2,7 @@ from pathlib import Path from typing import Any -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest from pytest import MonkeyPatch @@ -632,3 +632,14 @@ def test_wizard_accepts_rpipico_board(tmp_path: Path, monkeypatch: MonkeyPatch): # rpipico doesn't support WiFi, so no api_encryption_key or ota_password assert "api_encryption_key" not in call_kwargs assert "ota_password" not in call_kwargs + + +def test_fallback_psk_uses_secrets_choice( + default_config: dict[str, Any], +) -> None: + """Test that fallback PSK is generated using secrets.choice.""" + with patch("esphome.wizard.secrets.choice", return_value="X") as mock_choice: + config = wz.wizard_file(**default_config) + + assert 'password: "XXXXXXXXXXXX"' in config + assert mock_choice.call_count == 12 From e24528c842f6cbab9b71a07d6d738006fd5dd509 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 03:25:59 -0600 Subject: [PATCH 11/16] [analyze-memory] Attribute CSWTCH symbols from SDK archives (#13850) --- esphome/analyze_memory/__init__.py | 156 +++++++++++++++++++++-------- 1 file changed, 114 insertions(+), 42 deletions(-) diff --git a/esphome/analyze_memory/__init__.py b/esphome/analyze_memory/__init__.py index d8c941e76f..d8abc8bafb 100644 --- a/esphome/analyze_memory/__init__.py +++ b/esphome/analyze_memory/__init__.py @@ -397,47 +397,38 @@ class MemoryAnalyzer: return pioenvs_dir return None - def _scan_cswtch_in_objects( - self, obj_dir: Path - ) -> dict[str, list[tuple[str, int]]]: - """Scan object files for CSWTCH symbols using a single nm invocation. + @staticmethod + def _parse_nm_cswtch_output( + output: str, + base_dir: Path | None, + cswtch_map: dict[str, list[tuple[str, int]]], + ) -> None: + """Parse nm output for CSWTCH symbols and add to cswtch_map. - Uses ``nm --print-file-name -S`` on all ``.o`` files at once. - Output format: ``/path/to/file.o:address size type name`` + Handles both ``.o`` files and ``.a`` archives. + + nm output formats:: + + .o files: /path/file.o:hex_addr hex_size type name + .a files: /path/lib.a:member.o:hex_addr hex_size type name + + For ``.o`` files, paths are made relative to *base_dir* when possible. + For ``.a`` archives (detected by ``:`` in the file portion), paths are + formatted as ``archive_stem/member.o`` (e.g. ``liblwip2-536-feat/lwip-esp.o``). Args: - obj_dir: Directory containing object files (.pioenvs//) - - Returns: - Dict mapping "CSWTCH$NNN:size" to list of (source_file, size) tuples. + output: Raw stdout from ``nm --print-file-name -S``. + base_dir: Base directory for computing relative paths of ``.o`` files. + Pass ``None`` when scanning archives outside the build tree. + cswtch_map: Dict to populate, mapping ``"CSWTCH$N:size"`` to source list. """ - cswtch_map: dict[str, list[tuple[str, int]]] = defaultdict(list) - - if not self.nm_path: - return cswtch_map - - # Find all .o files recursively, sorted for deterministic output - obj_files = sorted(obj_dir.rglob("*.o")) - if not obj_files: - return cswtch_map - - _LOGGER.debug("Scanning %d object files for CSWTCH symbols", len(obj_files)) - - # Single nm call with --print-file-name for all object files - result = run_tool( - [self.nm_path, "--print-file-name", "-S"] + [str(f) for f in obj_files], - timeout=30, - ) - if result is None or result.returncode != 0: - return cswtch_map - - for line in result.stdout.splitlines(): + for line in output.splitlines(): if "CSWTCH$" not in line: continue # Split on last ":" that precedes a hex address. - # nm --print-file-name format: filepath:hex_addr hex_size type name - # We split from the right: find the last colon followed by hex digits. + # For .o: "filepath.o" : "hex_addr hex_size type name" + # For .a: "filepath.a:member.o" : "hex_addr hex_size type name" parts_after_colon = line.rsplit(":", 1) if len(parts_after_colon) != 2: continue @@ -457,16 +448,89 @@ class MemoryAnalyzer: except ValueError: continue - # Get relative path from obj_dir for readability - try: - rel_path = str(Path(file_path).relative_to(obj_dir)) - except ValueError: + # Determine readable source path + # Use ".a:" to detect archive format (not bare ":" which matches + # Windows drive letters like "C:\...\file.o"). + if ".a:" in file_path: + # Archive format: "archive.a:member.o" → "archive_stem/member.o" + archive_part, member = file_path.rsplit(":", 1) + archive_name = Path(archive_part).stem + rel_path = f"{archive_name}/{member}" + elif base_dir is not None: + try: + rel_path = str(Path(file_path).relative_to(base_dir)) + except ValueError: + rel_path = file_path + else: rel_path = file_path key = f"{sym_name}:{size}" cswtch_map[key].append((rel_path, size)) - return cswtch_map + def _run_nm_cswtch_scan( + self, + files: list[Path], + base_dir: Path | None, + cswtch_map: dict[str, list[tuple[str, int]]], + ) -> None: + """Run nm on *files* and add any CSWTCH symbols to *cswtch_map*. + + Args: + files: Object (``.o``) or archive (``.a``) files to scan. + base_dir: Base directory for relative path computation (see + :meth:`_parse_nm_cswtch_output`). + cswtch_map: Dict to populate with results. + """ + if not self.nm_path or not files: + return + + _LOGGER.debug("Scanning %d files for CSWTCH symbols", len(files)) + + result = run_tool( + [self.nm_path, "--print-file-name", "-S"] + [str(f) for f in files], + timeout=30, + ) + if result is None or result.returncode != 0: + _LOGGER.debug( + "nm failed or timed out scanning %d files for CSWTCH symbols", + len(files), + ) + return + + self._parse_nm_cswtch_output(result.stdout, base_dir, cswtch_map) + + def _scan_cswtch_in_sdk_archives( + self, cswtch_map: dict[str, list[tuple[str, int]]] + ) -> None: + """Scan SDK library archives (.a) for CSWTCH symbols. + + Prebuilt SDK libraries (e.g. lwip, bearssl) are not compiled from source, + so their CSWTCH symbols only exist inside ``.a`` archives. Results are + merged into *cswtch_map* for keys not already found in ``.o`` files. + + The same source file (e.g. ``lwip-esp.o``) often appears in multiple + library variants (``liblwip2-536.a``, ``liblwip2-1460-feat.a``, etc.), + so results are deduplicated by member name. + """ + sdk_dirs = self._find_sdk_library_dirs() + if not sdk_dirs: + return + + sdk_archives = sorted(a for sdk_dir in sdk_dirs for a in sdk_dir.glob("*.a")) + + sdk_map: dict[str, list[tuple[str, int]]] = defaultdict(list) + self._run_nm_cswtch_scan(sdk_archives, None, sdk_map) + + # Merge SDK results, deduplicating by member name. + for key, sources in sdk_map.items(): + if key in cswtch_map: + continue + seen: dict[str, tuple[str, int]] = {} + for path, sz in sources: + member = Path(path).name + if member not in seen: + seen[member] = (path, sz) + cswtch_map[key] = list(seen.values()) def _source_file_to_component(self, source_file: str) -> str: """Map a source object file path to its component name. @@ -505,17 +569,25 @@ class MemoryAnalyzer: CSWTCH symbols are compiler-generated lookup tables for switch statements. They are local symbols, so the same name can appear in different object files. - This method scans .o files to attribute them to their source components. + This method scans .o files and SDK archives to attribute them to their + source components. """ obj_dir = self._find_object_files_dir() if obj_dir is None: _LOGGER.debug("No object files directory found, skipping CSWTCH analysis") return - # Scan object files for CSWTCH symbols - cswtch_map = self._scan_cswtch_in_objects(obj_dir) + # Scan build-dir object files for CSWTCH symbols + cswtch_map: dict[str, list[tuple[str, int]]] = defaultdict(list) + self._run_nm_cswtch_scan(sorted(obj_dir.rglob("*.o")), obj_dir, cswtch_map) + + # Also scan SDK library archives (.a) for CSWTCH symbols. + # Prebuilt SDK libraries (e.g. lwip, bearssl) are not compiled from source + # so their symbols only exist inside .a archives, not as loose .o files. + self._scan_cswtch_in_sdk_archives(cswtch_map) + if not cswtch_map: - _LOGGER.debug("No CSWTCH symbols found in object files") + _LOGGER.debug("No CSWTCH symbols found in object files or SDK archives") return # Collect CSWTCH symbols from the ELF (already parsed in sections) From 46f8302d8ff4aa8acf9751d0df948f86e4b61aa0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 03:26:15 -0600 Subject: [PATCH 12/16] [mqtt] Use stack buffer for discovery topic to avoid heap allocation (#13812) --- esphome/components/mqtt/mqtt_component.cpp | 21 +++++++++++---------- esphome/components/mqtt/mqtt_component.h | 9 +++++++-- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index e4b80de50a..8cf393e2df 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -34,10 +34,7 @@ inline char *append_char(char *p, char c) { // MQTT_COMPONENT_TYPE_MAX_LEN, MQTT_SUFFIX_MAX_LEN, and MQTT_DEFAULT_TOPIC_MAX_LEN are in mqtt_component.h. // ESPHOME_DEVICE_NAME_MAX_LEN and OBJECT_ID_MAX_LEN are defined in entity_base.h. // This ensures the stack buffers below are always large enough. -static constexpr size_t DISCOVERY_PREFIX_MAX_LEN = 64; // Validated in Python: cv.Length(max=64) -// Format: prefix + "/" + type + "/" + name + "/" + object_id + "/config" + null -static constexpr size_t DISCOVERY_TOPIC_MAX_LEN = DISCOVERY_PREFIX_MAX_LEN + 1 + MQTT_COMPONENT_TYPE_MAX_LEN + 1 + - ESPHOME_DEVICE_NAME_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 7 + 1; +// MQTT_DISCOVERY_PREFIX_MAX_LEN and MQTT_DISCOVERY_TOPIC_MAX_LEN are defined in mqtt_component.h // Function implementation of LOG_MQTT_COMPONENT macro to reduce code size void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, bool command_topic) { @@ -54,15 +51,15 @@ void MQTTComponent::set_subscribe_qos(uint8_t qos) { this->subscribe_qos_ = qos; void MQTTComponent::set_retain(bool retain) { this->retain_ = retain; } -std::string MQTTComponent::get_discovery_topic_(const MQTTDiscoveryInfo &discovery_info) const { +StringRef MQTTComponent::get_discovery_topic_to_(std::span buf, + const MQTTDiscoveryInfo &discovery_info) const { char sanitized_name[ESPHOME_DEVICE_NAME_MAX_LEN + 1]; str_sanitize_to(sanitized_name, App.get_name().c_str()); const char *comp_type = this->component_type(); char object_id_buf[OBJECT_ID_MAX_LEN]; StringRef object_id = this->get_default_object_id_to_(object_id_buf); - char buf[DISCOVERY_TOPIC_MAX_LEN]; - char *p = buf; + char *p = buf.data(); p = append_str(p, discovery_info.prefix.data(), discovery_info.prefix.size()); p = append_char(p, '/'); @@ -72,8 +69,9 @@ std::string MQTTComponent::get_discovery_topic_(const MQTTDiscoveryInfo &discove p = append_char(p, '/'); p = append_str(p, object_id.c_str(), object_id.size()); p = append_str(p, "/config", 7); + *p = '\0'; - return std::string(buf, p - buf); + return StringRef(buf.data(), p - buf.data()); } StringRef MQTTComponent::get_default_topic_for_to_(std::span buf, const char *suffix, @@ -182,16 +180,19 @@ bool MQTTComponent::publish_json(const char *topic, const json::json_build_t &f) bool MQTTComponent::send_discovery_() { const MQTTDiscoveryInfo &discovery_info = global_mqtt_client->get_discovery_info(); + char discovery_topic_buf[MQTT_DISCOVERY_TOPIC_MAX_LEN]; + StringRef discovery_topic = this->get_discovery_topic_to_(discovery_topic_buf, discovery_info); + if (discovery_info.clean) { ESP_LOGV(TAG, "'%s': Cleaning discovery", this->friendly_name_().c_str()); - return global_mqtt_client->publish(this->get_discovery_topic_(discovery_info), "", 0, this->qos_, true); + return global_mqtt_client->publish(discovery_topic.c_str(), "", 0, this->qos_, true); } ESP_LOGV(TAG, "'%s': Sending discovery", this->friendly_name_().c_str()); // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson return global_mqtt_client->publish_json( - this->get_discovery_topic_(discovery_info), + discovery_topic.c_str(), [this](JsonObject root) { SendDiscoveryConfig config; config.state_topic = true; diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index 2cec6fda7e..76375fb106 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -32,6 +32,10 @@ static constexpr size_t MQTT_TOPIC_PREFIX_MAX_LEN = 64; // Validated in Python: // Format: prefix + "/" + type + "/" + object_id + "/" + suffix + null static constexpr size_t MQTT_DEFAULT_TOPIC_MAX_LEN = MQTT_TOPIC_PREFIX_MAX_LEN + 1 + MQTT_COMPONENT_TYPE_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 1 + MQTT_SUFFIX_MAX_LEN + 1; +static constexpr size_t MQTT_DISCOVERY_PREFIX_MAX_LEN = 64; // Validated in Python: cv.Length(max=64) +// Format: prefix + "/" + type + "/" + name + "/" + object_id + "/config" + null +static constexpr size_t MQTT_DISCOVERY_TOPIC_MAX_LEN = MQTT_DISCOVERY_PREFIX_MAX_LEN + 1 + MQTT_COMPONENT_TYPE_MAX_LEN + + 1 + ESPHOME_DEVICE_NAME_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 7 + 1; class MQTTComponent; // Forward declaration void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, bool command_topic); @@ -263,8 +267,9 @@ class MQTTComponent : public Component { void subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos = 0); protected: - /// Helper method to get the discovery topic for this component. - std::string get_discovery_topic_(const MQTTDiscoveryInfo &discovery_info) const; + /// Helper method to get the discovery topic for this component into a buffer. + StringRef get_discovery_topic_to_(std::span buf, + const MQTTDiscoveryInfo &discovery_info) const; /** Get this components state/command/... topic into a buffer. * From c3c0c40524105ad3165581b2c8241dc1990201cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 03:26:29 -0600 Subject: [PATCH 13/16] [mqtt] Return friendly_name_() by const reference to avoid string copies (#13810) --- esphome/components/mqtt/mqtt_component.cpp | 6 +++--- esphome/components/mqtt/mqtt_component.h | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index 8cf393e2df..09570106df 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -205,7 +205,7 @@ bool MQTTComponent::send_discovery_() { } // Fields from EntityBase - root[MQTT_NAME] = this->get_entity()->has_own_name() ? this->friendly_name_() : ""; + root[MQTT_NAME] = this->get_entity()->has_own_name() ? this->friendly_name_() : StringRef(); if (this->is_disabled_by_default_()) root[MQTT_ENABLED_BY_DEFAULT] = false; @@ -249,7 +249,7 @@ bool MQTTComponent::send_discovery_() { if (discovery_info.unique_id_generator == MQTT_MAC_ADDRESS_UNIQUE_ID_GENERATOR) { char friendly_name_hash[9]; buf_append_printf(friendly_name_hash, sizeof(friendly_name_hash), 0, "%08" PRIx32, - fnv1_hash(this->friendly_name_())); + fnv1_hash(this->friendly_name_().c_str())); // Format: mac-component_type-hash (e.g. "aabbccddeeff-sensor-12345678") // MAC (12) + "-" (1) + domain (max 20) + "-" (1) + hash (8) + null (1) = 43 char unique_id[MAC_ADDRESS_BUFFER_SIZE + ESPHOME_DOMAIN_MAX_LEN + 11]; @@ -415,7 +415,7 @@ void MQTTComponent::schedule_resend_state() { this->resend_state_ = true; } bool MQTTComponent::is_connected_() const { return global_mqtt_client->is_connected(); } // Pull these properties from EntityBase if not overridden -std::string MQTTComponent::friendly_name_() const { return this->get_entity()->get_name(); } +const StringRef &MQTTComponent::friendly_name_() const { return this->get_entity()->get_name(); } StringRef MQTTComponent::get_default_object_id_to_(std::span buf) const { return this->get_entity()->get_object_id_to(buf); } diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index 76375fb106..eb98158647 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -293,7 +293,7 @@ class MQTTComponent : public Component { virtual const EntityBase *get_entity() const = 0; /// Get the friendly name of this MQTT component. - std::string friendly_name_() const; + const StringRef &friendly_name_() const; /// Get the icon field of this component as StringRef StringRef get_icon_ref_() const; From 422f413680690b257429a4c4f8b51a700bee40e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 03:26:44 -0600 Subject: [PATCH 14/16] [lps22] Replace set_retry with set_interval to avoid heap allocation (#13841) --- esphome/components/lps22/lps22.cpp | 14 ++++++++++---- esphome/components/lps22/lps22.h | 5 +++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/esphome/components/lps22/lps22.cpp b/esphome/components/lps22/lps22.cpp index 526286ba72..7fc5774b08 100644 --- a/esphome/components/lps22/lps22.cpp +++ b/esphome/components/lps22/lps22.cpp @@ -38,22 +38,29 @@ void LPS22Component::dump_config() { LOG_UPDATE_INTERVAL(this); } +static constexpr uint32_t INTERVAL_READ = 0; + void LPS22Component::update() { uint8_t value = 0x00; this->read_register(CTRL_REG2, &value, 1); value |= CTRL_REG2_ONE_SHOT_MASK; this->write_register(CTRL_REG2, &value, 1); - this->set_retry(READ_INTERVAL, READ_ATTEMPTS, [this](uint8_t _) { return this->try_read_(); }); + this->read_attempts_remaining_ = READ_ATTEMPTS; + this->set_interval(INTERVAL_READ, READ_INTERVAL, [this]() { this->try_read_(); }); } -RetryResult LPS22Component::try_read_() { +void LPS22Component::try_read_() { uint8_t value = 0x00; this->read_register(STATUS, &value, 1); const uint8_t expected_status_mask = STATUS_T_DA_MASK | STATUS_P_DA_MASK; if ((value & expected_status_mask) != expected_status_mask) { ESP_LOGD(TAG, "STATUS not ready: %x", value); - return RetryResult::RETRY; + if (--this->read_attempts_remaining_ == 0) { + this->cancel_interval(INTERVAL_READ); + } + return; } + this->cancel_interval(INTERVAL_READ); if (this->temperature_sensor_ != nullptr) { uint8_t t_buf[2]{0}; @@ -68,7 +75,6 @@ RetryResult LPS22Component::try_read_() { uint32_t p_lsb = encode_uint24(p_buf[2], p_buf[1], p_buf[0]); this->pressure_sensor_->publish_state(PRESSURE_SCALE * static_cast(p_lsb)); } - return RetryResult::DONE; } } // namespace lps22 diff --git a/esphome/components/lps22/lps22.h b/esphome/components/lps22/lps22.h index 549ea524ea..95ee4ad442 100644 --- a/esphome/components/lps22/lps22.h +++ b/esphome/components/lps22/lps22.h @@ -17,10 +17,11 @@ class LPS22Component : public sensor::Sensor, public PollingComponent, public i2 void dump_config() override; protected: + void try_read_(); + sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *pressure_sensor_{nullptr}; - - RetryResult try_read_(); + uint8_t read_attempts_remaining_{0}; }; } // namespace lps22 From bed01da345c6c652ef464d800aa2c8152c9681a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 03:45:40 -0600 Subject: [PATCH 15/16] [api] Guard varint parsing against overlong encodings (#13870) --- esphome/components/api/proto.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 92978f765f..41ea0043f9 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -112,8 +112,12 @@ class ProtoVarInt { uint64_t result = buffer[0] & 0x7F; uint8_t bitpos = 7; + // A 64-bit varint is at most 10 bytes (ceil(64/7)). Reject overlong encodings + // to avoid undefined behavior from shifting uint64_t by >= 64 bits. + uint32_t max_len = std::min(len, uint32_t(10)); + // Start from the second byte since we've already processed the first - for (uint32_t i = 1; i < len; i++) { + for (uint32_t i = 1; i < max_len; i++) { uint8_t val = buffer[i]; result |= uint64_t(val & 0x7F) << uint64_t(bitpos); bitpos += 7; From fb9328372028c941d7a1c1bc446efbf157c835d0 Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 9 Feb 2026 01:52:49 -0800 Subject: [PATCH 16/16] [water_heater] Add state masking to distinguish explicit commands from no-change (#13879) --- .../components/water_heater/water_heater.cpp | 18 ++++++++++++------ esphome/components/water_heater/water_heater.h | 4 ++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/esphome/components/water_heater/water_heater.cpp b/esphome/components/water_heater/water_heater.cpp index e6d1562352..9d7ae0cbc0 100644 --- a/esphome/components/water_heater/water_heater.cpp +++ b/esphome/components/water_heater/water_heater.cpp @@ -65,6 +65,7 @@ WaterHeaterCall &WaterHeaterCall::set_away(bool away) { } else { this->state_ &= ~WATER_HEATER_STATE_AWAY; } + this->state_mask_ |= WATER_HEATER_STATE_AWAY; return *this; } @@ -74,6 +75,7 @@ WaterHeaterCall &WaterHeaterCall::set_on(bool on) { } else { this->state_ &= ~WATER_HEATER_STATE_ON; } + this->state_mask_ |= WATER_HEATER_STATE_ON; return *this; } @@ -92,11 +94,11 @@ void WaterHeaterCall::perform() { if (!std::isnan(this->target_temperature_high_)) { ESP_LOGD(TAG, " Target Temperature High: %.2f", this->target_temperature_high_); } - if (this->state_ & WATER_HEATER_STATE_AWAY) { - ESP_LOGD(TAG, " Away: YES"); + if (this->state_mask_ & WATER_HEATER_STATE_AWAY) { + ESP_LOGD(TAG, " Away: %s", (this->state_ & WATER_HEATER_STATE_AWAY) ? "YES" : "NO"); } - if (this->state_ & WATER_HEATER_STATE_ON) { - ESP_LOGD(TAG, " On: YES"); + if (this->state_mask_ & WATER_HEATER_STATE_ON) { + ESP_LOGD(TAG, " On: %s", (this->state_ & WATER_HEATER_STATE_ON) ? "YES" : "NO"); } this->parent_->control(*this); } @@ -137,13 +139,17 @@ void WaterHeaterCall::validate_() { this->target_temperature_high_ = NAN; } } - if ((this->state_ & WATER_HEATER_STATE_AWAY) && !traits.get_supports_away_mode()) { - ESP_LOGW(TAG, "'%s' - Away mode not supported", this->parent_->get_name().c_str()); + if (!traits.get_supports_away_mode()) { + if (this->state_ & WATER_HEATER_STATE_AWAY) { + ESP_LOGW(TAG, "'%s' - Away mode not supported", this->parent_->get_name().c_str()); + } this->state_ &= ~WATER_HEATER_STATE_AWAY; + this->state_mask_ &= ~WATER_HEATER_STATE_AWAY; } // If ON/OFF not supported, device is always on - clear the flag silently if (!traits.has_feature_flags(WATER_HEATER_SUPPORTS_ON_OFF)) { this->state_ &= ~WATER_HEATER_STATE_ON; + this->state_mask_ &= ~WATER_HEATER_STATE_ON; } } diff --git a/esphome/components/water_heater/water_heater.h b/esphome/components/water_heater/water_heater.h index 7bd05ba7f5..93fcf5f401 100644 --- a/esphome/components/water_heater/water_heater.h +++ b/esphome/components/water_heater/water_heater.h @@ -91,6 +91,8 @@ class WaterHeaterCall { float get_target_temperature_high() const { return this->target_temperature_high_; } /// Get state flags value uint32_t get_state() const { return this->state_; } + /// Get mask of state flags that are being changed + uint32_t get_state_mask() const { return this->state_mask_; } protected: void validate_(); @@ -100,6 +102,7 @@ class WaterHeaterCall { float target_temperature_low_{NAN}; float target_temperature_high_{NAN}; uint32_t state_{0}; + uint32_t state_mask_{0}; }; struct WaterHeaterCallInternal : public WaterHeaterCall { @@ -111,6 +114,7 @@ struct WaterHeaterCallInternal : public WaterHeaterCall { this->target_temperature_low_ = restore.target_temperature_low_; this->target_temperature_high_ = restore.target_temperature_high_; this->state_ = restore.state_; + this->state_mask_ = restore.state_mask_; return *this; } };