From 7223a1bac8d1296f66eae95907a08a330039c546 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 5 Feb 2026 04:13:16 -0500 Subject: [PATCH 1/5] [esp32] Skip downloading precompiled Arduino libs Create a stub package for framework-arduinoespressif32-libs to avoid downloading ~2GB of precompiled libraries that aren't needed when building from source. The stub contains minimal package.json and tools.json files that satisfy PlatformIO's package manager without the actual library contents. The version is derived from the IDF version. Co-Authored-By: Claude Opus 4.5 --- esphome/components/esp32/__init__.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 4c53b42e6f..bc95d58da8 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1132,6 +1132,20 @@ async def _write_exclude_components() -> None: ) +@coroutine_with_priority(CoroPriority.FINAL) +async def _write_arduino_libs_stub(stubs_dir: Path, idf_ver: cv.Version) -> None: + """Write stub package to skip downloading precompiled Arduino libs.""" + stubs_dir.mkdir(parents=True, exist_ok=True) + write_file_if_changed( + stubs_dir / "package.json", + f'{{"name":"framework-arduinoespressif32-libs","version":"{idf_ver.major}.{idf_ver.minor}.{idf_ver.patch}"}}', + ) + write_file_if_changed( + stubs_dir / "tools.json", + '{"packages":[{"platforms":[{"toolsDependencies":[]}],"tools":[]}]}', + ) + + @coroutine_with_priority(CoroPriority.FINAL) async def _add_yaml_idf_components(components: list[ConfigType]): """Add IDF components from YAML config with final priority to override code-added components.""" @@ -1241,6 +1255,15 @@ async def to_code(config): "platform_packages", [_format_framework_espidf_version(idf_ver, None)], ) + # Use stub package to skip downloading precompiled libs + stubs_dir = CORE.relative_build_path("arduino-libs-stub") + cg.add_platformio_option( + "platform_packages", + [ + f"pioarduino/framework-arduinoespressif32-libs@file://{stubs_dir}" + ], + ) + CORE.add_job(_write_arduino_libs_stub, stubs_dir, idf_ver) # ESP32-S2 Arduino: Disable USB Serial on boot to avoid TinyUSB dependency if get_esp32_variant() == VARIANT_ESP32S2: From be44d4801f578aeb4d6561f3a075872f4241724a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 5 Feb 2026 10:52:43 +0100 Subject: [PATCH 2/5] [esp32] Reduce Arduino build size by 44% and build time by 36% (#13623) --- esphome/components/bme680_bsec/__init__.py | 3 +- esphome/components/esp32/__init__.py | 266 +++++++++++++++++- esphome/components/esp32/const.py | 1 + esphome/components/espnow/__init__.py | 5 +- esphome/components/ethernet/__init__.py | 3 - .../i2s_audio/media_player/__init__.py | 1 + esphome/components/midea/climate.py | 5 +- esphome/components/network/__init__.py | 3 +- .../components/web_server_base/__init__.py | 5 +- esphome/components/wled/__init__.py | 3 + esphome/core/__init__.py | 10 + esphome/writer.py | 15 +- .../components/i2s_audio/test.esp32-ard.yaml | 16 ++ tests/unit_tests/test_core.py | 75 +++++ 14 files changed, 384 insertions(+), 27 deletions(-) create mode 100644 tests/components/i2s_audio/test.esp32-ard.yaml diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index 06e641d34d..a86e061cd4 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -89,8 +89,9 @@ async def to_code(config): var.set_state_save_interval(config[CONF_STATE_SAVE_INTERVAL].total_milliseconds) ) - # Although this component does not use SPI, the BSEC library requires the SPI library + # Although this component does not use SPI/Wire directly, the BSEC library requires them cg.add_library("SPI", None) + cg.add_library("Wire", None) cg.add_define("USE_BSEC") cg.add_library("boschsensortec/BSEC Software Library", "1.6.1480") diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 77753e277f..e1e3164b68 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -46,10 +46,11 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, write_file_if_changed from esphome.types import ConfigType -from esphome.writer import clean_cmake_cache +from esphome.writer import clean_cmake_cache, rmtree from .boards import BOARDS, STANDARD_BOARDS from .const import ( # noqa + KEY_ARDUINO_LIBRARIES, KEY_BOARD, KEY_COMPONENTS, KEY_ESP32, @@ -152,6 +153,168 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "wifi_provisioning", # WiFi provisioning - ESPHome uses its own improv implementation ) +# Additional IDF managed components to exclude for Arduino framework builds +# These are pulled in by the Arduino framework's idf_component.yml but not used by ESPHome +# Note: Component names include the namespace prefix (e.g., "espressif__cbor") because +# that's how managed components are registered in the IDF build system +# List includes direct dependencies from arduino-esp32/idf_component.yml +# plus transitive dependencies from RainMaker/Insights (except espressif/mdns which we need) +ARDUINO_EXCLUDED_IDF_COMPONENTS = ( + "chmorgan__esp-libhelix-mp3", # MP3 decoder - not used + "espressif__cbor", # CBOR library - only used by RainMaker/Insights + "espressif__esp-dsp", # DSP library - not used + "espressif__esp-modbus", # Modbus - ESPHome has its own + "espressif__esp-sr", # Speech recognition - not used + "espressif__esp-zboss-lib", # Zigbee ZBOSS library - not used + "espressif__esp-zigbee-lib", # Zigbee library - not used + "espressif__esp_diag_data_store", # Diagnostics - not used + "espressif__esp_diagnostics", # Diagnostics - not used + "espressif__esp_hosted", # ESP hosted - only for ESP32-P4 + "espressif__esp_insights", # ESP Insights - not used + "espressif__esp_modem", # Modem library - not used + "espressif__esp_rainmaker", # RainMaker - not used + "espressif__esp_rcp_update", # RCP update - RainMaker transitive dep + "espressif__esp_schedule", # Schedule - RainMaker transitive dep + "espressif__esp_secure_cert_mgr", # Secure cert - RainMaker transitive dep + "espressif__esp_wifi_remote", # WiFi remote - only for ESP32-P4 + "espressif__json_generator", # JSON generator - RainMaker transitive dep + "espressif__json_parser", # JSON parser - RainMaker transitive dep + "espressif__lan867x", # Ethernet PHY - ESPHome uses ESP-IDF ethernet directly + "espressif__libsodium", # Crypto - ESPHome uses its own noise-c library + "espressif__network_provisioning", # Network provisioning - not used + "espressif__qrcode", # QR code - not used + "espressif__rmaker_common", # RainMaker common - not used + "joltwallet__littlefs", # LittleFS - ESPHome doesn't use filesystem +) + +# Mapping of Arduino libraries to IDF managed components they require +# When an Arduino library is enabled via cg.add_library(), these components +# are automatically un-stubbed from ARDUINO_EXCLUDED_IDF_COMPONENTS. +# +# Note: Some libraries (Matter, LittleFS, ESP_SR, WiFiProv, ArduinoOTA) already have +# conditional maybe_add_component() calls in arduino-esp32/CMakeLists.txt that handle +# their managed component dependencies. Our mapping is primarily needed for libraries +# that don't have such conditionals (Ethernet, PPP, Zigbee, RainMaker, Insights, etc.) +# and to ensure the stubs are removed from our idf_component.yml overrides. +ARDUINO_LIBRARY_IDF_COMPONENTS: dict[str, tuple[str, ...]] = { + "BLE": ("esp_driver_gptimer",), + "BluetoothSerial": ("esp_driver_gptimer",), + "ESP_HostedOTA": ("espressif__esp_hosted", "espressif__esp_wifi_remote"), + "ESP_SR": ("espressif__esp-sr",), + "Ethernet": ("espressif__lan867x",), + "FFat": ("fatfs",), + "Insights": ( + "espressif__cbor", + "espressif__esp_insights", + "espressif__esp_diagnostics", + "espressif__esp_diag_data_store", + "espressif__rmaker_common", # Transitive dep from esp_insights + ), + "LittleFS": ("joltwallet__littlefs",), + "Matter": ("espressif__esp_matter",), + "PPP": ("espressif__esp_modem",), + "RainMaker": ( + # Direct deps from idf_component.yml + "espressif__cbor", + "espressif__esp_rainmaker", + "espressif__esp_insights", + "espressif__esp_diagnostics", + "espressif__esp_diag_data_store", + "espressif__rmaker_common", + "espressif__qrcode", + # Transitive deps from esp_rainmaker + "espressif__esp_rcp_update", + "espressif__esp_schedule", + "espressif__esp_secure_cert_mgr", + "espressif__json_generator", + "espressif__json_parser", + "espressif__network_provisioning", + ), + "SD": ("fatfs",), + "SD_MMC": ("fatfs",), + "SPIFFS": ("spiffs",), + "WiFiProv": ("espressif__network_provisioning", "espressif__qrcode"), + "Zigbee": ("espressif__esp-zigbee-lib", "espressif__esp-zboss-lib"), +} + +# Arduino library to Arduino library dependencies +# When enabling one library, also enable its dependencies +# Kconfig "select" statements don't work with CONFIG_ARDUINO_SELECTIVE_COMPILATION +ARDUINO_LIBRARY_DEPENDENCIES: dict[str, tuple[str, ...]] = { + "Ethernet": ("Network",), + "WiFi": ("Network",), +} + + +def _idf_component_stub_name(component: str) -> str: + """Get stub directory name from IDF component name. + + Component names are typically namespace__name (e.g., espressif__cbor). + Returns just the name part (e.g., cbor). If no namespace is present, + returns the original component name. + """ + _prefix, sep, suffix = component.partition("__") + return suffix if sep else component + + +def _idf_component_dep_name(component: str) -> str: + """Convert IDF component name to dependency format. + + Converts espressif__cbor to espressif/cbor. + """ + return component.replace("__", "/") + + +# Arduino libraries to disable by default when using Arduino framework +# ESPHome uses ESP-IDF APIs directly; we only need the Arduino core +# (HardwareSerial, Print, Stream, GPIO functions which are always compiled) +# Components use cg.add_library() which auto-enables any they need +# This list must match ARDUINO_ALL_LIBRARIES from arduino-esp32/CMakeLists.txt +ARDUINO_DISABLED_LIBRARIES: frozenset[str] = frozenset( + { + "ArduinoOTA", + "AsyncUDP", + "BLE", + "BluetoothSerial", + "DNSServer", + "EEPROM", + "ESP_HostedOTA", + "ESP_I2S", + "ESP_NOW", + "ESP_SR", + "ESPmDNS", + "Ethernet", + "FFat", + "FS", + "Hash", + "HTTPClient", + "HTTPUpdate", + "Insights", + "LittleFS", + "Matter", + "NetBIOS", + "Network", + "NetworkClientSecure", + "OpenThread", + "PPP", + "Preferences", + "RainMaker", + "SD", + "SD_MMC", + "SimpleBLE", + "SPI", + "SPIFFS", + "Ticker", + "Update", + "USB", + "WebServer", + "WiFi", + "WiFiProv", + "Wire", + "Zigbee", + } +) + # ESP32 (original) chip revision options # Setting minimum revision to 3.0 or higher: # - Reduces flash size by excluding workaround code for older chip bugs @@ -243,7 +406,13 @@ def set_core_data(config): CORE.data[KEY_ESP32][KEY_COMPONENTS] = {} # Initialize with default exclusions - components can call include_builtin_idf_component() # to re-enable any they need - CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = set(DEFAULT_EXCLUDED_IDF_COMPONENTS) + excluded = set(DEFAULT_EXCLUDED_IDF_COMPONENTS) + # Add Arduino-specific managed component exclusions when using Arduino framework + if conf[CONF_TYPE] == FRAMEWORK_ARDUINO: + excluded.update(ARDUINO_EXCLUDED_IDF_COMPONENTS) + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = excluded + # Initialize Arduino library tracking - cg.add_library() auto-enables libraries + CORE.data[KEY_ESP32][KEY_ARDUINO_LIBRARIES] = set() CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( config[CONF_FRAMEWORK][CONF_VERSION] ) @@ -391,6 +560,26 @@ def include_builtin_idf_component(name: str) -> None: CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS].discard(name) +def _enable_arduino_library(name: str) -> None: + """Enable an Arduino library that is disabled by default. + + This is called automatically by CORE.add_library() when a component adds + an Arduino library via cg.add_library(). Components should not call this + directly - just use cg.add_library("LibName", None). + + Args: + name: The library name (e.g., "Wire", "SPI", "WiFi") + """ + enabled_libs: set[str] = CORE.data[KEY_ESP32][KEY_ARDUINO_LIBRARIES] + enabled_libs.add(name) + # Also enable any required Arduino library dependencies + for dep_lib in ARDUINO_LIBRARY_DEPENDENCIES.get(name, ()): + enabled_libs.add(dep_lib) + # Also enable any required IDF components + for idf_component in ARDUINO_LIBRARY_IDF_COMPONENTS.get(name, ()): + include_builtin_idf_component(idf_component) + + def add_extra_script(stage: str, filename: str, path: Path): """Add an extra script to the project.""" key = f"{stage}:{filename}" @@ -1132,6 +1321,27 @@ async def _write_exclude_components() -> None: ) +@coroutine_with_priority(CoroPriority.FINAL) +async def _write_arduino_libraries_sdkconfig() -> None: + """Write Arduino selective compilation sdkconfig after all components have added libraries. + + This must run at FINAL priority so that all components have had a chance to call + cg.add_library() which auto-enables Arduino libraries via _enable_arduino_library(). + """ + if KEY_ESP32 not in CORE.data: + return + # Enable Arduino selective compilation to disable unused Arduino libraries + # ESPHome uses ESP-IDF APIs directly; we only need the Arduino core + # (HardwareSerial, Print, Stream, GPIO functions which are always compiled) + # cg.add_library() auto-enables needed libraries; users can also add + # libraries via esphome: libraries: config which calls cg.add_library() + add_idf_sdkconfig_option("CONFIG_ARDUINO_SELECTIVE_COMPILATION", True) + enabled_libs = CORE.data[KEY_ESP32].get(KEY_ARDUINO_LIBRARIES, set()) + for lib in ARDUINO_DISABLED_LIBRARIES: + # Enable if explicitly requested, disable otherwise + add_idf_sdkconfig_option(f"CONFIG_ARDUINO_SELECTIVE_{lib}", lib in enabled_libs) + + @coroutine_with_priority(CoroPriority.FINAL) async def _add_yaml_idf_components(components: list[ConfigType]): """Add IDF components from YAML config with final priority to override code-added components.""" @@ -1550,6 +1760,11 @@ async def to_code(config): # Default exclusions are added in set_core_data() during config validation. CORE.add_job(_write_exclude_components) + # Write Arduino selective compilation sdkconfig at FINAL priority after all + # components have had a chance to call cg.add_library() to enable libraries they need. + if conf[CONF_TYPE] == FRAMEWORK_ARDUINO: + CORE.add_job(_write_arduino_libraries_sdkconfig) + APP_PARTITION_SIZES = { "2MB": 0x0C0000, # 768 KB @@ -1630,11 +1845,49 @@ def _write_sdkconfig(): def _write_idf_component_yml(): yml_path = CORE.relative_build_path("src/idf_component.yml") + dependencies: dict[str, dict] = {} + + # For Arduino builds, override unused managed components from the Arduino framework + # by pointing them to empty stub directories using override_path + # This prevents the IDF component manager from downloading the real components + if CORE.using_arduino: + # Determine which IDF components are needed by enabled Arduino libraries + enabled_libs = CORE.data[KEY_ESP32].get(KEY_ARDUINO_LIBRARIES, set()) + required_idf_components = { + comp + for lib in enabled_libs + for comp in ARDUINO_LIBRARY_IDF_COMPONENTS.get(lib, ()) + } + + # Only stub components that are not required by any enabled Arduino library + components_to_stub = ( + set(ARDUINO_EXCLUDED_IDF_COMPONENTS) - required_idf_components + ) + + stubs_dir = CORE.relative_build_path("component_stubs") + stubs_dir.mkdir(exist_ok=True) + for component_name in components_to_stub: + # Create stub directory with minimal CMakeLists.txt + stub_path = stubs_dir / _idf_component_stub_name(component_name) + stub_path.mkdir(exist_ok=True) + stub_cmake = stub_path / "CMakeLists.txt" + if not stub_cmake.exists(): + stub_cmake.write_text("idf_component_register()\n") + dependencies[_idf_component_dep_name(component_name)] = { + "version": "*", + "override_path": str(stub_path), + } + + # Remove stubs for components that are now required by enabled libraries + for component_name in required_idf_components: + stub_path = stubs_dir / _idf_component_stub_name(component_name) + if stub_path.exists(): + rmtree(stub_path) + if CORE.data[KEY_ESP32][KEY_COMPONENTS]: components: dict = CORE.data[KEY_ESP32][KEY_COMPONENTS] - dependencies = {} for name, component in components.items(): - dependency = {} + dependency: dict[str, str] = {} if component[KEY_REF]: dependency["version"] = component[KEY_REF] if component[KEY_REPO]: @@ -1642,9 +1895,8 @@ def _write_idf_component_yml(): if component[KEY_PATH]: dependency["path"] = component[KEY_PATH] dependencies[name] = dependency - contents = yaml_util.dump({"dependencies": dependencies}) - else: - contents = "" + + contents = yaml_util.dump({"dependencies": dependencies}) if dependencies else "" if write_file_if_changed(yml_path, contents): dependencies_lock = CORE.relative_build_path("dependencies.lock") if dependencies_lock.is_file(): diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index db3eddebd5..7874c1c759 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -7,6 +7,7 @@ KEY_VARIANT = "variant" KEY_SDKCONFIG_OPTIONS = "sdkconfig_options" KEY_COMPONENTS = "components" KEY_EXCLUDE_COMPONENTS = "exclude_components" +KEY_ARDUINO_LIBRARIES = "arduino_libraries" KEY_REPO = "repo" KEY_REF = "ref" KEY_REFRESH = "refresh" diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index 1f5ca1104a..faeccd910e 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -13,7 +13,7 @@ from esphome.const import ( CONF_TRIGGER_ID, CONF_WIFI, ) -from esphome.core import CORE, HexInt +from esphome.core import HexInt from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] @@ -124,9 +124,6 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if CORE.using_arduino: - cg.add_library("WiFi", None) - # ESP-NOW uses wake_loop_threadsafe() to wake the main loop from ESP-NOW callbacks # This enables low-latency event processing instead of waiting for select() timeout socket.require_wake_loop_threadsafe() diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 23436cc5be..38489ceb2b 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -431,9 +431,6 @@ async def to_code(config): # Add LAN867x 10BASE-T1S PHY support component add_idf_component(name="espressif/lan867x", ref="2.0.0") - if CORE.using_arduino: - cg.add_library("WiFi", None) - if on_connect_config := config.get(CONF_ON_CONNECT): cg.add_define("USE_ETHERNET_CONNECT_TRIGGER") await automation.build_automation( diff --git a/esphome/components/i2s_audio/media_player/__init__.py b/esphome/components/i2s_audio/media_player/__init__.py index 35c42e1b06..426b211f47 100644 --- a/esphome/components/i2s_audio/media_player/__init__.py +++ b/esphome/components/i2s_audio/media_player/__init__.py @@ -114,6 +114,7 @@ async def to_code(config): cg.add(var.set_external_dac_channels(2 if config[CONF_MODE] == "stereo" else 1)) cg.add(var.set_i2s_comm_fmt_lsb(config[CONF_I2S_COMM_FMT] == "lsb")) + cg.add_library("WiFi", None) cg.add_library("NetworkClientSecure", None) cg.add_library("HTTPClient", None) cg.add_library("esphome/ESP32-audioI2S", "2.3.0") diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index b08a47afa9..8a3d4f22ba 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -30,7 +30,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_WATT, ) -from esphome.core import coroutine +from esphome.core import CORE, coroutine CODEOWNERS = ["@dudanov"] DEPENDENCIES = ["climate", "uart"] @@ -290,4 +290,7 @@ async def to_code(config): if CONF_HUMIDITY_SETPOINT in config: sens = await sensor.new_sensor(config[CONF_HUMIDITY_SETPOINT]) cg.add(var.set_humidity_setpoint_sensor(sens)) + # MideaUART library requires WiFi (WiFi auto-enables Network via dependency mapping) + if CORE.is_esp32: + cg.add_library("WiFi", None) cg.add_library("dudanov/MideaUART", "1.1.9") diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 5b63bbfce9..1f75b12178 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -137,8 +137,7 @@ CONFIG_SCHEMA = cv.Schema( @coroutine_with_priority(CoroPriority.NETWORK) async def to_code(config): cg.add_define("USE_NETWORK") - if CORE.using_arduino and CORE.is_esp32: - cg.add_library("Networking", None) + # ESP32 with Arduino uses ESP-IDF network APIs directly, no Arduino Network library needed # Apply high performance networking settings # Config can explicitly enable/disable, or default to component-driven behavior diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index 6326b4d6ff..11408ae260 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -38,11 +38,8 @@ async def to_code(config): cg.add_define("WEB_SERVER_DEFAULT_HEADERS_COUNT", 1) return + # ESP32 uses IDF web server (early return above), so this is for other Arduino platforms if CORE.using_arduino: - if CORE.is_esp32: - cg.add_library("WiFi", None) - cg.add_library("FS", None) - cg.add_library("Update", None) if CORE.is_esp8266: cg.add_library("ESP8266WiFi", None) if CORE.is_libretiny: diff --git a/esphome/components/wled/__init__.py b/esphome/components/wled/__init__.py index fb20a03010..49eb15dad6 100644 --- a/esphome/components/wled/__init__.py +++ b/esphome/components/wled/__init__.py @@ -3,6 +3,7 @@ from esphome.components.light.effects import register_addressable_effect from esphome.components.light.types import AddressableLightEffect import esphome.config_validation as cv from esphome.const import CONF_NAME, CONF_PORT +from esphome.core import CORE wled_ns = cg.esphome_ns.namespace("wled") WLEDLightEffect = wled_ns.class_("WLEDLightEffect", AddressableLightEffect) @@ -27,4 +28,6 @@ async def wled_light_effect_to_code(config, effect_id): cg.add(effect.set_port(config[CONF_PORT])) cg.add(effect.set_sync_group_mask(config[CONF_SYNC_GROUP_MASK])) cg.add(effect.set_blank_on_start(config[CONF_BLANK_ON_START])) + if CORE.is_esp32: + cg.add_library("WiFi", None) return effect diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 5308ad241e..484f679369 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -892,6 +892,16 @@ class EsphomeCore: library.name if "/" not in library.name else library.name.split("/")[-1] ) + # Auto-enable Arduino libraries on ESP32 Arduino builds + if self.is_esp32 and self.using_arduino: + from esphome.components.esp32 import ( + ARDUINO_DISABLED_LIBRARIES, + _enable_arduino_library, + ) + + if short_name in ARDUINO_DISABLED_LIBRARIES: + _enable_arduino_library(short_name) + if short_name not in self.platformio_libraries: _LOGGER.debug("Adding library: %s", library) self.platformio_libraries[short_name] = library diff --git a/esphome/writer.py b/esphome/writer.py index cb9c921693..661118e518 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -421,6 +421,11 @@ def _rmtree_error_handler( func(path) +def rmtree(path: Path | str) -> None: + """Remove a directory tree, handling read-only files on Windows.""" + shutil.rmtree(path, onerror=_rmtree_error_handler) + + def clean_build(clear_pio_cache: bool = True): # Allow skipping cache cleaning for integration tests if os.environ.get("ESPHOME_SKIP_CLEAN_BUILD"): @@ -430,11 +435,11 @@ def clean_build(clear_pio_cache: bool = True): pioenvs = CORE.relative_pioenvs_path() if pioenvs.is_dir(): _LOGGER.info("Deleting %s", pioenvs) - shutil.rmtree(pioenvs, onerror=_rmtree_error_handler) + rmtree(pioenvs) piolibdeps = CORE.relative_piolibdeps_path() if piolibdeps.is_dir(): _LOGGER.info("Deleting %s", piolibdeps) - shutil.rmtree(piolibdeps, onerror=_rmtree_error_handler) + rmtree(piolibdeps) dependencies_lock = CORE.relative_build_path("dependencies.lock") if dependencies_lock.is_file(): _LOGGER.info("Deleting %s", dependencies_lock) @@ -455,7 +460,7 @@ def clean_build(clear_pio_cache: bool = True): cache_dir = Path(config.get("platformio", "cache_dir")) if cache_dir.is_dir(): _LOGGER.info("Deleting PlatformIO cache %s", cache_dir) - shutil.rmtree(cache_dir, onerror=_rmtree_error_handler) + rmtree(cache_dir) def clean_all(configuration: list[str]): @@ -480,7 +485,7 @@ def clean_all(configuration: list[str]): if item.is_file() and not item.name.endswith(".json"): item.unlink() elif item.is_dir() and item.name != "storage": - shutil.rmtree(item, onerror=_rmtree_error_handler) + rmtree(item) # Clean PlatformIO project files try: @@ -494,7 +499,7 @@ def clean_all(configuration: list[str]): path = Path(config.get("platformio", pio_dir)) if path.is_dir(): _LOGGER.info("Deleting PlatformIO %s %s", pio_dir, path) - shutil.rmtree(path, onerror=_rmtree_error_handler) + rmtree(path) GITIGNORE_CONTENT = """# Gitignore settings for ESPHome diff --git a/tests/components/i2s_audio/test.esp32-ard.yaml b/tests/components/i2s_audio/test.esp32-ard.yaml new file mode 100644 index 0000000000..4276b4f922 --- /dev/null +++ b/tests/components/i2s_audio/test.esp32-ard.yaml @@ -0,0 +1,16 @@ +substitutions: + i2s_bclk_pin: GPIO15 + i2s_lrclk_pin: GPIO4 + i2s_mclk_pin: GPIO5 + +<<: !include common.yaml + +wifi: + ssid: test + password: test1234 + +media_player: + - platform: i2s_audio + name: Test Media Player + dac_type: internal + mode: stereo diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 1fc8dab358..174b3fec85 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -780,3 +780,78 @@ class TestEsphomeCore: target.config = {const.CONF_ESPHOME: {"name": "test"}, "logger": {}} assert target.has_networking is False + + def test_add_library__esp32_arduino_enables_disabled_library(self, target): + """Test add_library auto-enables Arduino libraries on ESP32 Arduino builds.""" + target.data[const.KEY_CORE] = { + const.KEY_TARGET_PLATFORM: "esp32", + const.KEY_TARGET_FRAMEWORK: "arduino", + } + + library = core.Library("WiFi", None) + + with patch("esphome.components.esp32._enable_arduino_library") as mock_enable: + target.add_library(library) + mock_enable.assert_called_once_with("WiFi") + + assert "WiFi" in target.platformio_libraries + + def test_add_library__esp32_arduino_ignores_non_arduino_library(self, target): + """Test add_library doesn't enable libraries not in ARDUINO_DISABLED_LIBRARIES.""" + target.data[const.KEY_CORE] = { + const.KEY_TARGET_PLATFORM: "esp32", + const.KEY_TARGET_FRAMEWORK: "arduino", + } + + library = core.Library("SomeOtherLib", "1.0.0") + + with patch("esphome.components.esp32._enable_arduino_library") as mock_enable: + target.add_library(library) + mock_enable.assert_not_called() + + assert "SomeOtherLib" in target.platformio_libraries + + def test_add_library__esp32_idf_does_not_enable_arduino_library(self, target): + """Test add_library doesn't auto-enable Arduino libraries on ESP32 IDF builds.""" + target.data[const.KEY_CORE] = { + const.KEY_TARGET_PLATFORM: "esp32", + const.KEY_TARGET_FRAMEWORK: "esp-idf", + } + + library = core.Library("WiFi", None) + + with patch("esphome.components.esp32._enable_arduino_library") as mock_enable: + target.add_library(library) + mock_enable.assert_not_called() + + assert "WiFi" in target.platformio_libraries + + def test_add_library__esp8266_does_not_enable_arduino_library(self, target): + """Test add_library doesn't auto-enable Arduino libraries on ESP8266.""" + target.data[const.KEY_CORE] = { + const.KEY_TARGET_PLATFORM: "esp8266", + const.KEY_TARGET_FRAMEWORK: "arduino", + } + + library = core.Library("WiFi", None) + + with patch("esphome.components.esp32._enable_arduino_library") as mock_enable: + target.add_library(library) + mock_enable.assert_not_called() + + assert "WiFi" in target.platformio_libraries + + def test_add_library__extracts_short_name_from_path(self, target): + """Test add_library extracts short name from library paths like owner/lib.""" + target.data[const.KEY_CORE] = { + const.KEY_TARGET_PLATFORM: "esp32", + const.KEY_TARGET_FRAMEWORK: "arduino", + } + + library = core.Library("arduino/Wire", None) + + with patch("esphome.components.esp32._enable_arduino_library") as mock_enable: + target.add_library(library) + mock_enable.assert_called_once_with("Wire") + + assert "Wire" in target.platformio_libraries From 940029c844b5dd38cf6b7329308b82010ea346d7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 5 Feb 2026 05:08:59 -0500 Subject: [PATCH 3/5] [esp32] Extract Arduino package names to constants Co-Authored-By: Claude Opus 4.5 --- esphome/components/esp32/__init__.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index a3f93c9e91..e97a97a52e 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -95,6 +95,10 @@ CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision" CONF_RELEASE = "release" +ARDUINO_FRAMEWORK_PKG = "pioarduino/framework-arduinoespressif32" +ARDUINO_LIBS_NAME = "framework-arduinoespressif32-libs" +ARDUINO_LIBS_PKG = f"pioarduino/{ARDUINO_LIBS_NAME}" + LOG_LEVELS_IDF = [ "NONE", "ERROR", @@ -599,14 +603,12 @@ def add_extra_build_file(filename: str, path: Path) -> bool: def _format_framework_arduino_version(ver: cv.Version) -> str: - # format the given arduino (https://github.com/espressif/arduino-esp32/releases) version to - # a PIO pioarduino/framework-arduinoespressif32 value # 3.3.6+ changed filename from esp32-{ver}.zip to esp32-core-{ver}.tar.xz if ver >= cv.Version(3, 3, 6): filename = f"esp32-core-{ver}.tar.xz" else: filename = f"esp32-{ver}.zip" - return f"pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/{ver}/{filename}" + return f"{ARDUINO_FRAMEWORK_PKG}@https://github.com/espressif/arduino-esp32/releases/download/{ver}/{filename}" def _format_framework_espidf_version(ver: cv.Version, release: str) -> str: @@ -741,9 +743,7 @@ def _check_versions(config): CONF_SOURCE, _format_framework_arduino_version(version) ) if _is_framework_url(value[CONF_SOURCE]): - value[CONF_SOURCE] = ( - f"pioarduino/framework-arduinoespressif32@{value[CONF_SOURCE]}" - ) + value[CONF_SOURCE] = f"{ARDUINO_FRAMEWORK_PKG}@{value[CONF_SOURCE]}" else: if version < cv.Version(5, 0, 0): raise cv.Invalid("Only ESP-IDF 5.0+ is supported.") @@ -1327,7 +1327,7 @@ async def _write_arduino_libs_stub(stubs_dir: Path, idf_ver: cv.Version) -> None stubs_dir.mkdir(parents=True, exist_ok=True) write_file_if_changed( stubs_dir / "package.json", - f'{{"name":"framework-arduinoespressif32-libs","version":"{idf_ver.major}.{idf_ver.minor}.{idf_ver.patch}"}}', + f'{{"name":"{ARDUINO_LIBS_NAME}","version":"{idf_ver.major}.{idf_ver.minor}.{idf_ver.patch}"}}', ) write_file_if_changed( stubs_dir / "tools.json", @@ -1469,9 +1469,7 @@ async def to_code(config): stubs_dir = CORE.relative_build_path("arduino-libs-stub") cg.add_platformio_option( "platform_packages", - [ - f"pioarduino/framework-arduinoespressif32-libs@file://{stubs_dir}" - ], + [f"{ARDUINO_LIBS_PKG}@file://{stubs_dir}"], ) CORE.add_job(_write_arduino_libs_stub, stubs_dir, idf_ver) From aa20b232034d6ea048ca9655a2e6ad58ae3637cc Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 5 Feb 2026 05:11:06 -0500 Subject: [PATCH 4/5] [esp32] DRY Arduino package name constants Co-Authored-By: Claude Opus 4.5 --- esphome/components/esp32/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index e97a97a52e..0692c5fc5b 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -95,8 +95,9 @@ CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision" CONF_RELEASE = "release" -ARDUINO_FRAMEWORK_PKG = "pioarduino/framework-arduinoespressif32" -ARDUINO_LIBS_NAME = "framework-arduinoespressif32-libs" +ARDUINO_FRAMEWORK_NAME = "framework-arduinoespressif32" +ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}" +ARDUINO_LIBS_NAME = f"{ARDUINO_FRAMEWORK_NAME}-libs" ARDUINO_LIBS_PKG = f"pioarduino/{ARDUINO_LIBS_NAME}" LOG_LEVELS_IDF = [ From 55fb382445dcc70254116fe5d9433ebd1f2bd987 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 5 Feb 2026 05:13:40 -0500 Subject: [PATCH 5/5] Cleanup --- esphome/components/esp32/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 0692c5fc5b..f5a8bc8994 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1469,8 +1469,7 @@ async def to_code(config): # Use stub package to skip downloading precompiled libs stubs_dir = CORE.relative_build_path("arduino-libs-stub") cg.add_platformio_option( - "platform_packages", - [f"{ARDUINO_LIBS_PKG}@file://{stubs_dir}"], + "platform_packages", [f"{ARDUINO_LIBS_PKG}@file://{stubs_dir}"] ) CORE.add_job(_write_arduino_libs_stub, stubs_dir, idf_ver)