1
0
mirror of https://github.com/esphome/esphome.git synced 2025-10-25 13:13:48 +01:00

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

This commit is contained in:
J. Nick Koston
2025-10-20 21:58:30 -10:00
13 changed files with 861 additions and 249 deletions

View File

@@ -313,7 +313,7 @@ optional<float> DeltaFilter::new_value(float value) {
} }
// OrFilter // OrFilter
OrFilter::OrFilter(std::vector<Filter *> filters) : filters_(std::move(filters)), phi_(this) {} OrFilter::OrFilter(std::initializer_list<Filter *> filters) : filters_(filters), phi_(this) {}
OrFilter::PhiNode::PhiNode(OrFilter *or_parent) : or_parent_(or_parent) {} OrFilter::PhiNode::PhiNode(OrFilter *or_parent) : or_parent_(or_parent) {}
optional<float> OrFilter::PhiNode::new_value(float value) { optional<float> OrFilter::PhiNode::new_value(float value) {
@@ -326,14 +326,14 @@ optional<float> OrFilter::PhiNode::new_value(float value) {
} }
optional<float> OrFilter::new_value(float value) { optional<float> OrFilter::new_value(float value) {
this->has_value_ = false; this->has_value_ = false;
for (Filter *filter : this->filters_) for (auto *filter : this->filters_)
filter->input(value); filter->input(value);
return {}; return {};
} }
void OrFilter::initialize(Sensor *parent, Filter *next) { void OrFilter::initialize(Sensor *parent, Filter *next) {
Filter::initialize(parent, next); Filter::initialize(parent, next);
for (Filter *filter : this->filters_) { for (auto *filter : this->filters_) {
filter->initialize(parent, &this->phi_); filter->initialize(parent, &this->phi_);
} }
this->phi_.initialize(parent, nullptr); this->phi_.initialize(parent, nullptr);
@@ -386,18 +386,24 @@ void HeartbeatFilter::setup() {
} }
float HeartbeatFilter::get_setup_priority() const { return setup_priority::HARDWARE; } float HeartbeatFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
CalibrateLinearFilter::CalibrateLinearFilter(std::initializer_list<std::array<float, 3>> linear_functions)
: linear_functions_(linear_functions) {}
optional<float> CalibrateLinearFilter::new_value(float value) { optional<float> CalibrateLinearFilter::new_value(float value) {
for (std::array<float, 3> f : this->linear_functions_) { for (const auto &f : this->linear_functions_) {
if (!std::isfinite(f[2]) || value < f[2]) if (!std::isfinite(f[2]) || value < f[2])
return (value * f[0]) + f[1]; return (value * f[0]) + f[1];
} }
return NAN; return NAN;
} }
CalibratePolynomialFilter::CalibratePolynomialFilter(std::initializer_list<float> coefficients)
: coefficients_(coefficients) {}
optional<float> CalibratePolynomialFilter::new_value(float value) { optional<float> CalibratePolynomialFilter::new_value(float value) {
float res = 0.0f; float res = 0.0f;
float x = 1.0f; float x = 1.0f;
for (float coefficient : this->coefficients_) { for (const auto &coefficient : this->coefficients_) {
res += x * coefficient; res += x * coefficient;
x *= value; x *= value;
} }

View File

@@ -422,7 +422,7 @@ class DeltaFilter : public Filter {
class OrFilter : public Filter { class OrFilter : public Filter {
public: public:
explicit OrFilter(std::vector<Filter *> filters); explicit OrFilter(std::initializer_list<Filter *> filters);
void initialize(Sensor *parent, Filter *next) override; void initialize(Sensor *parent, Filter *next) override;
@@ -438,28 +438,27 @@ class OrFilter : public Filter {
OrFilter *or_parent_; OrFilter *or_parent_;
}; };
std::vector<Filter *> filters_; FixedVector<Filter *> filters_;
PhiNode phi_; PhiNode phi_;
bool has_value_{false}; bool has_value_{false};
}; };
class CalibrateLinearFilter : public Filter { class CalibrateLinearFilter : public Filter {
public: public:
CalibrateLinearFilter(std::vector<std::array<float, 3>> linear_functions) explicit CalibrateLinearFilter(std::initializer_list<std::array<float, 3>> linear_functions);
: linear_functions_(std::move(linear_functions)) {}
optional<float> new_value(float value) override; optional<float> new_value(float value) override;
protected: protected:
std::vector<std::array<float, 3>> linear_functions_; FixedVector<std::array<float, 3>> linear_functions_;
}; };
class CalibratePolynomialFilter : public Filter { class CalibratePolynomialFilter : public Filter {
public: public:
CalibratePolynomialFilter(std::vector<float> coefficients) : coefficients_(std::move(coefficients)) {} explicit CalibratePolynomialFilter(std::initializer_list<float> coefficients);
optional<float> new_value(float value) override; optional<float> new_value(float value) override;
protected: protected:
std::vector<float> coefficients_; FixedVector<float> coefficients_;
}; };
class ClampFilter : public Filter { class ClampFilter : public Filter {

View File

@@ -43,7 +43,6 @@ from enum import StrEnum
from functools import cache from functools import cache
import json import json
import os import os
from pathlib import Path
import subprocess import subprocess
import sys import sys
from typing import Any from typing import Any
@@ -53,10 +52,13 @@ from helpers import (
CPP_FILE_EXTENSIONS, CPP_FILE_EXTENSIONS,
PYTHON_FILE_EXTENSIONS, PYTHON_FILE_EXTENSIONS,
changed_files, changed_files,
filter_component_files,
get_all_dependencies, get_all_dependencies,
get_changed_components,
get_component_from_path, get_component_from_path,
get_component_test_files, get_component_test_files,
get_components_from_integration_fixtures, get_components_from_integration_fixtures,
get_components_with_dependencies,
git_ls_files, git_ls_files,
parse_test_filename, parse_test_filename,
root_path, root_path,
@@ -561,16 +563,29 @@ def main() -> None:
run_python_linters = should_run_python_linters(args.branch) run_python_linters = should_run_python_linters(args.branch)
changed_cpp_file_count = count_changed_cpp_files(args.branch) changed_cpp_file_count = count_changed_cpp_files(args.branch)
# Get both directly changed and all changed components (with dependencies) in one call # Get changed components
script_path = Path(__file__).parent / "list-components.py" # get_changed_components() returns:
cmd = [sys.executable, str(script_path), "--changed-with-deps"] # None: Core files changed (need full scan)
if args.branch: # []: No components changed
cmd.extend(["-b", args.branch]) # [list]: Changed components (already includes dependencies)
changed_components_result = get_changed_components()
result = subprocess.run(cmd, capture_output=True, text=True, check=True) if changed_components_result is None:
component_data = json.loads(result.stdout) # Core files changed - will trigger full clang-tidy scan
directly_changed_components = component_data["directly_changed"] # No specific components to test
changed_components = component_data["all_changed"] changed_components = []
directly_changed_components = []
is_core_change = True
else:
# Get both directly changed and all changed (with dependencies)
changed = changed_files(args.branch)
component_files = [f for f in changed if filter_component_files(f)]
directly_changed_components = get_components_with_dependencies(
component_files, False
)
changed_components = get_components_with_dependencies(component_files, True)
is_core_change = False
# Filter to only components that have test files # Filter to only components that have test files
# Components without tests shouldn't generate CI test jobs # Components without tests shouldn't generate CI test jobs
@@ -581,11 +596,11 @@ def main() -> None:
# Get directly changed components with tests (for isolated testing) # Get directly changed components with tests (for isolated testing)
# These will be tested WITHOUT --testing-mode in CI to enable full validation # These will be tested WITHOUT --testing-mode in CI to enable full validation
# (pin conflicts, etc.) since they contain the actual changes being reviewed # (pin conflicts, etc.) since they contain the actual changes being reviewed
directly_changed_with_tests = [ directly_changed_with_tests = {
component component
for component in directly_changed_components for component in directly_changed_components
if _component_has_tests(component) if _component_has_tests(component)
] }
# Get dependency-only components (for grouped testing) # Get dependency-only components (for grouped testing)
dependency_only_components = [ dependency_only_components = [
@@ -599,7 +614,8 @@ def main() -> None:
# Determine clang-tidy mode based on actual files that will be checked # Determine clang-tidy mode based on actual files that will be checked
if run_clang_tidy: if run_clang_tidy:
is_full_scan = _is_clang_tidy_full_scan() # Full scan needed if: hash changed OR core files changed
is_full_scan = _is_clang_tidy_full_scan() or is_core_change
if is_full_scan: if is_full_scan:
# Full scan checks all files - always use split mode for efficiency # Full scan checks all files - always use split mode for efficiency
@@ -638,7 +654,7 @@ def main() -> None:
"python_linters": run_python_linters, "python_linters": run_python_linters,
"changed_components": changed_components, "changed_components": changed_components,
"changed_components_with_tests": changed_components_with_tests, "changed_components_with_tests": changed_components_with_tests,
"directly_changed_components_with_tests": directly_changed_with_tests, "directly_changed_components_with_tests": list(directly_changed_with_tests),
"dependency_only_components_with_tests": dependency_only_components, "dependency_only_components_with_tests": dependency_only_components,
"component_test_count": len(changed_components_with_tests), "component_test_count": len(changed_components_with_tests),
"directly_changed_count": len(directly_changed_with_tests), "directly_changed_count": len(directly_changed_with_tests),

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from functools import cache from functools import cache
import json import json
import os import os
@@ -7,6 +8,7 @@ import os.path
from pathlib import Path from pathlib import Path
import re import re
import subprocess import subprocess
import sys
import time import time
from typing import Any from typing import Any
@@ -304,7 +306,10 @@ def get_changed_components() -> list[str] | None:
for f in changed for f in changed
) )
if core_cpp_changed: if core_cpp_changed:
print("Core C++/header files changed - will run full clang-tidy scan") print(
"Core C++/header files changed - will run full clang-tidy scan",
file=sys.stderr,
)
return None return None
# Use list-components.py to get changed components # Use list-components.py to get changed components
@@ -318,7 +323,10 @@ def get_changed_components() -> list[str] | None:
return parse_list_components_output(result.stdout) return parse_list_components_output(result.stdout)
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
# If the script fails, fall back to full scan # If the script fails, fall back to full scan
print("Could not determine changed components - will run full clang-tidy scan") print(
"Could not determine changed components - will run full clang-tidy scan",
file=sys.stderr,
)
return None return None
@@ -370,14 +378,14 @@ def _filter_changed_ci(files: list[str]) -> list[str]:
if f in changed and not f.startswith(ESPHOME_COMPONENTS_PATH) if f in changed and not f.startswith(ESPHOME_COMPONENTS_PATH)
] ]
if not files: if not files:
print("No files changed") print("No files changed", file=sys.stderr)
return files return files
# Scenario 3: Specific components changed # Scenario 3: Specific components changed
# Action: Check ALL files in each changed component # Action: Check ALL files in each changed component
# Convert component list to set for O(1) lookups # Convert component list to set for O(1) lookups
component_set = set(components) component_set = set(components)
print(f"Changed components: {', '.join(sorted(components))}") print(f"Changed components: {', '.join(sorted(components))}", file=sys.stderr)
# The 'files' parameter contains ALL files in the codebase that clang-tidy would check. # The 'files' parameter contains ALL files in the codebase that clang-tidy would check.
# We filter this down to only files in the changed components. # We filter this down to only files in the changed components.
@@ -648,3 +656,220 @@ def get_components_from_integration_fixtures() -> set[str]:
components.add(item["platform"]) components.add(item["platform"])
return components return components
def filter_component_files(file_path: str) -> bool:
"""Check if a file path is a component file.
Args:
file_path: Path to check
Returns:
True if the file is in a component directory
"""
return file_path.startswith("esphome/components/") or file_path.startswith(
"tests/components/"
)
def extract_component_names_from_files(files: list[str]) -> list[str]:
"""Extract unique component names from a list of file paths.
Args:
files: List of file paths
Returns:
List of unique component names (preserves order)
"""
return list(
dict.fromkeys(comp for file in files if (comp := get_component_from_path(file)))
)
def add_item_to_components_graph(
components_graph: dict[str, list[str]], parent: str, child: str
) -> None:
"""Add a dependency relationship to the components graph.
Args:
components_graph: Graph mapping parent components to their children
parent: Parent component name
child: Child component name (dependent)
"""
if not parent.startswith("__") and parent != child:
if parent not in components_graph:
components_graph[parent] = []
if child not in components_graph[parent]:
components_graph[parent].append(child)
def resolve_auto_load(
auto_load: list[str] | Callable[[], list[str]] | Callable[[dict | None], list[str]],
config: dict | None = None,
) -> list[str]:
"""Resolve AUTO_LOAD to a list, handling callables with or without config parameter.
Args:
auto_load: The AUTO_LOAD value (list or callable)
config: Optional config to pass to callable AUTO_LOAD functions
Returns:
List of component names to auto-load
"""
if not callable(auto_load):
return auto_load
import inspect
if inspect.signature(auto_load).parameters:
return auto_load(config)
return auto_load()
def create_components_graph() -> dict[str, list[str]]:
"""Create a graph of component dependencies.
Returns:
Dictionary mapping parent components to their children (dependencies)
"""
from pathlib import Path
from esphome import const
from esphome.core import CORE
from esphome.loader import ComponentManifest, get_component, get_platform
# The root directory of the repo
root = Path(__file__).parent.parent
components_dir = root / "esphome" / "components"
# Fake some directory so that get_component works
CORE.config_path = root
# Various configuration to capture different outcomes used by `AUTO_LOAD` function.
KEY_CORE = const.KEY_CORE
KEY_TARGET_FRAMEWORK = const.KEY_TARGET_FRAMEWORK
KEY_TARGET_PLATFORM = const.KEY_TARGET_PLATFORM
PLATFORM_ESP32 = const.PLATFORM_ESP32
PLATFORM_ESP8266 = const.PLATFORM_ESP8266
TARGET_CONFIGURATIONS = [
{KEY_TARGET_FRAMEWORK: None, KEY_TARGET_PLATFORM: None},
{KEY_TARGET_FRAMEWORK: "arduino", KEY_TARGET_PLATFORM: None},
{KEY_TARGET_FRAMEWORK: "esp-idf", KEY_TARGET_PLATFORM: None},
{KEY_TARGET_FRAMEWORK: None, KEY_TARGET_PLATFORM: PLATFORM_ESP32},
{KEY_TARGET_FRAMEWORK: None, KEY_TARGET_PLATFORM: PLATFORM_ESP8266},
]
CORE.data[KEY_CORE] = TARGET_CONFIGURATIONS[0]
components_graph = {}
platforms = []
components: list[tuple[ComponentManifest, str, Path]] = []
for path in components_dir.iterdir():
if not path.is_dir():
continue
if not (path / "__init__.py").is_file():
continue
name = path.name
comp = get_component(name)
if comp is None:
raise RuntimeError(
f"Cannot find component {name}. Make sure current path is pip installed ESPHome"
)
components.append((comp, name, path))
if comp.is_platform_component:
platforms.append(name)
platforms = set(platforms)
for comp, name, path in components:
for dependency in comp.dependencies:
add_item_to_components_graph(
components_graph, dependency.split(".")[0], name
)
for target_config in TARGET_CONFIGURATIONS:
CORE.data[KEY_CORE] = target_config
for item in resolve_auto_load(comp.auto_load, config=None):
add_item_to_components_graph(components_graph, item, name)
# restore config
CORE.data[KEY_CORE] = TARGET_CONFIGURATIONS[0]
for platform_path in path.iterdir():
platform_name = platform_path.stem
if platform_name == name or platform_name not in platforms:
continue
platform = get_platform(platform_name, name)
if platform is None:
continue
add_item_to_components_graph(components_graph, platform_name, name)
for dependency in platform.dependencies:
add_item_to_components_graph(
components_graph, dependency.split(".")[0], name
)
for target_config in TARGET_CONFIGURATIONS:
CORE.data[KEY_CORE] = target_config
for item in resolve_auto_load(platform.auto_load, config={}):
add_item_to_components_graph(components_graph, item, name)
# restore config
CORE.data[KEY_CORE] = TARGET_CONFIGURATIONS[0]
return components_graph
def find_children_of_component(
components_graph: dict[str, list[str]], component_name: str, depth: int = 0
) -> list[str]:
"""Find all components that depend on the given component (recursively).
Args:
components_graph: Graph mapping parent components to their children
component_name: Component name to find children for
depth: Current recursion depth (max 10)
Returns:
List of all dependent component names (may contain duplicates removed at end)
"""
if component_name not in components_graph:
return []
children = []
for child in components_graph[component_name]:
children.append(child)
if depth < 10:
children.extend(
find_children_of_component(components_graph, child, depth + 1)
)
# Remove duplicate values
return list(set(children))
def get_components_with_dependencies(
files: list[str], get_dependencies: bool = False
) -> list[str]:
"""Get component names from files, optionally including their dependencies.
Args:
files: List of file paths
get_dependencies: If True, include all dependent components
Returns:
Sorted list of component names
"""
components = extract_component_names_from_files(files)
if get_dependencies:
components_graph = create_components_graph()
all_components = components.copy()
for c in components:
all_components.extend(find_children_of_component(components_graph, c))
# Remove duplicate values
all_changed_components = list(set(all_components))
return sorted(all_changed_components)
return sorted(components)

View File

@@ -1,24 +1,12 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import argparse import argparse
from collections.abc import Callable
from pathlib import Path
import sys
from helpers import changed_files, get_component_from_path, git_ls_files from helpers import (
changed_files,
from esphome.const import ( filter_component_files,
KEY_CORE, get_components_with_dependencies,
KEY_TARGET_FRAMEWORK, git_ls_files,
KEY_TARGET_PLATFORM,
PLATFORM_ESP32,
PLATFORM_ESP8266,
) )
from esphome.core import CORE
from esphome.loader import ComponentManifest, get_component, get_platform
def filter_component_files(str):
return str.startswith("esphome/components/") | str.startswith("tests/components/")
def get_all_component_files() -> list[str]: def get_all_component_files() -> list[str]:
@@ -27,156 +15,6 @@ def get_all_component_files() -> list[str]:
return list(filter(filter_component_files, files)) return list(filter(filter_component_files, files))
def extract_component_names_array_from_files_array(files):
components = []
for file in files:
component_name = get_component_from_path(file)
if component_name and component_name not in components:
components.append(component_name)
return components
def add_item_to_components_graph(components_graph, parent, child):
if not parent.startswith("__") and parent != child:
if parent not in components_graph:
components_graph[parent] = []
if child not in components_graph[parent]:
components_graph[parent].append(child)
def resolve_auto_load(
auto_load: list[str] | Callable[[], list[str]] | Callable[[dict | None], list[str]],
config: dict | None = None,
) -> list[str]:
"""Resolve AUTO_LOAD to a list, handling callables with or without config parameter.
Args:
auto_load: The AUTO_LOAD value (list or callable)
config: Optional config to pass to callable AUTO_LOAD functions
Returns:
List of component names to auto-load
"""
if not callable(auto_load):
return auto_load
import inspect
if inspect.signature(auto_load).parameters:
return auto_load(config)
return auto_load()
def create_components_graph():
# The root directory of the repo
root = Path(__file__).parent.parent
components_dir = root / "esphome" / "components"
# Fake some directory so that get_component works
CORE.config_path = root
# Various configuration to capture different outcomes used by `AUTO_LOAD` function.
TARGET_CONFIGURATIONS = [
{KEY_TARGET_FRAMEWORK: None, KEY_TARGET_PLATFORM: None},
{KEY_TARGET_FRAMEWORK: "arduino", KEY_TARGET_PLATFORM: None},
{KEY_TARGET_FRAMEWORK: "esp-idf", KEY_TARGET_PLATFORM: None},
{KEY_TARGET_FRAMEWORK: None, KEY_TARGET_PLATFORM: PLATFORM_ESP32},
{KEY_TARGET_FRAMEWORK: None, KEY_TARGET_PLATFORM: PLATFORM_ESP8266},
]
CORE.data[KEY_CORE] = TARGET_CONFIGURATIONS[0]
components_graph = {}
platforms = []
components: list[tuple[ComponentManifest, str, Path]] = []
for path in components_dir.iterdir():
if not path.is_dir():
continue
if not (path / "__init__.py").is_file():
continue
name = path.name
comp = get_component(name)
if comp is None:
print(
f"Cannot find component {name}. Make sure current path is pip installed ESPHome"
)
sys.exit(1)
components.append((comp, name, path))
if comp.is_platform_component:
platforms.append(name)
platforms = set(platforms)
for comp, name, path in components:
for dependency in comp.dependencies:
add_item_to_components_graph(
components_graph, dependency.split(".")[0], name
)
for target_config in TARGET_CONFIGURATIONS:
CORE.data[KEY_CORE] = target_config
for item in resolve_auto_load(comp.auto_load, config=None):
add_item_to_components_graph(components_graph, item, name)
# restore config
CORE.data[KEY_CORE] = TARGET_CONFIGURATIONS[0]
for platform_path in path.iterdir():
platform_name = platform_path.stem
if platform_name == name or platform_name not in platforms:
continue
platform = get_platform(platform_name, name)
if platform is None:
continue
add_item_to_components_graph(components_graph, platform_name, name)
for dependency in platform.dependencies:
add_item_to_components_graph(
components_graph, dependency.split(".")[0], name
)
for target_config in TARGET_CONFIGURATIONS:
CORE.data[KEY_CORE] = target_config
for item in resolve_auto_load(platform.auto_load, config={}):
add_item_to_components_graph(components_graph, item, name)
# restore config
CORE.data[KEY_CORE] = TARGET_CONFIGURATIONS[0]
return components_graph
def find_children_of_component(components_graph, component_name, depth=0):
if component_name not in components_graph:
return []
children = []
for child in components_graph[component_name]:
children.append(child)
if depth < 10:
children.extend(
find_children_of_component(components_graph, child, depth + 1)
)
# Remove duplicate values
return list(set(children))
def get_components(files: list[str], get_dependencies: bool = False):
components = extract_component_names_array_from_files_array(files)
if get_dependencies:
components_graph = create_components_graph()
all_components = components.copy()
for c in components:
all_components.extend(find_children_of_component(components_graph, c))
# Remove duplicate values
all_changed_components = list(set(all_components))
return sorted(all_changed_components)
return sorted(components)
def main(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument( parser.add_argument(
@@ -251,8 +89,8 @@ def main():
# Return JSON with both directly changed and all changed components # Return JSON with both directly changed and all changed components
import json import json
directly_changed = get_components(files, False) directly_changed = get_components_with_dependencies(files, False)
all_changed = get_components(files, True) all_changed = get_components_with_dependencies(files, True)
output = { output = {
"directly_changed": directly_changed, "directly_changed": directly_changed,
"all_changed": all_changed, "all_changed": all_changed,
@@ -260,11 +98,11 @@ def main():
print(json.dumps(output)) print(json.dumps(output))
elif args.changed_direct: elif args.changed_direct:
# Return only directly changed components (without dependencies) # Return only directly changed components (without dependencies)
for c in get_components(files, False): for c in get_components_with_dependencies(files, False):
print(c) print(c)
else: else:
# Return all changed components (with dependencies) - default behavior # Return all changed components (with dependencies) - default behavior
for c in get_components(files, args.changed): for c in get_components_with_dependencies(files, args.changed):
print(c) print(c)

View File

@@ -173,3 +173,66 @@ sensor:
timeout: 1000ms timeout: 1000ms
value: [42.0] value: [42.0]
- multiply: 2.0 - multiply: 2.0
# CalibrateLinearFilter - piecewise linear calibration
- platform: copy
source_id: source_sensor
name: "Calibrate Linear Two Points"
filters:
- calibrate_linear:
- 0.0 -> 0.0
- 100.0 -> 100.0
- platform: copy
source_id: source_sensor
name: "Calibrate Linear Multiple Segments"
filters:
- calibrate_linear:
- 0.0 -> 0.0
- 50.0 -> 55.0
- 100.0 -> 102.5
- platform: copy
source_id: source_sensor
name: "Calibrate Linear Least Squares"
filters:
- calibrate_linear:
method: least_squares
datapoints:
- 0.0 -> 0.0
- 50.0 -> 55.0
- 100.0 -> 102.5
# CalibratePolynomialFilter - polynomial calibration
- platform: copy
source_id: source_sensor
name: "Calibrate Polynomial Degree 2"
filters:
- calibrate_polynomial:
degree: 2
datapoints:
- 0.0 -> 0.0
- 50.0 -> 55.0
- 100.0 -> 102.5
- platform: copy
source_id: source_sensor
name: "Calibrate Polynomial Degree 3"
filters:
- calibrate_polynomial:
degree: 3
datapoints:
- 0.0 -> 0.0
- 25.0 -> 26.0
- 50.0 -> 55.0
- 100.0 -> 102.5
# OrFilter - filter branching
- platform: copy
source_id: source_sensor
name: "Or Filter with Multiple Branches"
filters:
- or:
- multiply: 2.0
- offset: 10.0
- lambda: return x * 3.0;

View File

@@ -0,0 +1,112 @@
esphome:
name: host-climate-test
host:
api:
logger:
climate:
- platform: thermostat
id: dual_mode_thermostat
name: Dual-mode Thermostat
sensor: host_thermostat_temperature_sensor
humidity_sensor: host_thermostat_humidity_sensor
humidity_hysteresis: 1.0
min_cooling_off_time: 20s
min_cooling_run_time: 20s
max_cooling_run_time: 30s
supplemental_cooling_delta: 3.0
min_heating_off_time: 20s
min_heating_run_time: 20s
max_heating_run_time: 30s
supplemental_heating_delta: 3.0
min_fanning_off_time: 20s
min_fanning_run_time: 20s
min_idle_time: 10s
visual:
min_humidity: 20%
max_humidity: 70%
min_temperature: 15.0
max_temperature: 32.0
temperature_step: 0.1
default_preset: home
preset:
- name: "away"
default_target_temperature_low: 18.0
default_target_temperature_high: 24.0
- name: "home"
default_target_temperature_low: 18.0
default_target_temperature_high: 24.0
auto_mode:
- logger.log: "AUTO mode set"
heat_cool_mode:
- logger.log: "HEAT_COOL mode set"
cool_action:
- switch.turn_on: air_cond
supplemental_cooling_action:
- switch.turn_on: air_cond_2
heat_action:
- switch.turn_on: heater
supplemental_heating_action:
- switch.turn_on: heater_2
dry_action:
- switch.turn_on: air_cond
fan_only_action:
- switch.turn_on: fan_only
idle_action:
- switch.turn_off: air_cond
- switch.turn_off: air_cond_2
- switch.turn_off: heater
- switch.turn_off: heater_2
- switch.turn_off: fan_only
humidity_control_humidify_action:
- switch.turn_on: humidifier
humidity_control_off_action:
- switch.turn_off: humidifier
sensor:
- platform: template
id: host_thermostat_humidity_sensor
unit_of_measurement: °C
accuracy_decimals: 2
state_class: measurement
force_update: true
lambda: return 42.0;
update_interval: 0.1s
- platform: template
id: host_thermostat_temperature_sensor
unit_of_measurement: °C
accuracy_decimals: 2
state_class: measurement
force_update: true
lambda: return 22.0;
update_interval: 0.1s
switch:
- platform: template
id: air_cond
name: Air Conditioner
optimistic: true
- platform: template
id: air_cond_2
name: Air Conditioner 2
optimistic: true
- platform: template
id: fan_only
name: Fan
optimistic: true
- platform: template
id: heater
name: Heater
optimistic: true
- platform: template
id: heater_2
name: Heater 2
optimistic: true
- platform: template
id: dehumidifier
name: Dehumidifier
optimistic: true
- platform: template
id: humidifier
name: Humidifier
optimistic: true

View File

@@ -0,0 +1,108 @@
esphome:
name: host-climate-test
host:
api:
logger:
climate:
- platform: thermostat
id: dual_mode_thermostat
name: Dual-mode Thermostat
sensor: host_thermostat_temperature_sensor
humidity_sensor: host_thermostat_humidity_sensor
humidity_hysteresis: 1.0
min_cooling_off_time: 20s
min_cooling_run_time: 20s
max_cooling_run_time: 30s
supplemental_cooling_delta: 3.0
min_heating_off_time: 20s
min_heating_run_time: 20s
max_heating_run_time: 30s
supplemental_heating_delta: 3.0
min_fanning_off_time: 20s
min_fanning_run_time: 20s
min_idle_time: 10s
visual:
min_humidity: 20%
max_humidity: 70%
min_temperature: 15.0
max_temperature: 32.0
temperature_step: 0.1
default_preset: home
preset:
- name: "away"
default_target_temperature_low: 18.0
default_target_temperature_high: 24.0
- name: "home"
default_target_temperature_low: 18.0
default_target_temperature_high: 24.0
auto_mode:
- logger.log: "AUTO mode set"
heat_cool_mode:
- logger.log: "HEAT_COOL mode set"
cool_action:
- switch.turn_on: air_cond
supplemental_cooling_action:
- switch.turn_on: air_cond_2
heat_action:
- switch.turn_on: heater
supplemental_heating_action:
- switch.turn_on: heater_2
dry_action:
- switch.turn_on: air_cond
fan_only_action:
- switch.turn_on: fan_only
idle_action:
- switch.turn_off: air_cond
- switch.turn_off: air_cond_2
- switch.turn_off: heater
- switch.turn_off: heater_2
- switch.turn_off: fan_only
humidity_control_humidify_action:
- switch.turn_on: humidifier
humidity_control_off_action:
- switch.turn_off: humidifier
sensor:
- platform: template
id: host_thermostat_humidity_sensor
unit_of_measurement: °C
accuracy_decimals: 2
state_class: measurement
force_update: true
lambda: return 42.0;
update_interval: 0.1s
- platform: template
id: host_thermostat_temperature_sensor
unit_of_measurement: °C
accuracy_decimals: 2
state_class: measurement
force_update: true
lambda: return 22.0;
update_interval: 0.1s
switch:
- platform: template
id: air_cond
name: Air Conditioner
optimistic: true
- platform: template
id: air_cond_2
name: Air Conditioner 2
optimistic: true
- platform: template
id: fan_only
name: Fan
optimistic: true
- platform: template
id: heater
name: Heater
optimistic: true
- platform: template
id: heater_2
name: Heater 2
optimistic: true
- platform: template
id: humidifier
name: Humidifier
optimistic: true

View File

@@ -210,7 +210,15 @@ sensor:
name: "Test Sensor 50" name: "Test Sensor 50"
lambda: return 50.0; lambda: return 50.0;
update_interval: 0.1s update_interval: 0.1s
# Temperature sensor for the thermostat # Sensors for the thermostat
- platform: template
name: "Humidity Sensor"
id: humidity_sensor
lambda: return 35.0;
unit_of_measurement: "%"
device_class: humidity
state_class: measurement
update_interval: 5s
- platform: template - platform: template
name: "Temperature Sensor" name: "Temperature Sensor"
id: temp_sensor id: temp_sensor
@@ -295,6 +303,11 @@ valve:
- logger.log: "Valve stopping" - logger.log: "Valve stopping"
output: output:
- platform: template
id: humidifier_output
type: binary
write_action:
- logger.log: "Humidifier output changed"
- platform: template - platform: template
id: heater_output id: heater_output
type: binary type: binary
@@ -305,18 +318,31 @@ output:
type: binary type: binary
write_action: write_action:
- logger.log: "Cooler output changed" - logger.log: "Cooler output changed"
- platform: template
id: fan_output
type: binary
write_action:
- logger.log: "Fan output changed"
climate: climate:
- platform: thermostat - platform: thermostat
name: "Test Thermostat" name: "Test Thermostat"
sensor: temp_sensor sensor: temp_sensor
humidity_sensor: humidity_sensor
default_preset: Home default_preset: Home
on_boot_restore_from: default_preset on_boot_restore_from: default_preset
min_heating_off_time: 1s min_heating_off_time: 1s
min_heating_run_time: 1s min_heating_run_time: 1s
min_cooling_off_time: 1s min_cooling_off_time: 1s
min_cooling_run_time: 1s min_cooling_run_time: 1s
min_fan_mode_switching_time: 1s
min_idle_time: 1s min_idle_time: 1s
visual:
min_humidity: 20%
max_humidity: 70%
min_temperature: 15.0
max_temperature: 32.0
temperature_step: 0.1
heat_action: heat_action:
- output.turn_on: heater_output - output.turn_on: heater_output
cool_action: cool_action:
@@ -324,6 +350,14 @@ climate:
idle_action: idle_action:
- output.turn_off: heater_output - output.turn_off: heater_output
- output.turn_off: cooler_output - output.turn_off: cooler_output
humidity_control_humidify_action:
- output.turn_on: humidifier_output
humidity_control_off_action:
- output.turn_off: humidifier_output
fan_mode_auto_action:
- output.turn_off: fan_output
fan_mode_on_action:
- output.turn_on: fan_output
preset: preset:
- name: Home - name: Home
default_target_temperature_low: 20 default_target_temperature_low: 20

View File

@@ -0,0 +1,49 @@
"""Integration test for Host mode with climate."""
from __future__ import annotations
import asyncio
import aioesphomeapi
from aioesphomeapi import ClimateAction, ClimateMode, ClimatePreset, EntityState
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_host_mode_climate_basic_state(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test basic climate state reporting."""
loop = asyncio.get_running_loop()
async with run_compiled(yaml_config), api_client_connected() as client:
states: dict[int, EntityState] = {}
climate_future: asyncio.Future[EntityState] = loop.create_future()
def on_state(state: EntityState) -> None:
states[state.key] = state
if (
isinstance(state, aioesphomeapi.ClimateState)
and not climate_future.done()
):
climate_future.set_result(state)
client.subscribe_states(on_state)
try:
climate_state = await asyncio.wait_for(climate_future, timeout=5.0)
except TimeoutError:
pytest.fail("Climate state not received within 5 seconds")
assert isinstance(climate_state, aioesphomeapi.ClimateState)
assert climate_state.mode == ClimateMode.OFF
assert climate_state.action == ClimateAction.OFF
assert climate_state.current_temperature == 22.0
assert climate_state.target_temperature_low == 18.0
assert climate_state.target_temperature_high == 24.0
assert climate_state.preset == ClimatePreset.HOME
assert climate_state.current_humidity == 42.0
assert climate_state.target_humidity == 20.0

View File

@@ -0,0 +1,76 @@
"""Integration test for Host mode with climate."""
from __future__ import annotations
import asyncio
import aioesphomeapi
from aioesphomeapi import ClimateInfo, ClimateMode, EntityState
import pytest
from .state_utils import InitialStateHelper
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_host_mode_climate_control(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test climate mode control."""
loop = asyncio.get_running_loop()
async with run_compiled(yaml_config), api_client_connected() as client:
states: dict[int, EntityState] = {}
climate_future: asyncio.Future[EntityState] = loop.create_future()
def on_state(state: EntityState) -> None:
states[state.key] = state
if (
isinstance(state, aioesphomeapi.ClimateState)
and state.mode == ClimateMode.HEAT
and state.target_temperature_low == 21.5
and state.target_temperature_high == 26.5
and not climate_future.done()
):
climate_future.set_result(state)
# Get entities and set up state synchronization
entities, services = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
assert len(climate_infos) >= 1, "Expected at least 1 climate entity"
# Subscribe with the wrapper that filters initial states
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
# Wait for all initial states to be broadcast
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
test_climate = next(
(c for c in climate_infos if c.name == "Dual-mode Thermostat"), None
)
assert test_climate is not None, (
"Dual-mode Thermostat thermostat climate not found"
)
# Adjust setpoints
client.climate_command(
test_climate.key,
mode=ClimateMode.HEAT,
target_temperature_low=21.5,
target_temperature_high=26.5,
)
try:
climate_state = await asyncio.wait_for(climate_future, timeout=5.0)
except TimeoutError:
pytest.fail("Climate state not received within 5 seconds")
assert isinstance(climate_state, aioesphomeapi.ClimateState)
assert climate_state.mode == ClimateMode.HEAT
assert climate_state.target_temperature_low == 21.5
assert climate_state.target_temperature_high == 26.5

View File

@@ -5,7 +5,10 @@ from __future__ import annotations
import asyncio import asyncio
from aioesphomeapi import ( from aioesphomeapi import (
ClimateFanMode,
ClimateFeature,
ClimateInfo, ClimateInfo,
ClimateMode,
DateInfo, DateInfo,
DateState, DateState,
DateTimeInfo, DateTimeInfo,
@@ -121,6 +124,46 @@ async def test_host_mode_many_entities(
assert len(climate_infos) >= 1, "Expected at least 1 climate entity" assert len(climate_infos) >= 1, "Expected at least 1 climate entity"
climate_info = climate_infos[0] climate_info = climate_infos[0]
# Verify feature flags set as expected
assert climate_info.feature_flags == (
ClimateFeature.SUPPORTS_ACTION
| ClimateFeature.SUPPORTS_CURRENT_HUMIDITY
| ClimateFeature.SUPPORTS_CURRENT_TEMPERATURE
| ClimateFeature.SUPPORTS_TWO_POINT_TARGET_TEMPERATURE
| ClimateFeature.SUPPORTS_TARGET_HUMIDITY
)
# Verify modes
assert climate_info.supported_modes == [
ClimateMode.OFF,
ClimateMode.COOL,
ClimateMode.HEAT,
], f"Expected modes [OFF, COOL, HEAT], got {climate_info.supported_modes}"
# Verify visual parameters
assert climate_info.visual_min_temperature == 15.0, (
f"Expected min_temperature=15.0, got {climate_info.visual_min_temperature}"
)
assert climate_info.visual_max_temperature == 32.0, (
f"Expected max_temperature=32.0, got {climate_info.visual_max_temperature}"
)
assert climate_info.visual_target_temperature_step == 0.1, (
f"Expected temperature_step=0.1, got {climate_info.visual_target_temperature_step}"
)
assert climate_info.visual_min_humidity == 20.0, (
f"Expected min_humidity=20.0, got {climate_info.visual_min_humidity}"
)
assert climate_info.visual_max_humidity == 70.0, (
f"Expected max_humidity=70.0, got {climate_info.visual_max_humidity}"
)
# Verify fan modes
assert climate_info.supported_fan_modes == [
ClimateFanMode.ON,
ClimateFanMode.AUTO,
], f"Expected fan modes [ON, AUTO], got {climate_info.supported_fan_modes}"
# Verify the thermostat has presets # Verify the thermostat has presets
assert len(climate_info.supported_presets) > 0, ( assert len(climate_info.supported_presets) > 0, (
"Expected climate to have presets" "Expected climate to have presets"

View File

@@ -96,17 +96,34 @@ def test_main_all_tests_should_run(
mock_should_run_clang_format.return_value = True mock_should_run_clang_format.return_value = True
mock_should_run_python_linters.return_value = True mock_should_run_python_linters.return_value = True
# Mock list-components.py output (now returns JSON with --changed-with-deps) # Mock changed_files to return non-component files (to avoid memory impact)
mock_result = Mock() # Memory impact only runs when component C++ files change
mock_result.stdout = json.dumps( mock_changed_files.return_value = [
{"directly_changed": ["wifi", "api"], "all_changed": ["wifi", "api", "sensor"]} "esphome/config.py",
) "esphome/helpers.py",
mock_subprocess_run.return_value = mock_result ]
# Run main function with mocked argv # Run main function with mocked argv
with ( with (
patch("sys.argv", ["determine-jobs.py"]), patch("sys.argv", ["determine-jobs.py"]),
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False), patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
patch.object(
determine_jobs,
"get_changed_components",
return_value=["wifi", "api", "sensor"],
),
patch.object(
determine_jobs,
"filter_component_files",
side_effect=lambda f: f.startswith("esphome/components/"),
),
patch.object(
determine_jobs,
"get_components_with_dependencies",
side_effect=lambda files, deps: ["wifi", "api"]
if not deps
else ["wifi", "api", "sensor"],
),
): ):
determine_jobs.main() determine_jobs.main()
@@ -130,9 +147,9 @@ def test_main_all_tests_should_run(
# changed_cpp_file_count should be present # changed_cpp_file_count should be present
assert "changed_cpp_file_count" in output assert "changed_cpp_file_count" in output
assert isinstance(output["changed_cpp_file_count"], int) assert isinstance(output["changed_cpp_file_count"], int)
# memory_impact should be present # memory_impact should be false (no component C++ files changed)
assert "memory_impact" in output assert "memory_impact" in output
assert output["memory_impact"]["should_run"] == "false" # No files changed assert output["memory_impact"]["should_run"] == "false"
def test_main_no_tests_should_run( def test_main_no_tests_should_run(
@@ -154,13 +171,18 @@ def test_main_no_tests_should_run(
mock_should_run_clang_format.return_value = False mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = False mock_should_run_python_linters.return_value = False
# Mock empty list-components.py output # Mock changed_files to return no component files
mock_result = Mock() mock_changed_files.return_value = []
mock_result.stdout = json.dumps({"directly_changed": [], "all_changed": []})
mock_subprocess_run.return_value = mock_result
# Run main function with mocked argv # Run main function with mocked argv
with patch("sys.argv", ["determine-jobs.py"]): with (
patch("sys.argv", ["determine-jobs.py"]),
patch.object(determine_jobs, "get_changed_components", return_value=[]),
patch.object(determine_jobs, "filter_component_files", return_value=False),
patch.object(
determine_jobs, "get_components_with_dependencies", return_value=[]
),
):
determine_jobs.main() determine_jobs.main()
# Check output # Check output
@@ -226,16 +248,22 @@ def test_main_with_branch_argument(
mock_should_run_clang_format.return_value = False mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = True mock_should_run_python_linters.return_value = True
# Mock list-components.py output # Mock changed_files to return non-component files (to avoid memory impact)
mock_result = Mock() # Memory impact only runs when component C++ files change
mock_result.stdout = json.dumps( mock_changed_files.return_value = ["esphome/config.py"]
{"directly_changed": ["mqtt"], "all_changed": ["mqtt"]}
)
mock_subprocess_run.return_value = mock_result
with ( with (
patch("sys.argv", ["script.py", "-b", "main"]), patch("sys.argv", ["script.py", "-b", "main"]),
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False), patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
patch.object(determine_jobs, "get_changed_components", return_value=["mqtt"]),
patch.object(
determine_jobs,
"filter_component_files",
side_effect=lambda f: f.startswith("esphome/components/"),
),
patch.object(
determine_jobs, "get_components_with_dependencies", return_value=["mqtt"]
),
): ):
determine_jobs.main() determine_jobs.main()
@@ -245,13 +273,6 @@ def test_main_with_branch_argument(
mock_should_run_clang_format.assert_called_once_with("main") mock_should_run_clang_format.assert_called_once_with("main")
mock_should_run_python_linters.assert_called_once_with("main") mock_should_run_python_linters.assert_called_once_with("main")
# Check that list-components.py was called with branch
mock_subprocess_run.assert_called_once()
call_args = mock_subprocess_run.call_args[0][0]
assert "--changed-with-deps" in call_args
assert "-b" in call_args
assert "main" in call_args
# Check output # Check output
captured = capsys.readouterr() captured = capsys.readouterr()
output = json.loads(captured.out) output = json.loads(captured.out)
@@ -272,7 +293,7 @@ def test_main_with_branch_argument(
# changed_cpp_file_count should be present # changed_cpp_file_count should be present
assert "changed_cpp_file_count" in output assert "changed_cpp_file_count" in output
assert isinstance(output["changed_cpp_file_count"], int) assert isinstance(output["changed_cpp_file_count"], int)
# memory_impact should be present # memory_impact should be false (no component C++ files changed)
assert "memory_impact" in output assert "memory_impact" in output
assert output["memory_impact"]["should_run"] == "false" assert output["memory_impact"]["should_run"] == "false"
@@ -500,16 +521,11 @@ def test_main_filters_components_without_tests(
mock_should_run_clang_format.return_value = False mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = False mock_should_run_python_linters.return_value = False
# Mock list-components.py output with 3 components # Mock changed_files to return component files
# wifi: has tests, sensor: has tests, airthings_ble: no tests mock_changed_files.return_value = [
mock_result = Mock() "esphome/components/wifi/wifi.cpp",
mock_result.stdout = json.dumps( "esphome/components/sensor/sensor.h",
{ ]
"directly_changed": ["wifi", "sensor"],
"all_changed": ["wifi", "sensor", "airthings_ble"],
}
)
mock_subprocess_run.return_value = mock_result
# Create test directory structure # Create test directory structure
tests_dir = tmp_path / "tests" / "components" tests_dir = tmp_path / "tests" / "components"
@@ -533,6 +549,23 @@ def test_main_filters_components_without_tests(
patch.object(determine_jobs, "root_path", str(tmp_path)), patch.object(determine_jobs, "root_path", str(tmp_path)),
patch.object(helpers, "root_path", str(tmp_path)), patch.object(helpers, "root_path", str(tmp_path)),
patch("sys.argv", ["determine-jobs.py"]), patch("sys.argv", ["determine-jobs.py"]),
patch.object(
determine_jobs,
"get_changed_components",
return_value=["wifi", "sensor", "airthings_ble"],
),
patch.object(
determine_jobs,
"filter_component_files",
side_effect=lambda f: f.startswith("esphome/components/"),
),
patch.object(
determine_jobs,
"get_components_with_dependencies",
side_effect=lambda files, deps: ["wifi", "sensor"]
if not deps
else ["wifi", "sensor", "airthings_ble"],
),
): ):
# Clear the cache since we're mocking root_path # Clear the cache since we're mocking root_path
determine_jobs._component_has_tests.cache_clear() determine_jobs._component_has_tests.cache_clear()
@@ -788,15 +821,18 @@ def test_clang_tidy_mode_full_scan(
mock_should_run_clang_format.return_value = False mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = False mock_should_run_python_linters.return_value = False
# Mock list-components.py output # Mock changed_files to return no component files
mock_result = Mock() mock_changed_files.return_value = []
mock_result.stdout = json.dumps({"directly_changed": [], "all_changed": []})
mock_subprocess_run.return_value = mock_result
# Mock full scan (hash changed) # Mock full scan (hash changed)
with ( with (
patch("sys.argv", ["determine-jobs.py"]), patch("sys.argv", ["determine-jobs.py"]),
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=True), patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=True),
patch.object(determine_jobs, "get_changed_components", return_value=[]),
patch.object(determine_jobs, "filter_component_files", return_value=False),
patch.object(
determine_jobs, "get_components_with_dependencies", return_value=[]
),
): ):
determine_jobs.main() determine_jobs.main()
@@ -853,12 +889,10 @@ def test_clang_tidy_mode_targeted_scan(
# Create component names # Create component names
components = [f"comp{i}" for i in range(component_count)] components = [f"comp{i}" for i in range(component_count)]
# Mock list-components.py output # Mock changed_files to return component files
mock_result = Mock() mock_changed_files.return_value = [
mock_result.stdout = json.dumps( f"esphome/components/{comp}/file.cpp" for comp in components
{"directly_changed": components, "all_changed": components} ]
)
mock_subprocess_run.return_value = mock_result
# Mock git_ls_files to return files for each component # Mock git_ls_files to return files for each component
cpp_files = { cpp_files = {
@@ -875,6 +909,15 @@ def test_clang_tidy_mode_targeted_scan(
patch("sys.argv", ["determine-jobs.py"]), patch("sys.argv", ["determine-jobs.py"]),
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False), patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
patch.object(determine_jobs, "git_ls_files", side_effect=mock_git_ls_files), patch.object(determine_jobs, "git_ls_files", side_effect=mock_git_ls_files),
patch.object(determine_jobs, "get_changed_components", return_value=components),
patch.object(
determine_jobs,
"filter_component_files",
side_effect=lambda f: f.startswith("esphome/components/"),
),
patch.object(
determine_jobs, "get_components_with_dependencies", return_value=components
),
): ):
determine_jobs.main() determine_jobs.main()