Merge pull request #17052 from esphome/bump-2026.6.1

2026.6.1
This commit is contained in:
Jesse Hills
2026-06-19 11:35:03 +12:00
committed by GitHub
15 changed files with 401 additions and 16 deletions

View File

@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version # could be handy for archiving the generated documentation or if some version
# control system is used. # control system is used.
PROJECT_NUMBER = 2026.6.0 PROJECT_NUMBER = 2026.6.1
# Using the PROJECT_BRIEF tag one can provide an optional one line description # 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 # 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 -r /requirements.txt
# Install the ESPHome Device Builder dashboard. # Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.0.9 RUN uv pip install --no-cache-dir esphome-device-builder==1.0.10
RUN \ RUN \
platformio settings set enable_telemetry No \ platformio settings set enable_telemetry No \

View File

@@ -504,6 +504,12 @@ def has_resolvable_address() -> bool:
if has_ip_address(): if has_ip_address():
return True 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(): if has_mdns():
return True return True
@@ -1765,6 +1771,21 @@ def command_update_all(args: ArgsProtocol) -> int | None:
def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
import json 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: if not CORE.using_toolchain_platformio:
_LOGGER.error( _LOGGER.error(
"The idedata command is not compatible with %s toolchain", "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") flash_size = env.BoardConfig().get("upload.flash_size", "4MB")
chip = env.BoardConfig().get("build.mcu", "esp32") 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 = [] sections = []
flasher_args_path = build_dir / "flasher_args.json" 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.") print("No valid flash sections found — skipping .factory.bin creation.")
return return
output_path = firmware_path.with_suffix(".factory.bin")
python_exe = f'"{env.subst("$PYTHONEXE")}"' python_exe = f'"{env.subst("$PYTHONEXE")}"'
cmd = [ cmd = [
python_exe, python_exe,

View File

@@ -175,6 +175,10 @@ void Logger::process_messages_() {
#ifdef USE_ESPHOME_TASK_LOG_BUFFER #ifdef USE_ESPHOME_TASK_LOG_BUFFER
// Process any buffered messages when available // Process any buffered messages when available
if (this->log_buffer_.has_messages()) { 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; logger::TaskLogBuffer::LogMessage *message;
uint16_t text_length; uint16_t text_length;
while (this->log_buffer_.borrow_message_main_loop(message, text_length)) { while (this->log_buffer_.borrow_message_main_loop(message, text_length)) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -89,8 +89,9 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None:
result = toolchain.get_idedata() result = toolchain.get_idedata()
mock_transform.assert_called_once() mock_transform.assert_called_once()
assert result == {"cxx_path": "g++"} prog_path = str(toolchain.get_elf_path())
assert json.loads(cache.read_text()) == {"cxx_path": "g++"} 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: 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() result = toolchain.get_idedata()
mock_transform.assert_called_once() 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: 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() result = toolchain.get_idedata()
mock_transform.assert_called_once() 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: 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_clean_all,
command_config, command_config,
command_config_hash, command_config_hash,
command_idedata,
command_rename, command_rename,
command_run, command_run,
command_update_all, 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"] 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: 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).""" """Test OTA device when API is configured (no upload without OTA in config)."""
setup_core(config={CONF_API: {}}, address="192.168.1.100") 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) setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address=None)
assert has_resolvable_address() is False 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: def test_has_name_add_mac_suffix() -> None:
"""Test has_name_add_mac_suffix function.""" """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( mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.100"], subscribe_states=True 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