From ff6191cfd4c5128f073f7e339fa749c08c96e00b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Oct 2025 12:55:03 -1000 Subject: [PATCH 1/7] [esp32_improv] Fix state not transitioning to PROVISIONED when WiFi configured via captive portal --- .../esp32_improv/esp32_improv_component.cpp | 53 ++++++++++++------- .../esp32_improv/esp32_improv_component.h | 1 + 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index f773083890..ed709da89c 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -40,6 +40,9 @@ void ESP32ImprovComponent::setup() { #endif global_ble_server->on_disconnect([this](uint16_t conn_id) { this->set_error_(improv::ERROR_NONE); }); + // Listen for WiFi connections to detect when provisioning happens via captive portal or other means + wifi::global_wifi_component->get_connect_trigger()->add_callback([this]() { this->on_wifi_connected_(); }); + // Start with loop disabled - will be enabled by start() when needed this->disable_loop(); } @@ -161,25 +164,7 @@ void ESP32ImprovComponent::loop() { case improv::STATE_PROVISIONING: { this->set_status_indicator_state_((now % 200) < 100); if (wifi::global_wifi_component->is_connected()) { - wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), - this->connecting_sta_.get_password()); - this->connecting_sta_ = {}; - this->cancel_timeout("wifi-connect-timeout"); - this->set_state_(improv::STATE_PROVISIONED); - - std::vector urls = {ESPHOME_MY_LINK}; -#ifdef USE_WEBSERVER - for (auto &ip : wifi::global_wifi_component->wifi_sta_ip_addresses()) { - if (ip.is_ip4()) { - std::string webserver_url = "http://" + ip.str() + ":" + to_string(USE_WEBSERVER_PORT); - urls.push_back(webserver_url); - break; - } - } -#endif - std::vector data = improv::build_rpc_response(improv::WIFI_SETTINGS, urls); - this->send_response_(data); - this->stop(); + this->on_wifi_connected_(); } break; } @@ -392,6 +377,36 @@ void ESP32ImprovComponent::on_wifi_connect_timeout_() { wifi::global_wifi_component->clear_sta(); } +void ESP32ImprovComponent::on_wifi_connected_() { + // Handle WiFi connection, whether from Improv provisioning or external (e.g., captive portal) + if (this->state_ == improv::STATE_PROVISIONING) { + // WiFi provisioned via Improv - save credentials and send response + wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), this->connecting_sta_.get_password()); + this->connecting_sta_ = {}; + this->cancel_timeout("wifi-connect-timeout"); + + std::vector urls = {ESPHOME_MY_LINK}; +#ifdef USE_WEBSERVER + for (auto &ip : wifi::global_wifi_component->wifi_sta_ip_addresses()) { + if (ip.is_ip4()) { + std::string webserver_url = "http://" + ip.str() + ":" + to_string(USE_WEBSERVER_PORT); + urls.push_back(webserver_url); + break; + } + } +#endif + std::vector data = improv::build_rpc_response(improv::WIFI_SETTINGS, urls); + this->send_response_(data); + } else if (this->is_active() && this->state_ != improv::STATE_PROVISIONED) { + // WiFi provisioned externally (e.g., captive portal) - just transition to provisioned + ESP_LOGD(TAG, "WiFi provisioned externally, transitioning to provisioned state"); + } + + // Common actions for both cases + this->set_state_(improv::STATE_PROVISIONED); + this->stop(); +} + void ESP32ImprovComponent::advertise_service_data_() { uint8_t service_data[IMPROV_SERVICE_DATA_SIZE] = {}; service_data[0] = IMPROV_PROTOCOL_ID_1; // PR diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index eb07e09dce..39c3483b2a 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -111,6 +111,7 @@ class ESP32ImprovComponent : public Component { void send_response_(std::vector &response); void process_incoming_data_(); void on_wifi_connect_timeout_(); + void on_wifi_connected_(); bool check_identify_(); void advertise_service_data_(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG From a193d5b40e3518f79ae6a246bd9bad43537b634d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Oct 2025 12:56:28 -1000 Subject: [PATCH 2/7] [esp32_improv] Fix state not transitioning to PROVISIONED when WiFi configured via captive portal --- esphome/components/esp32_improv/esp32_improv_component.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index ed709da89c..5060a0759a 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -399,7 +399,7 @@ void ESP32ImprovComponent::on_wifi_connected_() { this->send_response_(data); } else if (this->is_active() && this->state_ != improv::STATE_PROVISIONED) { // WiFi provisioned externally (e.g., captive portal) - just transition to provisioned - ESP_LOGD(TAG, "WiFi provisioned externally, transitioning to provisioned state"); + ESP_LOGD(TAG, "WiFi provisioned externally"); } // Common actions for both cases From c63902781b891f6434a4be3f2ac7a24f7d997b40 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Oct 2025 12:57:13 -1000 Subject: [PATCH 3/7] [esp32_improv] Fix state not transitioning to PROVISIONED when WiFi configured via captive portal --- esphome/components/esp32_improv/esp32_improv_component.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index 5060a0759a..49ec5e8ab9 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -402,7 +402,6 @@ void ESP32ImprovComponent::on_wifi_connected_() { ESP_LOGD(TAG, "WiFi provisioned externally"); } - // Common actions for both cases this->set_state_(improv::STATE_PROVISIONED); this->stop(); } From 5a0184cb35ef5e1b198ebbe2784abf2aad7abce4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Oct 2025 13:01:19 -1000 Subject: [PATCH 4/7] [esp32_improv] Fix state not transitioning to PROVISIONED when WiFi configured via captive portal --- esphome/components/esp32_improv/esp32_improv_component.cpp | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index 49ec5e8ab9..5c32f82abb 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -163,9 +163,6 @@ void ESP32ImprovComponent::loop() { } case improv::STATE_PROVISIONING: { this->set_status_indicator_state_((now % 200) < 100); - if (wifi::global_wifi_component->is_connected()) { - this->on_wifi_connected_(); - } break; } case improv::STATE_PROVISIONED: { From 678a93cc56edca3efc4fc7add5e50f42463260b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Oct 2025 13:08:10 -1000 Subject: [PATCH 5/7] fix --- .../esp32_improv/esp32_improv_component.cpp | 12 +++++++++--- .../components/esp32_improv/esp32_improv_component.h | 1 + 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index 5c32f82abb..b3258aedac 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -40,9 +40,6 @@ void ESP32ImprovComponent::setup() { #endif global_ble_server->on_disconnect([this](uint16_t conn_id) { this->set_error_(improv::ERROR_NONE); }); - // Listen for WiFi connections to detect when provisioning happens via captive portal or other means - wifi::global_wifi_component->get_connect_trigger()->add_callback([this]() { this->on_wifi_connected_(); }); - // Start with loop disabled - will be enabled by start() when needed this->disable_loop(); } @@ -146,6 +143,7 @@ void ESP32ImprovComponent::loop() { #else this->set_state_(improv::STATE_AUTHORIZED); #endif + this->check_wifi_connection_(); break; } case improv::STATE_AUTHORIZED: { @@ -159,10 +157,12 @@ void ESP32ImprovComponent::loop() { if (!this->check_identify_()) { this->set_status_indicator_state_((now % 1000) < 500); } + this->check_wifi_connection_(); break; } case improv::STATE_PROVISIONING: { this->set_status_indicator_state_((now % 200) < 100); + this->check_wifi_connection_(); break; } case improv::STATE_PROVISIONED: { @@ -374,6 +374,12 @@ void ESP32ImprovComponent::on_wifi_connect_timeout_() { wifi::global_wifi_component->clear_sta(); } +void ESP32ImprovComponent::check_wifi_connection_() { + if (wifi::global_wifi_component->is_connected()) { + this->on_wifi_connected_(); + } +} + void ESP32ImprovComponent::on_wifi_connected_() { // Handle WiFi connection, whether from Improv provisioning or external (e.g., captive portal) if (this->state_ == improv::STATE_PROVISIONING) { diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index 39c3483b2a..da670f54bc 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -112,6 +112,7 @@ class ESP32ImprovComponent : public Component { void process_incoming_data_(); void on_wifi_connect_timeout_(); void on_wifi_connected_(); + void check_wifi_connection_(); bool check_identify_(); void advertise_service_data_(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG From 3afa73b449b870be5a60de58d2c0a286bb0c8f2b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Oct 2025 13:27:18 -1000 Subject: [PATCH 6/7] [ci] Filter out components without tests from CI test jobs (#11134 followup) (#11178) --- .github/workflows/ci.yml | 6 ++- script/determine-jobs.py | 13 ++++- tests/script/test_determine_jobs.py | 75 ++++++++++++++++++++++++++++- 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4451007da0..f692b1f7d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,6 +177,7 @@ jobs: clang-tidy: ${{ steps.determine.outputs.clang-tidy }} 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 }} component-test-count: ${{ steps.determine.outputs.component-test-count }} steps: - name: Check out code from GitHub @@ -204,6 +205,7 @@ jobs: echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT 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 "component-test-count=$(echo "$output" | jq -r '.component_test_count')" >> $GITHUB_OUTPUT integration-tests: @@ -367,7 +369,7 @@ jobs: fail-fast: false max-parallel: 2 matrix: - file: ${{ fromJson(needs.determine-jobs.outputs.changed-components) }} + 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 @@ -414,7 +416,7 @@ jobs: . venv/bin/activate # Use intelligent splitter that groups components with same bus configs - components='${{ needs.determine-jobs.outputs.changed-components }}' + components='${{ needs.determine-jobs.outputs.changed-components-with-tests }}' echo "Splitting components intelligently..." output=$(python3 script/split_components_for_ci.py --components "$components" --batch-size 40 --output github) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index e26bc29c2f..a078fd8f9b 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -237,6 +237,16 @@ def main() -> None: result = subprocess.run(cmd, capture_output=True, text=True, check=True) changed_components = parse_list_components_output(result.stdout) + # 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_test_dir := tests_dir / component).exists() + and any(component_test_dir.glob("test.*.yaml")) + ] + # Build output output: dict[str, Any] = { "integration_tests": run_integration, @@ -244,7 +254,8 @@ def main() -> None: "clang_format": run_clang_format, "python_linters": run_python_linters, "changed_components": changed_components, - "component_test_count": len(changed_components), + "changed_components_with_tests": changed_components_with_tests, + "component_test_count": len(changed_components_with_tests), } # Output as JSON diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 7200afc2ee..5d8746f434 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -4,6 +4,7 @@ from collections.abc import Generator import importlib.util import json import os +from pathlib import Path import subprocess import sys from unittest.mock import Mock, call, patch @@ -90,7 +91,13 @@ def test_main_all_tests_should_run( assert output["clang_format"] is True assert output["python_linters"] is True assert output["changed_components"] == ["wifi", "api", "sensor"] - assert output["component_test_count"] == 3 + # changed_components_with_tests will only include components that actually have test files + assert "changed_components_with_tests" in output + assert isinstance(output["changed_components_with_tests"], list) + # component_test_count matches number of components with tests + assert output["component_test_count"] == len( + output["changed_components_with_tests"] + ) def test_main_no_tests_should_run( @@ -125,6 +132,7 @@ def test_main_no_tests_should_run( assert output["clang_format"] is False assert output["python_linters"] is False assert output["changed_components"] == [] + assert output["changed_components_with_tests"] == [] assert output["component_test_count"] == 0 @@ -197,7 +205,13 @@ def test_main_with_branch_argument( assert output["clang_format"] is False assert output["python_linters"] is True assert output["changed_components"] == ["mqtt"] - assert output["component_test_count"] == 1 + # changed_components_with_tests will only include components that actually have test files + assert "changed_components_with_tests" in output + assert isinstance(output["changed_components_with_tests"], list) + # component_test_count matches number of components with tests + assert output["component_test_count"] == len( + output["changed_components_with_tests"] + ) def test_should_run_integration_tests( @@ -377,3 +391,60 @@ def test_should_run_clang_format_with_branch() -> None: mock_changed.return_value = [] determine_jobs.should_run_clang_format("release") mock_changed.assert_called_once_with("release") + + +def test_main_filters_components_without_tests( + mock_should_run_integration_tests: Mock, + mock_should_run_clang_tidy: Mock, + mock_should_run_clang_format: Mock, + mock_should_run_python_linters: Mock, + mock_subprocess_run: Mock, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + """Test that components without test files are filtered out.""" + mock_should_run_integration_tests.return_value = False + mock_should_run_clang_tidy.return_value = False + mock_should_run_clang_format.return_value = False + mock_should_run_python_linters.return_value = False + + # 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_subprocess_run.return_value = mock_result + + # Create test directory structure + tests_dir = tmp_path / "tests" / "components" + + # wifi has tests + wifi_dir = tests_dir / "wifi" + wifi_dir.mkdir(parents=True) + (wifi_dir / "test.esp32.yaml").write_text("test: config") + + # sensor has tests + sensor_dir = tests_dir / "sensor" + sensor_dir.mkdir(parents=True) + (sensor_dir / "test.esp8266.yaml").write_text("test: config") + + # airthings_ble exists but has no test files + airthings_dir = tests_dir / "airthings_ble" + airthings_dir.mkdir(parents=True) + + # Mock root_path to use tmp_path + with ( + patch.object(determine_jobs, "root_path", str(tmp_path)), + patch("sys.argv", ["determine-jobs.py"]), + ): + determine_jobs.main() + + # Check output + captured = capsys.readouterr() + output = json.loads(captured.out) + + # changed_components should have all components + assert set(output["changed_components"]) == {"wifi", "sensor", "airthings_ble"} + # changed_components_with_tests should only have components with test files + assert set(output["changed_components_with_tests"]) == {"wifi", "sensor"} + # component_test_count should be based on components with tests + assert output["component_test_count"] == 2 From 3758b4c8015406e06d367c3afa8067715d7fa422 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Oct 2025 13:45:22 -1000 Subject: [PATCH 7/7] preen --- .../components/esp32_improv/esp32_improv_component.cpp | 9 ++------- esphome/components/esp32_improv/esp32_improv_component.h | 1 - 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index b3258aedac..d83caf931b 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -375,15 +375,11 @@ void ESP32ImprovComponent::on_wifi_connect_timeout_() { } void ESP32ImprovComponent::check_wifi_connection_() { - if (wifi::global_wifi_component->is_connected()) { - this->on_wifi_connected_(); + if (!wifi::global_wifi_component->is_connected()) { + return; } -} -void ESP32ImprovComponent::on_wifi_connected_() { - // Handle WiFi connection, whether from Improv provisioning or external (e.g., captive portal) if (this->state_ == improv::STATE_PROVISIONING) { - // WiFi provisioned via Improv - save credentials and send response wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), this->connecting_sta_.get_password()); this->connecting_sta_ = {}; this->cancel_timeout("wifi-connect-timeout"); @@ -401,7 +397,6 @@ void ESP32ImprovComponent::on_wifi_connected_() { std::vector data = improv::build_rpc_response(improv::WIFI_SETTINGS, urls); this->send_response_(data); } else if (this->is_active() && this->state_ != improv::STATE_PROVISIONED) { - // WiFi provisioned externally (e.g., captive portal) - just transition to provisioned ESP_LOGD(TAG, "WiFi provisioned externally"); } diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index da670f54bc..6782430ffe 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -111,7 +111,6 @@ class ESP32ImprovComponent : public Component { void send_response_(std::vector &response); void process_incoming_data_(); void on_wifi_connect_timeout_(); - void on_wifi_connected_(); void check_wifi_connection_(); bool check_identify_(); void advertise_service_data_();