Compare commits

...

5 Commits

Author SHA1 Message Date
J. Nick Koston
037ef732a8 cover 2025-12-01 22:43:14 -06:00
Jonathan Swoboda
629ebdd794 Fix 2025-12-01 23:32:36 -05:00
Jonathan Swoboda
fa2d206d74 Clean up 2025-12-01 22:56:57 -05:00
Jonathan Swoboda
4be240782e Filter noisy platformio log messages
Add a logging filter to suppress "Package configuration completed
successfully" messages from platformio platform modules. The existing
FILTER_PLATFORMIO_LINES mechanism only filters stdout/stderr, but
platformio's logger.info() calls bypass this by using Python's logging
module with handlers that reference the original streams.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 23:33:51 -05:00
Jonathan Swoboda
a7a5a0b9a2 [esp32] Improve IDF component support (#12127) 2025-11-26 22:46:17 -05:00
4 changed files with 236 additions and 17 deletions

View File

@@ -37,6 +37,7 @@ from esphome.const import (
__version__,
)
from esphome.core import CORE, HexInt, TimePeriod
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
@@ -262,15 +263,32 @@ def add_idf_component(
"deprecated and will be removed in ESPHome 2026.1. If you are seeing this, report "
"an issue to the external_component author and ask them to update it."
)
components_registry = CORE.data[KEY_ESP32][KEY_COMPONENTS]
if components:
for comp in components:
CORE.data[KEY_ESP32][KEY_COMPONENTS][comp] = {
existing = components_registry.get(comp)
if existing and existing.get(KEY_REF) != ref:
_LOGGER.warning(
"IDF component %s version conflict %s replaced by %s",
comp,
existing.get(KEY_REF),
ref,
)
components_registry[comp] = {
KEY_REPO: repo,
KEY_REF: ref,
KEY_PATH: f"{path}/{comp}" if path else comp,
}
else:
CORE.data[KEY_ESP32][KEY_COMPONENTS][name] = {
existing = components_registry.get(name)
if existing and existing.get(KEY_REF) != ref:
_LOGGER.warning(
"IDF component %s version conflict %s replaced by %s",
name,
existing.get(KEY_REF),
ref,
)
components_registry[name] = {
KEY_REPO: repo,
KEY_REF: ref,
KEY_PATH: path,
@@ -592,6 +610,14 @@ def require_vfs_dir() -> None:
CORE.data[KEY_VFS_DIR_REQUIRED] = True
def _parse_idf_component(value: str) -> ConfigType:
"""Parse IDF component shorthand syntax like 'owner/component^version'"""
if "^" not in value:
raise cv.Invalid(f"Invalid IDF component shorthand '{value}'")
name, ref = value.split("^", 1)
return {CONF_NAME: name, CONF_REF: ref}
def _validate_idf_component(config: ConfigType) -> ConfigType:
"""Validate IDF component config and warn about deprecated options."""
if CONF_REFRESH in config:
@@ -659,14 +685,19 @@ FRAMEWORK_SCHEMA = cv.Schema(
),
cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list(
cv.All(
cv.Schema(
{
cv.Required(CONF_NAME): cv.string_strict,
cv.Optional(CONF_SOURCE): cv.git_ref,
cv.Optional(CONF_REF): cv.string,
cv.Optional(CONF_PATH): cv.string,
cv.Optional(CONF_REFRESH): cv.All(cv.string, cv.source_refresh),
}
cv.Any(
cv.All(cv.string_strict, _parse_idf_component),
cv.Schema(
{
cv.Required(CONF_NAME): cv.string_strict,
cv.Optional(CONF_SOURCE): cv.git_ref,
cv.Optional(CONF_REF): cv.string,
cv.Optional(CONF_PATH): cv.string,
cv.Optional(CONF_REFRESH): cv.All(
cv.string, cv.source_refresh
),
}
),
),
_validate_idf_component,
)
@@ -851,6 +882,18 @@ def _configure_lwip_max_sockets(conf: dict) -> None:
add_idf_sdkconfig_option("CONFIG_LWIP_MAX_SOCKETS", max_sockets)
@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."""
for component in components:
add_idf_component(
name=component[CONF_NAME],
repo=component.get(CONF_SOURCE),
ref=component.get(CONF_REF),
path=component.get(CONF_PATH),
)
async def to_code(config):
cg.add_platformio_option("board", config[CONF_BOARD])
cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE])
@@ -1097,13 +1140,10 @@ async def to_code(config):
for name, value in conf[CONF_SDKCONFIG_OPTIONS].items():
add_idf_sdkconfig_option(name, RawSdkconfigValue(value))
for component in conf[CONF_COMPONENTS]:
add_idf_component(
name=component[CONF_NAME],
repo=component.get(CONF_SOURCE),
ref=component.get(CONF_REF),
path=component.get(CONF_PATH),
)
# Components from YAML are added in a separate coroutine with FINAL priority
# Schedule it to run after all other components
if conf[CONF_COMPONENTS]:
CORE.add_job(_add_yaml_idf_components, conf[CONF_COMPONENTS])
APP_PARTITION_SIZES = {

View File

@@ -107,9 +107,32 @@ FILTER_PLATFORMIO_LINES = [
r"Warning: DEPRECATED: 'esptool.py' is deprecated. Please use 'esptool' instead. The '.py' suffix will be removed in a future major release.",
r"Warning: esp-idf-size exited with code 2",
r"esp_idf_size: error: unrecognized arguments: --ng",
r"Package configuration completed successfully",
]
class PlatformioLogFilter(logging.Filter):
"""Filter to suppress noisy platformio log messages."""
_PATTERN = re.compile(
r"|".join(r"(?:" + pattern + r")" for pattern in FILTER_PLATFORMIO_LINES)
)
def filter(self, record: logging.LogRecord) -> bool:
# Only filter messages from platformio-related loggers
if "platformio" not in record.name.lower():
return True
return self._PATTERN.match(record.getMessage()) is None
def patch_platformio_logging() -> None:
"""Add filter to root logger handlers to suppress noisy platformio messages."""
root_logger = logging.getLogger()
for handler in root_logger.handlers:
if not any(isinstance(f, PlatformioLogFilter) for f in handler.filters):
handler.addFilter(PlatformioLogFilter())
def run_platformio_cli(*args, **kwargs) -> str | int:
os.environ["PLATFORMIO_FORCE_COLOR"] = "true"
os.environ["PLATFORMIO_BUILD_DIR"] = str(CORE.relative_pioenvs_path().absolute())
@@ -130,6 +153,8 @@ def run_platformio_cli(*args, **kwargs) -> str | int:
patch_structhash()
patch_file_downloader()
if not CORE.verbose:
patch_platformio_logging()
return run_external_command(platformio.__main__.main, *cmd, **kwargs)

View File

@@ -4,6 +4,10 @@ esp32:
cpu_frequency: 400MHz
framework:
type: esp-idf
components:
- espressif/mdns^1.8.2
- name: espressif/esp_hosted
ref: 2.6.6
advanced:
enable_idf_experimental_features: yes

View File

@@ -1,6 +1,7 @@
"""Tests for platformio_api.py path functions."""
import json
import logging
import os
from pathlib import Path
import shutil
@@ -670,3 +671,152 @@ def test_process_stacktrace_bad_alloc(
assert "Memory allocation of 512 bytes failed at 40201234" in caplog.text
mock_decode_pc.assert_called_once_with(config, "40201234")
assert state is False
def test_platformio_log_filter_allows_non_platformio_messages() -> None:
"""Test that non-platformio logger messages are allowed through."""
log_filter = platformio_api.PlatformioLogFilter()
record = logging.LogRecord(
name="esphome.core",
level=logging.INFO,
pathname="",
lineno=0,
msg="Some esphome message",
args=(),
exc_info=None,
)
assert log_filter.filter(record) is True
@pytest.mark.parametrize(
"msg",
[
pytest.param(
"Verbose mode can be enabled via `-v, --verbose` option", id="verbose_mode"
),
pytest.param("Found 5 compatible libraries", id="found_5_libs"),
pytest.param("Found 123 compatible libraries", id="found_123_libs"),
pytest.param("Building in release mode", id="release_mode"),
pytest.param("Building in debug mode", id="debug_mode"),
pytest.param("Merged 2 ELF section", id="merged_elf"),
pytest.param("esptool.py v4.7.0", id="esptool_py"),
pytest.param("esptool v4.8.1", id="esptool"),
pytest.param("PLATFORM: espressif32 @ 6.4.0", id="platform"),
pytest.param("Using cache: /path/to/cache", id="cache"),
pytest.param("Package configuration completed successfully", id="pkg_config"),
pytest.param("Scanning dependencies...", id="scanning_deps"),
pytest.param("Installing dependencies", id="installing_deps"),
pytest.param(
"Library Manager: Already installed, built-in library", id="lib_manager"
),
pytest.param(
"Memory Usage -> https://bit.ly/pio-memory-usage", id="memory_usage"
),
],
)
def test_platformio_log_filter_blocks_noisy_messages(msg: str) -> None:
"""Test that noisy platformio messages are filtered out."""
log_filter = platformio_api.PlatformioLogFilter()
record = logging.LogRecord(
name="platformio.builder",
level=logging.INFO,
pathname="",
lineno=0,
msg=msg,
args=(),
exc_info=None,
)
assert log_filter.filter(record) is False
@pytest.mark.parametrize(
"msg",
[
pytest.param("Compiling .pio/build/test/src/main.cpp.o", id="compiling"),
pytest.param("Linking .pio/build/test/firmware.elf", id="linking"),
pytest.param("Error: something went wrong", id="error"),
pytest.param("warning: unused variable", id="warning"),
],
)
def test_platformio_log_filter_allows_other_platformio_messages(msg: str) -> None:
"""Test that non-noisy platformio messages are allowed through."""
log_filter = platformio_api.PlatformioLogFilter()
record = logging.LogRecord(
name="platformio.builder",
level=logging.INFO,
pathname="",
lineno=0,
msg=msg,
args=(),
exc_info=None,
)
assert log_filter.filter(record) is True
@pytest.mark.parametrize(
"logger_name",
[
pytest.param("PLATFORMIO.builder", id="upper"),
pytest.param("PlatformIO.core", id="mixed"),
pytest.param("platformio.run", id="lower"),
],
)
def test_platformio_log_filter_case_insensitive_logger_name(logger_name: str) -> None:
"""Test that platformio logger name matching is case insensitive."""
log_filter = platformio_api.PlatformioLogFilter()
record = logging.LogRecord(
name=logger_name,
level=logging.INFO,
pathname="",
lineno=0,
msg="Found 5 compatible libraries",
args=(),
exc_info=None,
)
assert log_filter.filter(record) is False
def test_patch_platformio_logging_adds_filter() -> None:
"""Test that patch_platformio_logging adds filter to all handlers."""
test_handler = logging.StreamHandler()
root_logger = logging.getLogger()
original_handlers = root_logger.handlers.copy()
try:
root_logger.addHandler(test_handler)
assert not any(
isinstance(f, platformio_api.PlatformioLogFilter)
for f in test_handler.filters
)
platformio_api.patch_platformio_logging()
assert any(
isinstance(f, platformio_api.PlatformioLogFilter)
for f in test_handler.filters
)
finally:
root_logger.handlers = original_handlers
def test_patch_platformio_logging_no_duplicate_filters() -> None:
"""Test that patch_platformio_logging doesn't add duplicate filters."""
test_handler = logging.StreamHandler()
root_logger = logging.getLogger()
original_handlers = root_logger.handlers.copy()
try:
root_logger.addHandler(test_handler)
platformio_api.patch_platformio_logging()
platformio_api.patch_platformio_logging()
filter_count = sum(
1
for f in test_handler.filters
if isinstance(f, platformio_api.PlatformioLogFilter)
)
assert filter_count == 1
finally:
root_logger.handlers = original_handlers