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

This commit is contained in:
J. Nick Koston
2026-05-13 21:53:17 -07:00
34 changed files with 1534 additions and 365 deletions

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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;
}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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(),
)

View File

@@ -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):

View File

@@ -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)

View File

@@ -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));

View File

@@ -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};

View File

@@ -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"

View File

@@ -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 = (

View File

@@ -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,

View 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

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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),

View File

View 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()

View 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

View 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,
)

View File

@@ -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) {

View File

@@ -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_;

View File

@@ -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"),
[

View 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

View File

@@ -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):

View File

@@ -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)

View File

@@ -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",
[