mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 14:19:03 +00:00
Merge remote-tracking branch 'upstream/dev' into integration
This commit is contained in:
2
.github/workflows/auto-label-pr.yml
vendored
2
.github/workflows/auto-label-pr.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@@ -252,6 +252,8 @@ jobs:
|
||||
python-linters: ${{ steps.determine.outputs.python-linters }}
|
||||
import-time: ${{ steps.determine.outputs.import-time }}
|
||||
device-builder: ${{ steps.determine.outputs.device-builder }}
|
||||
native-idf: ${{ steps.determine.outputs.native-idf }}
|
||||
native-idf-components: ${{ steps.determine.outputs.native-idf-components }}
|
||||
changed-components: ${{ steps.determine.outputs.changed-components }}
|
||||
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
|
||||
directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }}
|
||||
@@ -297,6 +299,8 @@ jobs:
|
||||
echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT
|
||||
echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT
|
||||
echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT
|
||||
echo "native-idf=$(echo "$output" | jq -r '.native_idf')" >> $GITHUB_OUTPUT
|
||||
echo "native-idf-components=$(echo "$output" | jq -r '.native_idf_components')" >> $GITHUB_OUTPUT
|
||||
echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT
|
||||
echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT
|
||||
echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT
|
||||
@@ -419,7 +423,10 @@ jobs:
|
||||
- name: Run CodSpeed benchmarks
|
||||
uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # v4.15.1
|
||||
with:
|
||||
run: ${{ steps.build.outputs.binary }}
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
${{ steps.build.outputs.binary }}
|
||||
pytest tests/benchmarks/python/ --codspeed --no-cov
|
||||
mode: simulation
|
||||
|
||||
clang-tidy-single:
|
||||
@@ -823,10 +830,14 @@ jobs:
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.native-idf == 'true'
|
||||
env:
|
||||
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
|
||||
TEST_COMPONENTS: esp32,api,heatpumpir,bme280_i2c,bh1750,aht10,esp32_ble,esp32_ble_beacon,esp32_ble_client,esp32_ble_server,esp32_ble_tracker,ble_client,ble_presence,ble_rssi,ble_scanner
|
||||
# Comma-joined subset of the native-IDF representative component list,
|
||||
# computed by script/determine-jobs.py (native_idf_components_to_test).
|
||||
# Single source of truth -- the full list lives in
|
||||
# script/determine-jobs.py::NATIVE_IDF_TEST_COMPONENTS.
|
||||
TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.native-idf-components }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
2
.github/workflows/external-component-bot.yml
vendored
2
.github/workflows/external-component-bot.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
13
.github/workflows/pr-title-check.yml
vendored
13
.github/workflows/pr-title-check.yml
vendored
@@ -68,14 +68,15 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for angle brackets not wrapped in backticks.
|
||||
// Astro docs MDX treats bare < as JSX component opening tags.
|
||||
// Check for MDX syntax characters not wrapped in backticks.
|
||||
// Astro docs MDX treats bare `<` as JSX component opening tags and
|
||||
// bare `{` as JS expressions, so both must be escaped in changelog entries.
|
||||
const stripped = title.replace(/`[^`]*`/g, '');
|
||||
if (/[<>]/.test(stripped)) {
|
||||
if (/[<>{}]/.test(stripped)) {
|
||||
core.setFailed(
|
||||
'PR title contains `<` or `>` not wrapped in backticks.\n' +
|
||||
'Astro docs MDX interprets bare `<` as JSX components.\n' +
|
||||
'Please wrap angle brackets with backticks, e.g.: [component] Add `<feature>` support'
|
||||
'PR title contains `<`, `>`, `{`, or `}` not wrapped in backticks.\n' +
|
||||
'Astro docs MDX interprets bare `<` as JSX components and bare `{` as JS expressions.\n' +
|
||||
'Please wrap these characters with backticks, e.g.: [component] Add `<feature>` support'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -221,7 +221,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
@@ -257,7 +257,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
@@ -289,7 +289,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
2
.github/workflows/sync-device-classes.yml
vendored
2
.github/workflows/sync-device-classes.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
2
Doxyfile
2
Doxyfile
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.5.0-dev
|
||||
PROJECT_NUMBER = 2026.6.0-dev
|
||||
|
||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
||||
# for a project that appears at the top of each page and should give viewer a
|
||||
|
||||
@@ -10,11 +10,14 @@ from esphome.writer import update_storage_json
|
||||
|
||||
|
||||
def get_available_components() -> list[str] | None:
|
||||
"""Get list of available ESP-IDF components from project_description.json.
|
||||
"""Get list of built-in ESP-IDF components from project_description.json.
|
||||
|
||||
Returns only internal ESP-IDF components, excluding external/managed
|
||||
components (from idf_component.yml).
|
||||
Excludes ``src``, IDF-managed components (``managed_components/``), and
|
||||
converted PIO libs (``pio_components/``). Returns ``None`` if the build
|
||||
dir or ``project_description.json`` isn't ready yet.
|
||||
"""
|
||||
if CORE.build_path is None:
|
||||
return None
|
||||
project_desc = Path(CORE.build_path) / "build" / "project_description.json"
|
||||
if not project_desc.exists():
|
||||
return None
|
||||
@@ -31,9 +34,9 @@ def get_available_components() -> list[str] | None:
|
||||
if name == "src":
|
||||
continue
|
||||
|
||||
# Exclude managed/external components
|
||||
# Exclude IDF-managed and converted-PIO components (external).
|
||||
comp_dir = info.get("dir", "")
|
||||
if "managed_components" in comp_dir:
|
||||
if "managed_components" in comp_dir or "pio_components" in comp_dir:
|
||||
continue
|
||||
|
||||
result.append(name)
|
||||
@@ -48,17 +51,63 @@ def has_discovered_components() -> bool:
|
||||
return get_available_components() is not None
|
||||
|
||||
|
||||
def get_project_cmakelists() -> str:
|
||||
"""Generate the top-level CMakeLists.txt for ESP-IDF project."""
|
||||
def get_project_cmakelists(minimal: bool = False) -> str:
|
||||
"""Generate the top-level CMakeLists.txt for ESP-IDF project.
|
||||
|
||||
When ``minimal`` is true, omit ``ESPHOME_PROJECT_BUILTIN_COMPONENTS``
|
||||
since ``project_description.json`` may be stale on the first write.
|
||||
"""
|
||||
# Get IDF target from ESP32 variant (e.g., ESP32S3 -> esp32s3)
|
||||
variant = get_esp32_variant()
|
||||
idf_target = variant.lower().replace("-", "")
|
||||
|
||||
# Extract compile definitions from build flags (-DXXX -> XXX)
|
||||
compile_defs = [flag for flag in sorted(CORE.build_flags) if flag.startswith("-D")]
|
||||
# Project-wide compile options: -D defines and -W warning flags (skip
|
||||
# -Wl, linker flags — those go on the src component via
|
||||
# target_link_options below). Emitted via idf_build_set_property so the
|
||||
# flags propagate to every IDF component (including managed ones like
|
||||
# esphome__micro-mp3) rather than just src/. Required so suppressions
|
||||
# like ``-Wno-error=maybe-uninitialized`` actually silence warnings in
|
||||
# third-party components we don't author.
|
||||
project_compile_opts = [
|
||||
flag
|
||||
for flag in sorted(CORE.build_flags)
|
||||
if flag.startswith("-D")
|
||||
or (flag.startswith("-W") and not flag.startswith("-Wl,"))
|
||||
]
|
||||
extra_compile_options = "\n".join(
|
||||
f'idf_build_set_property(COMPILE_OPTIONS "{compile_def}" APPEND)'
|
||||
for compile_def in compile_defs
|
||||
f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)'
|
||||
for flag in project_compile_opts
|
||||
)
|
||||
|
||||
# Per-project list exposed as a CMake variable so converted PIO libs
|
||||
# can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking
|
||||
# project-specific names into their cached CMakeLists.
|
||||
#
|
||||
# Emit via idf_build_set_property (not plain set()) so the value is
|
||||
# serialised into build_properties.temp.cmake and visible to IDF's
|
||||
# early requirements-expansion pass (component_get_requirements.cmake
|
||||
# runs as a separate CMake script invocation that doesn't load the
|
||||
# project's top-level CMakeLists; without this, ${ESPHOME_PROJECT_
|
||||
# MANAGED_COMPONENTS} in a converted-lib REQUIRES expands to empty).
|
||||
from esphome.components.esp32 import get_managed_component_require_names
|
||||
|
||||
managed_components_property = "\n".join(
|
||||
f"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS {name} APPEND)"
|
||||
for name in get_managed_component_require_names()
|
||||
)
|
||||
|
||||
# Built-in IDF components exposed via our own property (not IDF's
|
||||
# __COMPONENT_REQUIRES_COMMON, which would append them to every
|
||||
# component's REQUIRES including real IDF components). Referenced by
|
||||
# src/CMakeLists and by each converted PIO lib's CMakeLists. Skipped
|
||||
# on minimal writes because project_description.json may be stale.
|
||||
builtin_components_property = (
|
||||
""
|
||||
if minimal
|
||||
else "\n".join(
|
||||
f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)"
|
||||
for name in sorted(get_available_components() or [])
|
||||
)
|
||||
)
|
||||
|
||||
return f"""\
|
||||
@@ -88,6 +137,10 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
|
||||
|
||||
{extra_compile_options}
|
||||
|
||||
{managed_components_property}
|
||||
|
||||
{builtin_components_property}
|
||||
|
||||
project({CORE.name})
|
||||
|
||||
# Emit raw JSON size data for ESPHome to read post-build.
|
||||
@@ -102,46 +155,49 @@ add_custom_command(
|
||||
"""
|
||||
|
||||
|
||||
def get_component_cmakelists(minimal: bool = False) -> str:
|
||||
"""Generate the main component CMakeLists.txt."""
|
||||
idf_requires = [] if minimal else (get_available_components() or [])
|
||||
requires_str = " ".join(idf_requires)
|
||||
def get_component_cmakelists() -> str:
|
||||
"""Generate the main component CMakeLists.txt.
|
||||
|
||||
# Extract compile options (-W flags, excluding linker flags)
|
||||
compile_opts = [
|
||||
flag
|
||||
for flag in CORE.build_flags
|
||||
if flag.startswith("-W") and not flag.startswith("-Wl,")
|
||||
]
|
||||
compile_opts_str = "\n ".join(sorted(compile_opts)) if compile_opts else ""
|
||||
|
||||
# Extract linker options (-Wl, flags)
|
||||
REQUIRES pulls in the discovered built-in IDF components via the
|
||||
project-level variables set in the top-level CMakeLists.
|
||||
"""
|
||||
# Extract linker options (-Wl, flags). Compile flags (-D, -W) are
|
||||
# emitted project-wide via idf_build_set_property in
|
||||
# get_project_cmakelists so they reach every component, not just src/.
|
||||
link_opts = [flag for flag in CORE.build_flags if flag.startswith("-Wl,")]
|
||||
link_opts_str = "\n ".join(sorted(link_opts)) if link_opts else ""
|
||||
|
||||
return f"""\
|
||||
# Auto-generated by ESPHome
|
||||
file(GLOB_RECURSE app_sources
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
|
||||
)
|
||||
# CONFIGURE_DEPENDS asks CMake to re-check the glob each build so test
|
||||
# runs that reuse the build dir don't compile stale source paths. It's
|
||||
# invalid in script mode (cmake -P), which is how IDF's
|
||||
# component_get_requirements.cmake includes us, so skip it there.
|
||||
if(CMAKE_SCRIPT_MODE_FILE)
|
||||
file(GLOB_RECURSE app_sources
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
|
||||
)
|
||||
else()
|
||||
file(GLOB_RECURSE app_sources CONFIGURE_DEPENDS
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
|
||||
)
|
||||
endif()
|
||||
|
||||
idf_component_register(
|
||||
SRCS ${{app_sources}}
|
||||
INCLUDE_DIRS "." "esphome"
|
||||
REQUIRES {requires_str}
|
||||
REQUIRES ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}}
|
||||
)
|
||||
|
||||
# Apply C++ standard
|
||||
target_compile_features(${{COMPONENT_LIB}} PUBLIC cxx_std_20)
|
||||
|
||||
# ESPHome compile options
|
||||
target_compile_options(${{COMPONENT_LIB}} PUBLIC
|
||||
{compile_opts_str}
|
||||
)
|
||||
|
||||
# ESPHome linker options
|
||||
target_link_options(${{COMPONENT_LIB}} PUBLIC
|
||||
{link_opts_str}
|
||||
@@ -162,11 +218,11 @@ def write_project(minimal: bool = False) -> None:
|
||||
# Write top-level CMakeLists.txt
|
||||
write_file_if_changed(
|
||||
CORE.relative_build_path("CMakeLists.txt"),
|
||||
get_project_cmakelists(),
|
||||
get_project_cmakelists(minimal=minimal),
|
||||
)
|
||||
|
||||
# Write component CMakeLists.txt in src/
|
||||
write_file_if_changed(
|
||||
CORE.relative_src_path("CMakeLists.txt"),
|
||||
get_component_cmakelists(minimal=minimal),
|
||||
get_component_cmakelists(),
|
||||
)
|
||||
|
||||
@@ -53,14 +53,6 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None:
|
||||
Returns None (caller falls back to read_config) when the cache is
|
||||
missing, older than the source YAML, unparseable, or the sidecar
|
||||
is incomplete.
|
||||
|
||||
Known limitation: the mtime check only stats the top-level YAML.
|
||||
A package / !include / !secret edit that changes a CLI-side key
|
||||
(e.g. wifi.use_address pinned to a manual IP) without re-touching
|
||||
the main YAML or recompiling would keep the stale address in the
|
||||
cache. Realistic workflows recompile when anything material
|
||||
changes, so this is YAGNI -- revisit if someone actually trips
|
||||
over it.
|
||||
"""
|
||||
cache_path = compiled_config_path(conf_path.name)
|
||||
if not _cache_is_fresh(cache_path, conf_path):
|
||||
|
||||
@@ -588,6 +588,18 @@ def add_idf_component(
|
||||
}
|
||||
|
||||
|
||||
def get_managed_component_require_names() -> list[str]:
|
||||
"""Return sorted IDF require names for components added via
|
||||
``add_idf_component`` (``owner/name`` -> ``owner__name``).
|
||||
|
||||
The build_gen layer (``build_gen.espidf.get_project_cmakelists``)
|
||||
feeds this list into ``ESPHOME_PROJECT_MANAGED_COMPONENTS`` so
|
||||
converted PIO libraries can REQUIRE them by name at configure time.
|
||||
"""
|
||||
components_registry = CORE.data.get(KEY_ESP32, {}).get(KEY_COMPONENTS, {})
|
||||
return sorted(name.replace("/", "__") for name in components_registry)
|
||||
|
||||
|
||||
def exclude_builtin_idf_component(name: str) -> None:
|
||||
"""Exclude an ESP-IDF component from the build.
|
||||
|
||||
@@ -2452,8 +2464,14 @@ def _write_sdkconfig():
|
||||
)
|
||||
|
||||
want_opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
||||
# Include the resolved framework version as a Kconfig comment so a
|
||||
# version switch that happens to leave the option set unchanged still
|
||||
# bumps this file's content -- which is what has_outdated_files()
|
||||
# uses to decide whether to reconfigure.
|
||||
framework_version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
|
||||
contents = (
|
||||
"\n".join(
|
||||
f"# ESPHOME_IDF_VERSION={framework_version}\n"
|
||||
+ "\n".join(
|
||||
f"{name}={_format_sdkconfig_val(value)}"
|
||||
for name, value in sorted(want_opts.items())
|
||||
)
|
||||
@@ -2497,7 +2515,12 @@ def _write_idf_component_yml():
|
||||
|
||||
stubs_dir = CORE.relative_build_path("component_stubs")
|
||||
stubs_dir.mkdir(exist_ok=True)
|
||||
for component_name in components_to_stub:
|
||||
# Sort so the dict insertion order (and thus the generated
|
||||
# src/idf_component.yml) is deterministic across runs; otherwise
|
||||
# the manifest content shuffles every build, write_file_if_changed
|
||||
# always writes, and ninja keeps triggering CMake re-runs on
|
||||
# otherwise-cached rebuilds.
|
||||
for component_name in sorted(components_to_stub):
|
||||
# Create stub directory with minimal CMakeLists.txt
|
||||
stub_path = stubs_dir / _idf_component_stub_name(component_name)
|
||||
stub_path.mkdir(exist_ok=True)
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace esphome::mitsubishi_cn105 {
|
||||
|
||||
static const char *const TAG = "mitsubishi_cn105.driver";
|
||||
|
||||
static constexpr uint32_t WRITE_TIMEOUT_MS = 2000;
|
||||
static constexpr uint32_t RESPONSE_TIMEOUT_MS = 2000;
|
||||
|
||||
static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31;
|
||||
|
||||
@@ -30,44 +30,85 @@ static constexpr uint8_t STATUS_MSG_ROOM_TEMP = 0x03;
|
||||
static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41;
|
||||
static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61;
|
||||
|
||||
static constexpr std::array<std::optional<MitsubishiCN105::Mode>, 9> PROTOCOL_MODE_MAP = {
|
||||
std::nullopt, // 0x00
|
||||
template<auto Unknown, size_t N> struct LookupMap {
|
||||
using value_type = decltype(Unknown);
|
||||
static constexpr auto UNKNOWN_VALUE = Unknown;
|
||||
const std::array<value_type, N> table;
|
||||
|
||||
constexpr value_type lookup(uint8_t raw) const { return (raw < N) ? this->table[raw] : UNKNOWN_VALUE; }
|
||||
|
||||
constexpr bool reverse_lookup(value_type value, uint8_t &out) const {
|
||||
static_assert(N <= std::numeric_limits<uint8_t>::max());
|
||||
if (value == UNKNOWN_VALUE) {
|
||||
return false;
|
||||
}
|
||||
for (uint8_t i = 0; i < static_cast<uint8_t>(N); ++i) {
|
||||
if (this->table[i] == value) {
|
||||
out = i;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
constexpr bool is_valid(value_type value) const {
|
||||
uint8_t raw;
|
||||
return reverse_lookup(value, raw);
|
||||
}
|
||||
};
|
||||
|
||||
template<auto Unknown, class T, std::size_t N> static constexpr auto make_map(const T (&values)[N]) {
|
||||
return LookupMap<Unknown, N>{std::to_array(values)};
|
||||
}
|
||||
|
||||
static constexpr auto PROTOCOL_MODE_MAP = make_map<MitsubishiCN105::Mode::UNKNOWN>({
|
||||
MitsubishiCN105::Mode::UNKNOWN, // 0x00
|
||||
MitsubishiCN105::Mode::HEAT, // 0x01
|
||||
MitsubishiCN105::Mode::DRY, // 0x02
|
||||
MitsubishiCN105::Mode::COOL, // 0x03
|
||||
std::nullopt, // 0x04
|
||||
std::nullopt, // 0x05
|
||||
std::nullopt, // 0x06
|
||||
MitsubishiCN105::Mode::UNKNOWN, // 0x04
|
||||
MitsubishiCN105::Mode::UNKNOWN, // 0x05
|
||||
MitsubishiCN105::Mode::UNKNOWN, // 0x06
|
||||
MitsubishiCN105::Mode::FAN_ONLY, // 0x07
|
||||
MitsubishiCN105::Mode::AUTO // 0x08
|
||||
};
|
||||
});
|
||||
|
||||
static constexpr std::array<std::optional<MitsubishiCN105::FanMode>, 7> PROTOCOL_FAN_MODE_MAP = {
|
||||
static constexpr auto PROTOCOL_FAN_MODE_MAP = make_map<MitsubishiCN105::FanMode::UNKNOWN>({
|
||||
MitsubishiCN105::FanMode::AUTO, // 0x00
|
||||
MitsubishiCN105::FanMode::QUIET, // 0x01
|
||||
MitsubishiCN105::FanMode::SPEED_1, // 0x02
|
||||
MitsubishiCN105::FanMode::SPEED_2, // 0x03
|
||||
std::nullopt, // 0x04
|
||||
MitsubishiCN105::FanMode::UNKNOWN, // 0x04
|
||||
MitsubishiCN105::FanMode::SPEED_3, // 0x05
|
||||
MitsubishiCN105::FanMode::SPEED_4 // 0x06
|
||||
};
|
||||
});
|
||||
|
||||
template<typename T, size_t N>
|
||||
static constexpr std::optional<T> lookup(const std::array<std::optional<T>, N> &table, uint8_t value) {
|
||||
return (value < N) ? table[value] : std::nullopt;
|
||||
}
|
||||
static constexpr auto PROTOCOL_VANE_MODE_MAP = make_map<MitsubishiCN105::VaneMode::UNKNOWN>({
|
||||
MitsubishiCN105::VaneMode::AUTO, // 0x00
|
||||
MitsubishiCN105::VaneMode::POSITION_1, // 0x01
|
||||
MitsubishiCN105::VaneMode::POSITION_2, // 0x02
|
||||
MitsubishiCN105::VaneMode::POSITION_3, // 0x03
|
||||
MitsubishiCN105::VaneMode::POSITION_4, // 0x04
|
||||
MitsubishiCN105::VaneMode::POSITION_5, // 0x05
|
||||
MitsubishiCN105::VaneMode::UNKNOWN, // 0x06
|
||||
MitsubishiCN105::VaneMode::SWING // 0x07
|
||||
});
|
||||
|
||||
template<typename T, size_t N>
|
||||
static constexpr bool reverse_lookup(const std::array<std::optional<T>, N> &table, T value, uint8_t &placeholder) {
|
||||
for (size_t i = 0; i < N; ++i) {
|
||||
const auto &table_value = table[i];
|
||||
if (table_value.has_value() && table_value == value) {
|
||||
placeholder = i;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
static constexpr auto PROTOCOL_WIDE_VANE_MODE_MAP = make_map<MitsubishiCN105::WideVaneMode::UNKNOWN>({
|
||||
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x00
|
||||
MitsubishiCN105::WideVaneMode::FAR_LEFT, // 0x01
|
||||
MitsubishiCN105::WideVaneMode::LEFT, // 0x02
|
||||
MitsubishiCN105::WideVaneMode::CENTER, // 0x03
|
||||
MitsubishiCN105::WideVaneMode::RIGHT, // 0x04
|
||||
MitsubishiCN105::WideVaneMode::FAR_RIGHT, // 0x05
|
||||
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x06
|
||||
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x07
|
||||
MitsubishiCN105::WideVaneMode::LEFT_RIGHT, // 0x08
|
||||
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x09
|
||||
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0A
|
||||
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0B
|
||||
MitsubishiCN105::WideVaneMode::SWING // 0x0C
|
||||
});
|
||||
|
||||
static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) {
|
||||
return static_cast<uint8_t>(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0}));
|
||||
@@ -82,7 +123,7 @@ static constexpr auto make_packet(uint8_t type, const std::array<uint8_t, Payloa
|
||||
return packet;
|
||||
}
|
||||
|
||||
static float decode_temperature(int temp_a, int temp_b, int delta) {
|
||||
static constexpr float decode_temperature(int temp_a, int temp_b, int delta) {
|
||||
return temp_b != 0 ? (temp_b - 128) / 2.0f : delta + temp_a;
|
||||
}
|
||||
|
||||
@@ -91,25 +132,31 @@ static constexpr auto CONNECT_PACKET = make_packet(PACKET_TYPE_CONNECT_REQUEST,
|
||||
void MitsubishiCN105::initialize() { this->set_state_(State::CONNECTING); }
|
||||
|
||||
bool MitsubishiCN105::update() {
|
||||
if (const auto start = this->status_update_start_ms_) {
|
||||
if (this->pending_updates_.any()) {
|
||||
this->status_update_wait_credit_ms_ = std::min(this->update_interval_ms_, get_loop_time_ms() - *start);
|
||||
this->cancel_waiting_and_transition_to_(State::APPLYING_SETTINGS);
|
||||
return false;
|
||||
}
|
||||
switch (this->state_) {
|
||||
case State::WAITING_FOR_SCHEDULED_STATUS_UPDATE:
|
||||
if (this->pending_updates_.any()) {
|
||||
this->status_update_wait_credit_ms_ =
|
||||
std::min(this->update_interval_ms_, get_loop_time_ms() - this->operation_start_ms_);
|
||||
this->set_state_(State::APPLYING_SETTINGS);
|
||||
return false;
|
||||
}
|
||||
if (this->has_timed_out_(this->update_interval_ms_)) {
|
||||
this->set_state_(State::UPDATING_STATUS);
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
if ((get_loop_time_ms() - *start) >= this->update_interval_ms_) {
|
||||
this->cancel_waiting_and_transition_to_(State::UPDATING_STATUS);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
case State::CONNECTING:
|
||||
case State::UPDATING_STATUS:
|
||||
case State::APPLYING_SETTINGS:
|
||||
if (this->has_timed_out_(RESPONSE_TIMEOUT_MS)) {
|
||||
this->set_state_(State::READ_TIMEOUT);
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
if (const auto start = this->write_timeout_start_ms_; start && (get_loop_time_ms() - *start) >= WRITE_TIMEOUT_MS) {
|
||||
this->write_timeout_start_ms_.reset();
|
||||
this->frame_parser_.reset();
|
||||
this->status_update_wait_credit_ms_ = 0;
|
||||
this->set_state_(State::READ_TIMEOUT);
|
||||
return false;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return this->frame_parser_.read_and_parse(this->device_, [this](uint8_t type, const uint8_t *payload, size_t len) {
|
||||
@@ -171,7 +218,6 @@ void MitsubishiCN105::did_transition_(State to) {
|
||||
break;
|
||||
|
||||
case State::CONNECTED:
|
||||
this->write_timeout_start_ms_.reset();
|
||||
this->current_status_msg_type_ = STATUS_MSG_SETTINGS;
|
||||
this->set_state_(State::UPDATING_STATUS);
|
||||
break;
|
||||
@@ -181,7 +227,6 @@ void MitsubishiCN105::did_transition_(State to) {
|
||||
break;
|
||||
|
||||
case State::STATUS_UPDATED: {
|
||||
this->write_timeout_start_ms_.reset();
|
||||
if (this->pending_updates_.any() && this->is_status_initialized()) {
|
||||
this->set_state_(State::APPLYING_SETTINGS);
|
||||
} else if (this->current_status_msg_type_ == STATUS_MSG_SETTINGS && this->should_request_room_temperature_()) {
|
||||
@@ -194,7 +239,7 @@ void MitsubishiCN105::did_transition_(State to) {
|
||||
}
|
||||
|
||||
case State::SCHEDULE_NEXT_STATUS_UPDATE:
|
||||
this->status_update_start_ms_ = get_loop_time_ms() - this->status_update_wait_credit_ms_;
|
||||
this->operation_start_ms_ = get_loop_time_ms() - this->status_update_wait_credit_ms_;
|
||||
this->status_update_wait_credit_ms_ = 0;
|
||||
this->current_status_msg_type_ = STATUS_MSG_SETTINGS;
|
||||
this->set_state_(State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
|
||||
@@ -205,11 +250,12 @@ void MitsubishiCN105::did_transition_(State to) {
|
||||
break;
|
||||
|
||||
case State::SETTINGS_APPLIED:
|
||||
this->write_timeout_start_ms_.reset();
|
||||
this->set_state_(State::SCHEDULE_NEXT_STATUS_UPDATE);
|
||||
break;
|
||||
|
||||
case State::READ_TIMEOUT:
|
||||
this->frame_parser_.reset();
|
||||
this->status_update_wait_credit_ms_ = 0;
|
||||
this->set_state_(State::CONNECTING);
|
||||
break;
|
||||
|
||||
@@ -233,7 +279,7 @@ bool MitsubishiCN105::should_request_room_temperature_() const {
|
||||
void MitsubishiCN105::send_packet_(const uint8_t *packet, size_t len) {
|
||||
FrameParser::dump_buffer_vv("TX", packet, len);
|
||||
this->device_.write_array(packet, len);
|
||||
this->write_timeout_start_ms_ = get_loop_time_ms();
|
||||
this->operation_start_ms_ = get_loop_time_ms();
|
||||
}
|
||||
|
||||
void MitsubishiCN105::update_status_() {
|
||||
@@ -241,11 +287,6 @@ void MitsubishiCN105::update_status_() {
|
||||
this->send_packet_(make_packet(PACKET_TYPE_STATUS_REQUEST, payload));
|
||||
}
|
||||
|
||||
void MitsubishiCN105::cancel_waiting_and_transition_to_(State state) {
|
||||
this->status_update_start_ms_.reset();
|
||||
this->set_state_(state);
|
||||
}
|
||||
|
||||
bool MitsubishiCN105::process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len) {
|
||||
switch (type) {
|
||||
case PACKET_TYPE_CONNECT_RESPONSE:
|
||||
@@ -281,9 +322,10 @@ bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len)
|
||||
this->set_state_(State::STATUS_UPDATED);
|
||||
}
|
||||
|
||||
bool changed = previous.power_on != this->status_.power_on || previous.mode != this->status_.mode ||
|
||||
previous.fan_mode != this->status_.fan_mode ||
|
||||
previous.target_temperature != this->status_.target_temperature;
|
||||
bool changed =
|
||||
previous.power_on != this->status_.power_on || previous.mode != this->status_.mode ||
|
||||
previous.fan_mode != this->status_.fan_mode || previous.target_temperature != this->status_.target_temperature ||
|
||||
previous.vane_mode != this->status_.vane_mode || previous.wide_vane_mode != this->status_.wide_vane_mode;
|
||||
|
||||
if (this->is_room_temperature_enabled()) {
|
||||
changed |= previous.room_temperature != this->status_.room_temperature;
|
||||
@@ -323,11 +365,20 @@ bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len)
|
||||
|
||||
if (!this->pending_updates_.contains(UpdateFlag::MODE)) {
|
||||
const bool i_see = payload[3] > 0x08;
|
||||
this->status_.mode = lookup(PROTOCOL_MODE_MAP, payload[3] - (i_see ? 0x08 : 0)).value_or(Mode::UNKNOWN);
|
||||
this->status_.mode = PROTOCOL_MODE_MAP.lookup(payload[3] - (i_see ? 0x08 : 0));
|
||||
}
|
||||
|
||||
if (!this->pending_updates_.contains(UpdateFlag::FAN)) {
|
||||
this->status_.fan_mode = lookup(PROTOCOL_FAN_MODE_MAP, payload[5]).value_or(FanMode::UNKNOWN);
|
||||
this->status_.fan_mode = PROTOCOL_FAN_MODE_MAP.lookup(payload[5]);
|
||||
}
|
||||
|
||||
if (!this->pending_updates_.contains(UpdateFlag::VANE)) {
|
||||
this->status_.vane_mode = PROTOCOL_VANE_MODE_MAP.lookup(payload[6]);
|
||||
}
|
||||
|
||||
this->set_wide_vane_high_bit_ = (payload[9] & 0xF0) == 0x80;
|
||||
if (!this->pending_updates_.contains(UpdateFlag::WIDE_VANE)) {
|
||||
this->status_.wide_vane_mode = PROTOCOL_WIDE_VANE_MODE_MAP.lookup(payload[9] & 0x0F);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -381,8 +432,7 @@ void MitsubishiCN105::set_target_temperature(float target_temperature) {
|
||||
}
|
||||
|
||||
void MitsubishiCN105::set_mode(Mode mode) {
|
||||
uint8_t placeholder;
|
||||
if (!reverse_lookup(PROTOCOL_MODE_MAP, mode, placeholder)) {
|
||||
if (!PROTOCOL_MODE_MAP.is_valid(mode)) {
|
||||
ESP_LOGD(TAG, "Setting invalid mode: %u", static_cast<uint8_t>(mode));
|
||||
return;
|
||||
}
|
||||
@@ -391,8 +441,7 @@ void MitsubishiCN105::set_mode(Mode mode) {
|
||||
}
|
||||
|
||||
void MitsubishiCN105::set_fan_mode(FanMode fan_mode) {
|
||||
uint8_t placeholder;
|
||||
if (!reverse_lookup(PROTOCOL_FAN_MODE_MAP, fan_mode, placeholder)) {
|
||||
if (!PROTOCOL_FAN_MODE_MAP.is_valid(fan_mode)) {
|
||||
ESP_LOGD(TAG, "Setting invalid fan mode: %u", static_cast<uint8_t>(fan_mode));
|
||||
return;
|
||||
}
|
||||
@@ -400,6 +449,24 @@ void MitsubishiCN105::set_fan_mode(FanMode fan_mode) {
|
||||
this->pending_updates_.set(UpdateFlag::FAN);
|
||||
}
|
||||
|
||||
void MitsubishiCN105::set_vane_mode(VaneMode vane_mode) {
|
||||
if (!PROTOCOL_VANE_MODE_MAP.is_valid(vane_mode)) {
|
||||
ESP_LOGD(TAG, "Setting invalid vane mode: %u", static_cast<uint8_t>(vane_mode));
|
||||
return;
|
||||
}
|
||||
this->status_.vane_mode = vane_mode;
|
||||
this->pending_updates_.set(UpdateFlag::VANE);
|
||||
}
|
||||
|
||||
void MitsubishiCN105::set_wide_vane_mode(WideVaneMode wide_vane_mode) {
|
||||
if (!PROTOCOL_WIDE_VANE_MODE_MAP.is_valid(wide_vane_mode)) {
|
||||
ESP_LOGD(TAG, "Setting invalid wide vane mode: %u", static_cast<uint8_t>(wide_vane_mode));
|
||||
return;
|
||||
}
|
||||
this->status_.wide_vane_mode = wide_vane_mode;
|
||||
this->pending_updates_.set(UpdateFlag::WIDE_VANE);
|
||||
}
|
||||
|
||||
void MitsubishiCN105::apply_settings_() {
|
||||
std::array<uint8_t, REQUEST_PAYLOAD_LEN> payload{};
|
||||
|
||||
@@ -432,16 +499,30 @@ void MitsubishiCN105::apply_settings_() {
|
||||
}
|
||||
|
||||
if (this->pending_updates_.contains(UpdateFlag::MODE) &&
|
||||
reverse_lookup(PROTOCOL_MODE_MAP, this->status_.mode, payload[4])) {
|
||||
PROTOCOL_MODE_MAP.reverse_lookup(this->status_.mode, payload[4])) {
|
||||
payload[1] |= 0x02;
|
||||
}
|
||||
|
||||
if (this->pending_updates_.contains(UpdateFlag::FAN) &&
|
||||
reverse_lookup(PROTOCOL_FAN_MODE_MAP, this->status_.fan_mode, payload[6])) {
|
||||
PROTOCOL_FAN_MODE_MAP.reverse_lookup(this->status_.fan_mode, payload[6])) {
|
||||
payload[1] |= 0x08;
|
||||
}
|
||||
|
||||
this->pending_updates_.clear(UpdateFlag::POWER, UpdateFlag::TEMPERATURE, UpdateFlag::MODE, UpdateFlag::FAN);
|
||||
if (this->pending_updates_.contains(UpdateFlag::VANE) &&
|
||||
PROTOCOL_VANE_MODE_MAP.reverse_lookup(this->status_.vane_mode, payload[7])) {
|
||||
payload[1] |= 0x10;
|
||||
}
|
||||
|
||||
if (this->pending_updates_.contains(UpdateFlag::WIDE_VANE) &&
|
||||
PROTOCOL_WIDE_VANE_MODE_MAP.reverse_lookup(this->status_.wide_vane_mode, payload[13])) {
|
||||
payload[2] |= 0x01;
|
||||
if (this->set_wide_vane_high_bit_) {
|
||||
payload[13] |= 0x80;
|
||||
}
|
||||
}
|
||||
|
||||
this->pending_updates_.clear(UpdateFlag::POWER, UpdateFlag::TEMPERATURE, UpdateFlag::MODE, UpdateFlag::FAN,
|
||||
UpdateFlag::VANE, UpdateFlag::WIDE_VANE);
|
||||
}
|
||||
|
||||
this->send_packet_(make_packet(PACKET_TYPE_WRITE_SETTINGS_REQUEST, payload));
|
||||
|
||||
@@ -29,12 +29,36 @@ class MitsubishiCN105 {
|
||||
UNKNOWN,
|
||||
};
|
||||
|
||||
enum class VaneMode : uint8_t {
|
||||
AUTO,
|
||||
POSITION_1,
|
||||
POSITION_2,
|
||||
POSITION_3,
|
||||
POSITION_4,
|
||||
POSITION_5,
|
||||
SWING,
|
||||
UNKNOWN,
|
||||
};
|
||||
|
||||
enum class WideVaneMode : uint8_t {
|
||||
FAR_LEFT,
|
||||
LEFT,
|
||||
CENTER,
|
||||
RIGHT,
|
||||
FAR_RIGHT,
|
||||
LEFT_RIGHT,
|
||||
SWING,
|
||||
UNKNOWN,
|
||||
};
|
||||
|
||||
struct Status {
|
||||
bool power_on{false};
|
||||
float target_temperature{NAN};
|
||||
float room_temperature{NAN};
|
||||
bool power_on{false};
|
||||
Mode mode{Mode::UNKNOWN};
|
||||
FanMode fan_mode{FanMode::UNKNOWN};
|
||||
float room_temperature{NAN};
|
||||
VaneMode vane_mode{VaneMode::UNKNOWN};
|
||||
WideVaneMode wide_vane_mode{WideVaneMode::UNKNOWN};
|
||||
};
|
||||
|
||||
explicit MitsubishiCN105(uart::UARTDevice &device) : device_(device) {}
|
||||
@@ -61,6 +85,8 @@ class MitsubishiCN105 {
|
||||
void set_target_temperature(float target_temperature);
|
||||
void set_mode(Mode mode);
|
||||
void set_fan_mode(FanMode fan_mode);
|
||||
void set_vane_mode(VaneMode vane_mode);
|
||||
void set_wide_vane_mode(WideVaneMode mode);
|
||||
void set_remote_temperature(float temperature);
|
||||
void clear_remote_temperature();
|
||||
|
||||
@@ -98,7 +124,9 @@ class MitsubishiCN105 {
|
||||
POWER = 1,
|
||||
MODE = 2,
|
||||
FAN = 3,
|
||||
REMOTE_TEMPERATURE = 4,
|
||||
VANE = 4,
|
||||
WIDE_VANE = 5,
|
||||
REMOTE_TEMPERATURE = 6,
|
||||
};
|
||||
|
||||
struct UpdateFlags {
|
||||
@@ -124,9 +152,9 @@ class MitsubishiCN105 {
|
||||
bool parse_status_room_temperature_(const uint8_t *payload, size_t len);
|
||||
void send_packet_(const uint8_t *packet, size_t len);
|
||||
void update_status_();
|
||||
void cancel_waiting_and_transition_to_(State state);
|
||||
bool should_request_room_temperature_() const;
|
||||
void apply_settings_();
|
||||
bool has_timed_out_(uint32_t timeout) const { return ((get_loop_time_ms() - this->operation_start_ms_) >= timeout); }
|
||||
void set_remote_temperature_half_deg_(uint8_t temperature_half_deg);
|
||||
template<typename T> void send_packet_(const T &packet) { this->send_packet_(packet.data(), packet.size()); }
|
||||
static bool should_transition(State from, State to);
|
||||
@@ -135,14 +163,14 @@ class MitsubishiCN105 {
|
||||
uart::UARTDevice &device_;
|
||||
uint32_t update_interval_ms_{1000};
|
||||
uint32_t status_update_wait_credit_ms_{0};
|
||||
uint32_t operation_start_ms_{0};
|
||||
uint32_t room_temperature_min_interval_ms_{60000};
|
||||
std::optional<uint32_t> write_timeout_start_ms_;
|
||||
std::optional<uint32_t> status_update_start_ms_;
|
||||
std::optional<uint32_t> last_room_temperature_update_ms_;
|
||||
Status status_{};
|
||||
State state_{State::NOT_CONNECTED};
|
||||
UpdateFlags pending_updates_;
|
||||
bool use_temperature_encoding_b_{false};
|
||||
bool set_wide_vane_high_bit_{false};
|
||||
FrameParser frame_parser_;
|
||||
uint8_t current_status_msg_type_{0};
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ void MitsubishiCN105Climate::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, " Current temperature min interval: %" PRIu32 " ms",
|
||||
this->hp_.get_room_temperature_min_interval());
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Current temperature: disabled");
|
||||
ESP_LOGCONFIG(TAG, " Current temperature: DISABLED");
|
||||
}
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Update interval: %" PRIu32 " ms\n"
|
||||
|
||||
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
|
||||
from esphome.enum import StrEnum
|
||||
|
||||
__version__ = "2026.5.0-dev"
|
||||
__version__ = "2026.6.0-dev"
|
||||
|
||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||
|
||||
@@ -12,7 +12,6 @@ from typing import TypeVar
|
||||
from urllib.parse import urlparse, urlsplit, urlunsplit
|
||||
|
||||
from esphome import git, yaml_util
|
||||
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
|
||||
from esphome.core import CORE, Library
|
||||
from esphome.espidf.framework import archive_extract_all, download_from_mirrors, rmdir
|
||||
from esphome.helpers import write_file_if_changed
|
||||
@@ -50,28 +49,6 @@ SRC_FILE_EXTENSIONS = [
|
||||
ESP32_PLATFORM = "espressif32"
|
||||
DOMAIN = "pio_components"
|
||||
|
||||
#
|
||||
# Constants for workarounds
|
||||
#
|
||||
|
||||
REQUIRES_DETECT_PATTERNS = {
|
||||
"mbedtls": [re.compile(r'^\s*#\s*include\s*[<"]mbedtls[^">]*[">]', re.MULTILINE)],
|
||||
"esp_netif": [
|
||||
re.compile(r'^\s*#\s*include\s*[<"]esp_netif[^">]*[">]', re.MULTILINE)
|
||||
],
|
||||
"esp_driver_gpio": [
|
||||
re.compile(r'^\s*#\s*include\s*[<"]driver/gpio\.h[^">]*[">]', re.MULTILINE)
|
||||
],
|
||||
"esp_timer": [
|
||||
re.compile(r'^\s*#\s*include\s*[<"]esp_timer\.h[^">]*[">]', re.MULTILINE)
|
||||
],
|
||||
"esp_wifi": [
|
||||
re.compile(
|
||||
r'^\s*#\s*include\s*[<"]WiFi\.h[^">]*[">]', re.MULTILINE
|
||||
) # Arduino WiFi
|
||||
],
|
||||
}
|
||||
|
||||
ESPHOME_DATA_KEY = "ESPHOME"
|
||||
ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE"
|
||||
|
||||
@@ -278,44 +255,34 @@ def _get_package_from_pio_registry(
|
||||
return owner, name, version["name"], pkgfile["download_url"]
|
||||
|
||||
|
||||
def _patch_component(component: IDFComponent, first_pass: bool):
|
||||
"""
|
||||
Apply patches/workarounds to specific components that have known issues.
|
||||
def _apply_extra_script(component: IDFComponent) -> None:
|
||||
"""Run a PIO ``extraScript`` and fold its captured env vars into
|
||||
``component.data["build"]["flags"]`` so the existing -L/-l/-D
|
||||
extraction in ``generate_cmakelists_txt`` picks them up."""
|
||||
extra_script = component.data.get("build", {}).get("extraScript")
|
||||
if not extra_script:
|
||||
return
|
||||
# Resolve and confine to the component dir so a malicious library.json
|
||||
# can't escape (e.g. ``"extraScript": "../../etc/passwd"``).
|
||||
library_root = component.path.resolve()
|
||||
script_path = (component.path / extra_script).resolve()
|
||||
if not script_path.is_relative_to(library_root) or not script_path.is_file():
|
||||
return
|
||||
from esphome.components.esp32 import get_esp32_variant
|
||||
from esphome.espidf.extra_script import captured_as_build_flags, run_extra_script
|
||||
|
||||
This function modifies component data to fix compatibility issues or missing
|
||||
dependencies for certain libraries. It applies different patches based on
|
||||
whether it's the first or second pass of processing.
|
||||
|
||||
Args:
|
||||
component: The IDFComponent object to potentially patch
|
||||
first_pass: Boolean indicating if this is the first pass of processing
|
||||
"""
|
||||
|
||||
# Patch only on the second step
|
||||
if not first_pass and CORE.using_arduino:
|
||||
# Add the missing dependency to Arduino framework. Source is None so
|
||||
# the IDF component manager resolves it from the registry instead of
|
||||
# cloning the 2 GB arduino-esp32 git history.
|
||||
component.dependencies.append(
|
||||
IDFComponent(
|
||||
"espressif/arduino-esp32",
|
||||
str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]),
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
#
|
||||
# fastled/FastLED
|
||||
#
|
||||
|
||||
# Patch only on the first step
|
||||
if (
|
||||
first_pass
|
||||
and component.name == _owner_pkgname_to_name("fastled", "FastLED")
|
||||
and not (component.path / "idf_component.yml").is_file()
|
||||
):
|
||||
# Force fake idf_component: This project already support ESP-IDF
|
||||
(component.path / "idf_component.yml").write_text("")
|
||||
idf_target = get_esp32_variant().lower().replace("-", "")
|
||||
result = run_extra_script(
|
||||
script_path, library_dir=component.path, idf_target=idf_target
|
||||
)
|
||||
extra_flags = captured_as_build_flags(result, library_dir=component.path)
|
||||
if not extra_flags:
|
||||
return
|
||||
flags = component.data.setdefault("build", {}).setdefault("flags", [])
|
||||
if isinstance(flags, str):
|
||||
flags = [flags]
|
||||
flags.extend(extra_flags)
|
||||
component.data["build"]["flags"] = flags
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
@@ -472,43 +439,6 @@ def _convert_library_to_component(library: Library) -> IDFComponent:
|
||||
return IDFComponent(name, version, source)
|
||||
|
||||
|
||||
def _detect_requires(build_src_files: list[str]) -> set[str]:
|
||||
"""
|
||||
Detect required components from source files.
|
||||
|
||||
Args:
|
||||
build_src_files: List of source file paths to analyze
|
||||
|
||||
Returns:
|
||||
Set of detected required components
|
||||
"""
|
||||
detected = set()
|
||||
|
||||
# 1. Process each source file
|
||||
for file in build_src_files:
|
||||
path = Path(file)
|
||||
|
||||
if not path.is_file():
|
||||
continue
|
||||
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8", errors="ignore")
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
continue
|
||||
|
||||
# 2. Add required component if one of these patterns matches
|
||||
for require_name, patterns in REQUIRES_DETECT_PATTERNS.items():
|
||||
if require_name in detected:
|
||||
continue # already found
|
||||
|
||||
for pattern in patterns:
|
||||
if pattern.search(content):
|
||||
detected.add(require_name)
|
||||
break
|
||||
|
||||
return detected
|
||||
|
||||
|
||||
def _split_list_by_condition(
|
||||
items: list[str], match_fn: Callable[[str], str | None]
|
||||
) -> tuple[list[str], list[str]]:
|
||||
@@ -575,13 +505,14 @@ def generate_cmakelists_txt(component: IDFComponent) -> str:
|
||||
component.path / Path(build_src_dir), build_src_filter
|
||||
)
|
||||
|
||||
# Detect in the files which requirements to add
|
||||
# By default in platformio, all the components are added: we need to detect them when using ESP-IDF
|
||||
requires = _detect_requires(build_src_files)
|
||||
|
||||
# Dependencies are required
|
||||
for dependency in component.dependencies:
|
||||
requires.add(dependency.get_require_name())
|
||||
# Only bake library.json-declared deps here. Project-managed and
|
||||
# built-in components come in via ${ESPHOME_PROJECT_MANAGED_COMPONENTS}
|
||||
# / ${ESPHOME_PROJECT_BUILTIN_COMPONENTS} set in the top-level
|
||||
# CMakeLists, so this file stays project-agnostic when shared from
|
||||
# the pio_components cache.
|
||||
requires: set[str] = {
|
||||
dependency.get_require_name() for dependency in component.dependencies
|
||||
}
|
||||
|
||||
# Only keep sources
|
||||
build_src_files = [os.path.relpath(p, component.path) for p in build_src_files]
|
||||
@@ -620,9 +551,19 @@ def generate_cmakelists_txt(component: IDFComponent) -> str:
|
||||
if build_include_dirs:
|
||||
str_include_dirs = " ".join([escape_entry(p) for p in build_include_dirs])
|
||||
content += f" INCLUDE_DIRS {str_include_dirs}\n"
|
||||
if requires:
|
||||
str_requires = " ".join(sorted(requires))
|
||||
content += f" REQUIRES {str_requires}\n"
|
||||
# Project-managed and built-in component lists are set per-project
|
||||
# via idf_build_set_property in the top-level CMakeLists; expanded
|
||||
# here at configure time. Keeping them out of the per-lib REQUIRES
|
||||
# means this CMakeLists is project-agnostic and reusable from the
|
||||
# pio_components cache across builds.
|
||||
str_requires = " ".join(
|
||||
[
|
||||
*sorted(requires),
|
||||
"${ESPHOME_PROJECT_MANAGED_COMPONENTS}",
|
||||
"${ESPHOME_PROJECT_BUILTIN_COMPONENTS}",
|
||||
]
|
||||
)
|
||||
content += f" REQUIRES {str_requires}\n"
|
||||
content += ")\n"
|
||||
|
||||
# Add public and private build flags
|
||||
@@ -698,13 +639,10 @@ def generate_idf_component_yml(component: IDFComponent) -> str:
|
||||
try:
|
||||
dep["override_path"] = str(dependency.path)
|
||||
except RuntimeError as e:
|
||||
# No local path; let the IDF component manager resolve.
|
||||
# GitSource gives an explicit URL; arduino-esp32 is resolved by
|
||||
# version from the registry. Anything else is a bug.
|
||||
if isinstance(dependency.source, GitSource):
|
||||
dep["git"] = dependency.source.url
|
||||
elif dependency.name != "espressif/arduino-esp32":
|
||||
# No local path: only a GitSource can substitute its URL.
|
||||
if not isinstance(dependency.source, GitSource):
|
||||
raise e
|
||||
dep["git"] = dependency.source.url
|
||||
|
||||
data["dependencies"][dependency.get_sanitized_name()] = dep
|
||||
|
||||
@@ -744,13 +682,6 @@ def _check_library_data(data: dict):
|
||||
if not valid_framework:
|
||||
raise InvalidIDFComponent(f"Unsupported library frameworks: {frameworks}")
|
||||
|
||||
extra_script = data.get("build", {}).get("extraScript", None)
|
||||
if extra_script:
|
||||
_LOGGER.warning(
|
||||
'Extra scripts are not supported. The script "%s" will not be executed.',
|
||||
extra_script,
|
||||
)
|
||||
|
||||
|
||||
def _process_dependencies(component: IDFComponent):
|
||||
"""
|
||||
@@ -876,12 +807,9 @@ def _generate_idf_component(library: Library, force: bool = False) -> IDFCompone
|
||||
cmakelists_txt_path = component.path / "CMakeLists.txt"
|
||||
idf_component_yml_path = component.path / "idf_component.yml"
|
||||
|
||||
# Apply patches to the library metadata
|
||||
_patch_component(component, True)
|
||||
|
||||
if cmakelists_txt_path.is_file() and idf_component_yml_path.is_file():
|
||||
# Already an ESP-IDF component
|
||||
return component
|
||||
# Bundled CMakeLists.txt / idf_component.yml are ignored -- library
|
||||
# authors' IDF support is frequently broken (bogus REQUIRES, hard-coded
|
||||
# arduino-esp32, etc.). We always regenerate.
|
||||
|
||||
if library_json_path.is_file():
|
||||
component.data = _parse_library_json(library_json_path)
|
||||
@@ -892,16 +820,20 @@ def _generate_idf_component(library: Library, force: bool = False) -> IDFCompone
|
||||
"Invalid PIO library: missing library.json and/or library.properties"
|
||||
)
|
||||
|
||||
# Apply additional patches to the library metadata
|
||||
_patch_component(component, False)
|
||||
|
||||
# Check if the component is usable with ESP-IDF
|
||||
# Check if the component is usable with ESP-IDF before executing any
|
||||
# third-party Python from the library (``_apply_extra_script`` below).
|
||||
_check_library_data(component.data)
|
||||
|
||||
# If the library declares a PIO ``extraScript``, run it against a
|
||||
# fake SCons env so we can fold its captured LIBPATH/LIBS/etc into
|
||||
# the build-flag pipeline ``generate_cmakelists_txt`` consumes
|
||||
# below. Without this, libraries that wire per-MCU archive linking
|
||||
# via extraScript fail to link under native ESP-IDF.
|
||||
_apply_extra_script(component)
|
||||
|
||||
# Handle the dependencies (convert PlatformIO library to ESP-IDF component if needed)
|
||||
_process_dependencies(component)
|
||||
|
||||
# Generate files
|
||||
_LOGGER.debug("Generating CMakeLists.txt for %s@%s ...", name, version)
|
||||
write_file_if_changed(
|
||||
cmakelists_txt_path,
|
||||
|
||||
161
esphome/espidf/extra_script.py
Normal file
161
esphome/espidf/extra_script.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Run a PlatformIO ``extraScript`` against a captured SCons-env stand-in.
|
||||
|
||||
PlatformIO libraries occasionally configure per-target link/build state
|
||||
via a Python ``extraScript`` declared in ``library.json``'s ``build``
|
||||
section instead of static fields. The script runs under SCons during
|
||||
PIO's build and mutates the active ``Environment`` (``env.Append``,
|
||||
``env.Replace``, …) — chiefly to set ``LIBPATH``/``LIBS`` per chip MCU.
|
||||
|
||||
ESPHome's PIO→IDF converter (``_generate_idf_component``) doesn't run
|
||||
SCons, so these scripts were previously ignored and any library
|
||||
relying on them failed to link under ``toolchain: esp-idf``. This
|
||||
module provides a small shim that ``exec``s an extra-script with a
|
||||
fake ``env`` object, captures the common ``env.Append(...)`` calls,
|
||||
and returns the captured vars so the caller can fold them back into
|
||||
the library's generated CMakeLists.
|
||||
|
||||
Caveats
|
||||
-------
|
||||
* Only the ``env.Append`` API is captured. ``env.Replace``,
|
||||
``env.Prepend``, ``env.AddPreAction``, SCons file generators, and any
|
||||
arbitrary I/O are silently no-ops. Scripts that depend on those will
|
||||
produce incomplete output.
|
||||
* Running arbitrary Python from third-party libraries is a non-trivial
|
||||
trust decision. The shim does no sandboxing — anything in the
|
||||
script's process can run. Use only with libraries whose source you
|
||||
trust.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Keys we know how to translate back into ESPHome's build-flag pipeline.
|
||||
# Other env.Append kwargs are recorded but ignored downstream.
|
||||
_CAPTURED_KEYS = frozenset({"LIBPATH", "LIBS", "CPPDEFINES", "LINKFLAGS", "CPPFLAGS"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtraScriptResult:
|
||||
"""Build-var deltas captured from a PIO extra-script ``env.Append`` call."""
|
||||
|
||||
libpath: list[str] = field(default_factory=list)
|
||||
libs: list[str] = field(default_factory=list)
|
||||
cppdefines: list[str | tuple[str, str]] = field(default_factory=list)
|
||||
linkflags: list[str] = field(default_factory=list)
|
||||
cppflags: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class _FakeSConsEnv:
|
||||
"""Minimal stand-in for SCons ``Environment`` exposed to extra-scripts.
|
||||
|
||||
Implements just enough surface area to let scripts query ``BOARD_MCU``
|
||||
/ ``PIOENV`` and call ``env.Append(LIBPATH=…, LIBS=…, …)``. Every
|
||||
other env method swallows silently so unrelated calls don't raise
|
||||
``AttributeError`` and abort the script.
|
||||
"""
|
||||
|
||||
def __init__(self, *, board_mcu: str, pio_env: str) -> None:
|
||||
self._vars: dict[str, str] = {
|
||||
"BOARD_MCU": board_mcu,
|
||||
"PIOPLATFORM": "espressif32",
|
||||
"PIOENV": pio_env,
|
||||
}
|
||||
self.result = ExtraScriptResult()
|
||||
|
||||
# ----- SCons env API the common scripts use -----
|
||||
|
||||
def get(self, key: str, default: str | None = None) -> str | None:
|
||||
return self._vars.get(key, default)
|
||||
|
||||
def Append(self, **kwargs) -> None: # noqa: N802 (SCons API name)
|
||||
for key, value in kwargs.items():
|
||||
if key not in _CAPTURED_KEYS:
|
||||
continue
|
||||
items = list(value) if isinstance(value, (list, tuple)) else [value]
|
||||
bucket = getattr(self.result, key.lower())
|
||||
bucket.extend(items)
|
||||
|
||||
# ----- Everything else is a no-op so unsupported scripts don't crash -----
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
def _noop(*args, **kwargs):
|
||||
return None
|
||||
|
||||
return _noop
|
||||
|
||||
|
||||
def run_extra_script(
|
||||
script_path: Path, *, library_dir: Path, idf_target: str
|
||||
) -> ExtraScriptResult:
|
||||
"""Execute ``script_path`` with a fake SCons env and return captured vars.
|
||||
|
||||
``idf_target`` is the active ESP-IDF target name (e.g. ``esp32``,
|
||||
``esp32s3``); it's exposed to the script as PlatformIO's
|
||||
``BOARD_MCU`` so chip-conditional logic resolves the same way it
|
||||
would under PIO. The script runs with ``library_dir`` as the
|
||||
process CWD so relative-path lookups (``join``, ``realpath``,
|
||||
``open``) resolve against the library tree.
|
||||
|
||||
On any exception inside the script we log at debug level and return
|
||||
an empty result — extra-scripts are best-effort, and an unsupported
|
||||
script shouldn't block the build.
|
||||
"""
|
||||
env = _FakeSConsEnv(board_mcu=idf_target, pio_env=f"esphome_{idf_target}")
|
||||
code = compile(script_path.read_text(), str(script_path), "exec")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(library_dir)
|
||||
exec( # noqa: S102 pylint: disable=exec-used
|
||||
code,
|
||||
{
|
||||
"Import": lambda *_args: None, # SCons-side import; harmless here
|
||||
"env": env,
|
||||
"__file__": str(script_path),
|
||||
"__name__": "__pio_extra_script__",
|
||||
},
|
||||
)
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
_LOGGER.warning("PIO extra-script %s raised %s; skipping", script_path, e)
|
||||
return ExtraScriptResult()
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
return env.result
|
||||
|
||||
|
||||
def captured_as_build_flags(
|
||||
result: ExtraScriptResult, *, library_dir: Path
|
||||
) -> list[str]:
|
||||
"""Translate captured env vars into the ``-L`` / ``-l`` / ``-D`` /
|
||||
raw-flag form ``_generate_cmakelists_txt`` already knows how to consume.
|
||||
|
||||
``LIBPATH`` entries are made relative to ``library_dir`` so the
|
||||
generated CMakeLists is portable; absolute paths outside the library
|
||||
tree are kept as-is (CMake handles absolute paths in
|
||||
``target_link_directories`` fine).
|
||||
"""
|
||||
flags: list[str] = []
|
||||
library_root = library_dir.resolve()
|
||||
for path in result.libpath:
|
||||
# Anchor relative paths to library_dir (not the current CWD, which
|
||||
# has been restored by the time we get here). Joining an absolute
|
||||
# path against library_dir returns the absolute path unchanged.
|
||||
resolved = (library_dir / path).resolve()
|
||||
try:
|
||||
flags.append(f"-L{resolved.relative_to(library_root)}")
|
||||
except ValueError:
|
||||
flags.append(f"-L{resolved}")
|
||||
flags.extend(f"-l{lib}" for lib in result.libs)
|
||||
for define in result.cppdefines:
|
||||
if isinstance(define, tuple) and len(define) == 2:
|
||||
flags.append(f"-D{define[0]}={define[1]}")
|
||||
else:
|
||||
flags.append(f"-D{define}")
|
||||
flags.extend(result.linkflags)
|
||||
flags.extend(result.cppflags)
|
||||
return flags
|
||||
@@ -191,17 +191,38 @@ def run_reconfigure() -> int:
|
||||
def has_outdated_files():
|
||||
"""Check if the build configuration is stale.
|
||||
|
||||
Returns True if required build files are missing or if configuration inputs
|
||||
are newer than the generated CMake/Ninja build artifacts.
|
||||
Returns True if required build files are missing or if ESPHome's
|
||||
resolved build inputs are newer than CMakeCache.txt:
|
||||
|
||||
- ``sdkconfig.<name>.esphomeinternal`` -- the canonical "what state
|
||||
did ESPHome resolve the YAML to" snapshot. Any change in build
|
||||
flags, enabled components, framework version, or target ends up
|
||||
rewriting it (we embed a ``# ESPHOME_IDF_VERSION=`` comment line
|
||||
for the version case where the option set would otherwise be
|
||||
identical).
|
||||
- ``src/idf_component.yml`` -- the project manifest. Managed
|
||||
component additions/removals (e.g. via ``add_idf_component``) can
|
||||
happen without any sdkconfig impact, and ``_write_idf_component_yml``
|
||||
already deletes ``dependencies.lock`` on a change but that signal
|
||||
gets lost as soon as the lock is missing.
|
||||
|
||||
We deliberately don't watch:
|
||||
- The top-level/src ``CMakeLists.txt`` -- ESPHome owns those, and
|
||||
ninja already tracks them as configure-time deps. Including them
|
||||
causes a perpetual reconfigure loop because CMake doesn't restamp
|
||||
``CMakeCache.txt`` when only ``idf_build_set_property`` values
|
||||
change between configures.
|
||||
- ``$IDF_PATH`` and CMake's ``build/config/`` -- both have mtime
|
||||
semantics that fire after the wrong configure (or not at all in
|
||||
common cases like in-place IDF version replacement). The sdkconfig
|
||||
and manifest hashes subsume the meaningful signal.
|
||||
"""
|
||||
cmakecache_txt_path = CORE.relative_build_path("build/CMakeCache.txt")
|
||||
|
||||
cmakelists_txt_build_path = CORE.relative_build_path("CMakeLists.txt")
|
||||
cmakelists_txt_src_path = CORE.relative_src_path("CMakeLists.txt")
|
||||
build_config_path = CORE.relative_build_path("build/config")
|
||||
sdkconfig_internal_path = CORE.relative_build_path(
|
||||
f"sdkconfig.{CORE.name}.esphomeinternal"
|
||||
)
|
||||
idf_component_yml_path = CORE.relative_build_path("src/idf_component.yml")
|
||||
dependency_lock_path = CORE.relative_build_path("dependencies.lock")
|
||||
build_ninja_path = CORE.relative_build_path("build/build.ninja")
|
||||
|
||||
@@ -219,14 +240,8 @@ def has_outdated_files():
|
||||
cmakecache_txt_mtime = os.path.getmtime(cmakecache_txt_path)
|
||||
return any(
|
||||
os.path.getmtime(f) > cmakecache_txt_mtime
|
||||
for f in [
|
||||
_get_idf_path(),
|
||||
cmakelists_txt_build_path,
|
||||
cmakelists_txt_src_path,
|
||||
sdkconfig_internal_path,
|
||||
build_config_path,
|
||||
]
|
||||
if f and os.path.exists(f)
|
||||
for f in [sdkconfig_internal_path, idf_component_yml_path]
|
||||
if f.exists()
|
||||
)
|
||||
|
||||
|
||||
@@ -303,9 +318,12 @@ def run_compile(config, verbose: bool) -> int:
|
||||
_LOGGER.info("Regenerating CMakeLists.txt with discovered components...")
|
||||
write_project(minimal=False)
|
||||
if CORE.testing_mode:
|
||||
# Reconfigure again so cmake is up to date with the full component
|
||||
# list. This ensures idf.py build won't re-run cmake, which would
|
||||
# regenerate memory.ld and wipe the DRAM/IRAM patches applied below.
|
||||
# Reconfigure again so cmake is up to date with the full
|
||||
# component list before the build's idf.py invocation runs --
|
||||
# idf.py build would otherwise re-run cmake and regenerate
|
||||
# memory.ld, wiping the DRAM/IRAM patches applied below.
|
||||
# Outside testing mode ninja's own configure-time dep on
|
||||
# CMakeLists.txt handles the re-run as part of the build step.
|
||||
rc = run_reconfigure()
|
||||
if rc != 0:
|
||||
_LOGGER.error("Reconfigure with discovered components failed")
|
||||
|
||||
@@ -490,6 +490,14 @@ def clean_build(clear_pio_cache: bool = True):
|
||||
if dependencies_lock.is_file():
|
||||
_LOGGER.info("Deleting %s", dependencies_lock)
|
||||
dependencies_lock.unlink()
|
||||
# Native ESP-IDF toolchain artifacts: the IDF CMake/ninja build dir
|
||||
# and the Component Manager's fetched managed components live under
|
||||
# the project's build path, not under .pioenvs / .piolibdeps.
|
||||
for name in ("build", "managed_components"):
|
||||
idf_path = CORE.relative_build_path(name)
|
||||
if idf_path.is_dir():
|
||||
_LOGGER.info("Deleting %s", idf_path)
|
||||
rmtree(idf_path)
|
||||
|
||||
if not clear_pio_cache:
|
||||
return
|
||||
|
||||
@@ -24,7 +24,7 @@ freetype-py==2.5.1
|
||||
jinja2==3.1.6
|
||||
bleak==2.1.1
|
||||
smpclient==6.0.0
|
||||
requests==2.34.0
|
||||
requests==2.34.1
|
||||
|
||||
# esp-idf >= 5.0 requires this
|
||||
pyparsing >= 3.3.2
|
||||
|
||||
@@ -13,5 +13,10 @@ pytest-xdist==3.8.0
|
||||
asyncmock==0.4.2
|
||||
hypothesis==6.92.1
|
||||
|
||||
# CodSpeed benchmarks under tests/benchmarks/python/
|
||||
# (skipped via pytest.importorskip when missing -- only required for the
|
||||
# benchmarks job in .github/workflows/ci.yml)
|
||||
pytest-codspeed==5.0.1
|
||||
|
||||
# Used by the import-time regression check (.github/workflows/ci.yml → import-time job)
|
||||
importtime-waterfall==1.0.0
|
||||
|
||||
@@ -495,6 +495,125 @@ def should_run_device_builder(branch: str | None = None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# Components tested by the native ESP-IDF compile-test job. This is the
|
||||
# single source of truth: the workflow reads the comma-joined list from the
|
||||
# `native-idf-components` output of `determine-jobs` and uses it as the
|
||||
# `TEST_COMPONENTS` env on the `test-native-idf` job.
|
||||
NATIVE_IDF_TEST_COMPONENTS = frozenset(
|
||||
{
|
||||
"esp32",
|
||||
"api",
|
||||
"heatpumpir",
|
||||
"bme280_i2c",
|
||||
"bh1750",
|
||||
"aht10",
|
||||
"esp32_ble",
|
||||
"esp32_ble_beacon",
|
||||
"esp32_ble_client",
|
||||
"esp32_ble_server",
|
||||
"esp32_ble_tracker",
|
||||
"ble_client",
|
||||
"ble_presence",
|
||||
"ble_rssi",
|
||||
"ble_scanner",
|
||||
}
|
||||
)
|
||||
|
||||
# Path prefixes whose changes always trigger the native ESP-IDF compile
|
||||
# test: anything under esphome/espidf/ (the native IDF runner / API /
|
||||
# framework / component generator).
|
||||
NATIVE_IDF_TRIGGER_PATH_PREFIXES = ("esphome/espidf/",)
|
||||
|
||||
# Standalone files that, when changed, also trigger the native ESP-IDF
|
||||
# compile test:
|
||||
# - esphome/build_gen/espidf.py -- the native IDF build generator
|
||||
# (other files under build_gen/ target PlatformIO and don't affect
|
||||
# the native IDF path)
|
||||
# - script/test_build_components.py -- the harness the job invokes
|
||||
# - .github/workflows/ci.yml -- the job's own definition
|
||||
NATIVE_IDF_TRIGGER_FILES = frozenset(
|
||||
{
|
||||
"esphome/build_gen/espidf.py",
|
||||
"script/test_build_components.py",
|
||||
".github/workflows/ci.yml",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _native_idf_path_or_file_trigger(files: list[str]) -> bool:
|
||||
"""Whether any changed file is a native IDF infrastructure / harness trigger."""
|
||||
for file in files:
|
||||
if file in NATIVE_IDF_TRIGGER_FILES:
|
||||
return True
|
||||
if any(file.startswith(prefix) for prefix in NATIVE_IDF_TRIGGER_PATH_PREFIXES):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def native_idf_components_to_test(branch: str | None = None) -> list[str]:
|
||||
"""Subset of ``NATIVE_IDF_TEST_COMPONENTS`` the job needs to compile.
|
||||
|
||||
The job builds components with the native ESP-IDF toolchain (no
|
||||
PlatformIO). When only a specific component (or something it depends
|
||||
on) changed, there's no value in re-building every other unrelated
|
||||
component in the test list -- the regular ``component-test`` matrix
|
||||
already covers them via PlatformIO. So we narrow to the intersection
|
||||
of ``NATIVE_IDF_TEST_COMPONENTS`` and the changed-component dependency
|
||||
closure.
|
||||
|
||||
Returns the full list (sorted) when we can't safely narrow:
|
||||
|
||||
1. Core C++/Python files changed (``esphome/core/*``).
|
||||
2. Native IDF infrastructure changed (``esphome/espidf/*`` or
|
||||
``esphome/build_gen/espidf.py``).
|
||||
3. The test harness or workflow itself changed
|
||||
(``script/test_build_components.py``, ``.github/workflows/ci.yml``).
|
||||
|
||||
Otherwise returns the intersection (sorted), which may be empty -- an
|
||||
empty list signals the job should be skipped.
|
||||
|
||||
The dependency closure is derived from ``files`` via
|
||||
``get_components_with_dependencies()`` (the same primitive ``main()``
|
||||
uses) so the result honors ``branch``. ``get_changed_components()``
|
||||
is deliberately not used here: it re-invokes ``changed_files()`` with
|
||||
its own default branch, which would silently ignore our ``branch``
|
||||
argument.
|
||||
|
||||
Args:
|
||||
branch: Branch to compare against. If None, uses default.
|
||||
|
||||
Returns:
|
||||
Sorted list of component names to compile.
|
||||
"""
|
||||
files = changed_files(branch)
|
||||
|
||||
if core_changed(files) or _native_idf_path_or_file_trigger(files):
|
||||
return sorted(NATIVE_IDF_TEST_COMPONENTS)
|
||||
|
||||
component_files = [f for f in files if filter_component_and_test_files(f)]
|
||||
changed = get_components_with_dependencies(component_files, True)
|
||||
|
||||
return sorted(NATIVE_IDF_TEST_COMPONENTS & set(changed))
|
||||
|
||||
|
||||
def should_run_native_idf(branch: str | None = None) -> bool:
|
||||
"""Determine if the `test-native-idf` compile-test job should run.
|
||||
|
||||
Runs whenever ``native_idf_components_to_test()`` returns a non-empty
|
||||
list. Skipping the job on unrelated Python-only PRs avoids ~5 min of
|
||||
CI per PR (worse on cold caches). The regular ``component-test``
|
||||
matrix still exercises the same components through PlatformIO when
|
||||
those components change.
|
||||
|
||||
Args:
|
||||
branch: Branch to compare against. If None, uses default.
|
||||
|
||||
Returns:
|
||||
True if the native ESP-IDF compile test should run, False otherwise.
|
||||
"""
|
||||
return bool(native_idf_components_to_test(branch))
|
||||
|
||||
|
||||
def determine_cpp_unit_tests(
|
||||
branch: str | None = None,
|
||||
) -> tuple[bool, list[str]]:
|
||||
@@ -957,6 +1076,8 @@ def main() -> None:
|
||||
run_python_linters = should_run_python_linters(args.branch)
|
||||
run_import_time = should_run_import_time(args.branch)
|
||||
run_device_builder = should_run_device_builder(args.branch)
|
||||
native_idf_components = native_idf_components_to_test(args.branch)
|
||||
run_native_idf = bool(native_idf_components)
|
||||
changed_cpp_file_count = count_changed_cpp_files(args.branch)
|
||||
|
||||
# Get changed components
|
||||
@@ -1102,6 +1223,8 @@ def main() -> None:
|
||||
"python_linters": run_python_linters,
|
||||
"import_time": run_import_time,
|
||||
"device_builder": run_device_builder,
|
||||
"native_idf": run_native_idf,
|
||||
"native_idf_components": ",".join(native_idf_components),
|
||||
"changed_components": changed_components,
|
||||
"changed_components_with_tests": changed_components_with_tests,
|
||||
"directly_changed_components_with_tests": list(directly_changed_with_tests),
|
||||
|
||||
0
tests/benchmarks/python/__init__.py
Normal file
0
tests/benchmarks/python/__init__.py
Normal file
22
tests/benchmarks/python/conftest.py
Normal file
22
tests/benchmarks/python/conftest.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Shared fixtures for the Python benchmark suite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_core_state() -> Generator[None]:
|
||||
"""Reset CORE before and after every benchmark.
|
||||
|
||||
Per-iteration setups inside benchmarks reset CORE for the loop body;
|
||||
this fixture handles the test-level boundary so stale state from
|
||||
fixture priming doesn't leak across benchmarks.
|
||||
"""
|
||||
CORE.reset()
|
||||
yield
|
||||
CORE.reset()
|
||||
62
tests/benchmarks/python/fixtures/bluetooth_proxy_device.yaml
Normal file
62
tests/benchmarks/python/fixtures/bluetooth_proxy_device.yaml
Normal file
@@ -0,0 +1,62 @@
|
||||
substitutions:
|
||||
devicename: bluetooth_proxy_device
|
||||
friendly_name: bluetooth_proxy_device
|
||||
|
||||
esphome:
|
||||
name: $devicename
|
||||
friendly_name: $friendly_name
|
||||
|
||||
esp32:
|
||||
board: esp32-poe-iso
|
||||
framework:
|
||||
type: esp-idf
|
||||
advanced:
|
||||
sram1_as_iram: true
|
||||
minimum_chip_revision: "3.0"
|
||||
|
||||
esp32_ble_tracker:
|
||||
scan_parameters:
|
||||
active: false
|
||||
|
||||
bluetooth_proxy:
|
||||
active: true
|
||||
|
||||
ethernet:
|
||||
type: LAN8720
|
||||
mdc_pin: GPIO23
|
||||
mdio_pin: GPIO18
|
||||
clk_mode: GPIO17_OUT
|
||||
phy_addr: 0
|
||||
power_pin: GPIO12
|
||||
|
||||
debug:
|
||||
logger:
|
||||
api:
|
||||
ota:
|
||||
platform: esphome
|
||||
|
||||
button:
|
||||
- platform: restart
|
||||
name: Restart
|
||||
|
||||
time:
|
||||
- platform: homeassistant
|
||||
id: homeassistant_time
|
||||
- platform: sntp
|
||||
id: sntp_time
|
||||
|
||||
sensor:
|
||||
- platform: uptime
|
||||
name: Ethernet Uptime
|
||||
- platform: template
|
||||
name: Free Memory
|
||||
lambda: return heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
|
||||
unit_of_measurement: B
|
||||
state_class: measurement
|
||||
- platform: debug
|
||||
free:
|
||||
name: Heap Free
|
||||
fragmentation:
|
||||
name: Heap Fragmentation
|
||||
min_free:
|
||||
name: Heap Min Free
|
||||
116
tests/benchmarks/python/test_compiled_config_bench.py
Normal file
116
tests/benchmarks/python/test_compiled_config_bench.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""CodSpeed benchmarks for the validated-config cache fast path.
|
||||
|
||||
PR #16381 added a cache that lets ``esphome upload`` / ``esphome logs``
|
||||
skip re-running the full config-validation pipeline. These benchmarks
|
||||
compare the cached path (``load_compiled_config``) against the slow
|
||||
path (``read_config``) on the same input.
|
||||
|
||||
The fixture YAML is a modest bluetooth-proxy device. The two paths
|
||||
end up close on a config this small -- the win grows with config
|
||||
complexity (external components, large package trees, deeply nested
|
||||
schemas), where the slow path can be orders of magnitude slower than
|
||||
the cache load.
|
||||
|
||||
Skipped when ``pytest-codspeed`` isn't installed so the regular
|
||||
unit-test suite keeps working unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.compiled_config import compiled_config_path, load_compiled_config
|
||||
from esphome.config import read_config
|
||||
from esphome.core import CORE
|
||||
from esphome.storage_json import ext_storage_path
|
||||
from esphome.writer import update_storage_json
|
||||
|
||||
pytest.importorskip("pytest_codspeed")
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
FIXTURE_YAML = HERE / "fixtures" / "bluetooth_proxy_device.yaml"
|
||||
|
||||
|
||||
def _stage_yaml(tmp_path: Path) -> Path:
|
||||
"""Copy fixture YAML into a fresh tmp dir.
|
||||
|
||||
Each benchmark gets its own copy so the cache files (under
|
||||
``.esphome/storage/`` next to the YAML) don't bleed between cases.
|
||||
"""
|
||||
target = tmp_path / FIXTURE_YAML.name
|
||||
shutil.copy2(FIXTURE_YAML, target)
|
||||
return target
|
||||
|
||||
|
||||
def _prime_cache(yaml_path: Path) -> None:
|
||||
"""Run full validation once and persist the cache + sidecar.
|
||||
|
||||
Mirrors ``esphome compile``: ``read_config`` populates ``CORE.config``,
|
||||
then ``update_storage_json`` writes both the StorageJSON sidecar and
|
||||
the ``.validated.yaml`` compiled-config cache.
|
||||
"""
|
||||
CORE.config_path = yaml_path
|
||||
config = read_config({}, skip_external_update=True)
|
||||
assert config is not None, f"fixture YAML failed to validate: {yaml_path}"
|
||||
CORE.config = config
|
||||
update_storage_json()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def staged_yaml(tmp_path: Path) -> Path:
|
||||
"""YAML copied into tmp_path; no cache files written yet."""
|
||||
return _stage_yaml(tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def primed_yaml(staged_yaml: Path) -> Path:
|
||||
"""YAML plus a fresh cache + sidecar on disk."""
|
||||
_prime_cache(staged_yaml)
|
||||
assert compiled_config_path(staged_yaml.name).is_file()
|
||||
assert ext_storage_path(staged_yaml.name).is_file()
|
||||
return staged_yaml
|
||||
|
||||
|
||||
def _resetting_setup(
|
||||
yaml_path: Path,
|
||||
args: tuple[Any, ...],
|
||||
kwargs: dict[str, Any],
|
||||
) -> Callable[[], tuple[tuple[Any, ...], dict[str, Any]]]:
|
||||
"""Build a per-iteration setup that resets CORE and re-pins config_path."""
|
||||
|
||||
def setup() -> tuple[tuple[Any, ...], dict[str, Any]]:
|
||||
CORE.reset()
|
||||
CORE.config_path = yaml_path
|
||||
return args, kwargs
|
||||
|
||||
return setup
|
||||
|
||||
|
||||
def test_load_compiled_config_cached(primed_yaml: Path, benchmark) -> None:
|
||||
"""Fast path: deserialize the cached, already-validated config."""
|
||||
benchmark.pedantic(
|
||||
load_compiled_config,
|
||||
setup=_resetting_setup(primed_yaml, (primed_yaml,), {}),
|
||||
rounds=5,
|
||||
iterations=1,
|
||||
)
|
||||
|
||||
|
||||
def test_read_config_uncached(primed_yaml: Path, benchmark) -> None:
|
||||
"""Slow path: full validation pipeline (yaml load + schema + components).
|
||||
|
||||
Uses the same primed fixture as the cached path -- ``read_config``
|
||||
ignores the cache file on disk, so the two benchmarks measure the
|
||||
same input from two different code paths.
|
||||
"""
|
||||
benchmark.pedantic(
|
||||
read_config,
|
||||
setup=_resetting_setup(primed_yaml, ({},), {"skip_external_update": True}),
|
||||
rounds=3,
|
||||
iterations=1,
|
||||
)
|
||||
@@ -16,13 +16,13 @@ TEST(MitsubishiCN105Tests, InitSendsConnectPacket) {
|
||||
ctx.sut.set_current_time(123);
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::NOT_CONNECTED);
|
||||
EXPECT_TRUE(ctx.uart.tx.empty());
|
||||
EXPECT_FALSE(ctx.sut.write_timeout_start_ms_.has_value());
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 0);
|
||||
|
||||
ctx.sut.initialize();
|
||||
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x5A, 0x01, 0x30, 0x02, 0xCA, 0x01, 0xA8));
|
||||
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{123});
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 123);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
|
||||
@@ -32,8 +32,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
|
||||
ctx.uart.tx.clear(); // Remove first connect packet bytes
|
||||
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
|
||||
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{0});
|
||||
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 0);
|
||||
|
||||
// Connect response
|
||||
ctx.uart.push_rx({0xFC, 0x7A, 0x01, 0x30, 0x00, 0x55});
|
||||
@@ -47,21 +46,22 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7B));
|
||||
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{200});
|
||||
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 200);
|
||||
|
||||
// Clear TX bytes.
|
||||
ctx.uart.tx.clear();
|
||||
|
||||
// Settings response
|
||||
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x08, 0x07,
|
||||
0x00, 0x00, 0x00, 0x00, 0x03, 0xB0, 0x00, 0x00, 0x00, 0x00, 0x99});
|
||||
0x00, 0x04, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3C});
|
||||
|
||||
// Settings should still have initial values
|
||||
EXPECT_FALSE(ctx.sut.status().power_on);
|
||||
EXPECT_THAT(ctx.sut.status().target_temperature, ::testing::IsNan());
|
||||
EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::UNKNOWN);
|
||||
EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::UNKNOWN);
|
||||
EXPECT_EQ(ctx.sut.status().vane_mode, MitsubishiCN105::VaneMode::UNKNOWN);
|
||||
EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::UNKNOWN);
|
||||
|
||||
ctx.sut.set_current_time(300);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
@@ -72,13 +72,14 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
|
||||
EXPECT_EQ(ctx.sut.status().target_temperature, 24.0f);
|
||||
EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::AUTO);
|
||||
EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::AUTO);
|
||||
EXPECT_EQ(ctx.sut.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_4);
|
||||
EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::SWING);
|
||||
|
||||
// Now fetch room temperature (0x03)
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7A));
|
||||
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{300});
|
||||
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 300);
|
||||
|
||||
// Clear TX bytes.
|
||||
ctx.uart.tx.clear();
|
||||
@@ -101,8 +102,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
|
||||
|
||||
EXPECT_TRUE(ctx.uart.tx.empty());
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
|
||||
EXPECT_FALSE(ctx.sut.write_timeout_start_ms_.has_value());
|
||||
EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional<uint32_t>{400});
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 400);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) {
|
||||
@@ -115,21 +115,21 @@ TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) {
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
|
||||
EXPECT_TRUE(ctx.uart.tx.empty());
|
||||
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{0});
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 0);
|
||||
|
||||
// Still no response after 1999ms, no retry yet
|
||||
ctx.sut.set_current_time(1999);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
|
||||
EXPECT_TRUE(ctx.uart.tx.empty());
|
||||
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{0});
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 0);
|
||||
|
||||
// Stop waiting after 2s and retry connect
|
||||
ctx.sut.set_current_time(2000);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x5A, 0x01, 0x30, 0x02, 0xCA, 0x01, 0xA8));
|
||||
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{2000});
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 2000);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, RxWatchdogLimitsProcessingPerUpdate) {
|
||||
@@ -233,15 +233,12 @@ TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) {
|
||||
ctx.sut.set_update_interval(2000);
|
||||
ctx.sut.set_current_time(80000);
|
||||
|
||||
// No scheduled status update
|
||||
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
|
||||
|
||||
// Status update completed, schedule next status update
|
||||
ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED;
|
||||
ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE);
|
||||
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
|
||||
EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional<uint32_t>{80000});
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 80000);
|
||||
|
||||
// Wait for update_interval (ms) before doing another status update
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
@@ -257,7 +254,7 @@ TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) {
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_FALSE(ctx.uart.tx.empty());
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
|
||||
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 82000);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedA) {
|
||||
@@ -310,6 +307,30 @@ TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedB) {
|
||||
EXPECT_EQ(ctx.sut.status().room_temperature, 30.0f);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitNotSet) {
|
||||
auto ctx = TestContext{};
|
||||
|
||||
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x58});
|
||||
|
||||
ctx.sut.update();
|
||||
|
||||
EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::CENTER);
|
||||
EXPECT_FALSE(ctx.sut.set_wide_vane_high_bit_);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitSet) {
|
||||
auto ctx = TestContext{};
|
||||
|
||||
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x83, 0x00, 0x00, 0x00, 0x00, 0x00, 0xD8});
|
||||
|
||||
ctx.sut.update();
|
||||
|
||||
EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::CENTER);
|
||||
EXPECT_TRUE(ctx.sut.set_wide_vane_high_bit_);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, ApplySettingsPowerOn) {
|
||||
auto ctx = TestContext{};
|
||||
|
||||
@@ -372,6 +393,37 @@ TEST(MitsubishiCN105Tests, ApplyFanModeSpeed1) {
|
||||
0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x73));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, ApplyVaneModeSwing) {
|
||||
auto ctx = TestContext{};
|
||||
|
||||
ctx.sut.set_vane_mode(MitsubishiCN105::VaneMode::SWING);
|
||||
ctx.sut.apply_settings();
|
||||
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x10, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x66));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitNotSet) {
|
||||
auto ctx = TestContext{};
|
||||
|
||||
ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT);
|
||||
ctx.sut.apply_settings();
|
||||
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x7A));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitSet) {
|
||||
auto ctx = TestContext{};
|
||||
|
||||
ctx.sut.set_wide_vane_high_bit_ = true;
|
||||
ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT);
|
||||
ctx.sut.apply_settings();
|
||||
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x82, 0x00, 0x00, 0xFA));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) {
|
||||
auto ctx = TestContext{};
|
||||
|
||||
@@ -382,14 +434,14 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) {
|
||||
ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED;
|
||||
ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE);
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
|
||||
EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional<uint32_t>{5000});
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 5000);
|
||||
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
|
||||
|
||||
// Nothing to do in update (rx empty, no timeout)
|
||||
ctx.sut.set_current_time(5500);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_TRUE(ctx.uart.tx.empty());
|
||||
EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional<uint32_t>{5000});
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 5000);
|
||||
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
|
||||
|
||||
// Write new values
|
||||
@@ -398,23 +450,22 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) {
|
||||
ctx.sut.set_target_temperature(25.0f);
|
||||
ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT);
|
||||
ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::AUTO);
|
||||
ctx.sut.set_vane_mode(MitsubishiCN105::VaneMode::AUTO);
|
||||
|
||||
// Waiting for next status update must be interrupted and new values send to AC
|
||||
ctx.sut.set_current_time(6000);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
|
||||
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 1000);
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x0F, 0x00, 0x00, 0x01, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB2, 0x00, 0xBB));
|
||||
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x1F, 0x00, 0x00, 0x01, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB2, 0x00, 0xAB));
|
||||
// Write ACK response
|
||||
ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E});
|
||||
ctx.sut.set_current_time(6500);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
|
||||
EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional<uint32_t>{6500 - 1000});
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 6500 - 1000);
|
||||
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
|
||||
}
|
||||
|
||||
@@ -502,7 +553,7 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect)
|
||||
ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED;
|
||||
ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE);
|
||||
ASSERT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
|
||||
ASSERT_EQ(ctx.sut.status_update_start_ms_, std::optional<uint32_t>{5000});
|
||||
ASSERT_EQ(ctx.sut.operation_start_ms_, 5000);
|
||||
ASSERT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
|
||||
|
||||
// Interrupt that wait with a write so credit is accumulated.
|
||||
@@ -514,7 +565,7 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect)
|
||||
ctx.sut.set_current_time(6000);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
ASSERT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
|
||||
ASSERT_FALSE(ctx.sut.status_update_start_ms_.has_value());
|
||||
ASSERT_EQ(ctx.sut.operation_start_ms_, 6000);
|
||||
ASSERT_EQ(ctx.sut.status_update_wait_credit_ms_, 1000);
|
||||
|
||||
// Do not ACK the write. Advance time far enough to force timeout/reconnect
|
||||
@@ -522,8 +573,8 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect)
|
||||
ctx.sut.set_current_time(36000);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_NE(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
|
||||
ASSERT_EQ(ctx.sut.operation_start_ms_, 36000);
|
||||
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
|
||||
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, SetOutOfRangeRemoteRoomTempIsIgnored) {
|
||||
|
||||
@@ -44,9 +44,9 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 {
|
||||
using MitsubishiCN105::State;
|
||||
using MitsubishiCN105::UpdateFlag;
|
||||
using MitsubishiCN105::state_;
|
||||
using MitsubishiCN105::write_timeout_start_ms_;
|
||||
using MitsubishiCN105::status_update_start_ms_;
|
||||
using MitsubishiCN105::operation_start_ms_;
|
||||
using MitsubishiCN105::use_temperature_encoding_b_;
|
||||
using MitsubishiCN105::set_wide_vane_high_bit_;
|
||||
using MitsubishiCN105::status_update_wait_credit_ms_;
|
||||
using MitsubishiCN105::pending_updates_;
|
||||
|
||||
|
||||
@@ -70,6 +70,17 @@ def mock_should_run_device_builder() -> Generator[Mock, None, None]:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_native_idf_components_to_test() -> Generator[Mock, None, None]:
|
||||
"""Mock native_idf_components_to_test from determine_jobs.
|
||||
|
||||
main() drives both the ``native_idf`` boolean output and the
|
||||
``native_idf_components`` CSV from this one function.
|
||||
"""
|
||||
with patch.object(determine_jobs, "native_idf_components_to_test") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]:
|
||||
"""Mock determine_cpp_unit_tests from helpers."""
|
||||
@@ -107,6 +118,7 @@ def test_main_all_tests_should_run(
|
||||
mock_should_run_python_linters: Mock,
|
||||
mock_should_run_import_time: Mock,
|
||||
mock_should_run_device_builder: Mock,
|
||||
mock_native_idf_components_to_test: Mock,
|
||||
mock_changed_files: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
@@ -122,6 +134,7 @@ def test_main_all_tests_should_run(
|
||||
mock_should_run_python_linters.return_value = True
|
||||
mock_should_run_import_time.return_value = True
|
||||
mock_should_run_device_builder.return_value = True
|
||||
mock_native_idf_components_to_test.return_value = ["api", "esp32"]
|
||||
mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"])
|
||||
|
||||
# Mock changed_files to return non-component files (to avoid memory impact)
|
||||
@@ -203,6 +216,8 @@ def test_main_all_tests_should_run(
|
||||
assert output["python_linters"] is True
|
||||
assert output["import_time"] is True
|
||||
assert output["device_builder"] is True
|
||||
assert output["native_idf"] is True
|
||||
assert output["native_idf_components"] == "api,esp32"
|
||||
assert output["changed_components"] == ["wifi", "api", "sensor"]
|
||||
# changed_components_with_tests will only include components that actually have test files
|
||||
assert "changed_components_with_tests" in output
|
||||
@@ -236,6 +251,7 @@ def test_main_no_tests_should_run(
|
||||
mock_should_run_python_linters: Mock,
|
||||
mock_should_run_import_time: Mock,
|
||||
mock_should_run_device_builder: Mock,
|
||||
mock_native_idf_components_to_test: Mock,
|
||||
mock_changed_files: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
@@ -251,6 +267,7 @@ def test_main_no_tests_should_run(
|
||||
mock_should_run_python_linters.return_value = False
|
||||
mock_should_run_import_time.return_value = False
|
||||
mock_should_run_device_builder.return_value = False
|
||||
mock_native_idf_components_to_test.return_value = []
|
||||
mock_determine_cpp_unit_tests.return_value = (False, [])
|
||||
|
||||
# Mock changed_files to return no component files
|
||||
@@ -291,6 +308,8 @@ def test_main_no_tests_should_run(
|
||||
assert output["python_linters"] is False
|
||||
assert output["import_time"] is False
|
||||
assert output["device_builder"] is False
|
||||
assert output["native_idf"] is False
|
||||
assert output["native_idf_components"] == ""
|
||||
assert output["changed_components"] == []
|
||||
assert output["changed_components_with_tests"] == []
|
||||
assert output["component_test_count"] == 0
|
||||
@@ -313,6 +332,7 @@ def test_main_with_branch_argument(
|
||||
mock_should_run_python_linters: Mock,
|
||||
mock_should_run_import_time: Mock,
|
||||
mock_should_run_device_builder: Mock,
|
||||
mock_native_idf_components_to_test: Mock,
|
||||
mock_changed_files: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
@@ -328,6 +348,7 @@ def test_main_with_branch_argument(
|
||||
mock_should_run_python_linters.return_value = True
|
||||
mock_should_run_import_time.return_value = True
|
||||
mock_should_run_device_builder.return_value = True
|
||||
mock_native_idf_components_to_test.return_value = ["esp32"]
|
||||
mock_determine_cpp_unit_tests.return_value = (False, ["mqtt"])
|
||||
|
||||
# Mock changed_files to return non-component files (to avoid memory impact)
|
||||
@@ -366,6 +387,7 @@ def test_main_with_branch_argument(
|
||||
mock_should_run_python_linters.assert_called_once_with("main")
|
||||
mock_should_run_import_time.assert_called_once_with("main")
|
||||
mock_should_run_device_builder.assert_called_once_with("main")
|
||||
mock_native_idf_components_to_test.assert_called_once_with("main")
|
||||
|
||||
# Check output
|
||||
captured = capsys.readouterr()
|
||||
@@ -379,6 +401,8 @@ def test_main_with_branch_argument(
|
||||
assert output["python_linters"] is True
|
||||
assert output["import_time"] is True
|
||||
assert output["device_builder"] is True
|
||||
assert output["native_idf"] is True
|
||||
assert output["native_idf_components"] == "esp32"
|
||||
assert output["changed_components"] == ["mqtt"]
|
||||
# changed_components_with_tests will only include components that actually have test files
|
||||
assert "changed_components_with_tests" in output
|
||||
@@ -827,6 +851,142 @@ def test_should_run_device_builder_skips_beta_release(target_branch: str) -> Non
|
||||
mock_changed.assert_not_called()
|
||||
|
||||
|
||||
_NATIVE_IDF_FULL_LIST_FILES = [
|
||||
# Core C++/Python changes -- caught by core_changed()
|
||||
["esphome/core/component.cpp"],
|
||||
["esphome/core/config.py"],
|
||||
# Native IDF infrastructure paths
|
||||
["esphome/espidf/framework.py"],
|
||||
["esphome/espidf/component.py"],
|
||||
["esphome/espidf/api.py"],
|
||||
["esphome/build_gen/espidf.py"],
|
||||
# Workflow / harness files
|
||||
["script/test_build_components.py"],
|
||||
[".github/workflows/ci.yml"],
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("changed_files", _NATIVE_IDF_FULL_LIST_FILES)
|
||||
def test_native_idf_components_to_test_returns_full_list_on_infrastructure(
|
||||
changed_files: list[str],
|
||||
) -> None:
|
||||
"""Infrastructure / core / harness changes fall back to the full component list."""
|
||||
with (
|
||||
patch.object(determine_jobs, "changed_files", return_value=changed_files),
|
||||
# The dep-closure path shouldn't be consulted at all -- if it is,
|
||||
# the obviously-wrong "wifi" sneaks in and the assertion catches it.
|
||||
patch.object(
|
||||
determine_jobs, "get_components_with_dependencies", return_value=["wifi"]
|
||||
),
|
||||
):
|
||||
result = determine_jobs.native_idf_components_to_test()
|
||||
assert result == sorted(determine_jobs.NATIVE_IDF_TEST_COMPONENTS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changed_files", "dependency_closure", "expected"),
|
||||
[
|
||||
# Single tested component changed -- narrow to just that component.
|
||||
(
|
||||
["esphome/components/esp32/__init__.py"],
|
||||
["esp32"],
|
||||
["esp32"],
|
||||
),
|
||||
# Dependency closure: multiple BLE components in the changed set
|
||||
# are all intersected with the test list and returned sorted.
|
||||
(
|
||||
["esphome/components/esp32_ble/ble.cpp"],
|
||||
["esp32_ble", "esp32_ble_tracker", "ble_scanner"],
|
||||
["ble_scanner", "esp32_ble", "esp32_ble_tracker"],
|
||||
),
|
||||
# api in the test set -- narrow to [api] even though the closure
|
||||
# has other (unrelated to native-IDF coverage) entries.
|
||||
(
|
||||
["esphome/components/api/api_connection.cpp"],
|
||||
["api", "logger"],
|
||||
["api"],
|
||||
),
|
||||
# Components outside the test set return an empty list (job skipped).
|
||||
(
|
||||
["esphome/components/wifi/wifi_component.cpp"],
|
||||
["wifi", "network"],
|
||||
[],
|
||||
),
|
||||
# Pure Python-only change outside trigger paths -> empty.
|
||||
(["esphome/yaml_util.py"], [], []),
|
||||
# Non-IDF files in esphome/build_gen/ do NOT trigger the full
|
||||
# list -- only esphome/build_gen/espidf.py is a trigger.
|
||||
(["esphome/build_gen/platformio.py"], [], []),
|
||||
# Docs / unrelated files -> empty.
|
||||
(["README.md"], [], []),
|
||||
([], [], []),
|
||||
],
|
||||
)
|
||||
def test_native_idf_components_to_test_narrowing(
|
||||
changed_files: list[str],
|
||||
dependency_closure: list[str],
|
||||
expected: list[str],
|
||||
) -> None:
|
||||
"""Component changes narrow the test list to the intersection."""
|
||||
with (
|
||||
patch.object(determine_jobs, "changed_files", return_value=changed_files),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"get_components_with_dependencies",
|
||||
return_value=dependency_closure,
|
||||
),
|
||||
):
|
||||
result = determine_jobs.native_idf_components_to_test()
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_native_idf_components_to_test_with_branch() -> None:
|
||||
"""native_idf_components_to_test passes branch argument through.
|
||||
|
||||
Regression test: an earlier version called ``get_changed_components()``,
|
||||
which silently ignored the branch argument because that helper re-runs
|
||||
``changed_files()`` with its own default. The current implementation
|
||||
derives the closure from ``files = changed_files(branch)`` directly,
|
||||
so a branch arg has to flow through ``changed_files``.
|
||||
"""
|
||||
with (
|
||||
patch.object(determine_jobs, "changed_files") as mock_changed,
|
||||
patch.object(
|
||||
determine_jobs, "get_components_with_dependencies", return_value=[]
|
||||
),
|
||||
):
|
||||
mock_changed.return_value = []
|
||||
determine_jobs.native_idf_components_to_test("release")
|
||||
mock_changed.assert_called_once_with("release")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("components_to_test", "expected"),
|
||||
[
|
||||
([], False),
|
||||
(["esp32"], True),
|
||||
(["esp32", "api"], True),
|
||||
],
|
||||
)
|
||||
def test_should_run_native_idf(components_to_test: list[str], expected: bool) -> None:
|
||||
"""should_run_native_idf is a thin wrapper around the component list."""
|
||||
with patch.object(
|
||||
determine_jobs,
|
||||
"native_idf_components_to_test",
|
||||
return_value=components_to_test,
|
||||
):
|
||||
assert determine_jobs.should_run_native_idf() is expected
|
||||
|
||||
|
||||
def test_should_run_native_idf_with_branch() -> None:
|
||||
"""Test should_run_native_idf passes branch argument through."""
|
||||
with patch.object(
|
||||
determine_jobs, "native_idf_components_to_test", return_value=[]
|
||||
) as mock_inner:
|
||||
determine_jobs.should_run_native_idf("release")
|
||||
mock_inner.assert_called_once_with("release")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changed_files", "expected_result"),
|
||||
[
|
||||
|
||||
159
tests/unit_tests/build_gen/test_espidf.py
Normal file
159
tests/unit_tests/build_gen/test_espidf.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Tests for esphome.build_gen.espidf module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.esp32 import (
|
||||
KEY_COMPONENTS,
|
||||
KEY_ESP32,
|
||||
KEY_PATH,
|
||||
KEY_REF,
|
||||
KEY_REPO,
|
||||
)
|
||||
from esphome.const import KEY_CORE
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_core(tmp_path: Path) -> None:
|
||||
"""Give each test its own CORE.build_path and a clean esp32 data slot."""
|
||||
CORE.build_path = str(tmp_path)
|
||||
CORE.data.setdefault(KEY_CORE, {})
|
||||
CORE.data[KEY_ESP32] = {KEY_COMPONENTS: {}}
|
||||
|
||||
|
||||
def _write_project_description(tmp_path: Path, components: dict[str, str]) -> None:
|
||||
"""Stub a project_description.json with the given component_name -> dir map."""
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir(exist_ok=True)
|
||||
(build_dir / "project_description.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"build_component_info": {
|
||||
name: {"dir": dir_} for name, dir_ in components.items()
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_get_available_components_returns_none_without_build_path() -> None:
|
||||
"""No build_path set yet: must not raise on Path(None)."""
|
||||
CORE.build_path = None
|
||||
from esphome.build_gen.espidf import get_available_components
|
||||
|
||||
assert get_available_components() is None
|
||||
|
||||
|
||||
def test_get_available_components_returns_none_without_project_description(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from esphome.build_gen.espidf import get_available_components
|
||||
|
||||
assert get_available_components() is None
|
||||
|
||||
|
||||
def test_get_available_components_filters_src_managed_and_pio(tmp_path: Path) -> None:
|
||||
"""Built-ins are returned; src/, managed_components/, pio_components/ skipped."""
|
||||
_write_project_description(
|
||||
tmp_path,
|
||||
{
|
||||
"src": f"{tmp_path}/src",
|
||||
"esp_lcd": "/idf/components/esp_lcd",
|
||||
"espressif__arduino-esp32": f"{tmp_path}/managed_components/arduino",
|
||||
"JPEGDEC": f"{tmp_path}/pio_components/arduino/abc/bitbank2/JPEGDEC",
|
||||
"freertos": "/idf/components/freertos",
|
||||
},
|
||||
)
|
||||
from esphome.build_gen.espidf import get_available_components
|
||||
|
||||
assert sorted(get_available_components()) == ["esp_lcd", "freertos"]
|
||||
|
||||
|
||||
def test_get_project_cmakelists_minimal_omits_builtin_components_property(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Minimal write must not emit ESPHOME_PROJECT_BUILTIN_COMPONENTS even
|
||||
when project_description.json exists (the data may be stale on the
|
||||
first write before the discovery pass refreshes it)."""
|
||||
_write_project_description(tmp_path, {"esp_lcd": "/idf/components/esp_lcd"})
|
||||
|
||||
with (
|
||||
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
|
||||
patch.object(CORE, "name", "test"),
|
||||
):
|
||||
from esphome.build_gen.espidf import get_project_cmakelists
|
||||
|
||||
content = get_project_cmakelists(minimal=True)
|
||||
|
||||
assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS" not in content
|
||||
|
||||
|
||||
def test_get_project_cmakelists_full_emits_builtin_components_property(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Non-minimal write emits one idf_build_set_property line per built-in,
|
||||
sorted, and excludes src/managed/pio components."""
|
||||
_write_project_description(
|
||||
tmp_path,
|
||||
{
|
||||
"src": f"{tmp_path}/src",
|
||||
"esp_lcd": "/idf/components/esp_lcd",
|
||||
"freertos": "/idf/components/freertos",
|
||||
"espressif__esp-dsp": f"{tmp_path}/managed_components/esp-dsp",
|
||||
"JPEGDEC": f"{tmp_path}/pio_components/arduino/abc/bitbank2/JPEGDEC",
|
||||
},
|
||||
)
|
||||
|
||||
with (
|
||||
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
|
||||
patch.object(CORE, "name", "test"),
|
||||
):
|
||||
from esphome.build_gen.espidf import get_project_cmakelists
|
||||
|
||||
content = get_project_cmakelists(minimal=False)
|
||||
|
||||
assert (
|
||||
"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS esp_lcd APPEND)"
|
||||
in content
|
||||
)
|
||||
assert (
|
||||
"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS freertos APPEND)"
|
||||
in content
|
||||
)
|
||||
# Excluded by get_available_components filtering.
|
||||
assert "espressif__esp-dsp APPEND" not in content
|
||||
assert "JPEGDEC APPEND" not in content
|
||||
|
||||
|
||||
def test_get_project_cmakelists_emits_managed_components_property(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""ESPHOME_PROJECT_MANAGED_COMPONENTS is always emitted (both modes)
|
||||
from the esp32 add_idf_component registry."""
|
||||
CORE.data[KEY_ESP32][KEY_COMPONENTS] = {
|
||||
"espressif/esp-dsp": {KEY_REPO: None, KEY_REF: "1.7.1", KEY_PATH: None},
|
||||
"espressif/arduino-esp32": {KEY_REPO: None, KEY_REF: "3.3.8", KEY_PATH: None},
|
||||
}
|
||||
|
||||
with (
|
||||
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
|
||||
patch.object(CORE, "name", "test"),
|
||||
):
|
||||
from esphome.build_gen.espidf import get_project_cmakelists
|
||||
|
||||
for minimal in (True, False):
|
||||
content = get_project_cmakelists(minimal=minimal)
|
||||
assert (
|
||||
"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS"
|
||||
" espressif__arduino-esp32 APPEND)"
|
||||
) in content
|
||||
assert (
|
||||
"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS"
|
||||
" espressif__esp-dsp APPEND)"
|
||||
) in content
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -21,7 +22,6 @@ from esphome.espidf.component import (
|
||||
_check_library_data,
|
||||
_collect_filtered_files,
|
||||
_convert_library_to_component,
|
||||
_detect_requires,
|
||||
_parse_library_json,
|
||||
_parse_library_properties,
|
||||
_process_dependencies,
|
||||
@@ -83,19 +83,6 @@ def test_collect_filtered_files_exclude(tmp_path):
|
||||
assert str(f2) not in result
|
||||
|
||||
|
||||
def test_detect_requires(tmp_path):
|
||||
f = tmp_path / "main.c"
|
||||
f.write_text('#include "mbedtls/foo.h"')
|
||||
|
||||
result = _detect_requires([str(f)])
|
||||
assert "mbedtls" in result
|
||||
|
||||
|
||||
def test_detect_requires_ignores_invalid_file(tmp_path):
|
||||
result = _detect_requires([str(tmp_path / "missing.c")])
|
||||
assert result == set()
|
||||
|
||||
|
||||
def test_split_list_by_condition():
|
||||
items = ["-Iinclude", "-Llib", "-Wall"]
|
||||
|
||||
@@ -142,7 +129,7 @@ def test_generate_cmakelists_txt_with_flags(tmp_component, tmp_path):
|
||||
== f"""idf_component_register(
|
||||
SRCS "src{sep}main.c"
|
||||
INCLUDE_DIRS "src"
|
||||
REQUIRES dep
|
||||
REQUIRES dep ${{ESPHOME_PROJECT_MANAGED_COMPONENTS}} ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}}
|
||||
)
|
||||
target_compile_options(${{COMPONENT_LIB}} PUBLIC
|
||||
"-DTEST"
|
||||
@@ -160,6 +147,58 @@ target_link_libraries(${{COMPONENT_LIB}} INTERFACE
|
||||
)
|
||||
|
||||
|
||||
def test_generate_cmakelists_txt_references_project_managed_components_variable(
|
||||
tmp_component: IDFComponent,
|
||||
) -> None:
|
||||
# The CMakeLists is cached under pio_components/<hash>/ and shared
|
||||
# across projects, so the project-managed REQUIRES list is exposed via
|
||||
# a CMake variable expanded at configure time rather than baked here.
|
||||
src_dir = tmp_component.path / "src"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "main.c").write_text("int main() {}")
|
||||
tmp_component.data = {}
|
||||
|
||||
content = generate_cmakelists_txt(tmp_component)
|
||||
assert "${ESPHOME_PROJECT_MANAGED_COMPONENTS}" in content
|
||||
|
||||
|
||||
def test_generate_idf_component_overwrites_bundled_files(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
esp32_idf_core: None,
|
||||
) -> None:
|
||||
# A library that ships its own CMakeLists.txt + idf_component.yml must
|
||||
# have both replaced by ESPHome's generated content. Library authors'
|
||||
# bundled IDF metadata is frequently broken (bogus REQUIRES, hard-coded
|
||||
# frameworks), so we always regenerate from library.json.
|
||||
from esphome.espidf.component import _generate_idf_component
|
||||
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "main.cpp").write_text("// dummy\n")
|
||||
(tmp_path / "library.json").write_text(json.dumps({"name": "tripwire-lib"}))
|
||||
(tmp_path / "CMakeLists.txt").write_text("# TRIPWIRE_BUNDLED_CMAKELISTS\n")
|
||||
(tmp_path / "idf_component.yml").write_text("# TRIPWIRE_BUNDLED_MANIFEST\n")
|
||||
|
||||
fake_component = IDFComponent(
|
||||
"owner/tripwire-lib", "1.0.0", source=URLSource("http://dummy")
|
||||
)
|
||||
fake_component.path = tmp_path
|
||||
monkeypatch.setattr(
|
||||
esphome.espidf.component,
|
||||
"_convert_library_to_component",
|
||||
lambda _lib: fake_component,
|
||||
)
|
||||
monkeypatch.setattr(fake_component, "download", lambda force=False: None)
|
||||
|
||||
_generate_idf_component(Library("owner/tripwire-lib", "1.0.0", None))
|
||||
|
||||
cml = (tmp_path / "CMakeLists.txt").read_text()
|
||||
manifest = (tmp_path / "idf_component.yml").read_text()
|
||||
assert "TRIPWIRE_BUNDLED_CMAKELISTS" not in cml
|
||||
assert "TRIPWIRE_BUNDLED_MANIFEST" not in manifest
|
||||
assert "idf_component_register" in cml
|
||||
|
||||
|
||||
def test_generate_idf_component_yml_basic(tmp_component):
|
||||
tmp_component.data = {"description": "test", "repository": {"url": "http://aaa"}}
|
||||
result = generate_idf_component_yml(tmp_component)
|
||||
@@ -187,27 +226,6 @@ dependencies:
|
||||
)
|
||||
|
||||
|
||||
def test_generate_idf_component_yml_arduino_registry_dep(tmp_component):
|
||||
# Synthetic arduino-esp32 dep with no source / no path: should emit a
|
||||
# version-only entry so the IDF component manager resolves it from the
|
||||
# registry instead of via git.
|
||||
dep = IDFComponent("espressif/arduino-esp32", "3.3.8", source=None)
|
||||
|
||||
tmp_component.dependencies = [dep]
|
||||
tmp_component.data = {}
|
||||
|
||||
result = generate_idf_component_yml(tmp_component)
|
||||
|
||||
assert (
|
||||
result
|
||||
== """version: 1.0.0
|
||||
dependencies:
|
||||
espressif/arduino-esp32:
|
||||
version: 3.3.8
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def test_generate_idf_component_yml_missing_path_reraises(tmp_component):
|
||||
# A dep without a path and without a recognised source should re-raise
|
||||
# the underlying RuntimeError instead of silently producing a bad manifest.
|
||||
@@ -250,14 +268,126 @@ def test_check_library_data_invalid_framework(esp32_idf_core):
|
||||
_check_library_data({"platforms": "*", "frameworks": ["other"]})
|
||||
|
||||
|
||||
def test_extra_script_logs_warning(caplog, esp32_idf_core):
|
||||
extra_script = "myscript.sh"
|
||||
def test_extra_script_captures_libpath_libs_and_defines(tmp_path):
|
||||
from esphome.espidf.extra_script import captured_as_build_flags, run_extra_script
|
||||
|
||||
(tmp_path / "src" / "esp32").mkdir(parents=True)
|
||||
script = tmp_path / "extra_script.py"
|
||||
script.write_text(
|
||||
"Import('env')\n"
|
||||
"mcu = env.get('BOARD_MCU')\n"
|
||||
"env.Append(\n"
|
||||
" LIBPATH=[join('src', mcu)],\n"
|
||||
" LIBS=['algobsec'],\n"
|
||||
" CPPDEFINES=['FOO', ('BAR', '1')],\n"
|
||||
" LINKFLAGS=['-Wl,--gc-sections'],\n"
|
||||
")\n"
|
||||
)
|
||||
# The script uses bare ``join`` (PIO's extra-scripts run inside SCons
|
||||
# where this is in scope). Inject it via the script header so the
|
||||
# shim's exec namespace can resolve it.
|
||||
script.write_text("from os.path import join\n" + script.read_text())
|
||||
|
||||
result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32")
|
||||
|
||||
assert result.libpath == [os.path.join("src", "esp32")]
|
||||
assert result.libs == ["algobsec"]
|
||||
assert ("BAR", "1") in result.cppdefines
|
||||
assert "FOO" in result.cppdefines
|
||||
assert result.linkflags == ["-Wl,--gc-sections"]
|
||||
|
||||
flags = captured_as_build_flags(result, library_dir=tmp_path)
|
||||
sep = os.sep
|
||||
assert f"-Lsrc{sep}esp32" in flags
|
||||
assert "-lalgobsec" in flags
|
||||
assert "-DFOO" in flags
|
||||
assert "-DBAR=1" in flags
|
||||
assert "-Wl,--gc-sections" in flags
|
||||
|
||||
|
||||
def test_extra_script_libpath_relative_resolves_against_library_dir(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""Relative LIBPATH entries must resolve against ``library_dir``, not the
|
||||
caller's CWD (the shim restores CWD before ``captured_as_build_flags``
|
||||
runs)."""
|
||||
from esphome.espidf.extra_script import ExtraScriptResult, captured_as_build_flags
|
||||
|
||||
(tmp_path / "lib" / "esp32").mkdir(parents=True)
|
||||
elsewhere = tmp_path.parent / "not_the_library_dir"
|
||||
elsewhere.mkdir(exist_ok=True)
|
||||
monkeypatch.chdir(elsewhere)
|
||||
|
||||
result = ExtraScriptResult(libpath=["lib/esp32"])
|
||||
flags = captured_as_build_flags(result, library_dir=tmp_path)
|
||||
|
||||
sep = os.sep
|
||||
assert flags == [f"-Llib{sep}esp32"]
|
||||
|
||||
|
||||
def test_extra_script_libpath_absolute_outside_library_dir(tmp_path):
|
||||
from esphome.espidf.extra_script import ExtraScriptResult, captured_as_build_flags
|
||||
|
||||
outside = tmp_path.parent / "system_lib"
|
||||
outside.mkdir(exist_ok=True)
|
||||
result = ExtraScriptResult(libpath=[str(outside)])
|
||||
|
||||
flags = captured_as_build_flags(result, library_dir=tmp_path)
|
||||
assert flags == [f"-L{outside.resolve()}"]
|
||||
|
||||
|
||||
def test_extra_script_failure_returns_empty_result(tmp_path, caplog):
|
||||
from esphome.espidf.extra_script import run_extra_script
|
||||
|
||||
script = tmp_path / "broken.py"
|
||||
script.write_text("raise RuntimeError('boom')\n")
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
_check_library_data({"build": {"extraScript": extra_script}})
|
||||
result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32")
|
||||
|
||||
assert "not supported" in caplog.text
|
||||
assert "myscript.sh" in caplog.text
|
||||
assert result.libpath == []
|
||||
assert result.libs == []
|
||||
assert "broken.py" in caplog.text
|
||||
|
||||
|
||||
def test_apply_extra_script_path_traversal_is_rejected(tmp_path):
|
||||
from esphome.espidf.component import _apply_extra_script
|
||||
|
||||
library_dir = tmp_path / "lib"
|
||||
library_dir.mkdir()
|
||||
outside = tmp_path / "evil.py"
|
||||
outside.write_text("env.Append(LIBS=['pwned'])\n")
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = library_dir
|
||||
c.data = {"build": {"extraScript": "../evil.py"}}
|
||||
|
||||
_apply_extra_script(c)
|
||||
|
||||
# Nothing was folded into flags: the traversal was rejected before
|
||||
# the script could run.
|
||||
assert "flags" not in c.data["build"]
|
||||
|
||||
|
||||
def test_apply_extra_script_merges_into_existing_flags(tmp_path, monkeypatch):
|
||||
from esphome.components import esp32 as esp32_module
|
||||
|
||||
monkeypatch.setattr(esp32_module, "get_esp32_variant", lambda: "ESP32")
|
||||
|
||||
from esphome.espidf.component import _apply_extra_script
|
||||
|
||||
(tmp_path / "src").mkdir()
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("env.Append(LIBS=['algobsec'])\n")
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "extra.py", "flags": ["-DEXISTING"]}}
|
||||
|
||||
_apply_extra_script(c)
|
||||
|
||||
assert "-DEXISTING" in c.data["build"]["flags"]
|
||||
assert "-lalgobsec" in c.data["build"]["flags"]
|
||||
|
||||
|
||||
def test_parse_library_json(tmp_path):
|
||||
|
||||
@@ -443,6 +443,14 @@ def test_clean_build(
|
||||
dependencies_lock = tmp_path / "dependencies.lock"
|
||||
dependencies_lock.write_text("lock file")
|
||||
|
||||
# Native ESP-IDF toolchain artifacts.
|
||||
idf_build_dir = tmp_path / "build"
|
||||
idf_build_dir.mkdir()
|
||||
(idf_build_dir / "CMakeCache.txt").write_text("cache")
|
||||
managed_components_dir = tmp_path / "managed_components"
|
||||
managed_components_dir.mkdir()
|
||||
(managed_components_dir / "espressif__arduino-esp32").mkdir()
|
||||
|
||||
# Create PlatformIO cache directory
|
||||
platformio_cache_dir = tmp_path / ".platformio" / ".cache"
|
||||
platformio_cache_dir.mkdir(parents=True)
|
||||
@@ -456,11 +464,14 @@ def test_clean_build(
|
||||
mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir
|
||||
mock_core.relative_build_path.return_value = dependencies_lock
|
||||
mock_core.platformio_cache_dir = str(platformio_cache_dir)
|
||||
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
|
||||
|
||||
# Verify all exist before
|
||||
assert pioenvs_dir.exists()
|
||||
assert piolibdeps_dir.exists()
|
||||
assert dependencies_lock.exists()
|
||||
assert idf_build_dir.exists()
|
||||
assert managed_components_dir.exists()
|
||||
assert platformio_cache_dir.exists()
|
||||
|
||||
# Mock PlatformIO's ProjectConfig cache_dir
|
||||
@@ -483,6 +494,8 @@ def test_clean_build(
|
||||
assert not pioenvs_dir.exists()
|
||||
assert not piolibdeps_dir.exists()
|
||||
assert not dependencies_lock.exists()
|
||||
assert not idf_build_dir.exists()
|
||||
assert not managed_components_dir.exists()
|
||||
assert not platformio_cache_dir.exists()
|
||||
|
||||
# Verify logging
|
||||
@@ -490,6 +503,8 @@ def test_clean_build(
|
||||
assert ".pioenvs" in caplog.text
|
||||
assert ".piolibdeps" in caplog.text
|
||||
assert "dependencies.lock" in caplog.text
|
||||
assert str(idf_build_dir) in caplog.text
|
||||
assert str(managed_components_dir) in caplog.text
|
||||
assert "PlatformIO cache" in caplog.text
|
||||
|
||||
|
||||
@@ -511,7 +526,7 @@ def test_clean_build_partial_exists(
|
||||
# Setup mocks
|
||||
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
|
||||
mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir
|
||||
mock_core.relative_build_path.return_value = dependencies_lock
|
||||
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
|
||||
|
||||
# Verify only pioenvs exists
|
||||
assert pioenvs_dir.exists()
|
||||
@@ -548,7 +563,7 @@ def test_clean_build_nothing_exists(
|
||||
# Setup mocks
|
||||
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
|
||||
mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir
|
||||
mock_core.relative_build_path.return_value = dependencies_lock
|
||||
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
|
||||
|
||||
# Verify nothing exists
|
||||
assert not pioenvs_dir.exists()
|
||||
@@ -584,7 +599,7 @@ def test_clean_build_platformio_not_available(
|
||||
# Setup mocks
|
||||
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
|
||||
mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir
|
||||
mock_core.relative_build_path.return_value = dependencies_lock
|
||||
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
|
||||
|
||||
# Verify all exist before
|
||||
assert pioenvs_dir.exists()
|
||||
@@ -622,7 +637,7 @@ def test_clean_build_empty_cache_dir(
|
||||
# Setup mocks
|
||||
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
|
||||
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
|
||||
mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock"
|
||||
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
|
||||
|
||||
# Verify pioenvs exists before
|
||||
assert pioenvs_dir.exists()
|
||||
@@ -1351,7 +1366,7 @@ def test_clean_build_handles_readonly_files(
|
||||
# Setup mocks
|
||||
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
|
||||
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
|
||||
mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock"
|
||||
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
|
||||
|
||||
# Verify file is read-only
|
||||
assert not os.access(readonly_file, os.W_OK)
|
||||
@@ -1415,7 +1430,7 @@ def test_clean_build_reraises_for_other_errors(
|
||||
# Setup mocks
|
||||
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
|
||||
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
|
||||
mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock"
|
||||
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
|
||||
|
||||
try:
|
||||
# Mock os.access in writer module to return True (writable)
|
||||
|
||||
@@ -390,6 +390,21 @@ def test_track_yaml_loads_cleanup_on_exception(tmp_path: Path) -> None:
|
||||
assert len(yaml_util._load_listeners) == before
|
||||
|
||||
|
||||
def test_track_yaml_loads_no_duplicate_load_on_top_level_include_failure(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A failed top-level !include must not record any file twice in track_yaml_loads."""
|
||||
main = tmp_path / "main.yaml"
|
||||
main.write_text("!include missing.yaml\n")
|
||||
|
||||
with yaml_util.track_yaml_loads() as loaded, pytest.raises(EsphomeError):
|
||||
yaml_util.load_yaml(main)
|
||||
|
||||
assert len(loaded) == len(set(loaded)), (
|
||||
f"Files loaded more than once during a failed top-level include: {loaded}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user