diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 4901c0ccac..2cd4319325 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -049d60eed541730efaa4c0dc5d337b4287bf29b6daa350b5dfc1f23915f1c52f +d7693a1e996cacd4a3d1c9a16336799c2a8cc3db02e4e74084151ce964581248 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0363b5afdf..163e9ab9ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,8 +114,7 @@ jobs: matrix: python-version: - "3.11" - - "3.12" - - "3.13" + - "3.14" os: - ubuntu-latest - macOS-latest @@ -124,13 +123,9 @@ jobs: # Minimize CI resource usage # by only running the Python version # version used for docker images on Windows and macOS - - python-version: "3.13" + - python-version: "3.14" os: windows-latest - - python-version: "3.12" - os: windows-latest - - python-version: "3.13" - os: macOS-latest - - python-version: "3.12" + - python-version: "3.14" os: macOS-latest runs-on: ${{ matrix.os }} needs: @@ -178,6 +173,7 @@ jobs: python-linters: ${{ steps.determine.outputs.python-linters }} 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 }} component-test-count: ${{ steps.determine.outputs.component-test-count }} steps: - name: Check out code from GitHub @@ -206,6 +202,7 @@ jobs: echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $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 echo "component-test-count=$(echo "$output" | jq -r '.component_test_count')" >> $GITHUB_OUTPUT integration-tests: @@ -358,48 +355,13 @@ jobs: # yamllint disable-line rule:line-length if: always() - test-build-components: - name: Component test ${{ matrix.file }} - runs-on: ubuntu-24.04 - needs: - - common - - determine-jobs - if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) > 0 && fromJSON(needs.determine-jobs.outputs.component-test-count) < 100 - strategy: - fail-fast: false - max-parallel: 2 - matrix: - file: ${{ fromJson(needs.determine-jobs.outputs.changed-components-with-tests) }} - steps: - - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3 - with: - packages: libsdl2-dev - version: 1.0 - - - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - name: Restore Python - uses: ./.github/actions/restore-python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - cache-key: ${{ needs.common.outputs.cache-key }} - - name: Validate config for ${{ matrix.file }} - run: | - . venv/bin/activate - python3 script/test_build_components.py -e config -c ${{ matrix.file }} - - name: Compile config for ${{ matrix.file }} - run: | - . venv/bin/activate - python3 script/test_build_components.py -e compile -c ${{ matrix.file }} - test-build-components-splitter: name: Split components for intelligent grouping (40 weighted per batch) runs-on: ubuntu-24.04 needs: - common - determine-jobs - if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) >= 100 + if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) > 0 outputs: matrix: ${{ steps.split.outputs.components }} steps: @@ -417,9 +379,10 @@ jobs: # Use intelligent splitter that groups components with same bus configs components='${{ needs.determine-jobs.outputs.changed-components-with-tests }}' + directly_changed='${{ needs.determine-jobs.outputs.directly-changed-components-with-tests }}' echo "Splitting components intelligently..." - output=$(python3 script/split_components_for_ci.py --components "$components" --batch-size 40 --output github) + output=$(python3 script/split_components_for_ci.py --components "$components" --directly-changed "$directly_changed" --batch-size 40 --output github) echo "$output" >> $GITHUB_OUTPUT @@ -430,7 +393,7 @@ jobs: - common - determine-jobs - test-build-components-splitter - if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) >= 100 + if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) > 0 strategy: fail-fast: false max-parallel: ${{ (github.base_ref == 'beta' || github.base_ref == 'release') && 8 || 4 }} @@ -477,18 +440,34 @@ jobs: # Convert space-separated components to comma-separated for Python script components_csv=$(echo "${{ matrix.components }}" | tr ' ' ',') - echo "Testing components: $components_csv" + # Only isolate directly changed components when targeting dev branch + # For beta/release branches, group everything for faster CI + # + # WHY ISOLATE DIRECTLY CHANGED COMPONENTS? + # - Isolated tests run WITHOUT --testing-mode, enabling full validation + # - This catches pin conflicts and other issues in directly changed code + # - Grouped tests use --testing-mode to allow config merging (disables some checks) + # - Dependencies are safe to group since they weren't modified in this PR + if [ "${{ github.base_ref }}" = "beta" ] || [ "${{ github.base_ref }}" = "release" ]; then + directly_changed_csv="" + echo "Testing components: $components_csv" + echo "Target branch: ${{ github.base_ref }} - grouping all components" + else + directly_changed_csv=$(echo '${{ needs.determine-jobs.outputs.directly-changed-components-with-tests }}' | jq -r 'join(",")') + echo "Testing components: $components_csv" + echo "Target branch: ${{ github.base_ref }} - isolating directly changed components: $directly_changed_csv" + fi echo "" - # Run config validation with grouping - python3 script/test_build_components.py -e config -c "$components_csv" -f + # Run config validation with grouping and isolation + python3 script/test_build_components.py -e config -c "$components_csv" -f --isolate "$directly_changed_csv" echo "" echo "Config validation passed! Starting compilation..." echo "" - # Run compilation with grouping - python3 script/test_build_components.py -e compile -c "$components_csv" -f + # Run compilation with grouping and isolation + python3 script/test_build_components.py -e compile -c "$components_csv" -f --isolate "$directly_changed_csv" pre-commit-ci-lite: name: pre-commit.ci lite @@ -521,7 +500,6 @@ jobs: - integration-tests - clang-tidy - determine-jobs - - test-build-components - test-build-components-splitter - test-build-components-split - pre-commit-ci-lite diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 1ce2321bee..a5f0fbe32f 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -16,7 +16,9 @@ #include "bluetooth_connection.h" +#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include +#endif #include namespace esphome::bluetooth_proxy { diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 56ab2eda88..92f5c57638 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -324,7 +324,7 @@ def _is_framework_url(source: str) -> str: # The default/recommended arduino framework version # - https://github.com/espressif/arduino-esp32/releases ARDUINO_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(3, 2, 1), + "recommended": cv.Version(3, 3, 2), "latest": cv.Version(3, 3, 2), "dev": cv.Version(3, 3, 2), } @@ -343,7 +343,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # The default/recommended esp-idf framework version # - https://github.com/espressif/esp-idf/releases ESP_IDF_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(5, 4, 2), + "recommended": cv.Version(5, 5, 1), "latest": cv.Version(5, 5, 1), "dev": cv.Version(5, 5, 1), } @@ -363,7 +363,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { # The platform-espressif32 version # - https://github.com/pioarduino/platform-espressif32/releases PLATFORM_VERSION_LOOKUP = { - "recommended": cv.Version(54, 3, 21, "2"), + "recommended": cv.Version(55, 3, 31, "1"), "latest": cv.Version(55, 3, 31, "1"), "dev": cv.Version(55, 3, 31, "1"), } @@ -544,6 +544,7 @@ CONF_ENABLE_LWIP_MDNS_QUERIES = "enable_lwip_mdns_queries" CONF_ENABLE_LWIP_BRIDGE_INTERFACE = "enable_lwip_bridge_interface" CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING = "enable_lwip_tcpip_core_locking" CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY = "enable_lwip_check_thread_safety" +CONF_DISABLE_LIBC_LOCKS_IN_IRAM = "disable_libc_locks_in_iram" def _validate_idf_component(config: ConfigType) -> ConfigType: @@ -606,6 +607,9 @@ FRAMEWORK_SCHEMA = cv.All( cv.Optional( CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY, default=True ): cv.boolean, + cv.Optional( + CONF_DISABLE_LIBC_LOCKS_IN_IRAM, default=True + ): cv.boolean, cv.Optional(CONF_EXECUTE_FROM_PSRAM): cv.boolean, } ), @@ -864,6 +868,12 @@ async def to_code(config): if advanced.get(CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY, True): add_idf_sdkconfig_option("CONFIG_LWIP_CHECK_THREAD_SAFETY", True) + # Disable placing libc locks in IRAM to save RAM + # This is safe for ESPHome since no IRAM ISRs (interrupts that run while cache is disabled) + # use libc lock APIs. Saves approximately 1.3KB (1,356 bytes) of IRAM. + if advanced.get(CONF_DISABLE_LIBC_LOCKS_IN_IRAM, True): + add_idf_sdkconfig_option("CONFIG_LIBC_LOCKS_PLACE_IN_IRAM", False) + cg.add_platformio_option("board_build.partitions", "partitions.csv") if CONF_PARTITIONS in config: add_extra_build_file( diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py index 5f039492c8..cbb314650a 100644 --- a/esphome/components/esp32/boards.py +++ b/esphome/components/esp32/boards.py @@ -1564,6 +1564,10 @@ BOARDS = { "name": "DFRobot Beetle ESP32-C3", "variant": VARIANT_ESP32C3, }, + "dfrobot_firebeetle2_esp32c6": { + "name": "DFRobot FireBeetle 2 ESP32-C6", + "variant": VARIANT_ESP32C6, + }, "dfrobot_firebeetle2_esp32e": { "name": "DFRobot Firebeetle 2 ESP32-E", "variant": VARIANT_ESP32, @@ -1604,6 +1608,22 @@ BOARDS = { "name": "Ai-Thinker ESP-C3-M1-I-Kit", "variant": VARIANT_ESP32C3, }, + "esp32-c5-devkitc-1": { + "name": "Espressif ESP32-C5-DevKitC-1 4MB no PSRAM", + "variant": VARIANT_ESP32C5, + }, + "esp32-c5-devkitc1-n16r4": { + "name": "Espressif ESP32-C5-DevKitC-1 N16R4 (16 MB Flash Quad, 4 MB PSRAM Quad)", + "variant": VARIANT_ESP32C5, + }, + "esp32-c5-devkitc1-n4": { + "name": "Espressif ESP32-C5-DevKitC-1 N4 (4MB no PSRAM)", + "variant": VARIANT_ESP32C5, + }, + "esp32-c5-devkitc1-n8r4": { + "name": "Espressif ESP32-C5-DevKitC-1 N8R4 (8 MB Flash Quad, 4 MB PSRAM Quad)", + "variant": VARIANT_ESP32C5, + }, "esp32-c6-devkitc-1": { "name": "Espressif ESP32-C6-DevKitC-1", "variant": VARIANT_ESP32C6, @@ -2048,6 +2068,10 @@ BOARDS = { "name": "M5Stack Station", "variant": VARIANT_ESP32, }, + "m5stack-tab5-p4": { + "name": "M5STACK Tab5 esp32-p4 Board", + "variant": VARIANT_ESP32P4, + }, "m5stack-timer-cam": { "name": "M5Stack Timer CAM", "variant": VARIANT_ESP32, @@ -2476,6 +2500,10 @@ BOARDS = { "name": "YelloByte YB-ESP32-S3-AMP (Rev.3)", "variant": VARIANT_ESP32S3, }, + "yb_esp32s3_drv": { + "name": "YelloByte YB-ESP32-S3-DRV", + "variant": VARIANT_ESP32S3, + }, "yb_esp32s3_eth": { "name": "YelloByte YB-ESP32-S3-ETH", "variant": VARIANT_ESP32S3, diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index fc8c65a2f4..246ebf0729 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -387,6 +387,15 @@ def final_validation(config): max_connections = config.get(CONF_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTIONS) validate_connection_slots(max_connections) + # Check if hosted bluetooth is being used + if "esp32_hosted" in full_config: + add_idf_sdkconfig_option("CONFIG_BT_CLASSIC_ENABLED", False) + add_idf_sdkconfig_option("CONFIG_BT_BLE_ENABLED", True) + add_idf_sdkconfig_option("CONFIG_BT_BLUEDROID_ENABLED", True) + add_idf_sdkconfig_option("CONFIG_BT_CONTROLLER_DISABLED", True) + add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID", True) + add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_BLUEDROID_HCI_VHCI", True) + # Check if BLE Server is needed has_ble_server = "esp32_ble_server" in full_config diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 9f8ca1d149..5bbd5fe9ed 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -6,7 +6,15 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include +#else +extern "C" { +#include +#include +#include +} +#endif #include #include #include @@ -136,6 +144,7 @@ void ESP32BLE::advertising_init_() { bool ESP32BLE::ble_setup_() { esp_err_t err; +#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #ifdef USE_ARDUINO if (!btStart()) { ESP_LOGE(TAG, "btStart failed: %d", esp_bt_controller_get_status()); @@ -169,6 +178,28 @@ bool ESP32BLE::ble_setup_() { #endif esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT); +#else + esp_hosted_connect_to_slave(); // NOLINT + + if (esp_hosted_bt_controller_init() != ESP_OK) { + ESP_LOGW(TAG, "esp_hosted_bt_controller_init failed"); + return false; + } + + if (esp_hosted_bt_controller_enable() != ESP_OK) { + ESP_LOGW(TAG, "esp_hosted_bt_controller_enable failed"); + return false; + } + + hosted_hci_bluedroid_open(); + + esp_bluedroid_hci_driver_operations_t operations = { + .send = hosted_hci_bluedroid_send, + .check_send_available = hosted_hci_bluedroid_check_send_available, + .register_host_callback = hosted_hci_bluedroid_register_host_callback, + }; + esp_bluedroid_attach_hci_driver(&operations); +#endif err = esp_bluedroid_init(); if (err != ESP_OK) { @@ -257,6 +288,7 @@ bool ESP32BLE::ble_dismantle_() { return false; } +#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #ifdef USE_ARDUINO if (!btStop()) { ESP_LOGE(TAG, "btStop failed: %d", esp_bt_controller_get_status()); @@ -286,6 +318,19 @@ bool ESP32BLE::ble_dismantle_() { return false; } } +#endif +#else + if (esp_hosted_bt_controller_disable() != ESP_OK) { + ESP_LOGW(TAG, "esp_hosted_bt_controller_disable failed"); + return false; + } + + if (esp_hosted_bt_controller_deinit(false) != ESP_OK) { + ESP_LOGW(TAG, "esp_hosted_bt_controller_deinit failed"); + return false; + } + + hosted_hci_bluedroid_close(); #endif return true; } diff --git a/esphome/components/esp32_ble/ble_advertising.h b/esphome/components/esp32_ble/ble_advertising.h index 7a31d926f6..d7f1eeac9d 100644 --- a/esphome/components/esp32_ble/ble_advertising.h +++ b/esphome/components/esp32_ble/ble_advertising.h @@ -10,7 +10,9 @@ #ifdef USE_ESP32 #ifdef USE_ESP32_BLE_ADVERTISING +#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include +#endif #include #include diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp index af28804013..f2aa7e762e 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp @@ -3,7 +3,9 @@ #ifdef USE_ESP32 +#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include +#endif #include #include #include diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h index e37edf6cde..05afdc7379 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h @@ -5,7 +5,9 @@ #ifdef USE_ESP32 +#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include +#endif #include namespace esphome { diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 25cc97eeaf..0e58224a5a 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -10,7 +10,9 @@ #include #include #include +#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include +#endif #include #include diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index d07e67825b..8577f12a92 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -7,7 +7,9 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include +#endif #include #include #include @@ -845,6 +847,7 @@ void ESP32BLETracker::log_unexpected_state_(const char *operation, ScannerState #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE void ESP32BLETracker::update_coex_preference_(bool force_ble) { +#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID if (force_ble && !this->coex_prefer_ble_) { ESP_LOGD(TAG, "Setting coexistence to Bluetooth to make connection."); this->coex_prefer_ble_ = true; @@ -854,6 +857,7 @@ void ESP32BLETracker::update_coex_preference_(bool force_ble) { this->coex_prefer_ble_ = false; esp_coex_preference_set(ESP_COEX_PREFER_BALANCE); // Reset to default } +#endif // CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID } #endif diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 9cea02c322..7e9f1b05b5 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -92,9 +92,14 @@ async def to_code(config): framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] os.environ["ESP_IDF_VERSION"] = f"{framework_ver.major}.{framework_ver.minor}" - esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.10.2") - esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.0.11") + if framework_ver >= cv.Version(5, 5, 0): + esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.1.5") + esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.3") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.5.11") + else: + esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") + esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.0.11") esp32.add_extra_script( "post", "esp32_hosted.py", diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index fa43aa5950..2c7963b366 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -42,6 +42,11 @@ static size_t IRAM_ATTR HOT encoder_callback(const void *data, size_t size, size symbols[i] = params->bit0; } } +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 1) + if ((index + 1) >= size && params->reset.duration0 == 0 && params->reset.duration1 == 0) { + *done = true; + } +#endif return RMT_SYMBOLS_PER_BYTE; } diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index baee403b57..6464824c64 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -486,7 +486,6 @@ CONF_RESUME_ON_INPUT = "resume_on_input" CONF_RIGHT_BUTTON = "right_button" CONF_ROLLOVER = "rollover" CONF_ROOT_BACK_BTN = "root_back_btn" -CONF_ROWS = "rows" CONF_SCALE_LINES = "scale_lines" CONF_SCROLLBAR_MODE = "scrollbar_mode" CONF_SELECTED_INDEX = "selected_index" diff --git a/esphome/components/lvgl/widgets/buttonmatrix.py b/esphome/components/lvgl/widgets/buttonmatrix.py index c6b6d2440f..baeb1c8e3e 100644 --- a/esphome/components/lvgl/widgets/buttonmatrix.py +++ b/esphome/components/lvgl/widgets/buttonmatrix.py @@ -2,7 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components.key_provider import KeyProvider import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ITEMS, CONF_TEXT, CONF_WIDTH +from esphome.const import CONF_ID, CONF_ITEMS, CONF_ROWS, CONF_TEXT, CONF_WIDTH from esphome.cpp_generator import MockObj from ..automation import action_to_code @@ -15,7 +15,6 @@ from ..defines import ( CONF_ONE_CHECKED, CONF_PAD_COLUMN, CONF_PAD_ROW, - CONF_ROWS, CONF_SELECTED, ) from ..helpers import lvgl_components_required diff --git a/esphome/components/matrix_keypad/__init__.py b/esphome/components/matrix_keypad/__init__.py index f7a1d622a1..2e123323a0 100644 --- a/esphome/components/matrix_keypad/__init__.py +++ b/esphome/components/matrix_keypad/__init__.py @@ -2,7 +2,7 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import key_provider import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_KEY, CONF_PIN, CONF_TRIGGER_ID +from esphome.const import CONF_ID, CONF_ON_KEY, CONF_PIN, CONF_ROWS, CONF_TRIGGER_ID CODEOWNERS = ["@ssieb"] @@ -19,7 +19,6 @@ MatrixKeyTrigger = matrix_keypad_ns.class_( ) CONF_KEYPAD_ID = "keypad_id" -CONF_ROWS = "rows" CONF_COLUMNS = "columns" CONF_KEYS = "keys" CONF_DEBOUNCE_TIME = "debounce_time" diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index e056696bcf..c7cca62027 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -145,7 +145,7 @@ class BSDSocketImpl : public Socket { } ssize_t sendto(const void *buf, size_t len, int flags, const struct sockaddr *to, socklen_t tolen) override { - return ::sendto(fd_, buf, len, flags, to, tolen); + return ::sendto(fd_, buf, len, flags, to, tolen); // NOLINT(readability-suspicious-call-argument) } int setblocking(bool blocking) override { diff --git a/esphome/const.py b/esphome/const.py index 086b5b4ce3..d62dc617d1 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -836,6 +836,7 @@ CONF_RMT_CHANNEL = "rmt_channel" CONF_RMT_SYMBOLS = "rmt_symbols" CONF_ROTATION = "rotation" CONF_ROW = "row" +CONF_ROWS = "rows" CONF_RS_PIN = "rs_pin" CONF_RTD_NOMINAL_RESISTANCE = "rtd_nominal_resistance" CONF_RTD_WIRES = "rtd_wires" diff --git a/esphome/core/defines.h b/esphome/core/defines.h index f770edaa00..1afb296fc0 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -202,7 +202,7 @@ #define USB_HOST_MAX_REQUESTS 16 #ifdef USE_ARDUINO -#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 2, 1) +#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 2) #define USE_ETHERNET #define USE_ETHERNET_KSZ8081 #endif diff --git a/platformio.ini b/platformio.ini index 44b466a2b3..6b2a8657bb 100644 --- a/platformio.ini +++ b/platformio.ini @@ -125,9 +125,9 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script ; This are common settings for the ESP32 (all variants) using Arduino. [common:esp32-arduino] extends = common:arduino -platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.21-2/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.31-1/platform-espressif32.zip platform_packages = - pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.2.1/esp32-3.2.1.zip + pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.2/esp32-3.3.2.zip framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -161,9 +161,9 @@ extra_scripts = post:esphome/components/esp32/post_build.py.script ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.21-2/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.31-1/platform-espressif32.zip platform_packages = - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.4.2/esp-idf-v5.4.2.zip + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.1/esp-idf-v5.5.1.zip framework = espidf lib_deps = diff --git a/requirements.txt b/requirements.txt index 88ecb1c20e..b6e4a189d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,10 +23,6 @@ cairosvg==2.8.2 freetype-py==2.5.1 jinja2==3.1.6 -# esp-idf requires this, but doesn't bundle it by default -# https://github.com/espressif/esp-idf/blob/220590d599e134d7a5e7f1e683cc4550349ffbf8/requirements.txt#L24 -kconfiglib==13.7.1 - # esp-idf >= 5.0 requires this pyparsing >= 3.0 diff --git a/requirements_test.txt b/requirements_test.txt index c4227fdbde..56ac775a94 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -pylint==4.0.0 +pylint==4.0.1 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.14.0 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.0 # also change in .pre-commit-config.yaml when updating diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 39e1e66c75..24854178a0 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -75,6 +75,8 @@ ISOLATED_COMPONENTS = { "ethernet": "Defines ethernet: which conflicts with wifi: used by most components", "ethernet_info": "Related to ethernet component which conflicts with wifi", "lvgl": "Defines multiple SDL displays on host platform that conflict when merged with other display configs", + "openthread": "Conflicts with wifi: used by most components", + "openthread_info": "Conflicts with wifi: used by most components", "matrix_keypad": "Needs isolation due to keypad", "mcp4725": "no YAML config to specify i2c bus id", "mcp47a1": "no YAML config to specify i2c bus id", diff --git a/script/ci-custom.py b/script/ci-custom.py index bc1ebda93b..6b01623d92 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -501,7 +501,7 @@ def lint_constants_usage(): continue errs.append( f"Constant {highlight(constant)} is defined in {len(uses)} files. Please move all definitions of the " - f"constant to const.py (Uses: {', '.join(uses)}) in a separate PR. " + f"constant to const.py (Uses: {', '.join(str(u) for u in uses)}) in a separate PR. " "See https://developers.esphome.io/contributing/code/#python" ) return errs diff --git a/script/determine-jobs.py b/script/determine-jobs.py index a078fd8f9b..b000ecee3b 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -31,6 +31,7 @@ Options: from __future__ import annotations import argparse +from functools import cache import json import os from pathlib import Path @@ -45,7 +46,6 @@ from helpers import ( changed_files, get_all_dependencies, get_components_from_integration_fixtures, - parse_list_components_output, root_path, ) @@ -212,6 +212,24 @@ def _any_changed_file_endswith(branch: str | None, extensions: tuple[str, ...]) return any(file.endswith(extensions) for file in changed_files(branch)) +@cache +def _component_has_tests(component: str) -> bool: + """Check if a component has test files. + + Cached to avoid repeated filesystem operations for the same component. + + Args: + component: Component name to check + + Returns: + True if the component has test YAML files + """ + tests_dir = Path(root_path) / "tests" / "components" / component + if not tests_dir.exists(): + return False + return any(tests_dir.glob("test.*.yaml")) + + def main() -> None: """Main function that determines which CI jobs to run.""" parser = argparse.ArgumentParser( @@ -228,23 +246,37 @@ def main() -> None: run_clang_format = should_run_clang_format(args.branch) run_python_linters = should_run_python_linters(args.branch) - # Get changed components using list-components.py for exact compatibility + # Get both directly changed and all changed components (with dependencies) in one call script_path = Path(__file__).parent / "list-components.py" - cmd = [sys.executable, str(script_path), "--changed"] + cmd = [sys.executable, str(script_path), "--changed-with-deps"] if args.branch: cmd.extend(["-b", args.branch]) result = subprocess.run(cmd, capture_output=True, text=True, check=True) - changed_components = parse_list_components_output(result.stdout) + component_data = json.loads(result.stdout) + directly_changed_components = component_data["directly_changed"] + changed_components = component_data["all_changed"] # Filter to only components that have test files # Components without tests shouldn't generate CI test jobs - tests_dir = Path(root_path) / "tests" / "components" changed_components_with_tests = [ + component for component in changed_components if _component_has_tests(component) + ] + + # Get directly changed components with tests (for isolated testing) + # These will be tested WITHOUT --testing-mode in CI to enable full validation + # (pin conflicts, etc.) since they contain the actual changes being reviewed + directly_changed_with_tests = [ component - for component in changed_components - if (component_test_dir := tests_dir / component).exists() - and any(component_test_dir.glob("test.*.yaml")) + for component in directly_changed_components + if _component_has_tests(component) + ] + + # Get dependency-only components (for grouped testing) + dependency_only_components = [ + component + for component in changed_components_with_tests + if component not in directly_changed_components ] # Build output @@ -255,7 +287,11 @@ def main() -> None: "python_linters": run_python_linters, "changed_components": changed_components, "changed_components_with_tests": changed_components_with_tests, + "directly_changed_components_with_tests": directly_changed_with_tests, + "dependency_only_components_with_tests": dependency_only_components, "component_test_count": len(changed_components_with_tests), + "directly_changed_count": len(directly_changed_with_tests), + "dependency_only_count": len(dependency_only_components), } # Output as JSON diff --git a/script/list-components.py b/script/list-components.py index 9ab1cdd852..dffff6801a 100755 --- a/script/list-components.py +++ b/script/list-components.py @@ -185,18 +185,32 @@ def main(): "-c", "--changed", action="store_true", - help="List all components required for testing based on changes", + help="List all components required for testing based on changes (includes dependencies)", + ) + parser.add_argument( + "--changed-direct", + action="store_true", + help="List only directly changed components (without dependencies)", + ) + parser.add_argument( + "--changed-with-deps", + action="store_true", + help="Output JSON with both directly changed and all changed components", ) parser.add_argument( "-b", "--branch", help="Branch to compare changed files against" ) args = parser.parse_args() - if args.branch and not args.changed: - parser.error("--branch requires --changed") + if args.branch and not ( + args.changed or args.changed_direct or args.changed_with_deps + ): + parser.error( + "--branch requires --changed, --changed-direct, or --changed-with-deps" + ) - if args.changed: - # When --changed is passed, only get the changed files + if args.changed or args.changed_direct or args.changed_with_deps: + # When --changed* is passed, only get the changed files changed = changed_files(args.branch) # If any base test file(s) changed, there's no need to filter out components @@ -210,8 +224,25 @@ def main(): # Get all component files files = get_all_component_files() - for c in get_components(files, args.changed): - print(c) + if args.changed_with_deps: + # Return JSON with both directly changed and all changed components + import json + + directly_changed = get_components(files, False) + all_changed = get_components(files, True) + output = { + "directly_changed": directly_changed, + "all_changed": all_changed, + } + print(json.dumps(output)) + elif args.changed_direct: + # Return only directly changed components (without dependencies) + for c in get_components(files, False): + print(c) + else: + # Return all changed components (with dependencies) - default behavior + for c in get_components(files, args.changed): + print(c) if __name__ == "__main__": diff --git a/script/split_components_for_ci.py b/script/split_components_for_ci.py index ad2261499c..9730db4988 100755 --- a/script/split_components_for_ci.py +++ b/script/split_components_for_ci.py @@ -56,6 +56,7 @@ def create_intelligent_batches( components: list[str], tests_dir: Path, batch_size: int = 40, + directly_changed: set[str] | None = None, ) -> list[list[str]]: """Create batches optimized for component grouping. @@ -63,6 +64,7 @@ def create_intelligent_batches( components: List of component names to batch tests_dir: Path to tests/components directory batch_size: Target size for each batch + directly_changed: Set of directly changed components (for logging only) Returns: List of component batches (lists of component names) @@ -94,10 +96,17 @@ def create_intelligent_batches( for component in components_with_tests: # Components that can't be grouped get unique signatures - # This includes both manually curated ISOLATED_COMPONENTS and - # automatically detected non_groupable components + # This includes: + # - Manually curated ISOLATED_COMPONENTS + # - Automatically detected non_groupable components + # - Directly changed components (passed via --isolate in CI) # These can share a batch/runner but won't be grouped/merged - if component in ISOLATED_COMPONENTS or component in non_groupable: + is_isolated = ( + component in ISOLATED_COMPONENTS + or component in non_groupable + or (directly_changed and component in directly_changed) + ) + if is_isolated: signature_groups[f"isolated_{component}"].append(component) continue @@ -187,6 +196,10 @@ def main() -> int: default=Path("tests/components"), help="Path to tests/components directory", ) + parser.add_argument( + "--directly-changed", + help="JSON array of directly changed component names (for logging only)", + ) parser.add_argument( "--output", "-o", @@ -208,11 +221,21 @@ def main() -> int: print("Components must be a JSON array", file=sys.stderr) return 1 + # Parse directly changed components list from JSON (if provided) + directly_changed = None + if args.directly_changed: + try: + directly_changed = set(json.loads(args.directly_changed)) + except json.JSONDecodeError as e: + print(f"Error parsing directly-changed JSON: {e}", file=sys.stderr) + return 1 + # Create intelligent batches batches = create_intelligent_batches( components=components, tests_dir=args.tests_dir, batch_size=args.batch_size, + directly_changed=directly_changed, ) # Convert batches to space-separated strings for CI @@ -238,13 +261,37 @@ def main() -> int: isolated_count = sum( 1 for comp in all_batched_components - if comp in ISOLATED_COMPONENTS or comp in non_groupable + if comp in ISOLATED_COMPONENTS + or comp in non_groupable + or (directly_changed and comp in directly_changed) ) groupable_count = actual_components - isolated_count print("\n=== Intelligent Batch Summary ===", file=sys.stderr) print(f"Total components requested: {len(components)}", file=sys.stderr) print(f"Components with test files: {actual_components}", file=sys.stderr) + + # Show breakdown of directly changed vs dependencies + if directly_changed: + direct_count = sum( + 1 for comp in all_batched_components if comp in directly_changed + ) + dep_count = actual_components - direct_count + direct_comps = [ + comp for comp in all_batched_components if comp in directly_changed + ] + dep_comps = [ + comp for comp in all_batched_components if comp not in directly_changed + ] + print( + f" - Direct changes: {direct_count} ({', '.join(sorted(direct_comps))})", + file=sys.stderr, + ) + print( + f" - Dependencies: {dep_count} ({', '.join(sorted(dep_comps))})", + file=sys.stderr, + ) + print(f" - Groupable (weight=1): {groupable_count}", file=sys.stderr) print(f" - Isolated (weight=10): {isolated_count}", file=sys.stderr) if actual_components < len(components): diff --git a/script/test_build_components.py b/script/test_build_components.py index e27bd695c1..14fc10977c 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -365,6 +365,7 @@ def run_grouped_component_tests( build_dir: Path, esphome_command: str, continue_on_fail: bool, + additional_isolated: set[str] | None = None, ) -> tuple[set[tuple[str, str]], list[str], list[str], dict[str, str]]: """Run grouped component tests. @@ -376,6 +377,7 @@ def run_grouped_component_tests( build_dir: Path to build directory esphome_command: ESPHome command (config/compile) continue_on_fail: Whether to continue on failure + additional_isolated: Additional components to treat as isolated (not grouped) Returns: Tuple of (tested_components, passed_tests, failed_tests, failed_commands) @@ -397,6 +399,17 @@ def run_grouped_component_tests( # Track why components can't be grouped (for detailed output) non_groupable_reasons = {} + # Merge additional isolated components with predefined ones + # ISOLATED COMPONENTS are tested individually WITHOUT --testing-mode + # This is critical because: + # - Grouped tests use --testing-mode which disables pin conflict checks and other validation + # - These checks are disabled to allow config merging (multiple components in one build) + # - For directly changed components (via --isolate), we need full validation to catch issues + # - Dependencies are safe to group since they weren't modified in the PR + all_isolated = set(ISOLATED_COMPONENTS.keys()) + if additional_isolated: + all_isolated.update(additional_isolated) + # Group by (platform, bus_signature) for component, platforms in component_buses.items(): if component not in all_tests: @@ -404,7 +417,7 @@ def run_grouped_component_tests( # Skip components that must be tested in isolation # These are shown separately and should not be in non_groupable_reasons - if component in ISOLATED_COMPONENTS: + if component in all_isolated: continue # Skip base bus components (these test the bus platforms themselves) @@ -453,15 +466,28 @@ def run_grouped_component_tests( print("\nGrouping Plan:") print("-" * 80) - # Show isolated components (must test individually due to known issues) - isolated_in_tests = [c for c in ISOLATED_COMPONENTS if c in all_tests] + # Show isolated components (must test individually due to known issues or direct changes) + isolated_in_tests = [c for c in all_isolated if c in all_tests] if isolated_in_tests: - print( - f"\n⚠ {len(isolated_in_tests)} components must be tested in isolation (known build issues):" - ) - for comp in sorted(isolated_in_tests): - reason = ISOLATED_COMPONENTS[comp] - print(f" - {comp}: {reason}") + predefined_isolated = [c for c in isolated_in_tests if c in ISOLATED_COMPONENTS] + additional_in_tests = [ + c for c in isolated_in_tests if c in (additional_isolated or set()) + ] + + if predefined_isolated: + print( + f"\n⚠ {len(predefined_isolated)} components must be tested in isolation (known build issues):" + ) + for comp in sorted(predefined_isolated): + reason = ISOLATED_COMPONENTS[comp] + print(f" - {comp}: {reason}") + + if additional_in_tests: + print( + f"\n✓ {len(additional_in_tests)} components tested in isolation (directly changed in PR):" + ) + for comp in sorted(additional_in_tests): + print(f" - {comp}") # Show base bus components (test the bus platform implementations) base_bus_in_tests = [c for c in BASE_BUS_COMPONENTS if c in all_tests] @@ -733,6 +759,7 @@ def test_components( esphome_command: str, continue_on_fail: bool, enable_grouping: bool = True, + isolated_components: set[str] | None = None, ) -> int: """Test components with optional intelligent grouping. @@ -742,6 +769,10 @@ def test_components( esphome_command: ESPHome command (config/compile) continue_on_fail: Whether to continue on failure enable_grouping: Whether to enable component grouping + isolated_components: Set of component names to test in isolation (not grouped). + These are tested WITHOUT --testing-mode to enable full validation + (pin conflicts, etc). This is used in CI for directly changed components + to catch issues that would be missed with --testing-mode. Returns: Exit code (0 for success, 1 for failure) @@ -788,6 +819,7 @@ def test_components( build_dir=build_dir, esphome_command=esphome_command, continue_on_fail=continue_on_fail, + additional_isolated=isolated_components, ) # Then run individual tests for components not in groups @@ -912,18 +944,30 @@ def main() -> int: action="store_true", help="Disable component grouping (test each component individually)", ) + parser.add_argument( + "--isolate", + help="Comma-separated list of components to test in isolation (not grouped with others). " + "These are tested WITHOUT --testing-mode to enable full validation. " + "Used in CI for directly changed components to catch pin conflicts and other issues.", + ) args = parser.parse_args() # Parse component patterns component_patterns = [p.strip() for p in args.components.split(",")] + # Parse isolated components + isolated_components = None + if args.isolate: + isolated_components = {c.strip() for c in args.isolate.split(",") if c.strip()} + return test_components( component_patterns=component_patterns, platform_filter=args.target, esphome_command=args.esphome_command, continue_on_fail=args.continue_on_fail, enable_grouping=not args.no_grouping, + isolated_components=isolated_components, ) diff --git a/tests/components/bluetooth_proxy/test.esp32-p4-idf.yaml b/tests/components/bluetooth_proxy/test.esp32-p4-idf.yaml new file mode 100644 index 0000000000..edb18c1ada --- /dev/null +++ b/tests/components/bluetooth_proxy/test.esp32-p4-idf.yaml @@ -0,0 +1,19 @@ +<<: !include common.yaml + +esp32_ble_tracker: + max_connections: 9 + +bluetooth_proxy: + active: true + connection_slots: 9 + +esp32_hosted: + active_high: true + variant: ESP32C6 + reset_pin: GPIO54 + cmd_pin: GPIO19 + clk_pin: GPIO18 + d0_pin: GPIO14 + d1_pin: GPIO15 + d2_pin: GPIO16 + d3_pin: GPIO17 diff --git a/tests/components/esp32/test.esp32-idf.yaml b/tests/components/esp32/test.esp32-idf.yaml index ccf0b7cbd5..6338fe98dd 100644 --- a/tests/components/esp32/test.esp32-idf.yaml +++ b/tests/components/esp32/test.esp32-idf.yaml @@ -5,6 +5,7 @@ esp32: advanced: enable_lwip_mdns_queries: true enable_lwip_bridge_interface: true + disable_libc_locks_in_iram: false # Test explicit opt-out of RAM optimization wifi: ssid: MySSID diff --git a/tests/components/esp32/test.esp32-s3-idf.yaml b/tests/components/esp32/test.esp32-s3-idf.yaml index 1d5a5e52a4..4ae5e6b999 100644 --- a/tests/components/esp32/test.esp32-s3-idf.yaml +++ b/tests/components/esp32/test.esp32-s3-idf.yaml @@ -4,6 +4,7 @@ esp32: type: esp-idf advanced: execute_from_psram: true + disable_libc_locks_in_iram: true # Test default RAM optimization enabled psram: mode: octal diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 5d8746f434..0559d116be 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -73,9 +73,11 @@ def test_main_all_tests_should_run( mock_should_run_clang_format.return_value = True mock_should_run_python_linters.return_value = True - # Mock list-components.py output + # Mock list-components.py output (now returns JSON with --changed-with-deps) mock_result = Mock() - mock_result.stdout = "wifi\napi\nsensor\n" + mock_result.stdout = json.dumps( + {"directly_changed": ["wifi", "api"], "all_changed": ["wifi", "api", "sensor"]} + ) mock_subprocess_run.return_value = mock_result # Run main function with mocked argv @@ -116,7 +118,7 @@ def test_main_no_tests_should_run( # Mock empty list-components.py output mock_result = Mock() - mock_result.stdout = "" + mock_result.stdout = json.dumps({"directly_changed": [], "all_changed": []}) mock_subprocess_run.return_value = mock_result # Run main function with mocked argv @@ -177,7 +179,9 @@ def test_main_with_branch_argument( # Mock list-components.py output mock_result = Mock() - mock_result.stdout = "mqtt\n" + mock_result.stdout = json.dumps( + {"directly_changed": ["mqtt"], "all_changed": ["mqtt"]} + ) mock_subprocess_run.return_value = mock_result with patch("sys.argv", ["script.py", "-b", "main"]): @@ -192,7 +196,7 @@ def test_main_with_branch_argument( # Check that list-components.py was called with branch mock_subprocess_run.assert_called_once() call_args = mock_subprocess_run.call_args[0][0] - assert "--changed" in call_args + assert "--changed-with-deps" in call_args assert "-b" in call_args assert "main" in call_args @@ -411,7 +415,12 @@ def test_main_filters_components_without_tests( # Mock list-components.py output with 3 components # wifi: has tests, sensor: has tests, airthings_ble: no tests mock_result = Mock() - mock_result.stdout = "wifi\nsensor\nairthings_ble\n" + mock_result.stdout = json.dumps( + { + "directly_changed": ["wifi", "sensor"], + "all_changed": ["wifi", "sensor", "airthings_ble"], + } + ) mock_subprocess_run.return_value = mock_result # Create test directory structure @@ -436,6 +445,8 @@ def test_main_filters_components_without_tests( patch.object(determine_jobs, "root_path", str(tmp_path)), patch("sys.argv", ["determine-jobs.py"]), ): + # Clear the cache since we're mocking root_path + determine_jobs._component_has_tests.cache_clear() determine_jobs.main() # Check output