Compare commits

...

29 Commits

Author SHA1 Message Date
Jonathan Swoboda
9ab2a573ab Merge pull request #17093 from esphome/bump-2026.6.2
2026.6.2
2026-06-20 14:17:55 -04:00
Jonathan Swoboda
99d1c4eb69 Bump version to 2026.6.2 2026-06-20 13:33:41 -04:00
esphome[bot]
b079be756f Bump bundled esphome-device-builder to 1.0.12 (#17091) 2026-06-20 13:33:41 -04:00
J. Nick Koston
039a1f063e [ha-addon] Expose the device-builder public port only when port 6052 is mapped (#17076) 2026-06-20 13:33:41 -04:00
esphome[bot]
2354165e41 Bump bundled esphome-device-builder to 1.0.11 (#17081) 2026-06-20 13:33:41 -04:00
Jonathan Swoboda
f5697b0ae5 [packet_transport] Mark encryption key as cv.sensitive (#17066) 2026-06-20 13:33:41 -04:00
Jonathan Swoboda
fe794a26e8 [fastled_base] Fix RMT5 intr_priority conflict (#17072) 2026-06-20 13:33:41 -04:00
Jonathan Swoboda
8d77051b9a [espidf] Resolve IDF tools path to avoid unnormalized path warning (#17055) 2026-06-20 13:33:41 -04:00
Jesse Hills
9534ab2a19 Merge pull request #17052 from esphome/bump-2026.6.1
2026.6.1
2026-06-19 11:35:03 +12:00
Jesse Hills
1b1c8d767d Bump version to 2026.6.1 2026-06-19 10:06:13 +12:00
esphome[bot]
e3d68deef9 Bump bundled esphome-device-builder to 1.0.10 (#17051) 2026-06-19 10:06:13 +12:00
J. Nick Koston
20cd6a1771 [logger] Hold recursion guard while draining the task log buffer (#17044) 2026-06-19 10:06:13 +12:00
Jonathan Swoboda
d27229a1c7 [esp32] Don't overwrite PlatformIO's factory.bin (#17042) 2026-06-19 10:06:13 +12:00
Jonathan Swoboda
129aebe8f4 [esp32] Support esphome idedata with the native ESP-IDF toolchain (#17040) 2026-06-19 10:06:13 +12:00
Jonathan Swoboda
a84ad7b1f8 [uptime] Revert timestamp sensor device_class to timestamp (#17037) 2026-06-19 10:06:13 +12:00
Jonathan Swoboda
86096b96f5 [build] Skip target-platform deps when populating host unit-test config (#17039) 2026-06-19 10:06:13 +12:00
J. Nick Koston
ac5a28301a [core] Honor transferred address cache in has_resolvable_address (#17025)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-19 10:06:13 +12:00
Jesse Hills
e2157a3d26 Merge pull request #17022 from esphome/bump-2026.6.0
2026.6.0
2026-06-18 12:59:50 +12:00
esphome[bot]
d934fb3910 Bump bundled esphome-device-builder to 1.0.9 (#17021)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-06-18 10:46:22 +12:00
esphome[bot]
c4076ec8a9 Bump bundled esphome-device-builder to 1.0.8 (#17020) 2026-06-18 10:46:12 +12:00
esphome[bot]
9ac22f9244 Bump bundled esphome-device-builder to 1.0.7 (#17018) 2026-06-18 10:46:06 +12:00
Jesse Hills
9e7b3e0330 Bump version to 2026.6.0 2026-06-18 10:18:37 +12:00
Jesse Hills
2abe272867 Merge pull request #17017 from esphome/bump-2026.6.0b4
2026.6.0b4
2026-06-18 10:08:15 +12:00
Jesse Hills
db6b9166f4 Bump version to 2026.6.0b4 2026-06-18 08:20:15 +12:00
esphome[bot]
7ab95ddcb1 Bump bundled esphome-device-builder to 1.0.6 (#17016) 2026-06-18 08:20:02 +12:00
esphome[bot]
cdd2bfbc60 Bump bundled esphome-device-builder to 1.0.4 (#17013) 2026-06-18 08:19:05 +12:00
esphome[bot]
41f7f8cccb Bump bundled esphome-device-builder to 1.0.3 (#17005) 2026-06-18 08:19:05 +12:00
Jonathan Swoboda
045de436ba [ota] Scale ESP-IDF OTA erase watchdog to image size (#16998) 2026-06-18 08:19:05 +12:00
Jonathan Swoboda
24e276c3f9 [esp32_hosted] Bump esp_hosted to 2.12.9 (#16999) 2026-06-18 08:18:25 +12:00
23 changed files with 444 additions and 24 deletions

View File

@@ -1 +1 @@
34f6ce4a4775acf8c7201778f114b191f78269f232b67f01fed920f0cdf73686
72f02816e288b68ff4ef4b3d6fb66432c893b187a80ad3ebaa29afa443ff9ea6

View File

@@ -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.6.0b3
PROJECT_NUMBER = 2026.6.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

View File

@@ -32,7 +32,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.0.1
RUN uv pip install --no-cache-dir esphome-device-builder==1.0.12
RUN \
platformio settings set enable_telemetry No \

View File

@@ -49,7 +49,21 @@ if bashio::fs.directory_exists '/config/esphome/.esphome'; then
rm -rf /config/esphome/.esphome
fi
# Only signal device-builder to expose the public LAN port when the operator
# mapped port 6052, matching the legacy dashboard where nginx listened on the
# fixed port 6052 only when it was configured. We use the mapping purely as a
# presence check and don't forward the published value; device-builder binds
# its default port 6052 (the fixed container port, as the legacy
# "listen 6052" did). --ha-addon-allow-public is inert on its own: the no-auth
# gate is the DISABLE_HA_AUTHENTICATION env var set above, so both opt-ins are
# required to bind 6052 unauthenticated; either alone stays ingress-only.
set --
if bashio::var.has_value "$(bashio::addon.port 6052)"; then
set -- --ha-addon-allow-public
fi
bashio::log.info "Starting ESPHome Device Builder..."
exec esphome-device-builder /config/esphome \
--ha-addon \
--ingress-port "$(bashio::addon.ingress_port)"
--ingress-port "$(bashio::addon.ingress_port)" \
"$@"

View File

@@ -504,6 +504,12 @@ def has_resolvable_address() -> bool:
if has_ip_address():
return True
# The dashboard pre-resolves the device and passes the IPs via
# --mdns-address-cache/--dns-address-cache; honor a cached address even when the
# device has mDNS disabled (e.g. a .local host found via ping).
if CORE.address_cache and CORE.address_cache.get_addresses(CORE.address):
return True
if has_mdns():
return True
@@ -1765,6 +1771,21 @@ def command_update_all(args: ArgsProtocol) -> int | None:
def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
import json
if CORE.using_toolchain_esp_idf:
# Native ESP-IDF derives idedata from the build's compile_commands.json,
# so the configuration must already be compiled.
from esphome.espidf import toolchain as espidf_toolchain
idedata = espidf_toolchain.get_idedata()
if idedata is None:
_LOGGER.error(
"No idedata available; compile the configuration first",
)
return 1
print(json.dumps(idedata, indent=2) + "\n")
return 0
if not CORE.using_toolchain_platformio:
_LOGGER.error(
"The idedata command is not compatible with %s toolchain",

View File

@@ -224,6 +224,17 @@ def merge_factory_bin(source, target, env):
flash_size = env.BoardConfig().get("upload.flash_size", "4MB")
chip = env.BoardConfig().get("build.mcu", "esp32")
# PlatformIO's esp-idf builder already creates a correct firmware.factory.bin (right
# artifact names and partition offsets, including custom partition tables). The merge
# below is only a fallback and cannot honor custom layouts, so don't overwrite an image
# PlatformIO already produced. Post-build actions only run when firmware.bin is rebuilt,
# and PlatformIO's combined-image builder runs before us in that batch, so an existing
# file here is current.
output_path = firmware_path.with_suffix(".factory.bin")
if output_path.exists():
print(f"{output_path.name} already created by PlatformIO - skipping merge")
return
sections = []
flasher_args_path = build_dir / "flasher_args.json"
@@ -291,7 +302,6 @@ def merge_factory_bin(source, target, env):
print("No valid flash sections found — skipping .factory.bin creation.")
return
output_path = firmware_path.with_suffix(".factory.bin")
python_exe = f'"{env.subst("$PYTHONEXE")}"'
cmd = [
python_exe,

View File

@@ -257,7 +257,7 @@ async def to_code(config):
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1")
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2")
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.8")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.9")
else:
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0")
esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0")

View File

@@ -50,6 +50,11 @@ async def new_fastled_light(config):
ref="d44c800a9e876a8394caefc2ce4915dd96dac77b",
)
cg.add_library("SPI", None)
# FastLED's RMT5 driver hard-codes intr_priority=3, which conflicts with
# esphome's RMT channels (remote_transmitter etc., priority 0): the IDF
# driver rejects FastLED's channel and show() then hangs ~3s with no
# output. Override to 0 so it shares the interrupt. See #17063.
cg.add_build_flag("-DFL_RMT5_INTERRUPT_LEVEL=0")
else:
cg.add_library("fastled/FastLED", "3.9.16")
await light.register_light(var, config)

View File

@@ -175,6 +175,10 @@ void Logger::process_messages_() {
#ifdef USE_ESPHOME_TASK_LOG_BUFFER
// Process any buffered messages when available
if (this->log_buffer_.has_messages()) {
// Prevent main-task logs emitted by listener callbacks (e.g. the API send path) from re-entering
// and corrupting the shared tx_buffer_ / API shared_write_buffer_ while we are draining here.
// Mirrors the guard held by log_message_to_buffer_and_send_ on the synchronous logging path.
RecursionGuard guard(this->main_task_recursion_guard_);
logger::TaskLogBuffer::LogMessage *message;
uint16_t text_length;
while (this->log_buffer_.borrow_message_main_loop(message, text_length)) {

View File

@@ -57,7 +57,18 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type)
return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION;
}
watchdog::WatchdogManager watchdog(15000);
// esp_ota_begin() erases the destination region, which blocks loopTask and
// scales with the erase size -- a fixed watchdog overruns on large OTA slots.
// An unknown size (0, e.g. web_server uploads) erases the whole partition, so
// budget against the bytes actually erased. ~10ms/KiB (conservative
// ~100 KiB/s erase) over a 15s floor; panic stays on so a stuck erase still
// resets rather than hanging forever.
size_t erase_size = image_size;
if (erase_size == 0 || erase_size > this->partition_->size) {
erase_size = this->partition_->size;
}
const uint32_t erase_budget_ms = 15000 + (erase_size >> 10) * 10;
watchdog::WatchdogManager watchdog(erase_budget_ms);
esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_);
if (err != ESP_OK) {

View File

@@ -69,7 +69,7 @@ ENCRYPTION_SCHEMA = {
cv.Optional(CONF_ENCRYPTION): cv.maybe_simple_value(
cv.Schema(
{
cv.Required(CONF_KEY): cv.string,
cv.Required(CONF_KEY): cv.sensitive(cv.string),
}
),
key=CONF_KEY,

View File

@@ -4,7 +4,7 @@ import esphome.config_validation as cv
from esphome.const import (
CONF_TIME_ID,
DEVICE_CLASS_DURATION,
DEVICE_CLASS_UPTIME,
DEVICE_CLASS_TIMESTAMP,
ENTITY_CATEGORY_DIAGNOSTIC,
ICON_TIMER,
STATE_CLASS_TOTAL_INCREASING,
@@ -33,8 +33,9 @@ CONFIG_SCHEMA = cv.typed_schema(
).extend(cv.polling_component_schema("60s")),
"timestamp": sensor.sensor_schema(
UptimeTimestampSensor,
icon=ICON_TIMER,
accuracy_decimals=0,
device_class=DEVICE_CLASS_UPTIME,
device_class=DEVICE_CLASS_TIMESTAMP,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
)
.extend(

View File

@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.6.0b3"
__version__ = "2026.6.2"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (

View File

@@ -81,8 +81,13 @@ def _get_idf_tools_path() -> Path:
Path object pointing to the ESP-IDF tools directory
"""
if "ESPHOME_ESP_IDF_PREFIX" in os.environ:
return Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser()
return CORE.data_dir / "idf"
path = Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser()
else:
path = CORE.data_dir / "idf"
# Resolve so an unnormalized config path (e.g. compiling ``../config/x.yaml``)
# doesn't leave ``..`` segments in the IDF_TOOLS_PATH handed to idf.py, which
# otherwise warns that the venv interpreter path doesn't match the install.
return path.resolve()
# Windows' default MAX_PATH is 260 characters. ESP-IDF toolchains nest deeply

View File

@@ -472,6 +472,7 @@ def get_idedata() -> dict | None:
pass
data = idedata_from_build(compile_commands)
data["prog_path"] = str(get_elf_path())
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
return data

View File

@@ -38,7 +38,7 @@ dependencies:
rules:
- if: "target in [esp32h2, esp32p4]"
espressif/esp_hosted:
version: 2.12.8
version: 2.12.9
rules:
- if: "target in [esp32h2, esp32p4]"
zorxx/multipart-parser:

View File

@@ -70,12 +70,15 @@ def populate_dependency_config(
* ``domain.platform`` form (e.g. ``sensor.gpio``) appends
``{platform: <name>}`` to ``config[domain]``, creating the list if needed.
* Bare components are looked up via ``get_component_fn``. Platform
components (``IS_PLATFORM_COMPONENT``) and ``MULTI_CONF`` components are
initialised as ``[]`` so the sibling ``domain.platform`` branch can
``append`` into them. Everything else is populated by running the
component's schema with ``{}`` so defaults exist; if the schema requires
explicit input, an empty ``{}`` is used as a fallback.
* Bare components are looked up via ``get_component_fn``. Target-platform
components (``is_target_platform``, e.g. ``esp32``) are skipped entirely:
a host build targets ``host``, so a foreign target platform's sources are
guarded out and its schema must not run here (it would mutate global CORE
state as a side effect). Platform components (``IS_PLATFORM_COMPONENT``)
and ``MULTI_CONF`` components are initialised as ``[]`` so the sibling
``domain.platform`` branch can ``append`` into them. Everything else is
populated by running the component's schema with ``{}`` so defaults exist;
if the schema requires explicit input, an empty ``{}`` is used as a fallback.
Platform components must always be a list here even when no
``domain.platform`` entry follows, because the ``domain.platform`` branch
@@ -96,6 +99,12 @@ def populate_dependency_config(
component = get_component_fn(component_name)
if component is None:
continue
# Skip target platforms (e.g. esp32): a host build targets `host`, so a
# foreign target's sources are guarded out, and running its schema with
# {} leaks global CORE state (esp32 pins CORE.toolchain to ESP-IDF),
# crashing the host compile. See #17035.
if component.is_target_platform:
continue
if component.multi_conf or component.is_platform_component:
config.setdefault(component_name, [])
elif component_name not in config:

View File

@@ -0,0 +1,61 @@
esphome:
name: logger-recursion-test
host:
api:
logger:
level: DEBUG
on_message:
# Fires on the main loop for every message delivered to listeners, including
# messages drained from the task log buffer (i.e. logged from a non-main thread).
# The lambda logs again on the main task. Without a recursion guard on the buffered
# drain path this re-entrant log reuses the shared tx_buffer_ and clobbers the
# buffered message that is still being delivered, corrupting its console output.
- level: VERY_VERBOSE
then:
- lambda: |-
ESP_LOGD("reentry", "REENTRANT_CLOBBER_MARKER");
button:
- platform: template
name: "Start Race Test"
id: start_test_button
on_press:
- lambda: |-
// Keep the count well under the host task-log-buffer slot count so every
// message goes through the ring buffer (buffered drain path) instead of the
// emergency console fallback. The main loop is blocked in pthread_join while
// the thread logs, so all messages are drained together once it returns.
static const int NUM_MESSAGES = 30;
struct ThreadTest {
static void *thread_func(void *arg) {
char thread_name[16];
snprintf(thread_name, sizeof(thread_name), "LogThread");
#ifdef __APPLE__
pthread_setname_np(thread_name);
#else
pthread_setname_np(pthread_self(), thread_name);
#endif
for (int i = 0; i < NUM_MESSAGES; i++) {
// Verifiable payload: data is a deterministic function of the message
// index, so a clobbered buffer shows up as a missing or mismatched line.
ESP_LOGD("thread_test", "THREADMSG%03d_DATA_%08X", i, i * 12345);
}
return nullptr;
}
};
// RACE_TEST_START / RACE_TEST_COMPLETE are logged from the main task (the
// synchronous path, which already holds the recursion guard) so the test can
// always detect completion even when the buffered path is corrupted.
ESP_LOGI("thread_test", "RACE_TEST_START: logging %d messages from a thread", NUM_MESSAGES);
pthread_t thread;
if (pthread_create(&thread, nullptr, ThreadTest::thread_func, nullptr) != 0) {
ESP_LOGE("thread_test", "RACE_TEST_ERROR: Failed to create thread");
return;
}
pthread_join(thread, nullptr);
ESP_LOGI("thread_test", "RACE_TEST_COMPLETE: thread finished, expected %d messages", NUM_MESSAGES);

View File

@@ -0,0 +1,119 @@
"""Integration test for the recursion guard on the buffered logger drain path.
Regression test for a crash where a log message drained from the task log buffer
(i.e. logged from a non-main thread) re-entered the logger on the main task while it
was still being delivered to listeners. The buffered drain in
``Logger::process_messages_`` did not hold the main-task recursion guard that the
synchronous logging path holds, so a listener callback that logged again on the main
task (e.g. the API log-forwarding path, or a ``logger.on_message`` automation) reused
the shared ``tx_buffer_`` and clobbered the message mid-delivery. On ESP32 this showed
up as a ``StoreProhibited`` panic inside the API send path.
The fixture logs a small batch of verifiable messages from a non-main thread (kept
under the host task-log-buffer slot count so they all take the buffered drain path
rather than the emergency console fallback) while an ``on_message`` automation re-logs
``REENTRANT_CLOBBER_MARKER`` on the main task for every delivered message.
Without the guard the re-entrant marker is written into the shared ``tx_buffer_`` while
the buffered thread message is still being delivered, so the message the API receives is
contaminated (it contains the marker and an embedded newline glued onto the thread
payload). With the guard the re-entrant log is dropped during the drain, the marker
never appears, and every thread message is delivered clean.
"""
from __future__ import annotations
import asyncio
import re
from aioesphomeapi import LogLevel
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
# THREADMSGnnn_DATA_xxxxxxxx where data is a deterministic checksum of the index
THREAD_MSG_PATTERN = re.compile(r"THREADMSG(\d{3})_DATA_([0-9A-F]{8})")
NUM_MESSAGES = 30
@pytest.mark.asyncio
async def test_logger_buffered_recursion_guard(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Buffered (non-main-thread) log messages survive a re-entrant main-task log."""
api_messages: list[str] = []
all_drained = asyncio.Event()
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "logger-recursion-test"
# Subscribe over the API: this is the exact path that crashed in the field
# (the API log callback runs during the buffered drain). The API message field
# preserves embedded newlines, so it reliably exposes a clobbered buffer.
#
# Every buffered thread message is delivered here whether it survives intact or
# gets clobbered (a clobbered message still carries its THREADMSG payload), so
# counting THREADMSG occurrences is a deterministic "drain complete" signal: no
# arbitrary sleep, no dependence on the fix being present.
def on_log(msg) -> None:
text = msg.message.decode("utf-8", errors="replace")
api_messages.append(text)
received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages)
if received >= NUM_MESSAGES:
all_drained.set()
client.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERY_VERBOSE)
entities, _ = await client.list_entities_services()
buttons = [e for e in entities if e.name == "Start Race Test"]
assert buttons, "Could not find Start Race Test button"
client.button_command(buttons[0].key)
# Wait until every buffered thread message has been delivered over the API.
try:
await asyncio.wait_for(all_drained.wait(), timeout=30.0)
except TimeoutError:
received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages)
pytest.fail(
f"Only {received}/{NUM_MESSAGES} thread messages arrived before timeout; "
"device likely crashed or hung."
)
intact: set[int] = set()
contaminated: list[str] = []
for raw in api_messages:
text = _ANSI.sub("", raw)
if "THREADMSG" not in text:
continue
# A clean thread message is a single line carrying only its own payload. A
# clobbered buffer glues the re-entrant marker (and an embedded newline) onto it.
if "REENTRANT" in text or "\n" in text:
contaminated.append(repr(raw))
continue
match = THREAD_MSG_PATTERN.search(text)
assert match, f"Unexpected thread message format: {raw!r}"
msg_num = int(match.group(1))
expected = f"{msg_num * 12345:08X}"
if match.group(2) != expected:
contaminated.append(repr(raw))
continue
intact.add(msg_num)
assert not contaminated, (
"Buffered thread messages were clobbered by a re-entrant main-task log "
"(missing recursion guard on the buffered drain path):\n"
+ "\n".join(contaminated[:10])
)
assert len(intact) == NUM_MESSAGES, (
f"Expected {NUM_MESSAGES} intact buffered thread messages over the API, got "
f"{len(intact)}. Missing ids: {sorted(set(range(NUM_MESSAGES)) - intact)}"
)

View File

@@ -0,0 +1,76 @@
"""Unit tests for script/build_helpers.py."""
from pathlib import Path
import sys
import pytest
# Add the script directory to the path so we can import build_helpers.
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script"))
import build_helpers # noqa: E402
from esphome.core import CORE # noqa: E402
class _FakeComponent:
def __init__(self, config_schema, *, is_target_platform=False):
self.multi_conf = False
self.is_platform_component = False
self.is_target_platform = is_target_platform
self.config_schema = config_schema
@pytest.fixture(autouse=True)
def _restore_core_toolchain():
"""Keep CORE.toolchain changes from leaking between tests."""
saved = CORE.toolchain
try:
yield
finally:
CORE.toolchain = saved
def test_populate_dependency_config_skips_target_platforms() -> None:
"""Target-platform deps must be skipped, not config-populated, in a host build.
Regression test for #17035: esp32 (a target platform) appears only as a
transitive dependency of a host C++ unit test. Running its schema with {}
set ``CORE.toolchain = ESP_IDF`` as a side effect before failing validation,
which crashed the host compile with KeyError('esp32'). The fix skips
target-platform components entirely so their schema never runs.
"""
CORE.toolchain = None # the state a host build starts from
schema_calls = []
def leaky_schema(value):
# If this ever runs for a target platform, the bug is back.
schema_calls.append(value)
CORE.toolchain = "esp-idf-leak"
raise ValueError("no board or variant")
config: dict = {}
build_helpers.populate_dependency_config(
config,
["esp32"],
get_component_fn=lambda name: _FakeComponent(
leaky_schema, is_target_platform=True
),
register_platform_fn=lambda domain: None,
)
assert "esp32" not in config # skipped: no synthesized entry
assert schema_calls == [] # schema never run
assert CORE.toolchain is None # no global side effect leaked
def test_populate_dependency_config_populates_defaults() -> None:
"""A non-target-platform dep still has its schema defaults harvested."""
config: dict = {}
build_helpers.populate_dependency_config(
config,
["ok"],
get_component_fn=lambda name: _FakeComponent(lambda value: {"default": 1}),
register_platform_fn=lambda domain: None,
)
assert config["ok"] == {"default": 1}

View File

@@ -266,11 +266,13 @@ def _make_component_stub(
*,
multi_conf: bool = False,
is_platform_component: bool = False,
is_target_platform: bool = False,
config_schema=None,
) -> MagicMock:
stub = MagicMock()
stub.multi_conf = multi_conf
stub.is_platform_component = is_platform_component
stub.is_target_platform = is_target_platform
stub.config_schema = config_schema
return stub

View File

@@ -89,8 +89,9 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert result == {"cxx_path": "g++"}
assert json.loads(cache.read_text()) == {"cxx_path": "g++"}
prog_path = str(toolchain.get_elf_path())
assert result == {"cxx_path": "g++", "prog_path": prog_path}
assert json.loads(cache.read_text()) == {"cxx_path": "g++", "prog_path": prog_path}
def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None:
@@ -127,7 +128,7 @@ def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert result == {"cxx_path": "fresh"}
assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())}
def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
@@ -147,7 +148,26 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert result == {"cxx_path": "regen"}
assert result == {"cxx_path": "regen", "prog_path": str(toolchain.get_elf_path())}
def test_get_idedata_prog_path_points_at_firmware_elf(setup_core: Path) -> None:
"""The idedata exposes prog_path (the ELF) so consumers like build-action
can locate firmware.factory.bin / firmware.ota.bin as its siblings."""
compile_commands, _ = _setup_build(setup_core)
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
with patch(
"esphome.espidf.idedata.idedata_from_build",
return_value={"cxx_path": "g++"},
):
result = toolchain.get_idedata()
# Use Path semantics so the contract holds on Windows too (backslashes).
prog_path = Path(result["prog_path"])
assert prog_path.name == "firmware.elf"
assert prog_path.parent.name == "build"
def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None:

View File

@@ -32,6 +32,7 @@ from esphome.__main__ import (
command_clean_all,
command_config,
command_config_hash,
command_idedata,
command_rename,
command_run,
command_update_all,
@@ -689,6 +690,25 @@ def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None:
assert result == ["192.168.1.100"]
def test_choose_upload_log_host_ota_mdns_disabled_uses_address_cache() -> None:
"""A .local device with mDNS disabled resolves via the dashboard-supplied cache."""
setup_core(
config={
CONF_API: {},
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
CONF_MDNS: {CONF_DISABLED: True},
},
address="esp32-a1s.local",
)
CORE.address_cache = AddressCache(mdns_cache={"esp32-a1s.local": ["192.168.1.50"]})
for purpose in (Purpose.LOGGING, Purpose.UPLOADING):
result = choose_upload_log_host(
default="OTA", check_default=None, purpose=purpose
)
assert result == ["192.168.1.50"]
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")
@@ -3135,6 +3155,22 @@ def test_has_resolvable_address() -> None:
setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address=None)
assert has_resolvable_address() is False
# mDNS disabled + .local, but the dashboard cached the address -> resolvable
setup_core(
config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local"
)
CORE.address_cache = AddressCache(
mdns_cache={"esphome-device.local": ["192.168.1.100"]}
)
assert has_resolvable_address() is True
# mDNS disabled + .local, cache present but missing this host -> not resolvable
setup_core(
config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local"
)
CORE.address_cache = AddressCache(mdns_cache={"other-device.local": ["10.0.0.1"]})
assert has_resolvable_address() is False
def test_has_name_add_mac_suffix() -> None:
"""Test has_name_add_mac_suffix function."""
@@ -6222,3 +6258,28 @@ def test_command_run_defaults_subscribe_states_true(
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.100"], subscribe_states=True
)
def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None:
"""Under the native ESP-IDF toolchain, idedata is emitted as JSON."""
setup_core()
CORE.toolchain = Toolchain.ESP_IDF
data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"}
with patch("esphome.espidf.toolchain.get_idedata", return_value=data) as mock_get:
result = command_idedata(MagicMock(), CORE.config)
assert result == 0
mock_get.assert_called_once_with()
assert json.loads(capsys.readouterr().out) == data
def test_command_idedata_esp_idf_no_build_errors() -> None:
"""Under ESP-IDF, a missing build (no idedata) returns an error, not a crash."""
setup_core()
CORE.toolchain = Toolchain.ESP_IDF
with patch("esphome.espidf.toolchain.get_idedata", return_value=None):
result = command_idedata(MagicMock(), CORE.config)
assert result == 1