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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
112
tests/integration/fixtures/host_mode_climate_basic_state.yaml
Normal file
112
tests/integration/fixtures/host_mode_climate_basic_state.yaml
Normal 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
|
||||||
108
tests/integration/fixtures/host_mode_climate_control.yaml
Normal file
108
tests/integration/fixtures/host_mode_climate_control.yaml
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
49
tests/integration/test_host_mode_climate_basic_state.py
Normal file
49
tests/integration/test_host_mode_climate_basic_state.py
Normal 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
|
||||||
76
tests/integration/test_host_mode_climate_control.py
Normal file
76
tests/integration/test_host_mode_climate_control.py
Normal 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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user