From 7a9fce90cb1411a268701cc57b03d811db4269d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Dec 2025 18:13:40 +0100 Subject: [PATCH 01/12] [text] Add integration tests for text command API (#12401) --- tests/integration/fixtures/text_command.yaml | 37 ++++++ tests/integration/test_text_command.py | 126 +++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 tests/integration/fixtures/text_command.yaml create mode 100644 tests/integration/test_text_command.py diff --git a/tests/integration/fixtures/text_command.yaml b/tests/integration/fixtures/text_command.yaml new file mode 100644 index 0000000000..cc91e2f792 --- /dev/null +++ b/tests/integration/fixtures/text_command.yaml @@ -0,0 +1,37 @@ +esphome: + name: host-text-command-test + +host: + +api: + batch_delay: 0ms + +logger: + +text: + - platform: template + name: "Test Text" + id: test_text + optimistic: true + min_length: 0 + max_length: 255 + mode: text + initial_value: "initial" + + - platform: template + name: "Test Password" + id: test_password + optimistic: true + min_length: 4 + max_length: 32 + mode: password + initial_value: "secret" + + - platform: template + name: "Test Text Long" + id: test_text_long + optimistic: true + min_length: 0 + max_length: 255 + mode: text + initial_value: "" diff --git a/tests/integration/test_text_command.py b/tests/integration/test_text_command.py new file mode 100644 index 0000000000..82fe981578 --- /dev/null +++ b/tests/integration/test_text_command.py @@ -0,0 +1,126 @@ +"""Integration test for text command zero-copy optimization. + +Tests that TextCommandRequest correctly handles the pointer_to_buffer +optimization for the state field, ensuring text values are properly +transmitted via the API. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from aioesphomeapi import TextInfo, TextState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_text_command( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test text command with various string values including edge cases.""" + loop = asyncio.get_running_loop() + async with run_compiled(yaml_config), api_client_connected() as client: + # Verify we can get device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "host-text-command-test" + + # Get list of entities + entities, _ = await client.list_entities_services() + + # Find our text entities using require_entity + test_text = require_entity(entities, "test_text", TextInfo, "Test Text entity") + test_password = require_entity( + entities, "test_password", TextInfo, "Test Password entity" + ) + test_text_long = require_entity( + entities, "test_text_long", TextInfo, "Test Text Long entity" + ) + + # Track state changes + states: dict[int, Any] = {} + state_futures: dict[int, asyncio.Future[Any]] = {} + + def on_state(state: Any) -> None: + states[state.key] = state + if state.key in state_futures and not state_futures[state.key].done(): + state_futures[state.key].set_result(state) + + # Set up InitialStateHelper to swallow initial state broadcasts + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + # Wait for all initial states to be received + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + # Verify initial states were received + assert test_text.key in initial_state_helper.initial_states + initial_text_state = initial_state_helper.initial_states[test_text.key] + assert isinstance(initial_text_state, TextState) + assert initial_text_state.state == "initial" + + async def wait_for_state_change(key: int, timeout: float = 2.0) -> Any: + """Wait for a state change for the given entity key.""" + state_futures[key] = loop.create_future() + try: + return await asyncio.wait_for(state_futures[key], timeout) + finally: + state_futures.pop(key, None) + + # Test 1: Simple text value + client.text_command(key=test_text.key, state="hello world") + state = await wait_for_state_change(test_text.key) + assert state.state == "hello world" + + # Test 2: Empty string (edge case for zero-copy) + client.text_command(key=test_text.key, state="") + state = await wait_for_state_change(test_text.key) + assert state.state == "" + + # Test 3: Single character + client.text_command(key=test_text.key, state="x") + state = await wait_for_state_change(test_text.key) + assert state.state == "x" + + # Test 4: String with special characters + client.text_command(key=test_text.key, state="hello\tworld\n!") + state = await wait_for_state_change(test_text.key) + assert state.state == "hello\tworld\n!" + + # Test 5: Unicode characters + client.text_command(key=test_text.key, state="hello δΈ–η•Œ 🌍") + state = await wait_for_state_change(test_text.key) + assert state.state == "hello δΈ–η•Œ 🌍" + + # Test 6: Long string (tests buffer handling) + long_text = "a" * 200 + client.text_command(key=test_text_long.key, state=long_text) + state = await wait_for_state_change(test_text_long.key) + assert state.state == long_text + assert len(state.state) == 200 + + # Test 7: Password field (same mechanism, different mode) + client.text_command(key=test_password.key, state="newpassword123") + state = await wait_for_state_change(test_password.key) + assert state.state == "newpassword123" + + # Test 8: String with null bytes embedded (edge case) + # Note: protobuf strings should handle this but it's good to verify + client.text_command(key=test_text.key, state="before\x00after") + state = await wait_for_state_change(test_text.key) + assert state.state == "before\x00after" + + # Test 9: Rapid successive commands (tests buffer reuse) + for i in range(5): + client.text_command(key=test_text.key, state=f"rapid_{i}") + state = await wait_for_state_change(test_text.key) + assert state.state == f"rapid_{i}" From 22918d3bd5d96793dce4ad208262b81ca71ea709 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 22:21:29 +0100 Subject: [PATCH 02/12] Bump peter-evans/create-pull-request from 7.0.11 to 8.0.0 (#12409) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/sync-device-classes.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 2c3219e38e..8c830d99c7 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -41,7 +41,7 @@ jobs: python script/run-in-env.py pre-commit run --all-files - name: Commit changes - uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 with: commit-message: "Synchronise Device Classes from Home Assistant" committer: esphomebot From 1f0a27b18181cd13d8979b9ca88a21510c1844f6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 22:34:24 +0100 Subject: [PATCH 03/12] Bump codecov/codecov-action from 5.5.1 to 5.5.2 (#12408) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03eadb5f0a..c067df237f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,7 +152,7 @@ jobs: . venv/bin/activate pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache From 369cc70fdfa26b803ec24432952ddf2c0e5e571f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Dec 2025 02:10:42 +0100 Subject: [PATCH 04/12] [climate] Save 48 bytes per entity by conditionally compiling visual overrides (#12406) --- esphome/components/climate/__init__.py | 5 +++++ esphome/components/climate/climate.cpp | 27 ++++++++++++++------------ esphome/components/climate/climate.h | 16 +++++++++------ esphome/core/defines.h | 1 + 4 files changed, 31 insertions(+), 18 deletions(-) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 5824e68141..b8e49db6c0 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -275,10 +275,13 @@ async def setup_climate_core_(var, config): visual = config[CONF_VISUAL] if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None: + cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") cg.add(var.set_visual_min_temperature_override(min_temp)) if (max_temp := visual.get(CONF_MAX_TEMPERATURE)) is not None: + cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") cg.add(var.set_visual_max_temperature_override(max_temp)) if (temp_step := visual.get(CONF_TEMPERATURE_STEP)) is not None: + cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") cg.add( var.set_visual_temperature_step_override( temp_step[CONF_TARGET_TEMPERATURE], @@ -286,8 +289,10 @@ async def setup_climate_core_(var, config): ) ) if (min_humidity := visual.get(CONF_MIN_HUMIDITY)) is not None: + cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") cg.add(var.set_visual_min_humidity_override(min_humidity)) if (max_humidity := visual.get(CONF_MAX_HUMIDITY)) is not None: + cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") cg.add(var.set_visual_max_humidity_override(max_humidity)) if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index b0fba6aa62..3bc20a17c6 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -473,26 +473,28 @@ void Climate::publish_state() { ClimateTraits Climate::get_traits() { auto traits = this->traits(); - if (this->visual_min_temperature_override_.has_value()) { - traits.set_visual_min_temperature(*this->visual_min_temperature_override_); +#ifdef USE_CLIMATE_VISUAL_OVERRIDES + if (!std::isnan(this->visual_min_temperature_override_)) { + traits.set_visual_min_temperature(this->visual_min_temperature_override_); } - if (this->visual_max_temperature_override_.has_value()) { - traits.set_visual_max_temperature(*this->visual_max_temperature_override_); + if (!std::isnan(this->visual_max_temperature_override_)) { + traits.set_visual_max_temperature(this->visual_max_temperature_override_); } - if (this->visual_target_temperature_step_override_.has_value()) { - traits.set_visual_target_temperature_step(*this->visual_target_temperature_step_override_); - traits.set_visual_current_temperature_step(*this->visual_current_temperature_step_override_); + if (!std::isnan(this->visual_target_temperature_step_override_)) { + traits.set_visual_target_temperature_step(this->visual_target_temperature_step_override_); + traits.set_visual_current_temperature_step(this->visual_current_temperature_step_override_); } - if (this->visual_min_humidity_override_.has_value()) { - traits.set_visual_min_humidity(*this->visual_min_humidity_override_); + if (!std::isnan(this->visual_min_humidity_override_)) { + traits.set_visual_min_humidity(this->visual_min_humidity_override_); } - if (this->visual_max_humidity_override_.has_value()) { - traits.set_visual_max_humidity(*this->visual_max_humidity_override_); + if (!std::isnan(this->visual_max_humidity_override_)) { + traits.set_visual_max_humidity(this->visual_max_humidity_override_); } - +#endif return traits; } +#ifdef USE_CLIMATE_VISUAL_OVERRIDES void Climate::set_visual_min_temperature_override(float visual_min_temperature_override) { this->visual_min_temperature_override_ = visual_min_temperature_override; } @@ -513,6 +515,7 @@ void Climate::set_visual_min_humidity_override(float visual_min_humidity_overrid void Climate::set_visual_max_humidity_override(float visual_max_humidity_override) { this->visual_max_humidity_override_ = visual_max_humidity_override; } +#endif ClimateCall Climate::make_call() { return ClimateCall(this); } diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index 28a73d8c05..82df4b815f 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -213,11 +213,13 @@ class Climate : public EntityBase { */ ClimateTraits get_traits(); +#ifdef USE_CLIMATE_VISUAL_OVERRIDES void set_visual_min_temperature_override(float visual_min_temperature_override); void set_visual_max_temperature_override(float visual_max_temperature_override); void set_visual_temperature_step_override(float target, float current); void set_visual_min_humidity_override(float visual_min_humidity_override); void set_visual_max_humidity_override(float visual_max_humidity_override); +#endif /// Check if a custom fan mode is currently active. bool has_custom_fan_mode() const { return this->custom_fan_mode_ != nullptr; } @@ -321,12 +323,14 @@ class Climate : public EntityBase { CallbackManager state_callback_{}; CallbackManager control_callback_{}; ESPPreferenceObject rtc_; - optional visual_min_temperature_override_{}; - optional visual_max_temperature_override_{}; - optional visual_target_temperature_step_override_{}; - optional visual_current_temperature_step_override_{}; - optional visual_min_humidity_override_{}; - optional visual_max_humidity_override_{}; +#ifdef USE_CLIMATE_VISUAL_OVERRIDES + float visual_min_temperature_override_{NAN}; + float visual_max_temperature_override_{NAN}; + float visual_target_temperature_step_override_{NAN}; + float visual_current_temperature_step_override_{NAN}; + float visual_min_humidity_override_{NAN}; + float visual_max_humidity_override_{NAN}; +#endif private: /** The active custom fan mode (private - enforces use of safe setters). diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a5170d73ff..750cab5bba 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -28,6 +28,7 @@ #define USE_BUTTON #define USE_CAMERA #define USE_CLIMATE +#define USE_CLIMATE_VISUAL_OVERRIDES #define USE_CONTROLLER_REGISTRY #define USE_COVER #define USE_DATETIME From 74218bc74271b4b282514b1fae1b8547cdbaef6b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Dec 2025 19:33:22 -0600 Subject: [PATCH 05/12] [api] Release prologue memory after noise handshake completes (#12412) --- esphome/components/api/api_frame_helper_noise.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index ae69f0b673..1d6f32ee9d 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -539,7 +539,8 @@ APIError APINoiseFrameHelper::init_handshake_() { if (aerr != APIError::OK) return aerr; // set_prologue copies it into handshakestate, so we can get rid of it now - prologue_ = {}; + // Use swap idiom to actually release memory (= {} only clears size, not capacity) + std::vector().swap(prologue_); err = noise_handshakestate_start(handshake_); aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_start"), APIError::HANDSHAKESTATE_SETUP_FAILED); From 8d1e68c4c17e572856419f91d49914358f9a3023 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 17:53:12 -0600 Subject: [PATCH 06/12] Bump tornado from 6.5.2 to 6.5.3 (#12430) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 71aaf47ddb..7a50e1296f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 -tornado==6.5.2 +tornado==6.5.3 tzlocal==5.3.1 # from time tzdata>=2021.1 # from time pyserial==3.5 From d30d8156c1065897748c66a431df19d6102c343b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 12 Dec 2025 10:31:17 -0500 Subject: [PATCH 07/12] [http_request] Skip update check when network not connected (#12418) Co-authored-by: Claude --- .../components/http_request/update/http_request_update.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index c91b0eba73..26af754e69 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -36,6 +36,10 @@ void HttpRequestUpdate::setup() { } void HttpRequestUpdate::update() { + if (!network::is_connected()) { + ESP_LOGD(TAG, "Network not connected, skipping update check"); + return; + } #ifdef USE_ESP32 xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, (void *) this, 1, &this->update_task_handle_); #else From 1d13d18a165fcefcae203643db9a35e5c25c460c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Dec 2025 04:19:29 +0100 Subject: [PATCH 08/12] [light] Add zero-copy support for API effect commands (#12384) --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_connection.cpp | 2 +- esphome/components/api/api_pb2.cpp | 7 +++++-- esphome/components/api/api_pb2.h | 5 +++-- esphome/components/api/api_pb2_dump.cpp | 4 +++- esphome/components/light/light_call.cpp | 9 +++++---- esphome/components/light/light_call.h | 4 +++- 7 files changed, 21 insertions(+), 12 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 2534ad0b1f..50af5061c0 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -579,7 +579,7 @@ message LightCommandRequest { bool has_flash_length = 16; uint32 flash_length = 17; bool has_effect = 18; - string effect = 19; + string effect = 19 [(pointer_to_buffer) = true]; uint32 device_id = 28 [(field_ifdef) = "USE_DEVICES"]; } diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 4b10610281..09b311c1e4 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -533,7 +533,7 @@ void APIConnection::light_command(const LightCommandRequest &msg) { if (msg.has_flash_length) call.set_flash_length(msg.flash_length); if (msg.has_effect) - call.set_effect(msg.effect); + call.set_effect(reinterpret_cast(msg.effect), msg.effect_len); call.perform(); } #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 128f82fe7f..4a89ee78e1 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -611,9 +611,12 @@ bool LightCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { } bool LightCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 19: - this->effect = value.as_string(); + case 19: { + // Use raw data directly to avoid allocation + this->effect = value.data(); + this->effect_len = value.size(); break; + } default: return false; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 49f1ea3c52..f23a62fc3c 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -840,7 +840,7 @@ class LightStateResponse final : public StateResponseProtoMessage { class LightCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 32; - static constexpr uint8_t ESTIMATED_SIZE = 112; + static constexpr uint8_t ESTIMATED_SIZE = 122; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "light_command_request"; } #endif @@ -869,7 +869,8 @@ class LightCommandRequest final : public CommandProtoMessage { bool has_flash_length{false}; uint32_t flash_length{0}; bool has_effect{false}; - std::string effect{}; + const uint8_t *effect{nullptr}; + uint16_t effect_len{0}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index ca69d1ff00..5e271f41cb 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -999,7 +999,9 @@ void LightCommandRequest::dump_to(std::string &out) const { dump_field(out, "has_flash_length", this->has_flash_length); dump_field(out, "flash_length", this->flash_length); dump_field(out, "has_effect", this->has_effect); - dump_field(out, "effect", this->effect); + out.append(" effect: "); + out.append(format_hex_pretty(this->effect, this->effect_len)); + out.append("\n"); #ifdef USE_DEVICES dump_field(out, "device_id", this->device_id); #endif diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index dca5861734..8161e8b814 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -504,8 +504,8 @@ color_mode_bitmask_t LightCall::get_suitable_color_modes_mask_() { #undef KEY } -LightCall &LightCall::set_effect(const std::string &effect) { - if (strcasecmp(effect.c_str(), "none") == 0) { +LightCall &LightCall::set_effect(const char *effect, size_t len) { + if (len == 4 && strncasecmp(effect, "none", 4) == 0) { this->set_effect(0); return *this; } @@ -513,15 +513,16 @@ LightCall &LightCall::set_effect(const std::string &effect) { bool found = false; for (uint32_t i = 0; i < this->parent_->effects_.size(); i++) { LightEffect *e = this->parent_->effects_[i]; + const char *name = e->get_name(); - if (strcasecmp(effect.c_str(), e->get_name()) == 0) { + if (strncasecmp(effect, name, len) == 0 && name[len] == '\0') { this->set_effect(i + 1); found = true; break; } } if (!found) { - ESP_LOGW(TAG, "'%s': no such effect '%s'", this->parent_->get_name().c_str(), effect.c_str()); + ESP_LOGW(TAG, "'%s': no such effect '%.*s'", this->parent_->get_name().c_str(), (int) len, effect); } return *this; } diff --git a/esphome/components/light/light_call.h b/esphome/components/light/light_call.h index 6931b58b9d..0926ab6108 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -129,7 +129,9 @@ class LightCall { /// Set the effect of the light by its name. LightCall &set_effect(optional effect); /// Set the effect of the light by its name. - LightCall &set_effect(const std::string &effect); + LightCall &set_effect(const std::string &effect) { return this->set_effect(effect.data(), effect.size()); } + /// Set the effect of the light by its name and length (zero-copy from API). + LightCall &set_effect(const char *effect, size_t len); /// Set the effect of the light by its internal index number (only for internal use). LightCall &set_effect(uint32_t effect_number); LightCall &set_effect(optional effect_number); From 78b76045ce6fc3ebadb54740b75c23fa54678e2e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Dec 2025 04:20:23 +0100 Subject: [PATCH 09/12] [api] Fix potential buffer overflow in noise PSK base64 decode (#12395) --- esphome/components/api/api_connection.cpp | 2 +- esphome/core/helpers.cpp | 44 +++++++++++++++-------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 09b311c1e4..5186e5afda 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1669,7 +1669,7 @@ bool APIConnection::send_noise_encryption_set_key_response(const NoiseEncryption } else { ESP_LOGW(TAG, "Failed to clear encryption key"); } - } else if (base64_decode(msg.key, psk.data(), msg.key.size()) != psk.size()) { + } else if (base64_decode(msg.key, psk.data(), psk.size()) != psk.size()) { ESP_LOGW(TAG, "Invalid encryption key length"); } else if (!this->parent_->save_noise_psk(psk, true)) { ESP_LOGW(TAG, "Failed to save encryption key"); diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 77102c8db2..732e8b6f8b 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -480,22 +480,13 @@ std::string base64_encode(const uint8_t *buf, size_t buf_len) { } size_t base64_decode(const std::string &encoded_string, uint8_t *buf, size_t buf_len) { - std::vector decoded = base64_decode(encoded_string); - if (decoded.size() > buf_len) { - ESP_LOGW(TAG, "Base64 decode: buffer too small, truncating"); - decoded.resize(buf_len); - } - memcpy(buf, decoded.data(), decoded.size()); - return decoded.size(); -} - -std::vector base64_decode(const std::string &encoded_string) { int in_len = encoded_string.size(); int i = 0; int j = 0; int in = 0; + size_t out = 0; uint8_t char_array_4[4], char_array_3[3]; - std::vector ret; + bool truncated = false; // SAFETY: The loop condition checks is_base64() before processing each character. // This ensures base64_find_char() is only called on valid base64 characters, @@ -511,8 +502,13 @@ std::vector base64_decode(const std::string &encoded_string) { char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2); char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3]; - for (i = 0; (i < 3); i++) - ret.push_back(char_array_3[i]); + for (i = 0; i < 3; i++) { + if (out < buf_len) { + buf[out++] = char_array_3[i]; + } else { + truncated = true; + } + } i = 0; } } @@ -528,10 +524,28 @@ std::vector base64_decode(const std::string &encoded_string) { char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2); char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3]; - for (j = 0; (j < i - 1); j++) - ret.push_back(char_array_3[j]); + for (j = 0; j < i - 1; j++) { + if (out < buf_len) { + buf[out++] = char_array_3[j]; + } else { + truncated = true; + } + } } + if (truncated) { + ESP_LOGW(TAG, "Base64 decode: buffer too small, truncating"); + } + + return out; +} + +std::vector base64_decode(const std::string &encoded_string) { + // Calculate maximum decoded size: every 4 base64 chars = 3 bytes + size_t max_len = ((encoded_string.size() + 3) / 4) * 3; + std::vector ret(max_len); + size_t actual_len = base64_decode(encoded_string, ret.data(), max_len); + ret.resize(actual_len); return ret; } From 5567d96dd961c0379e3f3d6f512e3310a8cc6abe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Dec 2025 04:45:27 +0100 Subject: [PATCH 10/12] [esp8266] Eliminate up to 16ms socket latency (#12397) --- .../components/socket/lwip_raw_tcp_impl.cpp | 49 ++++++++++++++++--- esphome/components/socket/socket.h | 9 ++++ esphome/core/application.cpp | 7 +++ 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index e57af91b77..5538206058 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -14,13 +14,36 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifdef USE_ESP8266 +#include // For esp_schedule() +#endif + namespace esphome { namespace socket { +#ifdef USE_ESP8266 +// Flag to signal socket activity - checked by socket_delay() to exit early +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +static volatile bool s_socket_woke = false; + +void socket_delay(uint32_t ms) { + // Use esp_delay with a callback that checks if socket data arrived. + // This allows the delay to exit early when socket_wake() is called by + // lwip recv_fn/accept_fn callbacks, reducing socket latency. + s_socket_woke = false; + esp_delay(ms, []() { return !s_socket_woke; }); +} + +void socket_wake() { + s_socket_woke = true; + esp_schedule(); +} +#endif + static const char *const TAG = "socket.lwip"; // set to 1 to enable verbose lwip logging -#if 0 +#if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if) #define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__) #else #define LWIP_LOG(msg, ...) @@ -323,9 +346,10 @@ class LWIPRawImpl : public Socket { for (int i = 0; i < iovcnt; i++) { ssize_t err = read(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); if (err == -1) { - if (ret != 0) + if (ret != 0) { // if we already read some don't return an error break; + } return err; } ret += err; @@ -393,9 +417,10 @@ class LWIPRawImpl : public Socket { ssize_t written = internal_write(buf, len); if (written == -1) return -1; - if (written == 0) + if (written == 0) { // no need to output if nothing written return 0; + } if (nodelay_) { int err = internal_output(); if (err == -1) @@ -408,18 +433,20 @@ class LWIPRawImpl : public Socket { for (int i = 0; i < iovcnt; i++) { ssize_t err = internal_write(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); if (err == -1) { - if (written != 0) + if (written != 0) { // if we already read some don't return an error break; + } return err; } written += err; if ((size_t) err != iov[i].iov_len) break; } - if (written == 0) + if (written == 0) { // no need to output if nothing written return 0; + } if (nodelay_) { int err = internal_output(); if (err == -1) @@ -473,6 +500,10 @@ class LWIPRawImpl : public Socket { } else { pbuf_cat(rx_buf_, pb); } +#ifdef USE_ESP8266 + // Wake the main loop immediately so it can process the received data. + socket_wake(); +#endif return ERR_OK; } @@ -612,7 +643,7 @@ class LWIPRawListenImpl : public LWIPRawImpl { } private: - err_t accept_fn(struct tcp_pcb *newpcb, err_t err) { + err_t accept_fn_(struct tcp_pcb *newpcb, err_t err) { LWIP_LOG("accept(newpcb=%p err=%d)", newpcb, err); if (err != ERR_OK || newpcb == nullptr) { // "An error code if there has been an error accepting. Only return ERR_ABRT if you have @@ -633,12 +664,16 @@ class LWIPRawListenImpl : public LWIPRawImpl { sock->init(); accepted_sockets_[accepted_socket_count_++] = std::move(sock); LWIP_LOG("Accepted connection, queue size: %d", accepted_socket_count_); +#ifdef USE_ESP8266 + // Wake the main loop immediately so it can accept the new connection. + socket_wake(); +#endif return ERR_OK; } static err_t s_accept_fn(void *arg, struct tcp_pcb *newpcb, err_t err) { LWIPRawListenImpl *arg_this = reinterpret_cast(arg); - return arg_this->accept_fn(newpcb, err); + return arg_this->accept_fn_(newpcb, err); } // Accept queue - holds incoming connections briefly until the event loop calls accept() diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 78a89fe008..8936b2cd10 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -82,6 +82,15 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const std::stri /// Set a sockaddr to the any address and specified port for the IP version used by socket_ip(). socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port); +#if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) +/// Delay that can be woken early by socket activity. +/// On ESP8266, lwip callbacks set a flag and call esp_schedule() to wake the delay. +void socket_delay(uint32_t ms); + +/// Called by lwip callbacks to signal socket activity and wake delay. +void socket_wake(); +#endif + } // namespace socket } // namespace esphome #endif diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 75814ae253..a85d671a07 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -12,6 +12,10 @@ #include "esphome/components/status_led/status_led.h" #endif +#if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) +#include "esphome/components/socket/socket.h" +#endif + #ifdef USE_SOCKET_SELECT_SUPPORT #include @@ -627,6 +631,9 @@ void Application::yield_with_select_(uint32_t delay_ms) { // No sockets registered, use regular delay delay(delay_ms); } +#elif defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) + // No select support but can wake on socket activity via esp_schedule() + socket::socket_delay(delay_ms); #else // No select support, use regular delay delay(delay_ms); From 2c77668a058ae669dead37974e4bc48d7dc8fa99 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 12 Dec 2025 10:31:17 -0500 Subject: [PATCH 11/12] [http_request] Skip update check when network not connected (#12418) Co-authored-by: Claude --- .../components/http_request/update/http_request_update.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index c91b0eba73..26af754e69 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -36,6 +36,10 @@ void HttpRequestUpdate::setup() { } void HttpRequestUpdate::update() { + if (!network::is_connected()) { + ESP_LOGD(TAG, "Network not connected, skipping update check"); + return; + } #ifdef USE_ESP32 xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, (void *) this, 1, &this->update_task_handle_); #else From c9506b056d3157af85250dc3a8410679436f0d42 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:12:58 -0500 Subject: [PATCH 12/12] Bump version to 2025.12.0b2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index ecb156d1f3..75c624bf2b 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.12.0b1 +PROJECT_NUMBER = 2025.12.0b2 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 93dd39b982..61bdc7df8d 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.12.0b1" +__version__ = "2025.12.0b2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = (