diff --git a/Doxyfile b/Doxyfile index a72069f8449..614b9a6fc05 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 = 2026.10.0b3 +PROJECT_NUMBER = 2026.10.0b4 # 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/components/audio/__init__.py b/esphome/components/audio/__init__.py index 1480faa8c3f..332638e75bb 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -391,7 +391,7 @@ async def to_code(config: ConfigType) -> None: # Adds a define and IDF component for legacy `audio_decoder.cpp`. if data.flac_support: cg.add_define("USE_AUDIO_FLAC_SUPPORT") - add_idf_component(name="esphome/micro-flac", ref="0.2.0") + add_idf_component(name="esphome/micro-flac", ref="0.3.0") _emit_memory_pair( data.flac.buffer_memory, "CONFIG_MICRO_FLAC_PREFER_PSRAM", diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index f8649f968ed..16fddadc878 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -1039,11 +1039,13 @@ def run_compile(args, config: ConfigType) -> bool: framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] bootloader = zephyr_data()[KEY_BOOTLOADER] - # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes + # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes. + # The bootloader resets on a package whose sd_req does not list its SoftDevice, + # so v7 accepts S140 7.0.1, 7.2.0 and 7.3.0 (e.g. Seeed XIAO nRF52840). _GENPKG_PARAMS = { BOOTLOADER_ADAFRUIT_NRF52_SD132: ("0x0051", "0x009D"), BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: ("0x0052", "0x00B6"), - BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA"), + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA,0x0100,0x0123"), } # UF2 family IDs — nRF52832 vs nRF52840 per SoftDevice variant _UF2_FAMILY_IDS = { diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 23346ca6f4b..1ac37f8e456 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -194,6 +194,8 @@ def _parse_platform_version(value: Any) -> str: # * The new version needs to be thoroughly validated before changing the # recommended version as otherwise a bunch of devices could be bricked # * For all constants below, update platformio.ini (in this repo) +# * rp2_pio pins its own pioasm build (PIOASM_REPO_VERSION in rp2_pio/__init__.py); check +# https://github.com/earlephilhower/pico-quick-toolchain/releases for a matching release # The default/recommended arduino framework version # - https://github.com/earlephilhower/arduino-pico/releases diff --git a/esphome/components/rp2_pio/__init__.py b/esphome/components/rp2_pio/__init__.py index 915ab73b876..96ed422de2d 100644 --- a/esphome/components/rp2_pio/__init__.py +++ b/esphome/components/rp2_pio/__init__.py @@ -7,9 +7,9 @@ from esphome.types import ConfigType DEPENDENCIES = ["rp2"] -PIOASM_REPO_VERSION = "1.5.0-b" +PIOASM_REPO_VERSION = "4.1.0" PIOASM_REPO_BASE = f"https://github.com/earlephilhower/pico-quick-toolchain/releases/download/{PIOASM_REPO_VERSION}" -PIOASM_VERSION = "pioasm-2e6142b.230216" +PIOASM_VERSION = "pioasm-9fdfe11.250530" PIOASM_DOWNLOADS = { "linux": { "aarch64": f"aarch64-linux-gnu.{PIOASM_VERSION}.tar.gz", @@ -20,8 +20,8 @@ PIOASM_DOWNLOADS = { "amd64": f"x86_64-w64-mingw32.{PIOASM_VERSION}.zip", }, "darwin": { - "x86_64": f"x86_64-apple-darwin14.{PIOASM_VERSION}.tar.gz", - "arm64": f"x86_64-apple-darwin14.{PIOASM_VERSION}.tar.gz", + "x86_64": f"x86_64-apple-darwin20.4.{PIOASM_VERSION}.tar.gz", + "arm64": f"aarch64-apple-darwin20.4.{PIOASM_VERSION}.tar.gz", }, } diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index f00ad7f4afa..efd75d27e0e 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -295,8 +295,11 @@ template class ScriptWaitAction final : public Actionnum_running_ == 0) + if (this->num_running_ == 0) { + // Nothing left to wait for, e.g. once the last queued item has run + this->disable_loop(); return; + } if (this->script_->is_running()) return; @@ -305,9 +308,10 @@ template class ScriptWaitAction final : public Actionparam_queue_.empty()) { - auto ¶ms = this->param_queue_.front(); - std::apply([this](auto &...args) { this->play_next_(args...); }, params); + // Take the item out first: the chain may call stop(), which clears the queue + auto params = std::move(this->param_queue_.front()); this->param_queue_.pop_front(); + std::apply([this](auto &...args) { this->play_next_(args...); }, params); } else { // Queue is now empty - disable loop until next play_complex this->disable_loop(); diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index f357bf75b35..f500bc6e3f0 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -344,6 +344,12 @@ def validate_variant(_): ) +def _validate_min_auth_mode(value: str) -> str: + if value == "WPA3" and CORE.is_esp8266: + raise cv.Invalid("ESP8266 does not support WPA3; use WPA or WPA2 instead") + return value + + def _apply_min_auth_mode_default(config): """Apply platform-specific default for min_auth_mode.""" if CONF_MIN_AUTH_MODE not in config and (CORE.is_esp8266 or CORE.is_esp32): @@ -522,6 +528,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MIN_AUTH_MODE): cv.All( VALIDATE_WIFI_MIN_AUTH_MODE, cv.only_on([Platform.ESP32, Platform.ESP8266]), + _validate_min_auth_mode, ), cv.SplitDefault(CONF_OUTPUT_POWER, esp8266=20.0): cv.All( cv.decibel, cv.float_range(min=8.5, max=20.5) diff --git a/esphome/const.py b/esphome/const.py index 15dcce03ff9..3888f8f95d3 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.10.0b3" +__version__ = "2026.10.0b4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 3d9f1431f67..a490f007d94 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -10,7 +10,7 @@ dependencies: esphome/micro-decoder: version: 0.4.0 esphome/micro-flac: - version: 0.2.0 + version: 0.3.0 esphome/micro-mp3: version: 0.4.0 esphome/micro-opus: diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 12aaae315e0..769b86519fa 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -620,12 +620,11 @@ def _make_registry_client() -> Any: elsewhere, not by the PlatformIO registry. """ from platformio.package.manager._registry import PackageManagerRegistryMixin - from platformio.project.helpers import get_project_cache_dir from platformio.registry.client import RegistryClient - # PlatformIO creates its HTTP cache dir without exist_ok, so two builds - # making their first registry lookup at once race on it - (Path(get_project_cache_dir()) / "http").mkdir(parents=True, exist_ok=True) + from esphome.platformio.runner import ensure_http_cache_dir + + ensure_http_cache_dir() class _Registry(PackageManagerRegistryMixin): def __init__(self) -> None: diff --git a/esphome/platformio/prefetch.py b/esphome/platformio/prefetch.py index 991164eb993..a86b5a63b97 100644 --- a/esphome/platformio/prefetch.py +++ b/esphome/platformio/prefetch.py @@ -841,6 +841,10 @@ def _prefetch(build_dir: Path, env: str) -> None: from platformio.package.meta import PackageCompatibility, PackageSpec from platformio.platform.factory import PlatformFactory + from esphome.platformio.runner import ensure_http_cache_dir + + # The resolve and install pools each build their own registry client + ensure_http_cache_dir() platform_spec, config = _project_platform_and_config( build_dir / "platformio.ini", env ) diff --git a/esphome/platformio/runner.py b/esphome/platformio/runner.py index b9fbdec38d0..cde79af0b68 100644 --- a/esphome/platformio/runner.py +++ b/esphome/platformio/runner.py @@ -106,6 +106,17 @@ def patch_file_downloader() -> None: FileDownloader.__init__ = patched_init +def ensure_http_cache_dir() -> None: + """Create PlatformIO's HTTP cache dir before anything resolves a package. + + PlatformIO creates it without exist_ok on the first registry lookup, so + concurrent lookups (the prefetch's resolve threads, or two builds) race. + """ + from platformio.project.helpers import get_project_cache_dir + + (Path(get_project_cache_dir()) / "http").mkdir(parents=True, exist_ok=True) + + def patch_registry_private_packages() -> None: """Skip PlatformIO's private-package probe; it sleeps ~500 ms per lookup. diff --git a/requirements.txt b/requirements.txt index b7df6ca4fc7..a54897ef36d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.4.0 click==8.5.0 -aioesphomeapi==46.9.0 +aioesphomeapi==46.10.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 diff --git a/tests/component_tests/wifi/test_min_auth_mode.py b/tests/component_tests/wifi/test_min_auth_mode.py new file mode 100644 index 00000000000..e0e5ca98053 --- /dev/null +++ b/tests/component_tests/wifi/test_min_auth_mode.py @@ -0,0 +1,79 @@ +"""Platform restrictions for minimum Wi-Fi authentication modes.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_VARIANT +from esphome.components.esp32.const import VARIANT_ESP32 +from esphome.components.wifi import CONF_MIN_AUTH_MODE, CONFIG_SCHEMA +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def _validate(mode: str | None = None) -> dict: + config = {"ssid": "net", "password": "secret12", "use_address": "test.local"} + if mode is not None: + config[CONF_MIN_AUTH_MODE] = mode + return CONFIG_SCHEMA(config) + + +@pytest.mark.parametrize("mode", ["WPA3", "wpa3"]) +def test_esp8266_rejects_wpa3( + set_core_config: SetCoreConfigCallable, mode: str +) -> None: + set_core_config(PlatformFramework.ESP8266_ARDUINO) + with pytest.raises(cv.Invalid, match="ESP8266 does not support WPA3") as exc: + _validate(mode) + assert exc.value.path == [CONF_MIN_AUTH_MODE] + + +@pytest.mark.parametrize( + ("platform", "mode"), + [ + (PlatformFramework.ESP8266_ARDUINO, "WPA"), + (PlatformFramework.ESP8266_ARDUINO, "WPA2"), + (PlatformFramework.ESP32_IDF, "WPA"), + (PlatformFramework.ESP32_IDF, "WPA2"), + (PlatformFramework.ESP32_IDF, "WPA3"), + (PlatformFramework.ESP32_IDF, "wpa3"), + (PlatformFramework.ESP32_ARDUINO, "WPA"), + (PlatformFramework.ESP32_ARDUINO, "WPA2"), + (PlatformFramework.ESP32_ARDUINO, "WPA3"), + ], +) +def test_supported_modes( + set_core_config: SetCoreConfigCallable, platform: PlatformFramework, mode: str +) -> None: + set_core_config( + platform, + platform_data=( + {KEY_VARIANT: VARIANT_ESP32} + if platform + in (PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO) + else None + ), + ) + assert _validate(mode)[CONF_MIN_AUTH_MODE] == mode.upper() + + +@pytest.mark.parametrize( + "platform", + [ + PlatformFramework.ESP8266_ARDUINO, + PlatformFramework.ESP32_IDF, + PlatformFramework.ESP32_ARDUINO, + ], +) +def test_default_remains_wpa2( + set_core_config: SetCoreConfigCallable, platform: PlatformFramework +) -> None: + set_core_config( + platform, + platform_data=( + {KEY_VARIANT: VARIANT_ESP32} + if platform + in (PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO) + else None + ), + ) + assert _validate()[CONF_MIN_AUTH_MODE] == "WPA2" diff --git a/tests/components/rp2040_pio_led_strip/test.rp2350-ard.yaml b/tests/components/rp2040_pio_led_strip/test.rp2350-ard.yaml new file mode 100644 index 00000000000..25135f1d390 --- /dev/null +++ b/tests/components/rp2040_pio_led_strip/test.rp2350-ard.yaml @@ -0,0 +1,2 @@ +packages: + rp2040_pio_led_strip: !include common.yaml diff --git a/tests/components/script/test_script_wait_action.cpp b/tests/components/script/test_script_wait_action.cpp index 17f553996ea..c3cd55fc8ca 100644 --- a/tests/components/script/test_script_wait_action.cpp +++ b/tests/components/script/test_script_wait_action.cpp @@ -2,6 +2,7 @@ #include #include +#include #include "esphome/components/script/script.h" @@ -93,4 +94,89 @@ TEST(ScriptWaitActionTest, KeepsReferenceToNonCopyableArguments) { EXPECT_EQ(entity.value, 1); } +class StopWaitAction : public Action { + public: + void play(const std::string &value) override { + this->wait->stop_complex(); + this->seen = value; + } + Action *wait{nullptr}; + std::string seen; +}; + +// The chain after the wait may stop it, e.g. a script with mode: restart +TEST(ScriptWaitActionTest, StopDuringChainKeepsArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + StopWaitAction stop; + stop.wait = &wait; + ActionList actions; + actions.add_actions({&wait, &stop}); + + // With a second item waiting, stop() clears the queue while the first item's chain runs + actions.play(std::string("argument longer than the small string buffer")); + actions.play(std::string("second argument, also longer than the buffer")); + script.running = false; + wait.loop(); + EXPECT_EQ(stop.seen, "argument longer than the small string buffer"); + wait.loop(); + EXPECT_EQ(stop.seen, "argument longer than the small string buffer"); +} + +class RestartAction : public Action { + public: + void play(const std::string &value) override { + this->seen.push_back(value); + if (this->seen.size() > 1) + return; + // What a script with mode: restart does: stop, then run again while the awaited script is busy + this->wait->stop_complex(); + this->script->running = true; + this->actions->play(std::string("restarted, longer than the small string buffer")); + } + Action *wait{nullptr}; + FakeScript *script{nullptr}; + ActionList *actions{nullptr}; + std::vector seen; +}; + +// The old pop_front() after the chain removed the item queued by the restart +TEST(ScriptWaitActionTest, RestartDuringChainKeepsNewItem) { + FakeScript script; + ScriptWaitAction wait(&script); + RestartAction restart; + ActionList actions; + actions.add_actions({&wait, &restart}); + restart.wait = &wait; + restart.script = &script; + restart.actions = &actions; + + // With a second item waiting, the stop really clears the queue; the restart then queues a new item + actions.play(std::string("first argument, longer than the small string buffer")); + actions.play(std::string("second argument, dropped by the stop")); + script.running = false; + wait.loop(); + ASSERT_EQ(restart.seen.size(), 1u); + + script.running = false; + wait.loop(); + ASSERT_EQ(restart.seen.size(), 2u); + EXPECT_EQ(restart.seen[1], "restarted, longer than the small string buffer"); +} + +TEST(ScriptWaitActionTest, LoopDisabledOnceNothingWaits) { + FakeScript script; + ScriptWaitAction wait(&script); + RecordAction record; + ActionList actions; + actions.add_actions({&wait, &record}); + + actions.play(std::string("argument")); + script.running = false; + wait.loop(); + EXPECT_EQ(record.seen, "argument"); + wait.loop(); + EXPECT_TRUE(wait.is_idle()); +} + } // namespace esphome::script::testing diff --git a/tests/unit_tests/components/nrf52/test_run_compile_hex.py b/tests/unit_tests/components/nrf52/test_run_compile_hex.py index f86a29a9d24..4684f6189d0 100644 --- a/tests/unit_tests/components/nrf52/test_run_compile_hex.py +++ b/tests/unit_tests/components/nrf52/test_run_compile_hex.py @@ -128,3 +128,13 @@ def test_stale_merged_hex_is_removed_before_building( app_hex = str(build_dir / "zephyr" / "zephyr.hex") assert _uf2_hex(run_cmd) == app_hex assert _dfu_hex(run_cmd) == app_hex + + +def test_sd140_v7_dfu_accepts_all_s140_7_softdevices( + build_dir: Path, run_cmd: Mock +) -> None: + """Boards such as the Seeed XIAO nRF52840 ship S140 7.3.0, not 7.0.1.""" + _build(build_dir, cv.Version(3, 4, 0), ["zephyr/zephyr/zephyr.hex"], run_cmd) + cmd = next(c.args[0] for c in run_cmd.call_args_list if "genpkg" in c.args[0]) + sd_req = cmd[cmd.index("--sd-req") + 1].split(",") + assert sd_req == ["0x00CA", "0x0100", "0x0123"] diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index 3f01fb22e6d..8a60e9c3881 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -561,6 +561,19 @@ def test_registry_lock_deadline_skips_registration(tmp_path: Path) -> None: manager.set_download_utime.assert_not_called() +def test_prefetch_creates_http_cache_dir_first( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Eight resolve threads each build a registry client whose cache + init has no exist_ok; the dir must exist before any of them run.""" + from platformio.project import helpers + + monkeypatch.setattr(helpers, "get_project_cache_dir", lambda: str(tmp_path)) + with patch.object(pf, "_project_platform_and_config", return_value=(None, None)): + pf._prefetch(tmp_path, "testenv") + assert (tmp_path / "http").is_dir() + + def test_main_interrupt_exits_quietly(tmp_path: Path) -> None: """Ctrl-C reaches the child via the shared process group; it must exit without a traceback.""" diff --git a/tests/unit_tests/test_platformio_runner.py b/tests/unit_tests/test_platformio_runner.py index 007455f45a3..b108cf298ab 100644 --- a/tests/unit_tests/test_platformio_runner.py +++ b/tests/unit_tests/test_platformio_runner.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable import io +from pathlib import Path import sys from types import ModuleType from unittest.mock import Mock @@ -110,6 +111,20 @@ def test_main_applies_registry_private_packages_patch( assert order == ["patch", "pio"] +def test_ensure_http_cache_dir_is_idempotent( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The dir exists before PlatformIO's non-exist_ok makedirs, and a + second caller (another thread or build) does not trip over it.""" + from platformio.project import helpers + + monkeypatch.setattr(helpers, "get_project_cache_dir", lambda: str(tmp_path / "c")) + + runner.ensure_http_cache_dir() + assert (tmp_path / "c" / "http").is_dir() + runner.ensure_http_cache_dir() + + # Snapshot PlatformIO's own probe at import, before any test can patch it _PIO_PROBE = RegistryClient.__dict__["allowed_private_packages"]