From 9c235b4140019387c59c0eaa8d06effff5826f46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Oct 2025 23:45:42 -1000 Subject: [PATCH 01/12] [datetime] Fix DateTimeStateTrigger compilation when time component is not used (#11287) --- esphome/components/datetime/datetime_base.h | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/datetime/datetime_base.h b/esphome/components/datetime/datetime_base.h index b7645f5539..b5f54ac96f 100644 --- a/esphome/components/datetime/datetime_base.h +++ b/esphome/components/datetime/datetime_base.h @@ -30,14 +30,12 @@ class DateTimeBase : public EntityBase { #endif }; -#ifdef USE_TIME class DateTimeStateTrigger : public Trigger { public: explicit DateTimeStateTrigger(DateTimeBase *parent) { parent->add_on_state_callback([this, parent]() { this->trigger(parent->state_as_esptime()); }); } }; -#endif } // namespace datetime } // namespace esphome From 0d612fecfc79fd629c6aca86fc7714430aee6031 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Oct 2025 11:28:52 -0400 Subject: [PATCH 02/12] [core] Add ESP32 ROM functions to reserved ids (#11293) --- esphome/config_validation.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 7aaba886e3..738c3dfe50 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -244,6 +244,20 @@ RESERVED_IDS = [ "uart0", "uart1", "uart2", + # ESP32 ROM functions + "crc16_be", + "crc16_le", + "crc32_be", + "crc32_le", + "crc8_be", + "crc8_le", + "dbg_state", + "debug_timer", + "one_bits", + "recv_packet", + "send_packet", + "check_pos", + "software_reset", ] From bb24ad4a30aa43331bed16308355d64d679e9444 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Oct 2025 11:29:05 -0400 Subject: [PATCH 03/12] [htu21d] Revert register address change (#11291) --- esphome/components/htu21d/htu21d.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/htu21d/htu21d.cpp b/esphome/components/htu21d/htu21d.cpp index a7aae16f17..c5d91d3dd0 100644 --- a/esphome/components/htu21d/htu21d.cpp +++ b/esphome/components/htu21d/htu21d.cpp @@ -9,8 +9,8 @@ static const char *const TAG = "htu21d"; static const uint8_t HTU21D_ADDRESS = 0x40; static const uint8_t HTU21D_REGISTER_RESET = 0xFE; -static const uint8_t HTU21D_REGISTER_TEMPERATURE = 0xE3; -static const uint8_t HTU21D_REGISTER_HUMIDITY = 0xE5; +static const uint8_t HTU21D_REGISTER_TEMPERATURE = 0xF3; +static const uint8_t HTU21D_REGISTER_HUMIDITY = 0xF5; static const uint8_t HTU21D_WRITERHT_REG_CMD = 0xE6; /**< Write RH/T User Register 1 */ static const uint8_t HTU21D_REGISTER_STATUS = 0xE7; static const uint8_t HTU21D_WRITEHEATER_REG_CMD = 0x51; /**< Write Heater Control Register */ From 913095f6bebe031bb354ba295aae0d353cd401f2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Oct 2025 16:55:30 -0400 Subject: [PATCH 04/12] [esp32] Reduce tx power on Arduino (#11304) --- esphome/components/esp32/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 724cb2797d..76d0be345f 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -790,6 +790,7 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_AUTOSTART_ARDUINO", True) add_idf_sdkconfig_option("CONFIG_MBEDTLS_PSK_MODES", True) add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True) + add_idf_sdkconfig_option("CONFIG_ESP_PHY_REDUCE_TX_POWER", True) cg.add_build_flag("-Wno-nonnull-compare") From ea609dc0f60647d7ba13c4cdda2f11dc41585148 Mon Sep 17 00:00:00 2001 From: Daniel Stiner Date: Fri, 17 Oct 2025 09:02:28 +0200 Subject: [PATCH 05/12] [const] Add CONF_OPENTHREAD (#11318) --- esphome/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/const.py b/esphome/const.py index 54edcad8c9..c1abb8f530 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -696,6 +696,7 @@ CONF_OPEN_DRAIN = "open_drain" CONF_OPEN_DRAIN_INTERRUPT = "open_drain_interrupt" CONF_OPEN_DURATION = "open_duration" CONF_OPEN_ENDSTOP = "open_endstop" +CONF_OPENTHREAD = "openthread" CONF_OPERATION = "operation" CONF_OPTIMISTIC = "optimistic" CONF_OPTION = "option" From 8c1bd2fd85b445905a5d99e7246c9b8bdd6fb618 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Oct 2025 08:20:55 -1000 Subject: [PATCH 06/12] [dashboard] Fix binary download with packages using secrets after Path migration (#11313) --- esphome/dashboard/settings.py | 11 ++++- tests/dashboard/test_settings.py | 62 ++++++++++++++++++++++++++ tests/dashboard/test_web_server.py | 71 ++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) diff --git a/esphome/dashboard/settings.py b/esphome/dashboard/settings.py index 35b67c0d23..6035b4a1d6 100644 --- a/esphome/dashboard/settings.py +++ b/esphome/dashboard/settings.py @@ -10,6 +10,10 @@ from esphome.helpers import get_bool_env from .util.password import password_hash +# Sentinel file name used for CORE.config_path when dashboard initializes. +# This ensures .parent returns the config directory instead of root. +_DASHBOARD_SENTINEL_FILE = "___DASHBOARD_SENTINEL___.yaml" + class DashboardSettings: """Settings for the dashboard.""" @@ -48,7 +52,12 @@ class DashboardSettings: self.config_dir = Path(args.configuration) self.absolute_config_dir = self.config_dir.resolve() self.verbose = args.verbose - CORE.config_path = self.config_dir / "." + # Set to a sentinel file so .parent gives us the config directory. + # Previously this was `os.path.join(self.config_dir, ".")` which worked because + # os.path.dirname("/config/.") returns "/config", but Path("/config/.").parent + # normalizes to Path("/config") first, then .parent returns Path("/"), breaking + # secret resolution. Using a sentinel file ensures .parent gives the correct directory. + CORE.config_path = self.config_dir / _DASHBOARD_SENTINEL_FILE @property def relative_url(self) -> str: diff --git a/tests/dashboard/test_settings.py b/tests/dashboard/test_settings.py index c9097fe5e2..91a8ec70c3 100644 --- a/tests/dashboard/test_settings.py +++ b/tests/dashboard/test_settings.py @@ -2,11 +2,13 @@ from __future__ import annotations +from argparse import Namespace from pathlib import Path import tempfile import pytest +from esphome.core import CORE from esphome.dashboard.settings import DashboardSettings @@ -159,3 +161,63 @@ def test_rel_path_with_numeric_args(dashboard_settings: DashboardSettings) -> No result = dashboard_settings.rel_path("123", "456.789") expected = dashboard_settings.config_dir / "123" / "456.789" assert result == expected + + +def test_config_path_parent_resolves_to_config_dir(tmp_path: Path) -> None: + """Test that CORE.config_path.parent resolves to config_dir after parse_args. + + This is a regression test for issue #11280 where binary download failed + when using packages with secrets after the Path migration in 2025.10.0. + + The issue was that after switching from os.path to Path: + - Before: os.path.dirname("/config/.") → "/config" + - After: Path("/config/.").parent → Path("/") (normalized first!) + + The fix uses a sentinel file so .parent returns the correct directory: + - Fixed: Path("/config/___DASHBOARD_SENTINEL___.yaml").parent → Path("/config") + """ + # Create test directory structure with secrets and packages + config_dir = tmp_path / "config" + config_dir.mkdir() + + # Create secrets.yaml with obviously fake test values + secrets_file = config_dir / "secrets.yaml" + secrets_file.write_text( + "wifi_ssid: TEST-DUMMY-SSID\n" + "wifi_password: not-a-real-password-just-for-testing\n" + ) + + # Create package file that uses secrets + package_file = config_dir / "common.yaml" + package_file.write_text( + "wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_password\n" + ) + + # Create main device config that includes the package + device_config = config_dir / "test-device.yaml" + device_config.write_text( + "esphome:\n name: test-device\n\npackages:\n common: !include common.yaml\n" + ) + + # Set up dashboard settings with our test config directory + settings = DashboardSettings() + args = Namespace( + configuration=str(config_dir), + password=None, + username=None, + ha_addon=False, + verbose=False, + ) + settings.parse_args(args) + + # Verify that CORE.config_path.parent correctly points to the config directory + # This is critical for secret resolution in yaml_util.py which does: + # main_config_dir = CORE.config_path.parent + # main_secret_yml = main_config_dir / "secrets.yaml" + assert CORE.config_path.parent == config_dir.resolve() + assert (CORE.config_path.parent / "secrets.yaml").exists() + assert (CORE.config_path.parent / "common.yaml").exists() + + # Verify that CORE.config_path itself uses the sentinel file + assert CORE.config_path.name == "___DASHBOARD_SENTINEL___.yaml" + assert not CORE.config_path.exists() # Sentinel file doesn't actually exist diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 5bbe7e78fc..6c424e56d4 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -1,5 +1,6 @@ from __future__ import annotations +from argparse import Namespace import asyncio from collections.abc import Generator from contextlib import asynccontextmanager @@ -17,6 +18,8 @@ from tornado.ioloop import IOLoop from tornado.testing import bind_unused_port from tornado.websocket import WebSocketClientConnection, websocket_connect +from esphome import yaml_util +from esphome.core import CORE from esphome.dashboard import web_server from esphome.dashboard.const import DashboardEvent from esphome.dashboard.core import DASHBOARD @@ -1302,3 +1305,71 @@ async def test_dashboard_subscriber_refresh_event( # Give it a moment to clean up await asyncio.sleep(0.01) + + +@pytest.mark.asyncio +async def test_dashboard_yaml_loading_with_packages_and_secrets( + tmp_path: Path, +) -> None: + """Test dashboard YAML loading with packages referencing secrets. + + This is a regression test for issue #11280 where binary download failed + when using packages with secrets after the Path migration in 2025.10.0. + + This test verifies that CORE.config_path initialization in the dashboard + allows yaml_util.load_yaml() to correctly resolve secrets from packages. + """ + # Create test directory structure with secrets and packages + config_dir = tmp_path / "config" + config_dir.mkdir() + + # Create secrets.yaml with obviously fake test values + secrets_file = config_dir / "secrets.yaml" + secrets_file.write_text( + "wifi_ssid: TEST-DUMMY-SSID\n" + "wifi_password: not-a-real-password-just-for-testing\n" + ) + + # Create package file that uses secrets + package_file = config_dir / "common.yaml" + package_file.write_text( + "wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_password\n" + ) + + # Create main device config that includes the package + device_config = config_dir / "test-download-secrets.yaml" + device_config.write_text( + "esphome:\n name: test-download-secrets\n platform: ESP32\n board: esp32dev\n\n" + "packages:\n common: !include common.yaml\n" + ) + + # Initialize DASHBOARD settings with our test config directory + # This is what sets CORE.config_path - the critical code path for the bug + args = Namespace( + configuration=str(config_dir), + password=None, + username=None, + ha_addon=False, + verbose=False, + ) + DASHBOARD.settings.parse_args(args) + + # With the fix: CORE.config_path should be config_dir / "___DASHBOARD_SENTINEL___.yaml" + # so CORE.config_path.parent would be config_dir + # Without the fix: CORE.config_path is config_dir / "." which normalizes to config_dir + # so CORE.config_path.parent would be tmp_path (the parent of config_dir) + + # The fix ensures CORE.config_path.parent points to config_dir + assert CORE.config_path.parent == config_dir.resolve(), ( + f"CORE.config_path.parent should point to config_dir. " + f"Got {CORE.config_path.parent}, expected {config_dir.resolve()}. " + f"CORE.config_path is {CORE.config_path}" + ) + + # Now load the YAML with packages that reference secrets + # This is where the bug would manifest - yaml_util.load_yaml would fail + # to find secrets.yaml because CORE.config_path.parent pointed to the wrong place + config = yaml_util.load_yaml(device_config) + # If we get here, secret resolution worked! + assert "esphome" in config + assert config["esphome"]["name"] == "test-download-secrets" From 1483cee0fb3c558dcd6b5b0caee124c983cdf487 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 18 Oct 2025 19:32:12 +0200 Subject: [PATCH 07/12] [dashboard] fix migration to Path (#11342) Co-authored-by: J. Nick Koston --- esphome/dashboard/web_server.py | 3 +- tests/dashboard/test_web_server.py | 194 +++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 1 deletion(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index a79c67c3d2..804a2b99af 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -1058,7 +1058,8 @@ class DownloadBinaryRequestHandler(BaseHandler): "download", f"{storage_json.name}-{file_name}", ) - path = storage_json.firmware_bin_path.with_name(file_name) + + path = storage_json.firmware_bin_path.parent.joinpath(file_name) if not path.is_file(): args = ["esphome", "idedata", settings.rel_path(configuration)] diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 6c424e56d4..385841b1c8 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -35,6 +35,26 @@ from esphome.zeroconf import DiscoveredImport from .common import get_fixture_path +def get_build_path(base_path: Path, device_name: str) -> Path: + """Get the build directory path for a device. + + This is a test helper that constructs the standard ESPHome build directory + structure. Note: This helper does NOT perform path traversal sanitization + because it's only used in tests where we control the inputs. The actual + web_server.py code handles sanitization in DownloadBinaryRequestHandler.get() + via file_name.replace("..", "").lstrip("/"). + + Args: + base_path: The base temporary path (typically tmp_path from pytest) + device_name: The name of the device (should not contain path separators + in production use, but tests may use it for specific scenarios) + + Returns: + Path to the build directory (.esphome/build/device_name) + """ + return base_path / ".esphome" / "build" / device_name + + class DashboardTestHelper: def __init__(self, io_loop: IOLoop, client: AsyncHTTPClient, port: int) -> None: self.io_loop = io_loop @@ -417,6 +437,180 @@ async def test_download_binary_handler_idedata_fallback( assert response.body == b"bootloader content" +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +async def test_download_binary_handler_subdirectory_file( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_storage_json: MagicMock, +) -> None: + """Test the DownloadBinaryRequestHandler.get with file in subdirectory (nRF52 case). + + This is a regression test for issue #11343 where the Path migration broke + downloads for nRF52 firmware files in subdirectories like 'zephyr/zephyr.uf2'. + + The issue was that with_name() doesn't accept path separators: + - Before: path = storage_json.firmware_bin_path.with_name(file_name) + ValueError: Invalid name 'zephyr/zephyr.uf2' + - After: path = storage_json.firmware_bin_path.parent.joinpath(file_name) + Works correctly with subdirectory paths + """ + # Create a fake nRF52 build structure with firmware in subdirectory + build_dir = get_build_path(tmp_path, "nrf52-device") + zephyr_dir = build_dir / "zephyr" + zephyr_dir.mkdir(parents=True) + + # Create the main firmware binary (would be in build root) + firmware_file = build_dir / "firmware.bin" + firmware_file.write_bytes(b"main firmware") + + # Create the UF2 file in zephyr subdirectory (nRF52 specific) + uf2_file = zephyr_dir / "zephyr.uf2" + uf2_file.write_bytes(b"nRF52 UF2 firmware content") + + # Mock storage JSON + mock_storage = Mock() + mock_storage.name = "nrf52-device" + mock_storage.firmware_bin_path = firmware_file + mock_storage_json.load.return_value = mock_storage + + # Request the UF2 file with subdirectory path + response = await dashboard.fetch( + "/download.bin?configuration=nrf52-device.yaml&file=zephyr/zephyr.uf2", + method="GET", + ) + assert response.code == 200 + assert response.body == b"nRF52 UF2 firmware content" + assert response.headers["Content-Type"] == "application/octet-stream" + assert "attachment" in response.headers["Content-Disposition"] + # Download name should be device-name + full file path + assert "nrf52-device-zephyr/zephyr.uf2" in response.headers["Content-Disposition"] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +async def test_download_binary_handler_subdirectory_file_url_encoded( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_storage_json: MagicMock, +) -> None: + """Test the DownloadBinaryRequestHandler.get with URL-encoded subdirectory path. + + Verifies that URL-encoded paths (e.g., zephyr%2Fzephyr.uf2) are correctly + decoded and handled, and that custom download names work with subdirectories. + """ + # Create a fake build structure with firmware in subdirectory + build_dir = get_build_path(tmp_path, "test") + zephyr_dir = build_dir / "zephyr" + zephyr_dir.mkdir(parents=True) + + firmware_file = build_dir / "firmware.bin" + firmware_file.write_bytes(b"content") + + uf2_file = zephyr_dir / "zephyr.uf2" + uf2_file.write_bytes(b"content") + + # Mock storage JSON + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = firmware_file + mock_storage_json.load.return_value = mock_storage + + # Request with URL-encoded path and custom download name + response = await dashboard.fetch( + "/download.bin?configuration=test.yaml&file=zephyr%2Fzephyr.uf2&download=custom_name.bin", + method="GET", + ) + assert response.code == 200 + assert "custom_name.bin" in response.headers["Content-Disposition"] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +@pytest.mark.parametrize( + "attack_path", + [ + 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"), + ], +) +async def test_download_binary_handler_path_traversal_protection( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_storage_json: MagicMock, + attack_path: str, +) -> 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. + """ + # Create build structure + build_dir = get_build_path(tmp_path, "test") + build_dir.mkdir(parents=True) + firmware_file = build_dir / "firmware.bin" + firmware_file.write_bytes(b"firmware content") + + # Create a sensitive file outside the build directory that should NOT be accessible + sensitive_file = tmp_path / "secrets.yaml" + sensitive_file.write_bytes(b"secret: my_secret_password") + + # Mock storage JSON + mock_storage = Mock() + mock_storage.name = "test_device" + 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: + 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) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +async def test_download_binary_handler_multiple_subdirectory_levels( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_storage_json: MagicMock, +) -> None: + """Test downloading files from multiple subdirectory levels. + + Verifies that joinpath correctly handles multi-level paths like 'build/output/firmware.bin'. + """ + # Create nested directory structure + build_dir = get_build_path(tmp_path, "test") + nested_dir = build_dir / "build" / "output" + nested_dir.mkdir(parents=True) + + firmware_file = build_dir / "firmware.bin" + firmware_file.write_bytes(b"main") + + nested_file = nested_dir / "firmware.bin" + nested_file.write_bytes(b"nested firmware content") + + # Mock storage JSON + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = firmware_file + mock_storage_json.load.return_value = mock_storage + + response = await dashboard.fetch( + "/download.bin?configuration=test.yaml&file=build/output/firmware.bin", + method="GET", + ) + assert response.code == 200 + assert response.body == b"nested firmware content" + + @pytest.mark.asyncio async def test_edit_request_handler_post_invalid_file( dashboard: DashboardTestHelper, From 0e34d1b64d9341c1146f862d5631061283894d77 Mon Sep 17 00:00:00 2001 From: Spectre5 <31610422+Spectre5@users.noreply.github.com> Date: Sat, 18 Oct 2025 18:13:57 -0700 Subject: [PATCH 08/12] Change all temperature offsets to temperature_delta (#11347) --- esphome/components/bme680_bsec/__init__.py | 2 +- esphome/components/bme68x_bsec2/__init__.py | 2 +- esphome/components/scd4x/sensor.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index 330dc4dd9c..8a8d74b5f3 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -41,7 +41,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(BME680BSECComponent), - cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature, + cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature_delta, cv.Optional(CONF_IAQ_MODE, default="STATIC"): cv.enum( IAQ_MODE_OPTIONS, upper=True ), diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index f4235b31b4..e421efb2d6 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -139,7 +139,7 @@ CONFIG_SCHEMA_BASE = ( cv.Optional(CONF_SUPPLY_VOLTAGE, default="3.3V"): cv.enum( VOLTAGE_OPTIONS, upper=True ), - cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature, + cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature_delta, cv.Optional( CONF_STATE_SAVE_INTERVAL, default="6hours" ): cv.positive_time_period_minutes, diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index 6b2188cd5a..ec90234ac3 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -81,7 +81,7 @@ CONFIG_SCHEMA = ( cv.int_range(min=0, max=0xFFFF, max_included=False), ), cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION): cv.pressure, - cv.Optional(CONF_TEMPERATURE_OFFSET, default="4°C"): cv.temperature, + cv.Optional(CONF_TEMPERATURE_OFFSET, default="4°C"): cv.temperature_delta, cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE): cv.use_id( sensor.Sensor ), From 6aff1394ad55340b11dabac20b479ff1879446e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Oct 2025 09:15:47 -1000 Subject: [PATCH 09/12] [core] Fix IndexError when OTA devices cannot be resolved (#11311) --- esphome/__main__.py | 4 +- tests/unit_tests/test_main.py | 78 +++++++++++++++++++---------------- 2 files changed, 45 insertions(+), 37 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index d9bdfb175b..d7f11feef9 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -185,7 +185,9 @@ def choose_upload_log_host( else: resolved.append(device) if not resolved: - _LOGGER.error("All specified devices: %s could not be resolved.", defaults) + raise EsphomeError( + f"All specified devices {defaults} could not be resolved. Is the device connected to the network?" + ) return resolved # No devices specified, show interactive chooser diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 59d0433aa4..73dfe359f0 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -321,12 +321,14 @@ def test_choose_upload_log_host_with_serial_device_no_ports( ) -> None: """Test SERIAL device when no serial ports are found.""" setup_core() - result = choose_upload_log_host( - default="SERIAL", - check_default=None, - purpose=Purpose.UPLOADING, - ) - assert result == [] + with pytest.raises( + EsphomeError, match="All specified devices .* could not be resolved" + ): + choose_upload_log_host( + default="SERIAL", + check_default=None, + purpose=Purpose.UPLOADING, + ) assert "No serial ports found, skipping SERIAL device" in caplog.text @@ -367,12 +369,14 @@ def test_choose_upload_log_host_with_ota_device_with_api_config() -> None: """Test OTA device when API is configured (no upload without OTA in config).""" setup_core(config={CONF_API: {}}, address="192.168.1.100") - result = choose_upload_log_host( - default="OTA", - check_default=None, - purpose=Purpose.UPLOADING, - ) - assert result == [] + with pytest.raises( + EsphomeError, match="All specified devices .* could not be resolved" + ): + choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.UPLOADING, + ) def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> None: @@ -405,12 +409,14 @@ def test_choose_upload_log_host_with_ota_device_no_fallback() -> None: """Test OTA device with no valid fallback options.""" setup_core() - result = choose_upload_log_host( - default="OTA", - check_default=None, - purpose=Purpose.UPLOADING, - ) - assert result == [] + with pytest.raises( + EsphomeError, match="All specified devices .* could not be resolved" + ): + choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.UPLOADING, + ) @pytest.mark.usefixtures("mock_choose_prompt") @@ -615,21 +621,19 @@ def test_choose_upload_log_host_empty_defaults_list() -> None: @pytest.mark.usefixtures("mock_no_serial_ports", "mock_no_mqtt_logging") -def test_choose_upload_log_host_all_devices_unresolved( - caplog: pytest.LogCaptureFixture, -) -> None: +def test_choose_upload_log_host_all_devices_unresolved() -> None: """Test when all specified devices cannot be resolved.""" setup_core() - result = choose_upload_log_host( - default=["SERIAL", "OTA"], - check_default=None, - purpose=Purpose.UPLOADING, - ) - assert result == [] - assert ( - "All specified devices: ['SERIAL', 'OTA'] could not be resolved." in caplog.text - ) + with pytest.raises( + EsphomeError, + match=r"All specified devices \['SERIAL', 'OTA'\] could not be resolved", + ): + choose_upload_log_host( + default=["SERIAL", "OTA"], + check_default=None, + purpose=Purpose.UPLOADING, + ) @pytest.mark.usefixtures("mock_no_serial_ports", "mock_no_mqtt_logging") @@ -762,12 +766,14 @@ def test_choose_upload_log_host_no_address_with_ota_config() -> None: """Test OTA device when OTA is configured but no address is set.""" setup_core(config={CONF_OTA: {}}) - result = choose_upload_log_host( - default="OTA", - check_default=None, - purpose=Purpose.UPLOADING, - ) - assert result == [] + with pytest.raises( + EsphomeError, match="All specified devices .* could not be resolved" + ): + choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.UPLOADING, + ) @dataclass From ea38237f29330f12e9718088b665b20fce008147 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 19 Oct 2025 15:49:05 -0400 Subject: [PATCH 10/12] [esp32] Fix OTA rollback (#11300) Co-authored-by: J. Nick Koston --- esphome/components/esp32/core.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index f3bdfea2a0..3427c96e70 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -52,6 +53,16 @@ void arch_init() { disableCore1WDT(); #endif #endif + + // If the bootloader was compiled with CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE the current + // partition will get rolled back unless it is marked as valid. + esp_ota_img_states_t state; + const esp_partition_t *running = esp_ota_get_running_partition(); + if (esp_ota_get_state_partition(running, &state) == ESP_OK) { + if (state == ESP_OTA_IMG_PENDING_VERIFY) { + esp_ota_mark_app_valid_cancel_rollback(); + } + } } void IRAM_ATTR HOT arch_feed_wdt() { esp_task_wdt_reset(); } From a186c1062f28e15315187f9716a89498d380dd52 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 20 Oct 2025 10:06:43 +1300 Subject: [PATCH 11/12] Bump version to 2025.10.2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 78f004c7e3..6ed69336d2 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.10.1 +PROJECT_NUMBER = 2025.10.2 # 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 c1abb8f530..db8903fd96 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.10.1" +__version__ = "2025.10.2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 87ca8784ef969378ff56d78176add92be31471f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Oct 2025 11:12:56 -1000 Subject: [PATCH 12/12] [openthread] Backport address resolution support to prevent OTA crash (#11312) Co-authored-by: Daniel Stiner --- esphome/core/__init__.py | 4 ++++ tests/unit_tests/test_core.py | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 43febd28a2..2d49d29c5e 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( CONF_COMMENT, CONF_ESPHOME, CONF_ETHERNET, + CONF_OPENTHREAD, CONF_PORT, CONF_USE_ADDRESS, CONF_WEB_SERVER, @@ -641,6 +642,9 @@ class EsphomeCore: if CONF_ETHERNET in self.config: return self.config[CONF_ETHERNET][CONF_USE_ADDRESS] + if CONF_OPENTHREAD in self.config: + return f"{self.name}.local" + return None @property diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 48eae06ea6..41114ae18b 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -570,6 +570,13 @@ class TestEsphomeCore: assert target.address == "4.3.2.1" + def test_address__openthread(self, target): + target.name = "test-device" + target.config = {} + target.config[const.CONF_OPENTHREAD] = {} + + assert target.address == "test-device.local" + def test_is_esp32(self, target): target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"}