[core] Warn on crystal frequency mismatch during serial upload

When flashing an ESP32 via serial, esptool prints the detected crystal
frequency. This change parses that output in real-time and warns the
user if it doesn't match the configured CONFIG_XTAL_FREQ in sdkconfig.

This is particularly important for ESP32-C2 (ESP8684) boards where
some modules use 26MHz crystals but the default sdkconfig assumes 40MHz,
causing UART logging and other clock-dependent features to silently fail.

Also adds a generic line_callbacks mechanism to RedirectText so future
output-based checks can be added without modifying the class directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
J. Nick Koston
2026-03-06 21:58:29 -10:00
co-authored by Claude Opus 4.6
parent df11e2765e
commit c622ee6a6e
2 changed files with 74 additions and 5 deletions
+53 -1
View File
@@ -628,6 +628,50 @@ def _check_and_emit_build_info() -> None:
)
def _get_configured_xtal_freq() -> int | None:
"""Read the configured crystal frequency from the sdkconfig file."""
sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}")
if not sdkconfig_path.is_file():
return None
try:
content = sdkconfig_path.read_text()
for line in content.splitlines():
if line.startswith("CONFIG_XTAL_FREQ="):
return int(line.split("=", 1)[1])
except (OSError, ValueError):
pass
return None
def _make_crystal_freq_callback(
configured_freq: int,
) -> Callable[[str], str | None]:
"""Create a callback that checks esptool crystal frequency output."""
crystal_re = re.compile(r"Crystal frequency:\s+(\d+(?:\.\d+)?)\s*MHz")
def check_crystal_line(line: str) -> str | None:
if match := crystal_re.search(line):
detected = int(float(match.group(1)))
if detected != configured_freq:
return (
f"\n\033[33mWARNING: Crystal frequency mismatch! "
f"Device reports {detected}MHz but firmware is configured "
f"for {configured_freq}MHz.\n"
f"UART logging and other clock-dependent features will not "
f"work correctly.\n"
f"Set the correct crystal frequency with sdkconfig_options:\n"
f" esp32:\n"
f" framework:\n"
f" sdkconfig_options:\n"
f" CONFIG_XTAL_FREQ_{detected}: 'y'\n"
f" CONFIG_XTAL_FREQ_{configured_freq}: 'n'\n"
f' CONFIG_XTAL_FREQ: "{detected}"\033[0m\n\n'
)
return None
return check_crystal_line
def upload_using_esptool(
config: ConfigType, port: str, file: str, speed: int
) -> str | int:
@@ -656,6 +700,12 @@ def upload_using_esptool(
mcu = get_esp32_variant().lower()
line_callbacks = []
if CORE.is_esp32:
configured_freq = _get_configured_xtal_freq()
if configured_freq is not None:
line_callbacks.append(_make_crystal_freq_callback(configured_freq))
def run_esptool(baud_rate):
cmd = [
"esptool",
@@ -680,7 +730,9 @@ def upload_using_esptool(
if os.environ.get("ESPHOME_USE_SUBPROCESS") is None:
import esptool
return run_external_command(esptool.main, *cmd) # pylint: disable=no-member
return run_external_command(
esptool.main, *cmd, line_callbacks=line_callbacks
) # pylint: disable=no-member
return run_external_process(*cmd)
+21 -4
View File
@@ -124,7 +124,12 @@ ANSI_ESCAPE = re.compile(r"\033[@-_][0-?]*[ -/]*[@-~]")
class RedirectText:
def __init__(self, out, filter_lines=None):
def __init__(
self,
out,
filter_lines: str | None = None,
line_callbacks: list[Callable[[str], str | None]] | None = None,
) -> None:
self._out = out
if filter_lines is None:
self._filter_pattern = None
@@ -132,6 +137,7 @@ class RedirectText:
pattern = r"|".join(r"(?:" + pattern + r")" for pattern in filter_lines)
self._filter_pattern = re.compile(pattern)
self._line_buffer = ""
self._line_callbacks = line_callbacks or []
def __getattr__(self, item):
return getattr(self._out, item)
@@ -180,6 +186,9 @@ class RedirectText:
and (help_msg := get_esp32_arduino_flash_error_help())
):
self._write_color_replace(help_msg)
for callback in self._line_callbacks:
if msg := callback(line_without_end):
self._write_color_replace(msg)
else:
self._write_color_replace(s)
@@ -193,7 +202,11 @@ class RedirectText:
def run_external_command(
func, *cmd, capture_stdout: bool = False, filter_lines: str = None
func,
*cmd,
capture_stdout: bool = False,
filter_lines: str = None,
line_callbacks: list | None = None,
) -> int | str:
"""
Run a function from an external package that acts like a main method.
@@ -217,9 +230,13 @@ def run_external_command(
_LOGGER.debug("Running: %s", full_cmd)
orig_stdout = sys.stdout
sys.stdout = RedirectText(sys.stdout, filter_lines=filter_lines)
sys.stdout = RedirectText(
sys.stdout, filter_lines=filter_lines, line_callbacks=line_callbacks
)
orig_stderr = sys.stderr
sys.stderr = RedirectText(sys.stderr, filter_lines=filter_lines)
sys.stderr = RedirectText(
sys.stderr, filter_lines=filter_lines, line_callbacks=line_callbacks
)
if capture_stdout:
cap_stdout = sys.stdout = io.StringIO()