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

[ci] Merge components with different buses to reduce CI time (#11251)

Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
This commit is contained in:
J. Nick Koston
2025-10-15 17:36:03 -10:00
committed by GitHub
parent f2e0a412db
commit 14d76e9e4e
78 changed files with 954 additions and 266 deletions

View File

@@ -379,7 +379,16 @@ jobs:
# Use intelligent splitter that groups components with same bus configs # Use intelligent splitter that groups components with same bus configs
components='${{ needs.determine-jobs.outputs.changed-components-with-tests }}' components='${{ needs.determine-jobs.outputs.changed-components-with-tests }}'
# Only isolate directly changed components when targeting dev branch
# For beta/release branches, group everything for faster CI
if [[ "${{ github.base_ref }}" == beta* ]] || [[ "${{ github.base_ref }}" == release* ]]; then
directly_changed='[]'
echo "Target branch: ${{ github.base_ref }} - grouping all components"
else
directly_changed='${{ needs.determine-jobs.outputs.directly-changed-components-with-tests }}' directly_changed='${{ needs.determine-jobs.outputs.directly-changed-components-with-tests }}'
echo "Target branch: ${{ github.base_ref }} - isolating directly changed components"
fi
echo "Splitting components intelligently..." echo "Splitting components intelligently..."
output=$(python3 script/split_components_for_ci.py --components "$components" --directly-changed "$directly_changed" --batch-size 40 --output github) output=$(python3 script/split_components_for_ci.py --components "$components" --directly-changed "$directly_changed" --batch-size 40 --output github)
@@ -396,7 +405,7 @@ jobs:
if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) > 0 if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) > 0
strategy: strategy:
fail-fast: false fail-fast: false
max-parallel: ${{ (github.base_ref == 'beta' || github.base_ref == 'release') && 8 || 4 }} max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }}
matrix: matrix:
components: ${{ fromJson(needs.test-build-components-splitter.outputs.matrix) }} components: ${{ fromJson(needs.test-build-components-splitter.outputs.matrix) }}
steps: steps:
@@ -424,7 +433,17 @@ jobs:
- name: Validate and compile components with intelligent grouping - name: Validate and compile components with intelligent grouping
run: | run: |
. venv/bin/activate . venv/bin/activate
# Use /mnt for build files (70GB available vs ~29GB on /)
# Check if /mnt has more free space than / before bind mounting
# Extract available space in KB for comparison
root_avail=$(df -k / | awk 'NR==2 {print $4}')
mnt_avail=$(df -k /mnt 2>/dev/null | awk 'NR==2 {print $4}')
echo "Available space: / has ${root_avail}KB, /mnt has ${mnt_avail}KB"
# Only use /mnt if it has more space than /
if [ -n "$mnt_avail" ] && [ "$mnt_avail" -gt "$root_avail" ]; then
echo "Using /mnt for build files (more space available)"
# Bind mount PlatformIO directory to /mnt (tools, packages, build cache all go there) # Bind mount PlatformIO directory to /mnt (tools, packages, build cache all go there)
sudo mkdir -p /mnt/platformio sudo mkdir -p /mnt/platformio
sudo chown $USER:$USER /mnt/platformio sudo chown $USER:$USER /mnt/platformio
@@ -436,6 +455,9 @@ jobs:
sudo chown $USER:$USER /mnt/test_build_components_build sudo chown $USER:$USER /mnt/test_build_components_build
mkdir -p tests/test_build_components/build mkdir -p tests/test_build_components/build
sudo mount --bind /mnt/test_build_components_build tests/test_build_components/build sudo mount --bind /mnt/test_build_components_build tests/test_build_components/build
else
echo "Using / for build files (more space available than /mnt or /mnt unavailable)"
fi
# Convert space-separated components to comma-separated for Python script # Convert space-separated components to comma-separated for Python script
components_csv=$(echo "${{ matrix.components }}" | tr ' ' ',') components_csv=$(echo "${{ matrix.components }}" | tr ' ' ',')
@@ -448,7 +470,7 @@ jobs:
# - This catches pin conflicts and other issues in directly changed code # - This catches pin conflicts and other issues in directly changed code
# - Grouped tests use --testing-mode to allow config merging (disables some checks) # - Grouped tests use --testing-mode to allow config merging (disables some checks)
# - Dependencies are safe to group since they weren't modified in this PR # - Dependencies are safe to group since they weren't modified in this PR
if [ "${{ github.base_ref }}" = "beta" ] || [ "${{ github.base_ref }}" = "release" ]; then if [[ "${{ github.base_ref }}" == beta* ]] || [[ "${{ github.base_ref }}" == release* ]]; then
directly_changed_csv="" directly_changed_csv=""
echo "Testing components: $components_csv" echo "Testing components: $components_csv"
echo "Target branch: ${{ github.base_ref }} - grouping all components" echo "Target branch: ${{ github.base_ref }} - grouping all components"
@@ -459,6 +481,11 @@ jobs:
fi fi
echo "" echo ""
# Show disk space before validation (after bind mounts setup)
echo "Disk space before config validation:"
df -h
echo ""
# Run config validation with grouping and isolation # Run config validation with grouping and isolation
python3 script/test_build_components.py -e config -c "$components_csv" -f --isolate "$directly_changed_csv" python3 script/test_build_components.py -e config -c "$components_csv" -f --isolate "$directly_changed_csv"
@@ -466,6 +493,11 @@ jobs:
echo "Config validation passed! Starting compilation..." echo "Config validation passed! Starting compilation..."
echo "" echo ""
# Show disk space before compilation
echo "Disk space before compilation:"
df -h
echo ""
# Run compilation with grouping and isolation # Run compilation with grouping and isolation
python3 script/test_build_components.py -e compile -c "$components_csv" -f --isolate "$directly_changed_csv" python3 script/test_build_components.py -e compile -c "$components_csv" -f --isolate "$directly_changed_csv"
@@ -474,7 +506,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
- common - common
if: github.event_name == 'pull_request' && github.base_ref != 'beta' && github.base_ref != 'release' if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release')
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

View File

@@ -190,7 +190,7 @@ async def to_code(config):
cg.add_define("ESPHOME_VARIANT", "ESP8266") cg.add_define("ESPHOME_VARIANT", "ESP8266")
cg.add_define(ThreadModel.SINGLE) cg.add_define(ThreadModel.SINGLE)
cg.add_platformio_option("extra_scripts", ["post:post_build.py"]) cg.add_platformio_option("extra_scripts", ["pre:iram_fix.py", "post:post_build.py"])
conf = config[CONF_FRAMEWORK] conf = config[CONF_FRAMEWORK]
cg.add_platformio_option("framework", "arduino") cg.add_platformio_option("framework", "arduino")
@@ -230,6 +230,12 @@ async def to_code(config):
# For cases where nullptrs can be handled, use nothrow: `new (std::nothrow) T;` # For cases where nullptrs can be handled, use nothrow: `new (std::nothrow) T;`
cg.add_build_flag("-DNEW_OOM_ABORT") cg.add_build_flag("-DNEW_OOM_ABORT")
# In testing mode, fake a larger IRAM to allow linking grouped component tests
# Real ESP8266 hardware only has 32KB IRAM, but for CI testing we pretend it has 2MB
# This is done via a pre-build script that generates a custom linker script
if CORE.testing_mode:
cg.add_build_flag("-DESPHOME_TESTING_MODE")
cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE]) cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE])
ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
@@ -265,3 +271,8 @@ def copy_files():
post_build_file, post_build_file,
CORE.relative_build_path("post_build.py"), CORE.relative_build_path("post_build.py"),
) )
iram_fix_file = dir / "iram_fix.py.script"
copy_file_if_changed(
iram_fix_file,
CORE.relative_build_path("iram_fix.py"),
)

View File

@@ -0,0 +1,44 @@
import os
import re
# pylint: disable=E0602
Import("env") # noqa
def patch_linker_script_after_preprocess(source, target, env):
"""Patch the local linker script after PlatformIO preprocesses it."""
# Check if we're in testing mode by looking for the define
build_flags = env.get("BUILD_FLAGS", [])
testing_mode = any("-DESPHOME_TESTING_MODE" in flag for flag in build_flags)
if not testing_mode:
return
# Get the local linker script path
build_dir = env.subst("$BUILD_DIR")
local_ld = os.path.join(build_dir, "ld", "local.eagle.app.v6.common.ld")
if not os.path.exists(local_ld):
return
# Read the linker script
with open(local_ld, "r") as f:
content = f.read()
# Replace IRAM size from 0x8000 (32KB) to 0x200000 (2MB)
# The line looks like: iram1_0_seg : org = 0x40100000, len = 0x8000
updated = re.sub(
r"(iram1_0_seg\s*:\s*org\s*=\s*0x40100000\s*,\s*len\s*=\s*)0x8000",
r"\g<1>0x200000",
content,
)
if updated != content:
with open(local_ld, "w") as f:
f.write(updated)
print("ESPHome: Patched IRAM size to 2MB for testing mode")
# Hook into the build process right before linking
# This runs after PlatformIO has already preprocessed the linker scripts
env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", patch_linker_script_after_preprocess)

View File

