Merge remote-tracking branch 'upstream/dev' into integration

This commit is contained in:
J. Nick Koston
2026-03-10 12:33:56 -10:00
24 changed files with 409 additions and 43 deletions
+1
View File
@@ -132,6 +132,7 @@ esphome/components/dashboard_import/* @esphome/core
esphome/components/datetime/* @jesserockz @rfdarter
esphome/components/debug/* @esphome/core
esphome/components/delonghi/* @grob6000
esphome/components/dew_point/* @CFlix
esphome/components/dfplayer/* @glmnet
esphome/components/dfrobot_sen0395/* @niklasweber
esphome/components/dht/* @OttoWinter
+8 -7
View File
@@ -92,7 +92,6 @@ _RP2040_UDEV_HINT = (
"/blob/master/udev/60-picotool.rules"
)
# Special non-component keys that appear in configs
_NON_COMPONENT_KEYS = frozenset(
{
@@ -706,12 +705,12 @@ 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")
crystal_re = re.compile(r"Crystal frequency:\s+(\d+)\s*MHz")
def check_crystal_line(line: str) -> str | None:
if not (match := crystal_re.search(line)):
return None
detected = int(float(match.group(1)))
detected = int(match.group(1))
if detected == configured_freq:
return None
return (
@@ -724,9 +723,7 @@ def _make_crystal_freq_callback(
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'
f" CONFIG_XTAL_FREQ_{detected}: 'y'\033[0m\n\n"
)
return check_crystal_line
@@ -761,7 +758,11 @@ def upload_using_esptool(
mcu = get_esp32_variant().lower()
line_callbacks: list[Callable[[str], str | None]] = []
if CORE.is_esp32 and (configured_freq := _get_configured_xtal_freq()) is not None:
if (
CORE.is_esp32
and file is None
and (configured_freq := _get_configured_xtal_freq()) is not None
):
line_callbacks.append(_make_crystal_freq_callback(configured_freq))
def run_esptool(baud_rate):
+1
View File
@@ -0,0 +1 @@
CODEOWNERS = ["@CFlix"]
@@ -0,0 +1,82 @@
#include "dew_point.h"
namespace esphome::dew_point {
static const char *const TAG = "dew_point.sensor";
void DewPointComponent::setup() {
// Register callbacks for sensor updates
if (this->temperature_sensor_ != nullptr) {
this->temperature_sensor_->add_on_state_callback([this](float state) {
this->temperature_value_ = state;
this->enable_loop();
});
// Get initial value
if (this->temperature_sensor_->has_state()) {
this->temperature_value_ = this->temperature_sensor_->get_state();
}
}
if (this->humidity_sensor_ != nullptr) {
this->humidity_sensor_->add_on_state_callback([this](float state) {
this->humidity_value_ = state;
this->enable_loop();
});
// Get initial value
if (this->humidity_sensor_->has_state()) {
this->humidity_value_ = this->humidity_sensor_->get_state();
}
}
}
void DewPointComponent::dump_config() {
LOG_SENSOR("", "Dew Point", this);
ESP_LOGCONFIG(TAG,
"Sources\n"
" Temperature: '%s'\n"
" Humidity: '%s'",
this->temperature_sensor_->get_name().c_str(), this->humidity_sensor_->get_name().c_str());
}
float DewPointComponent::get_setup_priority() const { return setup_priority::DATA; }
void DewPointComponent::loop() {
// Only run once
this->disable_loop();
// Check if we have valid values for both sensors
if (std::isnan(this->temperature_value_) || std::isnan(this->humidity_value_)) {
ESP_LOGW(TAG, "Temperature or humidity value is NaN, skipping calculation");
this->publish_state(NAN);
return;
}
// Check for valid humidity range
if (this->humidity_value_ <= 0.0f || this->humidity_value_ > 100.0f) {
ESP_LOGW(TAG, "Humidity value out of range (0-100): %.2f", this->humidity_value_);
this->publish_state(NAN);
return;
}
// Magnus formula constants
const float a{17.625f};
const float b{243.04f};
// Calculate dew point using Magnus formula
// Td = (b * alpha) / (a - alpha)
// where alpha = ln(RH/100) + (a * T) / (b + T)
const float alpha{std::log(this->humidity_value_ / 100.0f) +
(a * this->temperature_value_) / (b + this->temperature_value_)};
const float dew_point{(b * alpha) / (a - alpha)};
// Publish the calculated dew point
this->publish_state(dew_point);
ESP_LOGD(TAG, "'%s' >> %.1f°C (T: %.1f°C, RH: %.1f%%)", this->get_name().c_str(), dew_point, this->temperature_value_,
this->humidity_value_);
}
} // namespace esphome::dew_point
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
namespace esphome::dew_point {
class DewPointComponent : public Component, public sensor::Sensor {
public:
void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; }
void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; }
void setup() override;
void dump_config() override;
void loop() override;
float get_setup_priority() const override;
protected:
sensor::Sensor *temperature_sensor_{nullptr};
sensor::Sensor *humidity_sensor_{nullptr};
float temperature_value_{NAN};
float humidity_value_{NAN};
};
} // namespace esphome::dew_point
+46
View File
@@ -0,0 +1,46 @@
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_HUMIDITY,
CONF_TEMPERATURE,
DEVICE_CLASS_TEMPERATURE,
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
)
DEPENDENCIES = ["sensor"]
dew_point_ns = cg.esphome_ns.namespace("dew_point")
DewPointComponent = dew_point_ns.class_(
"DewPointComponent", cg.Component, sensor.Sensor
)
CONFIG_SCHEMA = (
sensor.sensor_schema(
DewPointComponent,
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
icon="mdi:weather-rainy",
)
.extend(
{
cv.Required(CONF_TEMPERATURE): cv.use_id(sensor.Sensor),
cv.Required(CONF_HUMIDITY): cv.use_id(sensor.Sensor),
}
)
.extend(cv.COMPONENT_SCHEMA)
)
async def to_code(config):
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
temperature_sensor = await cg.get_variable(config[CONF_TEMPERATURE])
cg.add(var.set_temperature_sensor(temperature_sensor))
humidity_sensor = await cg.get_variable(config[CONF_HUMIDITY])
cg.add(var.set_humidity_sensor(humidity_sensor))
+2 -2
View File
@@ -88,8 +88,8 @@ def _translate_pin(value):
@dataclass
class ESP32ValidationFunctions:
pin_validation: Callable[[Any], Any]
usage_validation: Callable[[Any], Any]
pin_validation: Callable[[int], int]
usage_validation: Callable[[dict[str, Any]], dict[str, Any]]
_esp32_validations = {
+3 -2
View File
@@ -1,4 +1,5 @@
import logging
from typing import Any
import esphome.config_validation as cv
from esphome.const import (
@@ -22,7 +23,7 @@ _ESP32_STRAPPING_PINS = {0, 2, 5, 12, 15}
_LOGGER = logging.getLogger(__name__)
def esp32_validate_gpio_pin(value):
def esp32_validate_gpio_pin(value: int) -> int:
if value < 0 or value > 39:
raise cv.Invalid(f"Invalid pin number: {value} (must be 0-39)")
if value in _ESP_SDIO_PINS:
@@ -41,7 +42,7 @@ def esp32_validate_gpio_pin(value):
return value
def esp32_validate_supports(value):
def esp32_validate_supports(value: dict[str, Any]) -> dict[str, Any]:
num = value[CONF_NUMBER]
mode = value[CONF_MODE]
is_input = mode[CONF_INPUT]
+3 -2
View File
@@ -1,4 +1,5 @@
import logging
from typing import Any
import esphome.config_validation as cv
from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER
@@ -9,14 +10,14 @@ _ESP32C2_STRAPPING_PINS = {8, 9}
_LOGGER = logging.getLogger(__name__)
def esp32_c2_validate_gpio_pin(value):
def esp32_c2_validate_gpio_pin(value: int) -> int:
if value < 0 or value > 20:
raise cv.Invalid(f"Invalid pin number: {value} (must be 0-20)")
return value
def esp32_c2_validate_supports(value):
def esp32_c2_validate_supports(value: dict[str, Any]) -> dict[str, Any]:
num = value[CONF_NUMBER]
mode = value[CONF_MODE]
is_input = mode[CONF_INPUT]
+3 -2
View File
@@ -1,4 +1,5 @@
import logging
from typing import Any
import esphome.config_validation as cv
from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER
@@ -18,7 +19,7 @@ _ESP32C3_STRAPPING_PINS = {2, 8, 9}
_LOGGER = logging.getLogger(__name__)
def esp32_c3_validate_gpio_pin(value):
def esp32_c3_validate_gpio_pin(value: int) -> int:
if value < 0 or value > 21:
raise cv.Invalid(f"Invalid pin number: {value} (must be 0-21)")
if value in _ESP32C3_SPI_PSRAM_PINS:
@@ -29,7 +30,7 @@ def esp32_c3_validate_gpio_pin(value):
return value
def esp32_c3_validate_supports(value):
def esp32_c3_validate_supports(value: dict[str, Any]) -> dict[str, Any]:
num = value[CONF_NUMBER]
mode = value[CONF_MODE]
is_input = mode[CONF_INPUT]
+3 -2
View File
@@ -1,4 +1,5 @@
import logging
from typing import Any
import esphome.config_validation as cv
from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA
@@ -22,7 +23,7 @@ _ESP32C5_STRAPPING_PINS = {2, 7, 27, 28}
_LOGGER = logging.getLogger(__name__)
def esp32_c5_validate_gpio_pin(value):
def esp32_c5_validate_gpio_pin(value: int) -> int:
if value < 0 or value > 28:
raise cv.Invalid(f"Invalid pin number: {value} (must be 0-28)")
if value in _ESP32C5_SPI_PSRAM_PINS:
@@ -33,7 +34,7 @@ def esp32_c5_validate_gpio_pin(value):
return value
def esp32_c5_validate_supports(value):
def esp32_c5_validate_supports(value: dict[str, Any]) -> dict[str, Any]:
num = value[CONF_NUMBER]
mode = value[CONF_MODE]
is_input = mode[CONF_INPUT]
+3 -2
View File
@@ -1,4 +1,5 @@
import logging
from typing import Any
import esphome.config_validation as cv
from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA
@@ -22,7 +23,7 @@ _ESP32C6_STRAPPING_PINS = {8, 9, 15}
_LOGGER = logging.getLogger(__name__)
def esp32_c6_validate_gpio_pin(value):
def esp32_c6_validate_gpio_pin(value: int) -> int:
if value < 0 or value > 23:
raise cv.Invalid(f"Invalid pin number: {value} (must be 0-23)")
if value in _ESP32C6_SPI_PSRAM_PINS:
@@ -33,7 +34,7 @@ def esp32_c6_validate_gpio_pin(value):
return value
def esp32_c6_validate_supports(value):
def esp32_c6_validate_supports(value: dict[str, Any]) -> dict[str, Any]:
num = value[CONF_NUMBER]
mode = value[CONF_MODE]
is_input = mode[CONF_INPUT]
+3 -2
View File
@@ -1,4 +1,5 @@
import logging
from typing import Any
import esphome.config_validation as cv
from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER
@@ -20,7 +21,7 @@ _ESP32C61_STRAPPING_PINS = {8, 9}
_LOGGER = logging.getLogger(__name__)
def esp32_c61_validate_gpio_pin(value):
def esp32_c61_validate_gpio_pin(value: int) -> int:
if value < 0 or value > 29:
raise cv.Invalid(f"Invalid pin number: {value} (must be 0-29)")
if value in _ESP32C61_SPI_PSRAM_PINS:
@@ -31,7 +32,7 @@ def esp32_c61_validate_gpio_pin(value):
return value
def esp32_c61_validate_supports(value):
def esp32_c61_validate_supports(value: dict[str, Any]) -> dict[str, Any]:
num = value[CONF_NUMBER]
mode = value[CONF_MODE]
is_input = mode[CONF_INPUT]
+3 -2
View File
@@ -1,4 +1,5 @@
import logging
from typing import Any
import esphome.config_validation as cv
from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER
@@ -13,7 +14,7 @@ _ESP32H2_STRAPPING_PINS = {2, 3, 8, 9, 25}
_LOGGER = logging.getLogger(__name__)
def esp32_h2_validate_gpio_pin(value):
def esp32_h2_validate_gpio_pin(value: int) -> int:
if value < 0 or value > 27:
raise cv.Invalid(f"Invalid pin number: {value} (must be 0-27)")
if value in _ESP32H2_SPI_FLASH_PINS:
@@ -33,7 +34,7 @@ def esp32_h2_validate_gpio_pin(value):
return value
def esp32_h2_validate_supports(value):
def esp32_h2_validate_supports(value: dict[str, Any]) -> dict[str, Any]:
num = value[CONF_NUMBER]
mode = value[CONF_MODE]
is_input = mode[CONF_INPUT]
+3 -2
View File
@@ -1,4 +1,5 @@
import logging
from typing import Any
import esphome.config_validation as cv
from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA
@@ -14,7 +15,7 @@ _ESP32P4_STRAPPING_PINS = {34, 35, 36, 37, 38}
_LOGGER = logging.getLogger(__name__)
def esp32_p4_validate_gpio_pin(value):
def esp32_p4_validate_gpio_pin(value: int) -> int:
if value < 0 or value > 54:
raise cv.Invalid(f"Invalid pin number: {value} (must be 0-54)")
if value in _ESP32P4_USB_JTAG_PINS:
@@ -27,7 +28,7 @@ def esp32_p4_validate_gpio_pin(value):
return value
def esp32_p4_validate_supports(value):
def esp32_p4_validate_supports(value: dict[str, Any]) -> dict[str, Any]:
num = value[CONF_NUMBER]
mode = value[CONF_MODE]
is_input = mode[CONF_INPUT]
+3 -2
View File
@@ -1,4 +1,5 @@
import logging
from typing import Any
import esphome.config_validation as cv
from esphome.const import (
@@ -26,7 +27,7 @@ _ESP32S2_STRAPPING_PINS = {0, 45, 46}
_LOGGER = logging.getLogger(__name__)
def esp32_s2_validate_gpio_pin(value):
def esp32_s2_validate_gpio_pin(value: int) -> int:
if value < 0 or value > 46:
raise cv.Invalid(f"Invalid pin number: {value} (must be 0-46)")
@@ -43,7 +44,7 @@ def esp32_s2_validate_gpio_pin(value):
return value
def esp32_s2_validate_supports(value):
def esp32_s2_validate_supports(value: dict[str, Any]) -> dict[str, Any]:
num = value[CONF_NUMBER]
mode = value[CONF_MODE]
is_input = mode[CONF_INPUT]
+3 -2
View File
@@ -1,4 +1,5 @@
import logging
from typing import Any
import esphome.config_validation as cv
from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER
@@ -27,7 +28,7 @@ _ESP_32S3_STRAPPING_PINS = {0, 3, 45, 46}
_LOGGER = logging.getLogger(__name__)
def esp32_s3_validate_gpio_pin(value):
def esp32_s3_validate_gpio_pin(value: int) -> int:
if value < 0 or value > 48:
raise cv.Invalid(f"Invalid pin number: {value} (must be 0-48)")
@@ -49,7 +50,7 @@ def esp32_s3_validate_gpio_pin(value):
return value
def esp32_s3_validate_supports(value):
def esp32_s3_validate_supports(value: dict[str, Any]) -> dict[str, Any]:
num = value[CONF_NUMBER]
mode = value[CONF_MODE]
is_input = mode[CONF_INPUT]
+5 -3
View File
@@ -209,8 +209,8 @@ def run_external_command(
func,
*cmd,
capture_stdout: bool = False,
filter_lines: str = None,
line_callbacks: list | None = None,
filter_lines: list[str] | None = None,
line_callbacks: list[Callable[[str], str | None]] | None = None,
) -> int | str:
"""
Run a function from an external package that acts like a main method.
@@ -220,7 +220,9 @@ def run_external_command(
:param func: Function to execute
:param cmd: Command to run as (eg first element of sys.argv)
:param capture_stdout: Capture text from stdout and return that.
:param filter_lines: Regular expression used to filter captured output.
Note: line_callbacks are not invoked when capture_stdout is True.
:param filter_lines: Regular expressions used to filter captured output.
:param line_callbacks: Callbacks invoked per line; non-None returns are written to output.
:return: str if `capture_stdout` is set else int exit code.
"""
+19
View File
@@ -0,0 +1,19 @@
sensor:
- platform: dew_point
name: Dew Point
temperature: template_temperature
humidity: template_humidity
- platform: template
id: template_humidity
lambda: |-
if (millis() > 10000) {
return 0.6;
}
return 0.0;
- platform: template
id: template_temperature
lambda: |-
if (millis() > 10000) {
return 42.0;
}
return 0.0;
@@ -0,0 +1 @@
<<: !include common.yaml
@@ -0,0 +1 @@
<<: !include common.yaml
@@ -0,0 +1 @@
<<: !include common.yaml
+11 -11
View File
@@ -3722,38 +3722,38 @@ esp32:
assert "secrets.yaml" not in summary_section
def test_get_configured_xtal_freq_reads_sdkconfig(setup_core: Path) -> None:
def test_get_configured_xtal_freq_reads_sdkconfig(tmp_path: Path) -> None:
"""Test reading XTAL_FREQ from sdkconfig."""
CORE.name = "test-device"
CORE.build_path = setup_core
sdkconfig = setup_core / "sdkconfig.test-device"
CORE.build_path = tmp_path
sdkconfig = tmp_path / "sdkconfig.test-device"
sdkconfig.write_text(
"CONFIG_SOC_XTAL_SUPPORT_26M=y\nCONFIG_XTAL_FREQ=26\nCONFIG_XTAL_FREQ_26=y\n"
)
assert _get_configured_xtal_freq() == 26
def test_get_configured_xtal_freq_default_40(setup_core: Path) -> None:
def test_get_configured_xtal_freq_default_40(tmp_path: Path) -> None:
"""Test reading default 40MHz XTAL_FREQ from sdkconfig."""
CORE.name = "test-device"
CORE.build_path = setup_core
sdkconfig = setup_core / "sdkconfig.test-device"
CORE.build_path = tmp_path
sdkconfig = tmp_path / "sdkconfig.test-device"
sdkconfig.write_text("CONFIG_XTAL_FREQ=40\nCONFIG_XTAL_FREQ_40=y\n")
assert _get_configured_xtal_freq() == 40
def test_get_configured_xtal_freq_missing_file(setup_core: Path) -> None:
def test_get_configured_xtal_freq_missing_file(tmp_path: Path) -> None:
"""Test that missing sdkconfig returns None."""
CORE.name = "test-device"
CORE.build_path = setup_core
CORE.build_path = tmp_path
assert _get_configured_xtal_freq() is None
def test_get_configured_xtal_freq_no_xtal_line(setup_core: Path) -> None:
def test_get_configured_xtal_freq_no_xtal_line(tmp_path: Path) -> None:
"""Test that sdkconfig without XTAL_FREQ returns None."""
CORE.name = "test-device"
CORE.build_path = setup_core
sdkconfig = setup_core / "sdkconfig.test-device"
CORE.build_path = tmp_path
sdkconfig = tmp_path / "sdkconfig.test-device"
sdkconfig.write_text("CONFIG_OTHER=123\n")
assert _get_configured_xtal_freq() is None
+175
View File
@@ -2,9 +2,12 @@
from __future__ import annotations
from collections.abc import Callable
import io
from pathlib import Path
import subprocess
import sys
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
@@ -407,6 +410,178 @@ def test_shlex_quote_edge_cases() -> None:
assert util.shlex_quote(" ") == "' '"
def _make_redirect(
line_callbacks: list[Callable[[str], str | None]] | None = None,
filter_lines: list[str] | None = None,
) -> tuple[util.RedirectText, io.StringIO]:
"""Create a RedirectText that writes to a StringIO buffer."""
buf = io.StringIO()
redirect = util.RedirectText(
buf, filter_lines=filter_lines, line_callbacks=line_callbacks
)
return redirect, buf
def test_redirect_text_callback_called_on_matching_line() -> None:
"""Test that a line callback is called and its output is written."""
results: list[str] = []
def callback(line: str) -> str | None:
results.append(line)
if "target" in line:
return "CALLBACK OUTPUT\n"
return None
redirect, buf = _make_redirect(line_callbacks=[callback])
redirect.write("some target line\n")
assert "some target line" in buf.getvalue()
assert "CALLBACK OUTPUT" in buf.getvalue()
assert len(results) == 1
def test_redirect_text_callback_not_triggered_on_non_matching_line() -> None:
"""Test that callback returns None for non-matching lines."""
def callback(line: str) -> str | None:
if "target" in line:
return "FOUND\n"
return None
redirect, buf = _make_redirect(line_callbacks=[callback])
redirect.write("no match here\n")
assert "no match here" in buf.getvalue()
assert "FOUND" not in buf.getvalue()
def test_redirect_text_callback_works_without_filter_pattern() -> None:
"""Test that callbacks fire even when no filter_lines is set."""
def callback(line: str) -> str | None:
if "Crystal" in line:
return "WARNING: mismatch\n"
return None
redirect, buf = _make_redirect(line_callbacks=[callback])
redirect.write("Crystal frequency: 26MHz\n")
assert "Crystal frequency: 26MHz" in buf.getvalue()
assert "WARNING: mismatch" in buf.getvalue()
def test_redirect_text_callback_works_with_filter_pattern() -> None:
"""Test that callbacks fire alongside filter patterns."""
def callback(line: str) -> str | None:
if "important" in line:
return "NOTED\n"
return None
redirect, buf = _make_redirect(
line_callbacks=[callback],
filter_lines=[r"^skip this.*"],
)
redirect.write("skip this line\n")
redirect.write("important line\n")
assert "skip this" not in buf.getvalue()
assert "important line" in buf.getvalue()
assert "NOTED" in buf.getvalue()
def test_redirect_text_multiple_callbacks() -> None:
"""Test that multiple callbacks are all invoked."""
def callback_a(line: str) -> str | None:
if "test" in line:
return "FROM A\n"
return None
def callback_b(line: str) -> str | None:
if "test" in line:
return "FROM B\n"
return None
redirect, buf = _make_redirect(line_callbacks=[callback_a, callback_b])
redirect.write("test line\n")
output = buf.getvalue()
assert "FROM A" in output
assert "FROM B" in output
def test_redirect_text_incomplete_line_buffered() -> None:
"""Test that incomplete lines are buffered until newline."""
results: list[str] = []
def callback(line: str) -> str | None:
results.append(line)
return None
redirect, buf = _make_redirect(line_callbacks=[callback])
redirect.write("partial")
assert len(results) == 0
redirect.write(" line\n")
assert len(results) == 1
assert results[0] == "partial line"
def test_run_external_command_line_callbacks(capsys: pytest.CaptureFixture) -> None:
"""Test that run_external_command passes line_callbacks to RedirectText."""
results: list[str] = []
def callback(line: str) -> str | None:
results.append(line)
if "hello" in line:
return "CALLBACK FIRED\n"
return None
def fake_main() -> int:
print("hello world")
return 0
rc = util.run_external_command(fake_main, "fake", line_callbacks=[callback])
assert rc == 0
assert len(results) == 1
assert "hello world" in results[0]
captured = capsys.readouterr()
assert "CALLBACK FIRED" in captured.out
def test_run_external_process_line_callbacks() -> None:
"""Test that run_external_process passes line_callbacks to RedirectText."""
results: list[str] = []
def callback(line: str) -> str | None:
results.append(line)
if "from subprocess" in line:
return "PROCESS CALLBACK\n"
return None
with patch("esphome.util.subprocess.run") as mock_run:
def run_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
# Simulate subprocess writing to the stdout RedirectText
stdout = kwargs.get("stdout")
if stdout is not None and isinstance(stdout, util.RedirectText):
stdout.write("from subprocess\n")
return MagicMock(returncode=0)
mock_run.side_effect = run_side_effect
rc = util.run_external_process(
"echo",
"test",
line_callbacks=[callback],
)
assert rc == 0
assert any("from subprocess" in r for r in results)
def test_get_picotool_path_found(tmp_path: Path) -> None:
"""Test picotool path derivation from cc_path."""
# Create the expected directory structure