diff --git a/esphome/__main__.py b/esphome/__main__.py index fb51fdaf15..2b7e1ac85c 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -630,27 +630,9 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: return 1 _LOGGER.info("Starting log output from %s with baud rate %s", port, baud_rate) - # Stacktrace analysis is optional; a broken platform import must not - # stop serial log streaming, but it is a real breakage and must not - # masquerade as an ordinary capability gap. - try: - process_stacktrace = platform_hooks.get_platform_hook( - CORE.target_platform, "process_stacktrace" - ) - except ImportError as err: - _LOGGER.debug("Stacktrace analyzer import failed", exc_info=True) - _LOGGER.warning( - 'Stacktrace analysis is unavailable: analyzer for target platform "%s" failed to import: %s', - CORE.target_platform, - err, - ) - process_stacktrace = None - else: - if process_stacktrace is None: - _LOGGER.info( - 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".', - CORE.target_platform, - ) + # Stacktrace analysis is optional; platform_hooks owns resolution + # and the user-facing messages. + process_stacktrace = platform_hooks.get_stacktrace_handler(CORE.target_platform) backtrace_state = False ser = serial.Serial() diff --git a/esphome/platform_hooks.py b/esphome/platform_hooks.py index 2106f50319..184644f29c 100644 --- a/esphome/platform_hooks.py +++ b/esphome/platform_hooks.py @@ -10,9 +10,10 @@ imports each platform package and fails when they drift. The compile-path ``run_compile`` hook is deliberately not registered: compiling imports the platform package regardless, so its probe in -``__main__.py`` stays eager. The network log client's -``process_stacktrace`` probe in ``esphome/api_client.py`` still uses the -old importlib pattern; converting it is a separate change. +``__main__.py`` stays eager. The serial log path resolves +``process_stacktrace`` through get_stacktrace_handler below; the network +log client's probe in ``esphome/api_client.py`` still uses the old +importlib pattern and is converted separately. """ from __future__ import annotations @@ -111,3 +112,30 @@ def get_platform_hook(platform: str, hook: str) -> Callable[..., Any] | None: hook, ) return handler + + +def get_stacktrace_handler(platform: str) -> Callable[..., Any] | None: + """Resolve ``process_stacktrace`` for *platform*, degrading with a log. + + Stacktrace decoding is a diagnostic nicety. This only distinguishes + an import failure from an ordinary capability gap so the message is + accurate; it returns None for both, and callers own any further + containment. Shared so the user-facing message lives in one place. + """ + try: + handler = get_platform_hook(platform, "process_stacktrace") + except ImportError as err: + # A real breakage, not an ordinary capability gap; say so louder. + _LOGGER.debug("Stacktrace analyzer import failed", exc_info=True) + _LOGGER.warning( + 'Stacktrace analysis is unavailable: analyzer for target platform "%s" failed to import: %s', + platform, + err, + ) + return None + if handler is None: + _LOGGER.info( + 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".', + platform, + ) + return handler diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 2c9a88afb6..1ca78e1924 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -5732,7 +5732,7 @@ def test_run_miniterm_analyzer_import_failure_keeps_streaming( args = MockArgs() with ( - caplog.at_level("INFO", logger="esphome.__main__"), + caplog.at_level("INFO", logger="esphome.platform_hooks"), patch("serial.Serial", return_value=mock_serial), patch( "esphome.platform_hooks.get_platform_hook", @@ -5762,7 +5762,7 @@ def test_run_miniterm_no_stacktrace_analyzer( args = MockArgs() with ( - caplog.at_level("INFO", logger="esphome.__main__"), + caplog.at_level("INFO", logger="esphome.platform_hooks"), patch("serial.Serial", return_value=mock_serial), ): result = run_miniterm(config, "/dev/ttyUSB0", args) diff --git a/tests/unit_tests/test_platform_hooks.py b/tests/unit_tests/test_platform_hooks.py index 27d37e1d76..97b25e7c0f 100644 --- a/tests/unit_tests/test_platform_hooks.py +++ b/tests/unit_tests/test_platform_hooks.py @@ -9,12 +9,13 @@ that the fast path really avoids the import. from __future__ import annotations import importlib +import logging from unittest.mock import Mock import pytest from esphome import platform_hooks -from esphome.const import PLATFORM_ESP32, Platform +from esphome.const import PLATFORM_BK72XX, PLATFORM_ESP32, Platform def test_no_unregistered_platform_exposes_a_hook() -> None: @@ -151,3 +152,35 @@ def test_lookup_miss_does_not_import_platform_package( Mock(side_effect=AssertionError("platform package imported on registry miss")), ) assert platform_hooks.get_platform_hook(PLATFORM_ESP32, "show_logs") is None + + +def test_get_stacktrace_handler_resolves_registered_platform() -> None: + hook = platform_hooks.get_stacktrace_handler(PLATFORM_ESP32) + from esphome.components import esp32 + + assert hook is esp32.process_stacktrace + + +def test_get_stacktrace_handler_reports_missing_analyzer( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level("INFO", logger="esphome.platform_hooks") + assert platform_hooks.get_stacktrace_handler(PLATFORM_BK72XX) is None + assert "no compatible analyzer" in caplog.text + # A capability gap is ordinary; it must not warn. + assert not any(r.levelno >= logging.WARNING for r in caplog.records) + + +def test_get_stacktrace_handler_reports_import_failure( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.setattr( + platform_hooks, + "import_module", + Mock(side_effect=ImportError("broken install")), + ) + assert platform_hooks.get_stacktrace_handler(PLATFORM_ESP32) is None + assert "failed to import: broken install" in caplog.text + # A broken install is a real breakage; it must warn, not inform. + assert any(r.levelno == logging.WARNING for r in caplog.records)