@@ -56,6 +56,10 @@ DIRECT_BUS_TYPES = ("i2c", "spi", "uart", "modbus")
# These components can be merged with any other group # These components can be merged with any other group
NO_BUSES_SIGNATURE = "no_buses" NO_BUSES_SIGNATURE = "no_buses"
# Prefix for isolated component signatures
# Isolated components have unique signatures and cannot be merged with others
ISOLATED_SIGNATURE_PREFIX = "isolated_"
# Base bus components - these ARE the bus implementations and should not # Base bus components - these ARE the bus implementations and should not
# be flagged as needing migration since they are the platform/base components # be flagged as needing migration since they are the platform/base components
BASE_BUS_COMPONENTS = { BASE_BUS_COMPONENTS = {
@@ -75,6 +79,7 @@ ISOLATED_COMPONENTS = {
"ethernet": "Defines ethernet: which conflicts with wifi: used by most components", "ethernet": "Defines ethernet: which conflicts with wifi: used by most components",
"ethernet_info": "Related to ethernet component which conflicts with wifi", "ethernet_info": "Related to ethernet component which conflicts with wifi",
"lvgl": "Defines multiple SDL displays on host platform that conflict when merged with other display configs", "lvgl": "Defines multiple SDL displays on host platform that conflict when merged with other display configs",
"mapping": "Uses dict format for image/display sections incompatible with standard list format - ESPHome merge_config cannot handle",
"openthread": "Conflicts with wifi: used by most components", "openthread": "Conflicts with wifi: used by most components",
"openthread_info": "Conflicts with wifi: used by most components", "openthread_info": "Conflicts with wifi: used by most components",
"matrix_keypad": "Needs isolation due to keypad", "matrix_keypad": "Needs isolation due to keypad",
@@ -368,6 +373,143 @@ def analyze_all_components(
return components, non_groupable, direct_bus_components return components, non_groupable, direct_bus_components
@lru_cache(maxsize=256)
def _get_bus_configs(buses: tuple[str, ...]) -> frozenset[tuple[str, str]]:
"""Map bus type to set of configs for that type.
Args:
buses: Tuple of bus package names (e.g., ("uart_9600", "i2c"))
Returns:
Frozenset of (base_type, full_config) tuples
Example: frozenset({("uart", "uart_9600"), ("i2c", "i2c")})
"""
# Split on underscore to get base type: "uart_9600" -> "uart", "i2c" -> "i2c"
return frozenset((bus.split("_", 1)[0], bus) for bus in buses)
@lru_cache(maxsize=1024)
def are_buses_compatible(buses1: tuple[str, ...], buses2: tuple[str, ...]) -> bool:
"""Check if two bus tuples are compatible for merging.
Two bus lists are compatible if they don't have conflicting configurations
for the same bus type. For example:
- ("ble", "uart") and ("i2c",) are compatible (different buses)
- ("uart_9600",) and ("uart_19200",) are NOT compatible (same bus, different configs)
- ("uart_9600",) and ("uart_9600",) are compatible (same bus, same config)
Args:
buses1: First tuple of bus package names
buses2: Second tuple of bus package names
Returns:
True if buses can be merged without conflicts
"""
configs1 = _get_bus_configs(buses1)
configs2 = _get_bus_configs(buses2)
# Group configs by base type
bus_types1: dict[str, set[str]] = {}
for base_type, full_config in configs1:
if base_type not in bus_types1:
bus_types1[base_type] = set()
bus_types1[base_type].add(full_config)
bus_types2: dict[str, set[str]] = {}
for base_type, full_config in configs2:
if base_type not in bus_types2:
bus_types2[base_type] = set()
bus_types2[base_type].add(full_config)
# Check for conflicts: same bus type with different configs
for bus_type, configs in bus_types1.items():
if bus_type not in bus_types2:
continue # No conflict - different bus types
# Same bus type - check if configs match
if configs != bus_types2[bus_type]:
return False # Conflict - same bus type, different configs
return True # No conflicts found
def merge_compatible_bus_groups(
grouped_components: dict[tuple[str, str], list[str]],
) -> dict[tuple[str, str], list[str]]:
"""Merge groups with compatible (non-conflicting) buses.
This function takes groups keyed by (platform, bus_signature) and merges
groups that share the same platform and have compatible bus configurations.
Two groups can be merged if their buses don't conflict - meaning they don't
have different configurations for the same bus type.
For example:
- ["ble"] + ["uart"] = compatible (different buses)
- ["uart_9600"] + ["uart_19200"] = incompatible (same bus, different configs)
- ["uart_9600"] + ["uart_9600"] = compatible (same bus, same config)
Args:
grouped_components: Dictionary mapping (platform, signature) to list of component names
Returns:
Dictionary with same structure but with compatible groups merged
"""
merged_groups: dict[tuple[str, str], list[str]] = {}
processed_keys: set[tuple[str, str]] = set()
for (platform1, sig1), comps1 in sorted(grouped_components.items()):
if (platform1, sig1) in processed_keys:
continue
# Skip NO_BUSES_SIGNATURE - kept separate for flexible batch distribution
# These components have no bus requirements and can be added to any batch
# as "fillers" for load balancing across CI runners
if sig1 == NO_BUSES_SIGNATURE:
merged_groups[(platform1, sig1)] = comps1
processed_keys.add((platform1, sig1))
continue
# Skip isolated components - they can't be merged with others
if sig1.startswith(ISOLATED_SIGNATURE_PREFIX):
merged_groups[(platform1, sig1)] = comps1
processed_keys.add((platform1, sig1))
continue
# Start with this group's components
merged_comps: list[str] = list(comps1)
merged_sig: str = sig1
processed_keys.add((platform1, sig1))
# Get buses for this group as tuple for caching
buses1: tuple[str, ...] = tuple(sorted(sig1.split("+")))
# Try to merge with other groups on same platform
for (platform2, sig2), comps2 in sorted(grouped_components.items()):
if (platform2, sig2) in processed_keys:
continue
if platform2 != platform1:
continue # Different platforms can't be merged
if sig2 == NO_BUSES_SIGNATURE:
continue # Keep separate for flexible batch distribution
if sig2.startswith(ISOLATED_SIGNATURE_PREFIX):
continue # Isolated components can't be merged
# Check if buses are compatible
buses2: tuple[str, ...] = tuple(sorted(sig2.split("+")))
if are_buses_compatible(buses1, buses2):
# Compatible! Merge this group
merged_comps.extend(comps2)
processed_keys.add((platform2, sig2))
# Update merged signature to include all unique buses
all_buses: set[str] = set(buses1) | set(buses2)
merged_sig = "+".join(sorted(all_buses))
buses1 = tuple(sorted(all_buses)) # Update for next iteration
# Store merged group
merged_groups[(platform1, merged_sig)] = merged_comps
return merged_groups
def create_grouping_signature( def create_grouping_signature(
platform_buses: dict[str, list[str]], platform: str platform_buses: dict[str, list[str]], platform: str
) -> str: ) -> str:

View File

@@ -185,17 +185,20 @@ def main():
"-c", "-c",
"--changed", "--changed",
action="store_true", action="store_true",
help="List all components required for testing based on changes (includes dependencies)", help="List all components with dependencies (used by clang-tidy). "
"When base test infrastructure changes, returns ALL components.",
) )
parser.add_argument( parser.add_argument(
"--changed-direct", "--changed-direct",
action="store_true", action="store_true",
help="List only directly changed components (without dependencies)", help="List only directly changed components, ignoring infrastructure changes "
"(used by CI for isolation decisions)",
) )
parser.add_argument( parser.add_argument(
"--changed-with-deps", "--changed-with-deps",
action="store_true", action="store_true",
help="Output JSON with both directly changed and all changed components", help="Output JSON with both directly changed and all changed components "
"(with dependencies), ignoring infrastructure changes (used by CI for test determination)",
) )
parser.add_argument( parser.add_argument(
"-b", "--branch", help="Branch to compare changed files against" "-b", "--branch", help="Branch to compare changed files against"
@@ -213,12 +216,34 @@ def main():
# When --changed* is passed, only get the changed files # When --changed* is passed, only get the changed files
changed = changed_files(args.branch) changed = changed_files(args.branch)
# If any base test file(s) changed, there's no need to filter out components # If any base test file(s) changed, we need to check all components
if any("tests/test_build_components" in file for file in changed): # BUT only for --changed (used by clang-tidy for comprehensive checking)
# Need to get all component files # NOT for --changed-direct or --changed-with-deps (used by CI for targeted testing)
#
# Flag usage:
# - --changed: Used by clang-tidy (script/helpers.py get_changed_components)
# Returns: All components with dependencies when base test files change
# Reason: Test infrastructure changes may affect any component
#
# - --changed-direct: Used by CI isolation (script/determine-jobs.py)
# Returns: Only components with actual code changes (not infrastructure)
# Reason: Only directly changed components need isolated testing
#
# - --changed-with-deps: Used by CI test determination (script/determine-jobs.py)
# Returns: Components with code changes + their dependencies (not infrastructure)
# Reason: CI needs to test changed components and their dependents
base_test_changed = any(
"tests/test_build_components" in file for file in changed
)
if base_test_changed and not args.changed_direct and not args.changed_with_deps:
# Base test infrastructure changed - load all component files
# This is for --changed (clang-tidy) which needs comprehensive checking
files = get_all_component_files() files = get_all_component_files()
else: else:
# Only look at changed component files # Only look at changed component files (ignore infrastructure changes)
# For --changed-direct: only actual component code changes matter (for isolation)
# For --changed-with-deps: only actual component code changes matter (for testing)
files = [f for f in changed if filter_component_files(f)] files = [f for f in changed if filter_component_files(f)]
else: else:
# Get all component files # Get all component files

View File

@@ -16,6 +16,7 @@ The merger handles:
from __future__ import annotations from __future__ import annotations
import argparse import argparse
from functools import lru_cache
from pathlib import Path from pathlib import Path
import re import re
import sys import sys
@@ -28,6 +29,10 @@ from esphome import yaml_util
from esphome.config_helpers import merge_config from esphome.config_helpers import merge_config
from script.analyze_component_buses import PACKAGE_DEPENDENCIES, get_common_bus_packages from script.analyze_component_buses import PACKAGE_DEPENDENCIES, get_common_bus_packages
# Prefix for dependency markers in package tracking
# Used to mark packages that are included transitively (e.g., uart via modbus)
DEPENDENCY_MARKER_PREFIX = "_dep_"
def load_yaml_file(yaml_file: Path) -> dict: def load_yaml_file(yaml_file: Path) -> dict:
"""Load YAML file using ESPHome's YAML loader. """Load YAML file using ESPHome's YAML loader.
@@ -44,6 +49,34 @@ def load_yaml_file(yaml_file: Path) -> dict:
return yaml_util.load_yaml(yaml_file) return yaml_util.load_yaml(yaml_file)
@lru_cache(maxsize=256)
def get_component_packages(
component_name: str, platform: str, tests_dir_str: str
) -> dict:
"""Get packages dict from a component's test file with caching.
This function is cached to avoid re-loading and re-parsing the same file
multiple times when extracting packages during cross-bus merging.
Args:
component_name: Name of the component
platform: Platform name (e.g., "esp32-idf")
tests_dir_str: String path to tests/components directory (must be string for cache hashability)
Returns:
Dictionary with 'packages' key containing the raw packages dict from the YAML,
or empty dict if no packages section exists
"""
tests_dir = Path(tests_dir_str)
test_file = tests_dir / component_name / f"test.{platform}.yaml"
comp_data = load_yaml_file(test_file)
if "packages" not in comp_data or not isinstance(comp_data["packages"], dict):
return {}
return comp_data["packages"]
def extract_packages_from_yaml(data: dict) -> dict[str, str]: def extract_packages_from_yaml(data: dict) -> dict[str, str]:
"""Extract COMMON BUS package includes from parsed YAML. """Extract COMMON BUS package includes from parsed YAML.
@@ -82,7 +115,7 @@ def extract_packages_from_yaml(data: dict) -> dict[str, str]:
if dep not in common_bus_packages: if dep not in common_bus_packages:
continue continue
# Mark as included via dependency # Mark as included via dependency
packages[f"_dep_{dep}"] = f"(included via {name})" packages[f"{DEPENDENCY_MARKER_PREFIX}{dep}"] = f"(included via {name})"
return packages return packages
@@ -195,6 +228,9 @@ def merge_component_configs(
# Start with empty config # Start with empty config
merged_config_data = {} merged_config_data = {}
# Convert tests_dir to string for caching
tests_dir_str = str(tests_dir)
# Process each component # Process each component
for comp_name in component_names: for comp_name in component_names:
comp_dir = tests_dir / comp_name comp_dir = tests_dir / comp_name
@@ -206,26 +242,29 @@ def merge_component_configs(
# Load the component's test file # Load the component's test file
comp_data = load_yaml_file(test_file) comp_data = load_yaml_file(test_file)
# Validate packages are compatible # Merge packages from all components (cross-bus merging)
# Components with no packages (no_buses) can merge with any group # Components can have different packages (e.g., one with ble, another with uart)
# as long as they don't conflict (checked by are_buses_compatible before calling this)
comp_packages = extract_packages_from_yaml(comp_data) comp_packages = extract_packages_from_yaml(comp_data)
if all_packages is None: if all_packages is None:
# First component - set the baseline # First component - initialize package dict
all_packages = comp_packages all_packages = comp_packages if comp_packages else {}
elif not comp_packages: elif comp_packages:
# This component has no packages (no_buses) - it can merge with any group # Merge packages - combine all unique package types
pass # If both have the same package type, verify they're identical
elif not all_packages: for pkg_name, pkg_config in comp_packages.items():
# Previous components had no packages, but this one does - adopt these packages if pkg_name in all_packages:
all_packages = comp_packages # Same package type - verify config matches
elif comp_packages != all_packages: if all_packages[pkg_name] != pkg_config:
# Both have packages but they differ - this is an error
raise ValueError( raise ValueError(
f"Component {comp_name} has different packages than previous components. " f"Component {comp_name} has conflicting config for package '{pkg_name}'. "
f"Expected: {all_packages}, Got: {comp_packages}. " f"Expected: {all_packages[pkg_name]}, Got: {pkg_config}. "
f"All components must use the same common bus configs to be merged." f"Components with conflicting bus configs cannot be merged."
) )
else:
# New package type - add it
all_packages[pkg_name] = pkg_config
# Handle $component_dir by replacing with absolute path # Handle $component_dir by replacing with absolute path
# This allows components that use local file references to be grouped # This allows components that use local file references to be grouped
@@ -287,26 +326,51 @@ def merge_component_configs(
# merge_config handles list merging with ID-based deduplication automatically # merge_config handles list merging with ID-based deduplication automatically
merged_config_data = merge_config(merged_config_data, comp_data) merged_config_data = merge_config(merged_config_data, comp_data)
# Add packages back (only once, since they're identical) # Add merged packages back (union of all component packages)
# IMPORTANT: Only re-add common bus packages (spi, i2c, uart, etc.) # IMPORTANT: Only include common bus packages (spi, i2c, uart, etc.)
# Do NOT re-add component-specific packages as they contain unprefixed $component_dir refs # Do NOT re-add component-specific packages as they contain unprefixed $component_dir refs
if all_packages: if all_packages:
first_comp_data = load_yaml_file( # Build packages dict from merged all_packages
tests_dir / component_names[0] / f"test.{platform}.yaml" # all_packages is a dict mapping package_name -> str(package_value)
) # We need to reconstruct the actual package values by loading them from any component
if "packages" in first_comp_data and isinstance( # Since packages with the same name must have identical configs (verified above),
first_comp_data["packages"], dict # we can load the package value from the first component that has each package
):
# Filter to only include common bus packages
# Only dict format can contain common bus packages
common_bus_packages = get_common_bus_packages() common_bus_packages = get_common_bus_packages()
filtered_packages = { merged_packages: dict[str, Any] = {}
name: value
for name, value in first_comp_data["packages"].items() # Collect packages that are included as dependencies
if name in common_bus_packages # If modbus is present, uart is included via modbus.packages.uart
} packages_to_skip: set[str] = set()
if filtered_packages: for pkg_name in all_packages:
merged_config_data["packages"] = filtered_packages if pkg_name.startswith(DEPENDENCY_MARKER_PREFIX):
# Extract the actual package name (remove _dep_ prefix)
dep_name = pkg_name[len(DEPENDENCY_MARKER_PREFIX) :]
packages_to_skip.add(dep_name)
for pkg_name in all_packages:
# Skip dependency markers
if pkg_name.startswith(DEPENDENCY_MARKER_PREFIX):
continue
# Skip non-common-bus packages
if pkg_name not in common_bus_packages:
continue
# Skip packages that are included as dependencies of other packages
# This prevents duplicate definitions (e.g., uart via modbus + uart separately)
if pkg_name in packages_to_skip:
continue
# Find a component that has this package and extract its value
# Uses cached lookup to avoid re-loading the same files
for comp_name in component_names:
comp_packages = get_component_packages(
comp_name, platform, tests_dir_str
)
if pkg_name in comp_packages:
merged_packages[pkg_name] = comp_packages[pkg_name]
break
if merged_packages:
merged_config_data["packages"] = merged_packages
# Deduplicate items with same ID (keeps first occurrence) # Deduplicate items with same ID (keeps first occurrence)
merged_config_data = deduplicate_by_id(merged_config_data) merged_config_data = deduplicate_by_id(merged_config_data)

View File

@@ -22,9 +22,11 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
from script.analyze_component_buses import ( from script.analyze_component_buses import (
ISOLATED_COMPONENTS, ISOLATED_COMPONENTS,
ISOLATED_SIGNATURE_PREFIX,
NO_BUSES_SIGNATURE, NO_BUSES_SIGNATURE,
analyze_all_components, analyze_all_components,
create_grouping_signature, create_grouping_signature,
merge_compatible_bus_groups,
) )
# Weighting for batch creation # Weighting for batch creation
@@ -33,6 +35,10 @@ from script.analyze_component_buses import (
ISOLATED_WEIGHT = 10 ISOLATED_WEIGHT = 10
GROUPABLE_WEIGHT = 1 GROUPABLE_WEIGHT = 1
# Platform used for batching (platform-agnostic batching)
# Batches are split across CI runners and each runner tests all platforms
ALL_PLATFORMS = "all"
def has_test_files(component_name: str, tests_dir: Path) -> bool: def has_test_files(component_name: str, tests_dir: Path) -> bool:
"""Check if a component has test files. """Check if a component has test files.
@@ -57,7 +63,7 @@ def create_intelligent_batches(
tests_dir: Path, tests_dir: Path,
batch_size: int = 40, batch_size: int = 40,
directly_changed: set[str] | None = None, directly_changed: set[str] | None = None,
) -> list[list[str]]: ) -> tuple[list[list[str]], dict[tuple[str, str], list[str]]]:
"""Create batches optimized for component grouping. """Create batches optimized for component grouping.
Args: Args:
@@ -67,7 +73,9 @@ def create_intelligent_batches(
directly_changed: Set of directly changed components (for logging only) directly_changed: Set of directly changed components (for logging only)
Returns: Returns:
List of component batches (lists of component names) Tuple of (batches, signature_groups) where:
- batches: List of component batches (lists of component names)
- signature_groups: Dict mapping (platform, signature) to component lists
""" """
# Filter out components without test files # Filter out components without test files
# Platform components like 'climate' and 'climate_ir' don't have test files # Platform components like 'climate' and 'climate_ir' don't have test files
@@ -91,8 +99,9 @@ def create_intelligent_batches(
# Group components by their bus signature ONLY (ignore platform) # Group components by their bus signature ONLY (ignore platform)
# All platforms will be tested by test_build_components.py for each batch # All platforms will be tested by test_build_components.py for each batch
# Key: signature, Value: list of components # Key: (platform, signature), Value: list of components
signature_groups: dict[str, list[str]] = defaultdict(list) # We use ALL_PLATFORMS since batching is platform-agnostic
signature_groups: dict[tuple[str, str], list[str]] = defaultdict(list)
for component in components_with_tests: for component in components_with_tests:
# Components that can't be grouped get unique signatures # Components that can't be grouped get unique signatures
@@ -107,7 +116,9 @@ def create_intelligent_batches(
or (directly_changed and component in directly_changed) or (directly_changed and component in directly_changed)
) )
if is_isolated: if is_isolated:
signature_groups[f"isolated_{component}"].append(component) signature_groups[
(ALL_PLATFORMS, f"{ISOLATED_SIGNATURE_PREFIX}{component}")
].append(component)
continue continue
# Get signature from any platform (they should all have the same buses) # Get signature from any platform (they should all have the same buses)
@@ -117,11 +128,17 @@ def create_intelligent_batches(
if buses: if buses:
signature = create_grouping_signature({platform: buses}, platform) signature = create_grouping_signature({platform: buses}, platform)
# Group by signature only - platform doesn't matter for batching # Group by signature only - platform doesn't matter for batching
signature_groups[signature].append(component) # Use ALL_PLATFORMS since we're batching across all platforms
signature_groups[(ALL_PLATFORMS, signature)].append(component)
break # Only use first platform for grouping break # Only use first platform for grouping
else: else:
# No buses found for any platform - can be grouped together # No buses found for any platform - can be grouped together
signature_groups[NO_BUSES_SIGNATURE].append(component) signature_groups[(ALL_PLATFORMS, NO_BUSES_SIGNATURE)].append(component)
# Merge compatible bus groups (cross-bus optimization)
# This allows components with different buses (ble + uart) to be batched together
# improving the efficiency of test_build_components.py grouping
signature_groups = merge_compatible_bus_groups(signature_groups)
# Create batches by keeping signature groups together # Create batches by keeping signature groups together
# Components with the same signature stay in the same batches # Components with the same signature stay in the same batches
@@ -132,8 +149,8 @@ def create_intelligent_batches(
# 2. Sort groupable signatures by size (largest first) # 2. Sort groupable signatures by size (largest first)
# 3. "no_buses" components CAN be grouped together # 3. "no_buses" components CAN be grouped together
def sort_key(item): def sort_key(item):
signature, components = item (_platform, signature), components = item
is_isolated = signature.startswith("isolated_") is_isolated = signature.startswith(ISOLATED_SIGNATURE_PREFIX)
# Put "isolated_*" last (1), groupable first (0) # Put "isolated_*" last (1), groupable first (0)
# Within each category, sort by size (largest first) # Within each category, sort by size (largest first)
return (is_isolated, -len(components)) return (is_isolated, -len(components))
@@ -149,8 +166,8 @@ def create_intelligent_batches(
current_batch = [] current_batch = []
current_weight = 0 current_weight = 0
for signature, group_components in sorted_groups: for (_platform, signature), group_components in sorted_groups:
is_isolated = signature.startswith("isolated_") is_isolated = signature.startswith(ISOLATED_SIGNATURE_PREFIX)
weight_per_component = ISOLATED_WEIGHT if is_isolated else GROUPABLE_WEIGHT weight_per_component = ISOLATED_WEIGHT if is_isolated else GROUPABLE_WEIGHT
for component in group_components: for component in group_components:
@@ -169,7 +186,7 @@ def create_intelligent_batches(
if current_batch: if current_batch:
batches.append(current_batch) batches.append(current_batch)
return batches return batches, signature_groups
def main() -> int: def main() -> int:
@@ -231,7 +248,7 @@ def main() -> int:
return 1 return 1
# Create intelligent batches # Create intelligent batches
batches = create_intelligent_batches( batches, signature_groups = create_intelligent_batches(
components=components, components=components,
tests_dir=args.tests_dir, tests_dir=args.tests_dir,
batch_size=args.batch_size, batch_size=args.batch_size,
@@ -256,6 +273,58 @@ def main() -> int:
# Re-analyze to get isolated component counts for summary # Re-analyze to get isolated component counts for summary
_, non_groupable, _ = analyze_all_components(args.tests_dir) _, non_groupable, _ = analyze_all_components(args.tests_dir)
# Show grouping details
print("\n=== Component Grouping Details ===", file=sys.stderr)
# Sort groups by signature for readability
groupable_groups = []
isolated_groups = []
for (platform, signature), group_comps in sorted(signature_groups.items()):
if signature.startswith(ISOLATED_SIGNATURE_PREFIX):
isolated_groups.append((signature, group_comps))
else:
groupable_groups.append((signature, group_comps))
if groupable_groups:
print(
f"\nGroupable signatures ({len(groupable_groups)} merged groups after cross-bus optimization):",
file=sys.stderr,
)
for signature, group_comps in sorted(
groupable_groups, key=lambda x: (-len(x[1]), x[0])
):
# Check if this is a merged signature (contains +)
is_merged = "+" in signature and signature != NO_BUSES_SIGNATURE
# Special handling for no_buses components
if signature == NO_BUSES_SIGNATURE:
print(
f" [{signature}]: {len(group_comps)} components (used as fillers across batches)",
file=sys.stderr,
)
else:
merge_indicator = " [MERGED]" if is_merged else ""
print(
f" [{signature}]{merge_indicator}: {len(group_comps)} components",
file=sys.stderr,
)
# Show first few components as examples
examples = ", ".join(sorted(group_comps)[:8])
if len(group_comps) > 8:
examples += f", ... (+{len(group_comps) - 8} more)"
print(f"{examples}", file=sys.stderr)
if isolated_groups:
print(
f"\nIsolated components ({len(isolated_groups)} components - tested individually):",
file=sys.stderr,
)
isolated_names = sorted(
[comp for _, comps in isolated_groups for comp in comps]
)
# Group isolated components for compact display
for i in range(0, len(isolated_names), 10):
chunk = isolated_names[i : i + 10]
print(f" {', '.join(chunk)}", file=sys.stderr)
# Count isolated vs groupable components # Count isolated vs groupable components
all_batched_components = [comp for batch in batches for comp in batch] all_batched_components = [comp for batch in batches for comp in batch]
isolated_count = sum( isolated_count = sum(

View File

@@ -17,11 +17,13 @@ from __future__ import annotations
import argparse import argparse
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass
import hashlib import hashlib
import os import os
from pathlib import Path from pathlib import Path
import subprocess import subprocess
import sys import sys
import time
# Add esphome to path # Add esphome to path
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
@@ -34,32 +36,49 @@ from script.analyze_component_buses import (
analyze_all_components, analyze_all_components,
create_grouping_signature, create_grouping_signature,
is_platform_component, is_platform_component,
merge_compatible_bus_groups,
uses_local_file_references, uses_local_file_references,
) )
from script.merge_component_configs import merge_component_configs from script.merge_component_configs import merge_component_configs
# Platform-specific maximum group sizes
# ESP8266 has limited IRAM and can't handle large component groups @dataclass
PLATFORM_MAX_GROUP_SIZE = { class TestResult:
"esp8266-ard": 10, # ESP8266 Arduino has limited IRAM """Store information about a single test run."""
"esp8266-idf": 10, # ESP8266 IDF also has limited IRAM
# BK72xx now uses BK7252 board (1.62MB flash vs 1.03MB) - no limit needed test_id: str
# Other platforms can handle larger groups components: list[str]
} platform: str
success: bool
duration: float
command: str = ""
test_type: str = "compile" # "config" or "compile"
def show_disk_space_if_ci(esphome_command: str) -> None: def show_disk_space_if_ci(esphome_command: str) -> None:
"""Show disk space usage if running in CI during compile. """Show disk space usage if running in CI during compile.
Only shows output during compilation (not config validation) since
disk space is only relevant when actually building firmware.
Args: Args:
esphome_command: The esphome command being run (config/compile/clean) esphome_command: The esphome command being run (config/compile/clean)
""" """
if os.environ.get("GITHUB_ACTIONS") and esphome_command == "compile": # Only show disk space during compilation in CI
# Config validation doesn't build anything so disk space isn't relevant
if not os.environ.get("GITHUB_ACTIONS"):
return
if esphome_command != "compile":
return
print("\n" + "=" * 80) print("\n" + "=" * 80)
print("Disk Space After Build:") print("Disk Space After Build:")
print("=" * 80) print("=" * 80)
subprocess.run(["df", "-h"], check=False) # Use sys.stdout.flush() to ensure output appears immediately
sys.stdout.flush()
subprocess.run(["df", "-h"], check=False, stdout=sys.stdout, stderr=sys.stderr)
print("=" * 80 + "\n") print("=" * 80 + "\n")
sys.stdout.flush()
def find_component_tests( def find_component_tests(
@@ -128,6 +147,140 @@ def get_platform_base_files(base_dir: Path) -> dict[str, list[Path]]:
return dict(platform_files) return dict(platform_files)
def group_components_by_platform(
failed_results: list[TestResult],
) -> dict[tuple[str, str], list[str]]:
"""Group failed components by platform and test type for simplified reproduction commands.
Args:
failed_results: List of failed test results
Returns:
Dictionary mapping (platform, test_type) to list of component names
"""
platform_components: dict[tuple[str, str], list[str]] = {}
for result in failed_results:
key = (result.platform, result.test_type)
if key not in platform_components:
platform_components[key] = []
platform_components[key].extend(result.components)
# Remove duplicates and sort for each platform
return {
key: sorted(set(components)) for key, components in platform_components.items()
}
def format_github_summary(test_results: list[TestResult]) -> str:
"""Format test results as GitHub Actions job summary markdown.
Args:
test_results: List of all test results
Returns:
Markdown formatted summary string
"""
# Separate results into passed and failed
passed_results = [r for r in test_results if r.success]
failed_results = [r for r in test_results if not r.success]
lines = []
# Header with emoji based on success/failure
if failed_results:
lines.append("## :x: Component Tests Failed\n")
else:
lines.append("## :white_check_mark: Component Tests Passed\n")
# Summary statistics
total_time = sum(r.duration for r in test_results)
# Determine test type from results (all should be the same)
test_type = test_results[0].test_type if test_results else "unknown"
lines.append(
f"**Results:** {len(passed_results)} passed, {len(failed_results)} failed\n"
)
lines.append(f"**Total time:** {total_time:.1f}s\n")
lines.append(f"**Test type:** `{test_type}`\n")
# Show failed tests if any
if failed_results:
lines.append("### Failed Tests\n")
lines.append("| Test | Components | Platform | Duration |\n")
lines.append("|------|-----------|----------|----------|\n")
for result in failed_results:
components_str = ", ".join(result.components)
lines.append(
f"| `{result.test_id}` | {components_str} | {result.platform} | {result.duration:.1f}s |\n"
)
lines.append("\n")
# Show simplified commands to reproduce failures
# Group all failed components by platform for a single command per platform
lines.append("<details>\n")
lines.append("<summary>Commands to reproduce failures</summary>\n\n")
lines.append("```bash\n")
# Generate one command per platform and test type
platform_components = group_components_by_platform(failed_results)
for platform, test_type in sorted(platform_components.keys()):
components_csv = ",".join(platform_components[(platform, test_type)])
lines.append(
f"script/test_build_components.py -c {components_csv} -t {platform} -e {test_type}\n"
)
lines.append("```\n")
lines.append("</details>\n")
# Show passed tests
if passed_results:
lines.append("### Passed Tests\n\n")
lines.append(f"{len(passed_results)} tests passed successfully\n")
# Separate grouped and individual tests
grouped_results = [r for r in passed_results if len(r.components) > 1]
individual_results = [r for r in passed_results if len(r.components) == 1]
if grouped_results:
lines.append("#### Grouped Tests\n")
lines.append("| Components | Platform | Count | Duration |\n")
lines.append("|-----------|----------|-------|----------|\n")
for result in grouped_results:
components_str = ", ".join(result.components)
lines.append(
f"| {components_str} | {result.platform} | {len(result.components)} | {result.duration:.1f}s |\n"
)
lines.append("\n")
if individual_results:
lines.append("#### Individual Tests\n")
# Show first 10 individual tests with timing
if len(individual_results) <= 10:
lines.extend(
f"- `{result.test_id}` - {result.duration:.1f}s\n"
for result in individual_results
)
else:
lines.extend(
f"- `{result.test_id}` - {result.duration:.1f}s\n"
for result in individual_results[:10]
)
lines.append(f"\n...and {len(individual_results) - 10} more\n")
lines.append("\n")
return "".join(lines)
def write_github_summary(test_results: list[TestResult]) -> None:
"""Write GitHub Actions job summary with test results and timing.
Args:
test_results: List of all test results
"""
summary_content = format_github_summary(test_results)
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as f:
f.write(summary_content)
def extract_platform_with_version(base_file: Path) -> str: def extract_platform_with_version(base_file: Path) -> str:
"""Extract platform with version from base filename. """Extract platform with version from base filename.
@@ -151,7 +304,7 @@ def run_esphome_test(
esphome_command: str, esphome_command: str,
continue_on_fail: bool, continue_on_fail: bool,
use_testing_mode: bool = False, use_testing_mode: bool = False,
) -> tuple[bool, str]: ) -> TestResult:
"""Run esphome test for a single component. """Run esphome test for a single component.
Args: Args:
@@ -166,7 +319,7 @@ def run_esphome_test(
use_testing_mode: Whether to use --testing-mode flag use_testing_mode: Whether to use --testing-mode flag
Returns: Returns:
Tuple of (success status, command string) TestResult object with test details and timing
""" """
test_name = test_file.stem.split(".")[0] test_name = test_file.stem.split(".")[0]
@@ -221,9 +374,13 @@ def run_esphome_test(
if use_testing_mode: if use_testing_mode:
print(" (using --testing-mode)") print(" (using --testing-mode)")
start_time = time.time()
test_id = f"{component}.{test_name}.{platform_with_version}"
try: try:
result = subprocess.run(cmd, check=False) result = subprocess.run(cmd, check=False)
success = result.returncode == 0 success = result.returncode == 0
duration = time.time() - start_time
# Show disk space after build in CI during compile # Show disk space after build in CI during compile
show_disk_space_if_ci(esphome_command) show_disk_space_if_ci(esphome_command)
@@ -236,12 +393,30 @@ def run_esphome_test(
print(cmd_str) print(cmd_str)
print() print()
raise subprocess.CalledProcessError(result.returncode, cmd) raise subprocess.CalledProcessError(result.returncode, cmd)
return success, cmd_str
return TestResult(
test_id=test_id,
components=[component],
platform=platform_with_version,
success=success,
duration=duration,
command=cmd_str,
test_type=esphome_command,
)
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
duration = time.time() - start_time
# Re-raise if we're not continuing on fail # Re-raise if we're not continuing on fail
if not continue_on_fail: if not continue_on_fail:
raise raise
return False, cmd_str return TestResult(
test_id=test_id,
components=[component],
platform=platform_with_version,
success=False,
duration=duration,
command=cmd_str,
test_type=esphome_command,
)
def run_grouped_test( def run_grouped_test(
@@ -253,7 +428,7 @@ def run_grouped_test(
tests_dir: Path, tests_dir: Path,
esphome_command: str, esphome_command: str,
continue_on_fail: bool, continue_on_fail: bool,
) -> tuple[bool, str]: ) -> TestResult:
"""Run esphome test for a group of components with shared bus configs. """Run esphome test for a group of components with shared bus configs.
Args: Args:
@@ -267,7 +442,7 @@ def run_grouped_test(
continue_on_fail: Whether to continue on failure continue_on_fail: Whether to continue on failure
Returns: Returns:
Tuple of (success status, command string) TestResult object with test details and timing
""" """
# Create merged config # Create merged config
group_name = "_".join(components[:3]) # Use first 3 components for name group_name = "_".join(components[:3]) # Use first 3 components for name
@@ -294,8 +469,17 @@ def run_grouped_test(
print(f"Error merging configs for {components}: {e}") print(f"Error merging configs for {components}: {e}")
if not continue_on_fail: if not continue_on_fail:
raise raise
# Return empty command string since we failed before building the command # Return TestResult for merge failure
return False, f"# Failed during config merge: {e}" test_id = f"GROUPED[{','.join(components)}].{platform_with_version}"
return TestResult(
test_id=test_id,
components=components,
platform=platform_with_version,
success=False,
duration=0.0,
command=f"# Failed during config merge: {e}",
test_type=esphome_command,
)
# Create test file that includes merged config # Create test file that includes merged config
output_file = build_dir / f"test_{group_name}.{platform_with_version}.yaml" output_file = build_dir / f"test_{group_name}.{platform_with_version}.yaml"
@@ -334,9 +518,13 @@ def run_grouped_test(
print(f"> [GROUPED: {components_str}] [{platform_with_version}]") print(f"> [GROUPED: {components_str}] [{platform_with_version}]")
print(" (using --testing-mode)") print(" (using --testing-mode)")
start_time = time.time()
test_id = f"GROUPED[{','.join(components)}].{platform_with_version}"
try: try:
result = subprocess.run(cmd, check=False) result = subprocess.run(cmd, check=False)
success = result.returncode == 0 success = result.returncode == 0
duration = time.time() - start_time
# Show disk space after build in CI during compile # Show disk space after build in CI during compile
show_disk_space_if_ci(esphome_command) show_disk_space_if_ci(esphome_command)
@@ -349,12 +537,30 @@ def run_grouped_test(
print(cmd_str) print(cmd_str)
print() print()
raise subprocess.CalledProcessError(result.returncode, cmd) raise subprocess.CalledProcessError(result.returncode, cmd)
return success, cmd_str
return TestResult(
test_id=test_id,
components=components,
platform=platform_with_version,
success=success,
duration=duration,
command=cmd_str,
test_type=esphome_command,
)
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
duration = time.time() - start_time
# Re-raise if we're not continuing on fail # Re-raise if we're not continuing on fail
if not continue_on_fail: if not continue_on_fail:
raise raise
return False, cmd_str return TestResult(
test_id=test_id,
components=components,
platform=platform_with_version,
success=False,
duration=duration,
command=cmd_str,
test_type=esphome_command,
)
def run_grouped_component_tests( def run_grouped_component_tests(
@@ -366,7 +572,7 @@ def run_grouped_component_tests(
esphome_command: str, esphome_command: str,
continue_on_fail: bool, continue_on_fail: bool,
additional_isolated: set[str] | None = None, additional_isolated: set[str] | None = None,
) -> tuple[set[tuple[str, str]], list[str], list[str], dict[str, str]]: ) -> tuple[set[tuple[str, str]], list[TestResult]]:
"""Run grouped component tests. """Run grouped component tests.
Args: Args:
@@ -380,12 +586,10 @@ def run_grouped_component_tests(
additional_isolated: Additional components to treat as isolated (not grouped) additional_isolated: Additional components to treat as isolated (not grouped)
Returns: Returns:
Tuple of (tested_components, passed_tests, failed_tests, failed_commands) Tuple of (tested_components, test_results)
""" """
tested_components = set() tested_components = set()
passed_tests = [] test_results = []
failed_tests = []
failed_commands = {} # Map test_id to command string
# Group components by platform and bus signature # Group components by platform and bus signature
grouped_components: dict[tuple[str, str], list[str]] = defaultdict(list) grouped_components: dict[tuple[str, str], list[str]] = defaultdict(list)
@@ -462,6 +666,11 @@ def run_grouped_component_tests(
if signature: if signature:
grouped_components[(platform, signature)].append(component) grouped_components[(platform, signature)].append(component)
# Merge groups with compatible buses (cross-bus grouping optimization)
# This allows mixing components with different buses (e.g., ble + uart)
# as long as they don't have conflicting configurations for the same bus type
grouped_components = merge_compatible_bus_groups(grouped_components)
# Print detailed grouping plan # Print detailed grouping plan
print("\nGrouping Plan:") print("\nGrouping Plan:")
print("-" * 80) print("-" * 80)
@@ -560,28 +769,6 @@ def run_grouped_component_tests(
# No other groups for this platform - keep no_buses components together # No other groups for this platform - keep no_buses components together
grouped_components[(platform, NO_BUSES_SIGNATURE)] = no_buses_comps grouped_components[(platform, NO_BUSES_SIGNATURE)] = no_buses_comps
# Split groups that exceed platform-specific maximum sizes
# ESP8266 has limited IRAM and can't handle large component groups
split_groups = {}
for (platform, signature), components in list(grouped_components.items()):
max_size = PLATFORM_MAX_GROUP_SIZE.get(platform)
if max_size and len(components) > max_size:
# Split this group into smaller groups
print(
f"\n Splitting {platform} group (signature: {signature}) "
f"from {len(components)} to max {max_size} components per group"
)
# Remove original group
del grouped_components[(platform, signature)]
# Create split groups
for i in range(0, len(components), max_size):
split_components = components[i : i + max_size]
# Create unique signature for each split group
split_signature = f"{signature}_split{i // max_size + 1}"
split_groups[(platform, split_signature)] = split_components
# Add split groups back
grouped_components.update(split_groups)
groups_to_test = [] groups_to_test = []
individual_tests = set() # Use set to avoid duplicates individual_tests = set() # Use set to avoid duplicates
@@ -672,7 +859,7 @@ def run_grouped_component_tests(
continue continue
# Run grouped test # Run grouped test
success, cmd_str = run_grouped_test( test_result = run_grouped_test(
components=components_to_group, components=components_to_group,
platform=platform, platform=platform,
platform_with_version=platform_with_version, platform_with_version=platform_with_version,
@@ -687,17 +874,10 @@ def run_grouped_component_tests(
for comp in components_to_group: for comp in components_to_group:
tested_components.add((comp, platform_with_version)) tested_components.add((comp, platform_with_version))
# Record result for each component - show all components in grouped tests # Store test result
test_id = ( test_results.append(test_result)
f"GROUPED[{','.join(components_to_group)}].{platform_with_version}"
)
if success:
passed_tests.append(test_id)
else:
failed_tests.append(test_id)
failed_commands[test_id] = cmd_str
return tested_components, passed_tests, failed_tests, failed_commands return tested_components, test_results
def run_individual_component_test( def run_individual_component_test(
@@ -710,9 +890,7 @@ def run_individual_component_test(
esphome_command: str, esphome_command: str,
continue_on_fail: bool, continue_on_fail: bool,
tested_components: set[tuple[str, str]], tested_components: set[tuple[str, str]],
passed_tests: list[str], test_results: list[TestResult],
failed_tests: list[str],
failed_commands: dict[str, str],
) -> None: ) -> None:
"""Run an individual component test if not already tested in a group. """Run an individual component test if not already tested in a group.
@@ -726,16 +904,13 @@ def run_individual_component_test(
esphome_command: ESPHome command esphome_command: ESPHome command
continue_on_fail: Whether to continue on failure continue_on_fail: Whether to continue on failure
tested_components: Set of already tested components tested_components: Set of already tested components
passed_tests: List to append passed test IDs test_results: List to append test results
failed_tests: List to append failed test IDs
failed_commands: Dict to store failed test commands
""" """
# Skip if already tested in a group # Skip if already tested in a group
if (component, platform_with_version) in tested_components: if (component, platform_with_version) in tested_components:
return return
test_name = test_file.stem.split(".")[0] test_result = run_esphome_test(
success, cmd_str = run_esphome_test(
component=component, component=component,
test_file=test_file, test_file=test_file,
platform=platform, platform=platform,
@@ -745,12 +920,7 @@ def run_individual_component_test(
esphome_command=esphome_command, esphome_command=esphome_command,
continue_on_fail=continue_on_fail, continue_on_fail=continue_on_fail,
) )
test_id = f"{component}.{test_name}.{platform_with_version}" test_results.append(test_result)
if success:
passed_tests.append(test_id)
else:
failed_tests.append(test_id)
failed_commands[test_id] = cmd_str
def test_components( def test_components(
@@ -799,19 +969,12 @@ def test_components(
print(f"Found {len(all_tests)} components to test") print(f"Found {len(all_tests)} components to test")
# Run tests # Run tests
failed_tests = [] test_results = []
passed_tests = []
tested_components = set() # Track which components were tested in groups tested_components = set() # Track which components were tested in groups
failed_commands = {} # Track commands for failed tests
# First, run grouped tests if grouping is enabled # First, run grouped tests if grouping is enabled
if enable_grouping: if enable_grouping:
( tested_components, grouped_results = run_grouped_component_tests(
tested_components,
passed_tests,
failed_tests,
failed_commands,
) = run_grouped_component_tests(
all_tests=all_tests, all_tests=all_tests,
platform_filter=platform_filter, platform_filter=platform_filter,
platform_bases=platform_bases, platform_bases=platform_bases,
@@ -821,6 +984,7 @@ def test_components(
continue_on_fail=continue_on_fail, continue_on_fail=continue_on_fail,
additional_isolated=isolated_components, additional_isolated=isolated_components,
) )
test_results.extend(grouped_results)
# Then run individual tests for components not in groups # Then run individual tests for components not in groups
for component, test_files in sorted(all_tests.items()): for component, test_files in sorted(all_tests.items()):
@@ -846,9 +1010,7 @@ def test_components(
esphome_command=esphome_command, esphome_command=esphome_command,
continue_on_fail=continue_on_fail, continue_on_fail=continue_on_fail,
tested_components=tested_components, tested_components=tested_components,
passed_tests=passed_tests, test_results=test_results,
failed_tests=failed_tests,
failed_commands=failed_commands,
) )
else: else:
# Platform-specific test # Platform-specific test
@@ -880,31 +1042,40 @@ def test_components(
esphome_command=esphome_command, esphome_command=esphome_command,
continue_on_fail=continue_on_fail, continue_on_fail=continue_on_fail,
tested_components=tested_components, tested_components=tested_components,
passed_tests=passed_tests, test_results=test_results,
failed_tests=failed_tests,
failed_commands=failed_commands,
) )
# Separate results into passed and failed
passed_results = [r for r in test_results if r.success]
failed_results = [r for r in test_results if not r.success]
# Print summary # Print summary
print("\n" + "=" * 80) print("\n" + "=" * 80)
print(f"Test Summary: {len(passed_tests)} passed, {len(failed_tests)} failed") print(f"Test Summary: {len(passed_results)} passed, {len(failed_results)} failed")
print("=" * 80) print("=" * 80)
if failed_tests: if failed_results:
print("\nFailed tests:") print("\nFailed tests:")
for test in failed_tests: for result in failed_results:
print(f" - {test}") print(f" - {result.test_id}")
# Print failed commands at the end for easy copy-paste from CI logs # Print simplified commands grouped by platform and test type for easy copy-paste
print("\n" + "=" * 80) print("\n" + "=" * 80)
print("Failed test commands (copy-paste to reproduce locally):") print("Commands to reproduce failures (copy-paste to reproduce locally):")
print("=" * 80) print("=" * 80)
for test in failed_tests: platform_components = group_components_by_platform(failed_results)
if test in failed_commands: for platform, test_type in sorted(platform_components.keys()):
print(f"\n# {test}") components_csv = ",".join(platform_components[(platform, test_type)])
print(failed_commands[test]) print(
f"script/test_build_components.py -c {components_csv} -t {platform} -e {test_type}"
)
print() print()
# Write GitHub Actions job summary if in CI
if os.environ.get("GITHUB_STEP_SUMMARY"):
write_github_summary(test_results)
if failed_results:
return 1 return 1
return 0 return 0

View File

@@ -1,5 +1,5 @@
substitutions: substitutions:
irq0_pin: GPIO13 irq0_pin: GPIO0
irq1_pin: GPIO15 irq1_pin: GPIO15
reset_pin: GPIO16 reset_pin: GPIO16

View File

@@ -4,10 +4,13 @@ sensor:
irq_pin: ${irq_pin} irq_pin: ${irq_pin}
voltage: voltage:
name: ADE7953 Voltage name: ADE7953 Voltage
id: ade7953_i2c_voltage
current_a: current_a:
name: ADE7953 Current A name: ADE7953 Current A
id: ade7953_i2c_current_a
current_b: current_b:
name: ADE7953 Current B name: ADE7953 Current B
id: ade7953_i2c_current_b
power_factor_a: power_factor_a:
name: ADE7953 Power Factor A name: ADE7953 Power Factor A
power_factor_b: power_factor_b:

View File

@@ -4,13 +4,13 @@ sensor:
irq_pin: ${irq_pin} irq_pin: ${irq_pin}
voltage: voltage:
name: ADE7953 Voltage name: ADE7953 Voltage
id: ade7953_voltage id: ade7953_spi_voltage
current_a: current_a:
name: ADE7953 Current A name: ADE7953 Current A
id: ade7953_current_a id: ade7953_spi_current_a
current_b: current_b:
name: ADE7953 Current B name: ADE7953 Current B
id: ade7953_current_b id: ade7953_spi_current_b
power_factor_a: power_factor_a:
name: ADE7953 Power Factor A name: ADE7953 Power Factor A
power_factor_b: power_factor_b:

View File

@@ -1,13 +1,16 @@
as3935_i2c: as3935_i2c:
id: as3935_i2c_id
i2c_id: i2c_bus i2c_id: i2c_bus
irq_pin: ${irq_pin} irq_pin: ${irq_pin}
binary_sensor: binary_sensor:
- platform: as3935 - platform: as3935
as3935_id: as3935_i2c_id
name: Storm Alert name: Storm Alert
sensor: sensor:
- platform: as3935 - platform: as3935
as3935_id: as3935_i2c_id
lightning_energy: lightning_energy:
name: Lightning Energy name: Lightning Energy
distance: distance:

View File

@@ -1,13 +1,16 @@
as3935_spi: as3935_spi:
id: as3935_spi_id
cs_pin: ${cs_pin} cs_pin: ${cs_pin}
irq_pin: ${irq_pin} irq_pin: ${irq_pin}
binary_sensor: binary_sensor:
- platform: as3935 - platform: as3935
as3935_id: as3935_spi_id
name: Storm Alert name: Storm Alert
sensor: sensor:
- platform: as3935 - platform: as3935
as3935_id: as3935_spi_id
lightning_energy: lightning_energy:
name: Lightning Energy name: Lightning Energy
distance: distance:

View File

@@ -1,7 +1,7 @@
display: display:
- platform: ssd1306_i2c - platform: ssd1306_i2c
i2c_id: i2c_bus i2c_id: i2c_bus
id: ssd1306_display id: ssd1306_i2c_display
model: SSD1306_128X64 model: SSD1306_128X64
reset_pin: 19 reset_pin: 19
pages: pages:
@@ -13,6 +13,6 @@ touchscreen:
- platform: axs15231 - platform: axs15231
i2c_id: i2c_bus i2c_id: i2c_bus
id: axs15231_touchscreen id: axs15231_touchscreen
display: ssd1306_display display: ssd1306_i2c_display
interrupt_pin: 20 interrupt_pin: 20
reset_pin: 18 reset_pin: 18

View File

@@ -3,12 +3,12 @@ sensor:
i2c_id: i2c_bus i2c_id: i2c_bus
address: 0x76 address: 0x76
temperature: temperature:
id: bme280_temperature id: bme280_i2c_temperature
name: BME280 Temperature name: BME280 Temperature
humidity: humidity:
id: bme280_humidity id: bme280_i2c_humidity
name: BME280 Humidity name: BME280 Humidity
pressure: pressure:
id: bme280_pressure id: bme280_i2c_pressure
name: BME280 Pressure name: BME280 Pressure
update_interval: 15s update_interval: 15s

View File

@@ -2,12 +2,12 @@ sensor:
- platform: bme280_spi - platform: bme280_spi
cs_pin: ${cs_pin} cs_pin: ${cs_pin}
temperature: temperature:
id: bme280_temperature id: bme280_spi_temperature
name: BME280 Temperature name: BME280 Temperature
humidity: humidity:
id: bme280_humidity id: bme280_spi_humidity
name: BME280 Humidity name: BME280 Humidity
pressure: pressure:
id: bme280_pressure id: bme280_spi_pressure
name: BME280 Pressure name: BME280 Pressure
update_interval: 15s update_interval: 15s

View File

@@ -3,10 +3,10 @@ sensor:
i2c_id: i2c_bus i2c_id: i2c_bus
address: 0x77 address: 0x77
temperature: temperature:
id: bmp280_temperature id: bmp280_i2c_temperature
name: Outside Temperature name: Outside Temperature
pressure: pressure:
name: Outside Pressure name: Outside Pressure
id: bmp280_pressure id: bmp280_i2c_pressure
iir_filter: 16x iir_filter: 16x
update_interval: 15s update_interval: 15s

View File

@@ -2,10 +2,10 @@ sensor:
- platform: bmp280_spi - platform: bmp280_spi
cs_pin: ${cs_pin} cs_pin: ${cs_pin}
temperature: temperature:
id: bmp280_temperature id: bmp280_spi_temperature
name: Outside Temperature name: Outside Temperature
pressure: pressure:
name: Outside Pressure name: Outside Pressure
id: bmp280_pressure id: bmp280_spi_pressure
iir_filter: 16x iir_filter: 16x
update_interval: 15s update_interval: 15s

View File

@@ -3,8 +3,10 @@ sensor:
i2c_id: i2c_bus i2c_id: i2c_bus
address: 0x77 address: 0x77
temperature: temperature:
id: bmp3xx_i2c_temperature
name: BMP Temperature name: BMP Temperature
oversampling: 16x oversampling: 16x
pressure: pressure:
id: bmp3xx_i2c_pressure
name: BMP Pressure name: BMP Pressure
iir_filter: 2X iir_filter: 2X

View File

@@ -2,8 +2,10 @@ sensor:
- platform: bmp3xx_spi - platform: bmp3xx_spi
cs_pin: ${cs_pin} cs_pin: ${cs_pin}
temperature: temperature:
id: bmp3xx_spi_temperature
name: BMP Temperature name: BMP Temperature
oversampling: 16x oversampling: 16x
pressure: pressure:
id: bmp3xx_spi_pressure
name: BMP Pressure name: BMP Pressure
iir_filter: 2X iir_filter: 2X

View File

@@ -1,4 +1,4 @@
packages: packages:
camera: !include ../../test_build_components/common/camera/esp32-idf.yaml i2c_camera: !include ../../test_build_components/common/i2c_camera/esp32-idf.yaml
<<: !include common.yaml <<: !include common.yaml

View File

@@ -1,4 +1,4 @@
packages: packages:
camera: !include ../../test_build_components/common/camera/esp32-idf.yaml i2c_camera: !include ../../test_build_components/common/i2c_camera/esp32-idf.yaml
<<: !include common.yaml <<: !include common.yaml

View File

@@ -4,6 +4,7 @@ packages:
display: display:
- platform: ili9xxx - platform: ili9xxx
spi_id: spi_bus
id: ili9xxx_display id: ili9xxx_display
model: GC9A01A model: GC9A01A
invert_colors: True invert_colors: True
@@ -16,5 +17,6 @@ display:
touchscreen: touchscreen:
- platform: chsc6x - platform: chsc6x
i2c_id: i2c_bus
display: ili9xxx_display display: ili9xxx_display
interrupt_pin: 20 interrupt_pin: 20

View File

@@ -1,7 +1,7 @@
display: display:
- platform: ssd1306_i2c - platform: ssd1306_i2c
i2c_id: i2c_bus i2c_id: i2c_bus
id: ssd1306_display id: ssd1306_i2c_display
model: SSD1306_128X64 model: SSD1306_128X64
reset_pin: ${display_reset_pin} reset_pin: ${display_reset_pin}
pages: pages:
@@ -15,7 +15,7 @@ touchscreen:
id: ektf2232_touchscreen id: ektf2232_touchscreen
interrupt_pin: ${interrupt_pin} interrupt_pin: ${interrupt_pin}
reset_pin: ${touch_reset_pin} reset_pin: ${touch_reset_pin}
display: ssd1306_display display: ssd1306_i2c_display
on_touch: on_touch:
- logger.log: - logger.log:
format: Touch at (%d, %d) format: Touch at (%d, %d)

View File

@@ -3,8 +3,11 @@ sensor:
i2c_id: i2c_bus i2c_id: i2c_bus
address: 0x53 address: 0x53
eco2: eco2:
id: ens160_i2c_eco2
name: "ENS160 eCO2" name: "ENS160 eCO2"
tvoc: tvoc:
id: ens160_i2c_tvoc
name: "ENS160 Total Volatile Organic Compounds" name: "ENS160 Total Volatile Organic Compounds"
aqi: aqi:
id: ens160_i2c_aqi
name: "ENS160 Air Quality Index" name: "ENS160 Air Quality Index"

View File

@@ -2,8 +2,11 @@ sensor:
- platform: ens160_spi - platform: ens160_spi
cs_pin: ${cs_pin} cs_pin: ${cs_pin}
eco2: eco2:
id: ens160_spi_eco2
name: "ENS160 eCO2" name: "ENS160 eCO2"
tvoc: tvoc:
id: ens160_spi_tvoc
name: "ENS160 Total Volatile Organic Compounds" name: "ENS160 Total Volatile Organic Compounds"
aqi: aqi:
id: ens160_spi_aqi
name: "ENS160 Air Quality Index" name: "ENS160 Air Quality Index"

View File

@@ -1,4 +1,4 @@
packages: packages:
camera: !include ../../test_build_components/common/camera/esp32-idf.yaml i2c_camera: !include ../../test_build_components/common/i2c_camera/esp32-idf.yaml
<<: !include common.yaml <<: !include common.yaml

View File

@@ -1,4 +1,4 @@
packages: packages:
camera: !include ../../test_build_components/common/camera/esp32-idf.yaml i2c_camera: !include ../../test_build_components/common/i2c_camera/esp32-idf.yaml
<<: !include common.yaml <<: !include common.yaml

View File

@@ -49,6 +49,7 @@ font:
display: display:
- platform: ssd1306_i2c - platform: ssd1306_i2c
i2c_id: i2c_bus
id: ssd1306_display id: ssd1306_display
model: SSD1306_128X64 model: SSD1306_128X64
reset_pin: ${display_reset_pin} reset_pin: ${display_reset_pin}

View File

@@ -1,5 +1,5 @@
substitutions: substitutions:
interrupt_pin: GPIO12 interrupt_pin: GPIO0
reset_pin: GPIO16 reset_pin: GPIO16
packages: packages:

View File

@@ -11,6 +11,7 @@ graph:
display: display:
- platform: ssd1306_i2c - platform: ssd1306_i2c
i2c_id: i2c_bus
id: ssd1306_display id: ssd1306_display
model: SSD1306_128X64 model: SSD1306_128X64
reset_pin: ${reset_pin} reset_pin: ${reset_pin}

View File

@@ -1,6 +1,6 @@
display: display:
- platform: ssd1306_i2c - platform: ssd1306_i2c
id: ssd1306_display id: ssd1306_i2c_display
model: SSD1306_128X64 model: SSD1306_128X64
reset_pin: ${reset_pin} reset_pin: ${reset_pin}
pages: pages:
@@ -36,7 +36,7 @@ switch:
graphical_display_menu: graphical_display_menu:
id: test_graphical_display_menu id: test_graphical_display_menu
display: ssd1306_display display: ssd1306_i2c_display
font: roboto font: roboto
active: false active: false
mode: rotary mode: rotary

View File

@@ -1,7 +1,7 @@
display: display:
- platform: ssd1306_i2c - platform: ssd1306_i2c
i2c_id: i2c_bus i2c_id: i2c_bus
id: ssd1306_display id: ssd1306_i2c_display
model: SSD1306_128X64 model: SSD1306_128X64
reset_pin: ${display_reset_pin} reset_pin: ${display_reset_pin}
pages: pages:
@@ -13,7 +13,7 @@ touchscreen:
- platform: gt911 - platform: gt911
i2c_id: i2c_bus i2c_id: i2c_bus
id: gt911_touchscreen id: gt911_touchscreen
display: ssd1306_display display: ssd1306_i2c_display
interrupt_pin: ${interrupt_pin} interrupt_pin: ${interrupt_pin}
reset_pin: ${reset_pin} reset_pin: ${reset_pin}

View File

@@ -1,5 +1,5 @@
substitutions: substitutions:
clk_pin: GPIO4 clk_pin: GPIO0
dout_pin: GPIO5 dout_pin: GPIO2
<<: !include common.yaml <<: !include common.yaml

View File

@@ -7,9 +7,21 @@ sensor:
max_current: 40 A max_current: 40 A
adc_range: 1 adc_range: 1
temperature_coefficient: 50 temperature_coefficient: 50
shunt_voltage: "INA2xx Shunt Voltage" shunt_voltage:
bus_voltage: "INA2xx Bus Voltage" id: ina2xx_i2c_shunt_voltage
current: "INA2xx Current" name: "INA2xx Shunt Voltage"
power: "INA2xx Power" bus_voltage:
energy: "INA2xx Energy" id: ina2xx_i2c_bus_voltage
charge: "INA2xx Charge" name: "INA2xx Bus Voltage"
current:
id: ina2xx_i2c_current
name: "INA2xx Current"
power:
id: ina2xx_i2c_power
name: "INA2xx Power"
energy:
id: ina2xx_i2c_energy
name: "INA2xx Energy"
charge:
id: ina2xx_i2c_charge
name: "INA2xx Charge"

View File

@@ -6,9 +6,21 @@ sensor:
max_current: 40 A max_current: 40 A
adc_range: 1 adc_range: 1
temperature_coefficient: 50 temperature_coefficient: 50
shunt_voltage: "INA2xx Shunt Voltage" shunt_voltage:
bus_voltage: "INA2xx Bus Voltage" id: ina2xx_spi_shunt_voltage
current: "INA2xx Current" name: "INA2xx Shunt Voltage"
power: "INA2xx Power" bus_voltage:
energy: "INA2xx Energy" id: ina2xx_spi_bus_voltage
charge: "INA2xx Charge" name: "INA2xx Bus Voltage"
current:
id: ina2xx_spi_current
name: "INA2xx Current"
power:
id: ina2xx_spi_power
name: "INA2xx Power"
energy:
id: ina2xx_spi_energy
name: "INA2xx Energy"
charge:
id: ina2xx_spi_charge
name: "INA2xx Charge"

View File

@@ -1,7 +1,7 @@
display: display:
- platform: ssd1306_i2c - platform: ssd1306_i2c
i2c_id: i2c_bus i2c_id: i2c_bus
id: ssd1306_display id: ssd1306_i2c_display
model: SSD1306_128X64 model: SSD1306_128X64
reset_pin: ${reset_pin} reset_pin: ${reset_pin}
pages: pages:
@@ -14,7 +14,7 @@ touchscreen:
i2c_id: i2c_bus i2c_id: i2c_bus
id: lilygo_touchscreen id: lilygo_touchscreen
interrupt_pin: ${interrupt_pin} interrupt_pin: ${interrupt_pin}
display: ssd1306_display display: ssd1306_i2c_display
on_touch: on_touch:
- logger.log: - logger.log:
format: Touch at (%d, %d) format: Touch at (%d, %d)

View File

@@ -1,9 +1,9 @@
pn532_i2c: pn532_i2c:
i2c_id: i2c_bus i2c_id: i2c_bus
id: pn532_nfcc id: pn532_nfcc_i2c
binary_sensor: binary_sensor:
- platform: pn532 - platform: pn532
pn532_id: pn532_nfcc pn532_id: pn532_nfcc_i2c
name: PN532 NFC Tag name: PN532 NFC Tag
uid: 74-10-37-94 uid: 74-10-37-94

View File

@@ -1,9 +1,9 @@
pn532_spi: pn532_spi:
id: pn532_nfcc id: pn532_nfcc_spi
cs_pin: ${cs_pin} cs_pin: ${cs_pin}
binary_sensor: binary_sensor:
- platform: pn532 - platform: pn532
pn532_id: pn532_nfcc pn532_id: pn532_nfcc_spi
name: PN532 NFC Tag name: PN532 NFC Tag
uid: 74-10-37-94 uid: 74-10-37-94

View File

@@ -1,23 +1,23 @@
esphome: esphome:
on_boot: on_boot:
then: then:
- tag.set_clean_mode: nfcc_pn7160 - tag.set_clean_mode: nfcc_pn7160_i2c
- tag.set_format_mode: nfcc_pn7160 - tag.set_format_mode: nfcc_pn7160_i2c
- tag.set_read_mode: nfcc_pn7160 - tag.set_read_mode: nfcc_pn7160_i2c
- tag.set_write_message: - tag.set_write_message:
message: https://www.home-assistant.io/tag/pulse message: https://www.home-assistant.io/tag/pulse
include_android_app_record: false include_android_app_record: false
- tag.set_write_mode: nfcc_pn7160 - tag.set_write_mode: nfcc_pn7160_i2c
- tag.set_emulation_message: - tag.set_emulation_message:
message: https://www.home-assistant.io/tag/pulse message: https://www.home-assistant.io/tag/pulse
include_android_app_record: false include_android_app_record: false
- tag.emulation_off: nfcc_pn7160 - tag.emulation_off: nfcc_pn7160_i2c
- tag.emulation_on: nfcc_pn7160 - tag.emulation_on: nfcc_pn7160_i2c
- tag.polling_off: nfcc_pn7160 - tag.polling_off: nfcc_pn7160_i2c
- tag.polling_on: nfcc_pn7160 - tag.polling_on: nfcc_pn7160_i2c
pn7150_i2c: pn7150_i2c:
id: nfcc_pn7160 id: nfcc_pn7160_i2c
i2c_id: i2c_bus i2c_id: i2c_bus
irq_pin: ${irq_pin} irq_pin: ${irq_pin}
ven_pin: ${ven_pin} ven_pin: ${ven_pin}

View File

@@ -1,23 +1,23 @@
esphome: esphome:
on_boot: on_boot:
then: then:
- tag.set_clean_mode: nfcc_pn7160 - tag.set_clean_mode: nfcc_pn7160_spi
- tag.set_format_mode: nfcc_pn7160 - tag.set_format_mode: nfcc_pn7160_spi
- tag.set_read_mode: nfcc_pn7160 - tag.set_read_mode: nfcc_pn7160_spi
- tag.set_write_message: - tag.set_write_message:
message: https://www.home-assistant.io/tag/pulse message: https://www.home-assistant.io/tag/pulse
include_android_app_record: false include_android_app_record: false
- tag.set_write_mode: nfcc_pn7160 - tag.set_write_mode: nfcc_pn7160_spi
- tag.set_emulation_message: - tag.set_emulation_message:
message: https://www.home-assistant.io/tag/pulse message: https://www.home-assistant.io/tag/pulse
include_android_app_record: false include_android_app_record: false
- tag.emulation_off: nfcc_pn7160 - tag.emulation_off: nfcc_pn7160_spi
- tag.emulation_on: nfcc_pn7160 - tag.emulation_on: nfcc_pn7160_spi
- tag.polling_off: nfcc_pn7160 - tag.polling_off: nfcc_pn7160_spi
- tag.polling_on: nfcc_pn7160 - tag.polling_on: nfcc_pn7160_spi
pn7160_spi: pn7160_spi:
id: nfcc_pn7160 id: nfcc_pn7160_spi
cs_pin: ${cs_pin} cs_pin: ${cs_pin}
irq_pin: ${irq_pin} irq_pin: ${irq_pin}
ven_pin: ${ven_pin} ven_pin: ${ven_pin}

View File

@@ -1,5 +1,5 @@
rc522_i2c: rc522_i2c:
- id: rc522_nfcc - id: rc522_nfcc_i2c
i2c_id: i2c_bus i2c_id: i2c_bus
update_interval: 1s update_interval: 1s
on_tag: on_tag:
@@ -8,6 +8,6 @@ rc522_i2c:
binary_sensor: binary_sensor:
- platform: rc522 - platform: rc522
rc522_id: rc522_nfcc rc522_id: rc522_nfcc_i2c
name: RC522 NFC Tag name: RC522 NFC Tag
uid: 74-10-37-94 uid: 74-10-37-94

View File

@@ -1,9 +1,9 @@
rc522_spi: rc522_spi:
id: rc522_nfcc id: rc522_nfcc_spi
cs_pin: ${cs_pin} cs_pin: ${cs_pin}
binary_sensor: binary_sensor:
- platform: rc522 - platform: rc522
rc522_id: rc522_nfcc rc522_id: rc522_nfcc_spi
name: PN532 NFC Tag name: RC522 NFC Tag
uid: 74-10-37-94 uid: 74-10-37-94

View File

@@ -1,7 +1,7 @@
substitutions: substitutions:
tx_pin: GPIO0 tx_pin: GPIO0
rx_pin: GPIO2 rx_pin: GPIO2
flow_control_pin: GPIO4 flow_control_pin: GPIO15
packages: packages:
modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml

View File

@@ -2,8 +2,8 @@ packages:
spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml
substitutions: substitutions:
clock_pin: GPIO5 clock_pin: GPIO15
data_pin: GPIO4 data_pin: GPIO16
latch_pin1: GPIO2 latch_pin1: GPIO2
oe_pin1: GPIO0 oe_pin1: GPIO0
latch_pin2: GPIO3 latch_pin2: GPIO3

View File

@@ -4,7 +4,7 @@ display:
model: SSD1306_128X64 model: SSD1306_128X64
reset_pin: ${reset_pin} reset_pin: ${reset_pin}
address: 0x3C address: 0x3C
id: display1 id: ssd1306_i2c_display
contrast: 60% contrast: 60%
pages: pages:
- id: ssd1306_i2c_page1 - id: ssd1306_i2c_page1

View File

@@ -1,5 +1,6 @@
display: display:
- platform: ssd1306_spi - platform: ssd1306_spi
id: ssd1306_spi_display
model: SSD1306 128x64 model: SSD1306 128x64
cs_pin: ${cs_pin} cs_pin: ${cs_pin}
dc_pin: ${dc_pin} dc_pin: ${dc_pin}

View File

@@ -4,7 +4,7 @@ display:
model: SSD1327_128x128 model: SSD1327_128x128
reset_pin: ${reset_pin} reset_pin: ${reset_pin}
address: 0x3C address: 0x3C
id: display1 id: ssd1327_i2c_display
pages: pages:
- id: ssd1327_i2c_page1 - id: ssd1327_i2c_page1
lambda: |- lambda: |-

View File

@@ -1,5 +1,6 @@
display: display:
- platform: ssd1327_spi - platform: ssd1327_spi
id: ssd1327_spi_display
model: SSD1327 128x128 model: SSD1327 128x128
cs_pin: ${cs_pin} cs_pin: ${cs_pin}
dc_pin: ${dc_pin} dc_pin: ${dc_pin}

View File

@@ -3,7 +3,7 @@ display:
i2c_id: i2c_bus i2c_id: i2c_bus
reset_pin: ${reset_pin} reset_pin: ${reset_pin}
address: 0x3C address: 0x3C
id: display1 id: st7567_i2c_display
pages: pages:
- id: st7567_i2c_page1 - id: st7567_i2c_page1
lambda: |- lambda: |-

View File

@@ -1,5 +1,6 @@
display: display:
- platform: st7567_spi - platform: st7567_spi
id: st7567_spi_display
cs_pin: ${cs_pin} cs_pin: ${cs_pin}
dc_pin: ${dc_pin} dc_pin: ${dc_pin}
reset_pin: ${reset_pin} reset_pin: ${reset_pin}

View File

@@ -6,7 +6,8 @@ udp:
addresses: ["239.0.60.53"] addresses: ["239.0.60.53"]
time: time:
platform: host - platform: host
id: host_time
syslog: syslog:
port: 514 port: 514

View File

@@ -1,7 +1,7 @@
display: display:
- platform: ssd1306_i2c - platform: ssd1306_i2c
i2c_id: i2c_bus i2c_id: i2c_bus
id: ssd1306_display id: ssd1306_i2c_display
model: SSD1306_128X64 model: SSD1306_128X64
reset_pin: ${disp_reset_pin} reset_pin: ${disp_reset_pin}
pages: pages:
@@ -13,7 +13,7 @@ touchscreen:
- platform: tt21100 - platform: tt21100
i2c_id: i2c_bus i2c_id: i2c_bus
id: tt21100_touchscreen id: tt21100_touchscreen
display: ssd1306_display display: ssd1306_i2c_display
interrupt_pin: ${interrupt_pin} interrupt_pin: ${interrupt_pin}
reset_pin: ${reset_pin} reset_pin: ${reset_pin}

View File

@@ -1,4 +1,5 @@
packages: packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
uart_bridge_2: !include ../../test_build_components/common/uart_bridge_2/esp32-idf.yaml
<<: !include common.yaml <<: !include common.yaml

View File

@@ -4,5 +4,6 @@ substitutions:
packages: packages:
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
uart_bridge_2: !include ../../test_build_components/common/uart_bridge_2/esp32-s3-idf.yaml
<<: !include common.yaml <<: !include common.yaml

View File

@@ -1,20 +1,20 @@
wk2132_spi: wk2132_spi:
- id: wk2132_spi_id - id: wk2132_spi_bridge
cs_pin: ${cs_pin} cs_pin: ${cs_pin}
crystal: 11059200 crystal: 11059200
data_rate: 1MHz data_rate: 1MHz
uart: uart:
- id: wk2132_spi_id0 - id: wk2132_spi_uart0
channel: 0 channel: 0
baud_rate: 115200 baud_rate: 115200
stop_bits: 1 stop_bits: 1
parity: none parity: none
- id: wk2132_spi_id1 - id: wk2132_spi_uart1
channel: 1 channel: 1
baud_rate: 9600 baud_rate: 9600
# Ensures a sensor doesn't break validation # Ensures a sensor doesn't break validation
sensor: sensor:
- platform: a02yyuw - platform: a02yyuw
uart_id: wk2132_spi_id1 uart_id: wk2132_spi_uart1
id: distance_sensor id: distance_sensor

View File

@@ -3,5 +3,6 @@ substitutions:
packages: packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
uart_bridge_2: !include ../../test_build_components/common/uart_bridge_2/esp32-idf.yaml
<<: !include common.yaml <<: !include common.yaml

View File

@@ -6,5 +6,6 @@ substitutions:
packages: packages:
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
uart_bridge_2: !include ../../test_build_components/common/uart_bridge_2/esp32-s3-idf.yaml
<<: !include common.yaml <<: !include common.yaml

View File

@@ -1,4 +1,5 @@
packages: packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-idf.yaml
<<: !include common.yaml <<: !include common.yaml

View File

@@ -4,5 +4,6 @@ substitutions:
packages: packages:
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml
<<: !include common.yaml <<: !include common.yaml

View File

@@ -3,5 +3,6 @@ substitutions:
packages: packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-idf.yaml
<<: !include common.yaml <<: !include common.yaml

View File

@@ -6,5 +6,6 @@ substitutions:
packages: packages:
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml
<<: !include common.yaml <<: !include common.yaml

View File

@@ -1,4 +1,5 @@
packages: packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-idf.yaml
<<: !include common.yaml <<: !include common.yaml

View File

@@ -4,5 +4,6 @@ substitutions:
packages: packages:
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml
<<: !include common.yaml <<: !include common.yaml

View File

@@ -1,28 +1,28 @@
wk2204_spi: wk2204_spi:
- id: wk2204_spi_id - id: wk2204_spi_bridge
cs_pin: ${cs_pin} cs_pin: ${cs_pin}
crystal: 11059200 crystal: 11059200
data_rate: 1MHz data_rate: 1MHz
uart: uart:
- id: wk2204_spi_id0 - id: wk2204_spi_uart0
channel: 0 channel: 0
baud_rate: 115200 baud_rate: 115200
stop_bits: 1 stop_bits: 1
parity: none parity: none
- id: wk2204_spi_id1 - id: wk2204_spi_uart1
channel: 1 channel: 1
baud_rate: 921600 baud_rate: 921600
- id: wk2204_spi_id2 - id: wk2204_spi_uart2
channel: 2 channel: 2
baud_rate: 115200 baud_rate: 115200
stop_bits: 1 stop_bits: 1
parity: none parity: none
- id: wk2204_spi_id3 - id: wk2204_spi_uart3
channel: 3 channel: 3
baud_rate: 9600 baud_rate: 9600
# Ensures a sensor doesn't break validation # Ensures a sensor doesn't break validation
sensor: sensor:
- platform: a02yyuw - platform: a02yyuw
uart_id: wk2204_spi_id3 uart_id: wk2204_spi_uart3
id: distance_sensor id: distance_sensor

View File

@@ -3,5 +3,6 @@ substitutions:
packages: packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-idf.yaml
<<: !include common.yaml <<: !include common.yaml

View File

@@ -6,5 +6,6 @@ substitutions:
packages: packages:
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml
<<: !include common.yaml <<: !include common.yaml

View File

@@ -1,4 +1,5 @@
packages: packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-idf.yaml
<<: !include common.yaml <<: !include common.yaml

View File

@@ -4,5 +4,6 @@ substitutions:
packages: packages:
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml
<<: !include common.yaml <<: !include common.yaml

View File

@@ -3,5 +3,6 @@ substitutions:
packages: packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-idf.yaml
<<: !include common.yaml <<: !include common.yaml

View File

@@ -6,5 +6,6 @@ substitutions:
packages: packages:
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml
<<: !include common.yaml <<: !include common.yaml

View File

@@ -3,9 +3,13 @@ esphome:
friendly_name: $component_name friendly_name: $component_name
esp32: esp32:
board: nodemcu-32s # Use board with 8MB flash for testing large component groups
board: esp32-pico-devkitm-2
framework: framework:
type: esp-idf type: esp-idf
# Use custom partition table with larger app partitions (3MB each)
# Default IDF partitions only allow 1.75MB which is too small for grouped tests
partitions: ../partitions_testing.csv
logger: logger:
level: VERY_VERBOSE level: VERY_VERBOSE

View File

@@ -1,3 +1,10 @@
# I2C bus for camera sensor
i2c:
- id: i2c_camera_bus
sda: 25
scl: 23
frequency: 400kHz
esp32_camera: esp32_camera:
name: ESP32 Camera name: ESP32 Camera
data_pins: data_pins:
@@ -15,9 +22,7 @@ esp32_camera:
external_clock: external_clock:
pin: 27 pin: 27
frequency: 20MHz frequency: 20MHz
i2c_pins: i2c_id: i2c_camera_bus
sda: 25
scl: 23
reset_pin: 15 reset_pin: 15
power_down_pin: 1 power_down_pin: 1
resolution: 640x480 resolution: 640x480

View File

@@ -0,0 +1,11 @@
# Common configuration for 2-channel UART bridge/expander chips
# Used by components like wk2132 that create 2 UART channels
# Defines standardized UART IDs: uart_id_0, uart_id_1
substitutions:
# These will be overridden by component-specific values
uart_bridge_address: "0x70"
# Note: The actual UART instances are created by the bridge component
# This package just ensures all bridge components use the same ID naming convention
# so they can be grouped together without conflicts

View File

@@ -0,0 +1,11 @@
# Common configuration for 2-channel UART bridge/expander chips
# Used by components like wk2132 that create 2 UART channels
# Defines standardized UART IDs: uart_id_0, uart_id_1
substitutions:
# These will be overridden by component-specific values
uart_bridge_address: "0x70"
# Note: The actual UART instances are created by the bridge component
# This package just ensures all bridge components use the same ID naming convention
# so they can be grouped together without conflicts

View File

@@ -0,0 +1,11 @@
# Common configuration for 4-channel UART bridge/expander chips
# Used by components like wk2168, wk2204, wk2212 that create 4 UART channels
# Defines standardized UART IDs: uart_id_0, uart_id_1, uart_id_2, uart_id_3
substitutions:
# These will be overridden by component-specific values
uart_bridge_address: "0x70"
# Note: The actual UART instances are created by the bridge component
# This package just ensures all bridge components use the same ID naming convention
# so they can be grouped together without conflicts

View File

@@ -0,0 +1,11 @@
# Common configuration for 4-channel UART bridge/expander chips
# Used by components like wk2168, wk2204, wk2212 that create 4 UART channels
# Defines standardized UART IDs: uart_id_0, uart_id_1, uart_id_2, uart_id_3
substitutions:
# These will be overridden by component-specific values
uart_bridge_address: "0x70"
# Note: The actual UART instances are created by the bridge component
# This package just ensures all bridge components use the same ID naming convention
# so they can be grouped together without conflicts

View File

@@ -0,0 +1,10 @@
# ESP-IDF Partition Table for ESPHome Component Testing
# Single app partition to maximize space for large component group testing
# Fits in 4MB flash
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x4000,
otadata, data, ota, , 0x2000,
phy_init, data, phy, , 0x1000,
factory, app, factory, 0x10000, 0x300000,
nvs_key, data, nvs_keys,, 0x1000,
coredump, data, coredump,, 0xEB000,
1 # ESP-IDF Partition Table for ESPHome Component Testing
2 # Single app partition to maximize space for large component group testing
3 # Fits in 4MB flash
4 # Name, Type, SubType, Offset, Size, Flags
5 nvs, data, nvs, 0x9000, 0x4000,
6 otadata, data, ota, , 0x2000,
7 phy_init, data, phy, , 0x1000,
8 factory, app, factory, 0x10000, 0x300000,
9 nvs_key, data, nvs_keys,, 0x1000,
10 coredump, data, coredump,, 0xEB000,