diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9aa7856116..c24900d378 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8 + uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -86,6 +86,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8 + uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 with: category: "/language:${{matrix.language}}" diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 1f742dc1a8..494470cb48 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -407,7 +407,8 @@ async def to_code(config): cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE])) - cg.add(var.set_fast_connect(config[CONF_FAST_CONNECT])) + if config[CONF_FAST_CONNECT]: + cg.add_define("USE_WIFI_FAST_CONNECT") cg.add(var.set_passive_scan(config[CONF_PASSIVE_SCAN])) if CONF_OUTPUT_POWER in config: cg.add(var.set_output_power(config[CONF_OUTPUT_POWER])) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 9fb0d8a122..aaa52612cb 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -84,9 +84,9 @@ void WiFiComponent::start() { uint32_t hash = this->has_sta() ? fnv1_hash(App.get_compilation_time()) : 88491487UL; this->pref_ = global_preferences->make_preference(hash, true); - if (this->fast_connect_) { - this->fast_connect_pref_ = global_preferences->make_preference(hash + 1, false); - } +#ifdef USE_WIFI_FAST_CONNECT + this->fast_connect_pref_ = global_preferences->make_preference(hash + 1, false); +#endif SavedWifiSettings save{}; if (this->pref_.load(&save)) { @@ -108,16 +108,16 @@ void WiFiComponent::start() { ESP_LOGV(TAG, "Setting Power Save Option failed"); } - if (this->fast_connect_) { - this->trying_loaded_ap_ = this->load_fast_connect_settings_(); - if (!this->trying_loaded_ap_) { - this->ap_index_ = 0; - this->selected_ap_ = this->sta_[this->ap_index_]; - } - this->start_connecting(this->selected_ap_, false); - } else { - this->start_scanning(); +#ifdef USE_WIFI_FAST_CONNECT + this->trying_loaded_ap_ = this->load_fast_connect_settings_(); + if (!this->trying_loaded_ap_) { + this->ap_index_ = 0; + this->selected_ap_ = this->sta_[this->ap_index_]; } + this->start_connecting(this->selected_ap_, false); +#else + this->start_scanning(); +#endif #ifdef USE_WIFI_AP } else if (this->has_ap()) { this->setup_ap_config_(); @@ -168,13 +168,19 @@ void WiFiComponent::loop() { case WIFI_COMPONENT_STATE_COOLDOWN: { this->status_set_warning(LOG_STR("waiting to reconnect")); if (millis() - this->action_started_ > 5000) { - if (this->fast_connect_ || this->retry_hidden_) { +#ifdef USE_WIFI_FAST_CONNECT + if (!this->selected_ap_.get_bssid().has_value()) + this->selected_ap_ = this->sta_[0]; + this->start_connecting(this->selected_ap_, false); +#else + if (!this->retry_hidden_) { + this->start_scanning(); + } else { if (!this->selected_ap_.get_bssid().has_value()) this->selected_ap_ = this->sta_[0]; this->start_connecting(this->selected_ap_, false); - } else { - this->start_scanning(); } +#endif } break; } @@ -244,7 +250,6 @@ WiFiComponent::WiFiComponent() { global_wifi_component = this; } bool WiFiComponent::has_ap() const { return this->has_ap_; } bool WiFiComponent::has_sta() const { return !this->sta_.empty(); } -void WiFiComponent::set_fast_connect(bool fast_connect) { this->fast_connect_ = fast_connect; } #ifdef USE_WIFI_11KV_SUPPORT void WiFiComponent::set_btm(bool btm) { this->btm_ = btm; } void WiFiComponent::set_rrm(bool rrm) { this->rrm_ = rrm; } @@ -721,9 +726,9 @@ void WiFiComponent::check_connecting_finished() { this->scan_result_.shrink_to_fit(); } - if (this->fast_connect_) { - this->save_fast_connect_settings_(); - } +#ifdef USE_WIFI_FAST_CONNECT + this->save_fast_connect_settings_(); +#endif return; } @@ -771,31 +776,31 @@ void WiFiComponent::retry_connect() { delay(10); if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_() && (this->num_retried_ > 3 || this->error_from_callback_)) { - if (this->fast_connect_) { - if (this->trying_loaded_ap_) { - this->trying_loaded_ap_ = false; - this->ap_index_ = 0; // Retry from the first configured AP - } else if (this->ap_index_ >= this->sta_.size() - 1) { - ESP_LOGW(TAG, "No more APs to try"); - this->ap_index_ = 0; - this->restart_adapter(); - } else { - // Try next AP - this->ap_index_++; - } - this->num_retried_ = 0; - this->selected_ap_ = this->sta_[this->ap_index_]; +#ifdef USE_WIFI_FAST_CONNECT + if (this->trying_loaded_ap_) { + this->trying_loaded_ap_ = false; + this->ap_index_ = 0; // Retry from the first configured AP + } else if (this->ap_index_ >= this->sta_.size() - 1) { + ESP_LOGW(TAG, "No more APs to try"); + this->ap_index_ = 0; + this->restart_adapter(); } else { - if (this->num_retried_ > 5) { - // If retry failed for more than 5 times, let's restart STA - this->restart_adapter(); - } else { - // Try hidden networks after 3 failed retries - ESP_LOGD(TAG, "Retrying with hidden networks"); - this->retry_hidden_ = true; - this->num_retried_++; - } + // Try next AP + this->ap_index_++; } + this->num_retried_ = 0; + this->selected_ap_ = this->sta_[this->ap_index_]; +#else + if (this->num_retried_ > 5) { + // If retry failed for more than 5 times, let's restart STA + this->restart_adapter(); + } else { + // Try hidden networks after 3 failed retries + ESP_LOGD(TAG, "Retrying with hidden networks"); + this->retry_hidden_ = true; + this->num_retried_++; + } +#endif } else { this->num_retried_++; } @@ -841,6 +846,7 @@ bool WiFiComponent::is_esp32_improv_active_() { #endif } +#ifdef USE_WIFI_FAST_CONNECT bool WiFiComponent::load_fast_connect_settings_() { SavedWifiFastConnectSettings fast_connect_save{}; @@ -875,6 +881,7 @@ void WiFiComponent::save_fast_connect_settings_() { ESP_LOGD(TAG, "Saved fast_connect settings"); } } +#endif void WiFiAP::set_ssid(const std::string &ssid) { this->ssid_ = ssid; } void WiFiAP::set_bssid(bssid_t bssid) { this->bssid_ = bssid; } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 508024a235..a1dacdc694 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -240,7 +240,6 @@ class WiFiComponent : public Component { void start_scanning(); void check_scanning_finished(); void start_connecting(const WiFiAP &ap, bool two); - void set_fast_connect(bool fast_connect); void set_ap_timeout(uint32_t ap_timeout) { ap_timeout_ = ap_timeout; } void check_connecting_finished(); @@ -364,8 +363,10 @@ class WiFiComponent : public Component { bool is_captive_portal_active_(); bool is_esp32_improv_active_(); +#ifdef USE_WIFI_FAST_CONNECT bool load_fast_connect_settings_(); void save_fast_connect_settings_(); +#endif #ifdef USE_ESP8266 static void wifi_event_callback(System_Event_t *event); @@ -399,7 +400,9 @@ class WiFiComponent : public Component { WiFiAP ap_; optional output_power_; ESPPreferenceObject pref_; +#ifdef USE_WIFI_FAST_CONNECT ESPPreferenceObject fast_connect_pref_; +#endif // Group all 32-bit integers together uint32_t action_started_; @@ -417,8 +420,9 @@ class WiFiComponent : public Component { #endif /* USE_NETWORK_IPV6 */ // Group all boolean values together - bool fast_connect_{false}; +#ifdef USE_WIFI_FAST_CONNECT bool trying_loaded_ap_{false}; +#endif bool retry_hidden_{false}; bool has_ap_{false}; bool handled_connected_state_{false}; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1afb296fc0..b1bd7f92d7 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -199,6 +199,7 @@ #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_WEBSERVER_SORTING #define USE_WIFI_11KV_SUPPORT +#define USE_WIFI_FAST_CONNECT #define USB_HOST_MAX_REQUESTS 16 #ifdef USE_ARDUINO diff --git a/esphome/dashboard/settings.py b/esphome/dashboard/settings.py index 35b67c0d23..6035b4a1d6 100644 --- a/esphome/dashboard/settings.py +++ b/esphome/dashboard/settings.py @@ -10,6 +10,10 @@ from esphome.helpers import get_bool_env from .util.password import password_hash +# Sentinel file name used for CORE.config_path when dashboard initializes. +# This ensures .parent returns the config directory instead of root. +_DASHBOARD_SENTINEL_FILE = "___DASHBOARD_SENTINEL___.yaml" + class DashboardSettings: """Settings for the dashboard.""" @@ -48,7 +52,12 @@ class DashboardSettings: self.config_dir = Path(args.configuration) self.absolute_config_dir = self.config_dir.resolve() self.verbose = args.verbose - CORE.config_path = self.config_dir / "." + # Set to a sentinel file so .parent gives us the config directory. + # Previously this was `os.path.join(self.config_dir, ".")` which worked because + # os.path.dirname("/config/.") returns "/config", but Path("/config/.").parent + # normalizes to Path("/config") first, then .parent returns Path("/"), breaking + # secret resolution. Using a sentinel file ensures .parent gives the correct directory. + CORE.config_path = self.config_dir / _DASHBOARD_SENTINEL_FILE @property def relative_url(self) -> str: diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index fc7d021cbe..98edfef145 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -83,8 +83,6 @@ ISOLATED_COMPONENTS = { "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", "modbus_controller": "Defines multiple modbus buses for testing client/server functionality - conflicts with package modbus bus", "neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)", "packages": "cannot merge packages", diff --git a/tests/components/mcp4725/common.yaml b/tests/components/mcp4725/common.yaml index 871c2805a3..9352ebfd19 100644 --- a/tests/components/mcp4725/common.yaml +++ b/tests/components/mcp4725/common.yaml @@ -1,3 +1,4 @@ output: - platform: mcp4725 id: mcp4725_dac_output + i2c_id: i2c_bus diff --git a/tests/components/mcp47a1/common.yaml b/tests/components/mcp47a1/common.yaml index 3448fcfb31..5a5786ff0f 100644 --- a/tests/components/mcp47a1/common.yaml +++ b/tests/components/mcp47a1/common.yaml @@ -1,3 +1,4 @@ output: - platform: mcp47a1 id: output_mcp47a1 + i2c_id: i2c_bus diff --git a/tests/components/wifi/common-eap.yaml b/tests/components/wifi/common-eap.yaml index 779cd6b49a..52319fa5a1 100644 --- a/tests/components/wifi/common-eap.yaml +++ b/tests/components/wifi/common-eap.yaml @@ -1,4 +1,5 @@ wifi: + fast_connect: true networks: - ssid: MySSID eap: diff --git a/tests/dashboard/test_settings.py b/tests/dashboard/test_settings.py index c9097fe5e2..91a8ec70c3 100644 --- a/tests/dashboard/test_settings.py +++ b/tests/dashboard/test_settings.py @@ -2,11 +2,13 @@ from __future__ import annotations +from argparse import Namespace from pathlib import Path import tempfile import pytest +from esphome.core import CORE from esphome.dashboard.settings import DashboardSettings @@ -159,3 +161,63 @@ def test_rel_path_with_numeric_args(dashboard_settings: DashboardSettings) -> No result = dashboard_settings.rel_path("123", "456.789") expected = dashboard_settings.config_dir / "123" / "456.789" assert result == expected + + +def test_config_path_parent_resolves_to_config_dir(tmp_path: Path) -> None: + """Test that CORE.config_path.parent resolves to config_dir after parse_args. + + This is a regression test for issue #11280 where binary download failed + when using packages with secrets after the Path migration in 2025.10.0. + + The issue was that after switching from os.path to Path: + - Before: os.path.dirname("/config/.") → "/config" + - After: Path("/config/.").parent → Path("/") (normalized first!) + + The fix uses a sentinel file so .parent returns the correct directory: + - Fixed: Path("/config/___DASHBOARD_SENTINEL___.yaml").parent → Path("/config") + """ + # Create test directory structure with secrets and packages + config_dir = tmp_path / "config" + config_dir.mkdir() + + # Create secrets.yaml with obviously fake test values + secrets_file = config_dir / "secrets.yaml" + secrets_file.write_text( + "wifi_ssid: TEST-DUMMY-SSID\n" + "wifi_password: not-a-real-password-just-for-testing\n" + ) + + # Create package file that uses secrets + package_file = config_dir / "common.yaml" + package_file.write_text( + "wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_password\n" + ) + + # Create main device config that includes the package + device_config = config_dir / "test-device.yaml" + device_config.write_text( + "esphome:\n name: test-device\n\npackages:\n common: !include common.yaml\n" + ) + + # Set up dashboard settings with our test config directory + settings = DashboardSettings() + args = Namespace( + configuration=str(config_dir), + password=None, + username=None, + ha_addon=False, + verbose=False, + ) + settings.parse_args(args) + + # Verify that CORE.config_path.parent correctly points to the config directory + # This is critical for secret resolution in yaml_util.py which does: + # main_config_dir = CORE.config_path.parent + # main_secret_yml = main_config_dir / "secrets.yaml" + assert CORE.config_path.parent == config_dir.resolve() + assert (CORE.config_path.parent / "secrets.yaml").exists() + assert (CORE.config_path.parent / "common.yaml").exists() + + # Verify that CORE.config_path itself uses the sentinel file + assert CORE.config_path.name == "___DASHBOARD_SENTINEL___.yaml" + assert not CORE.config_path.exists() # Sentinel file doesn't actually exist diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 5bbe7e78fc..6c424e56d4 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -1,5 +1,6 @@ from __future__ import annotations +from argparse import Namespace import asyncio from collections.abc import Generator from contextlib import asynccontextmanager @@ -17,6 +18,8 @@ from tornado.ioloop import IOLoop from tornado.testing import bind_unused_port from tornado.websocket import WebSocketClientConnection, websocket_connect +from esphome import yaml_util +from esphome.core import CORE from esphome.dashboard import web_server from esphome.dashboard.const import DashboardEvent from esphome.dashboard.core import DASHBOARD @@ -1302,3 +1305,71 @@ async def test_dashboard_subscriber_refresh_event( # Give it a moment to clean up await asyncio.sleep(0.01) + + +@pytest.mark.asyncio +async def test_dashboard_yaml_loading_with_packages_and_secrets( + tmp_path: Path, +) -> None: + """Test dashboard YAML loading with packages referencing secrets. + + This is a regression test for issue #11280 where binary download failed + when using packages with secrets after the Path migration in 2025.10.0. + + This test verifies that CORE.config_path initialization in the dashboard + allows yaml_util.load_yaml() to correctly resolve secrets from packages. + """ + # Create test directory structure with secrets and packages + config_dir = tmp_path / "config" + config_dir.mkdir() + + # Create secrets.yaml with obviously fake test values + secrets_file = config_dir / "secrets.yaml" + secrets_file.write_text( + "wifi_ssid: TEST-DUMMY-SSID\n" + "wifi_password: not-a-real-password-just-for-testing\n" + ) + + # Create package file that uses secrets + package_file = config_dir / "common.yaml" + package_file.write_text( + "wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_password\n" + ) + + # Create main device config that includes the package + device_config = config_dir / "test-download-secrets.yaml" + device_config.write_text( + "esphome:\n name: test-download-secrets\n platform: ESP32\n board: esp32dev\n\n" + "packages:\n common: !include common.yaml\n" + ) + + # Initialize DASHBOARD settings with our test config directory + # This is what sets CORE.config_path - the critical code path for the bug + args = Namespace( + configuration=str(config_dir), + password=None, + username=None, + ha_addon=False, + verbose=False, + ) + DASHBOARD.settings.parse_args(args) + + # With the fix: CORE.config_path should be config_dir / "___DASHBOARD_SENTINEL___.yaml" + # so CORE.config_path.parent would be config_dir + # Without the fix: CORE.config_path is config_dir / "." which normalizes to config_dir + # so CORE.config_path.parent would be tmp_path (the parent of config_dir) + + # The fix ensures CORE.config_path.parent points to config_dir + assert CORE.config_path.parent == config_dir.resolve(), ( + f"CORE.config_path.parent should point to config_dir. " + f"Got {CORE.config_path.parent}, expected {config_dir.resolve()}. " + f"CORE.config_path is {CORE.config_path}" + ) + + # Now load the YAML with packages that reference secrets + # This is where the bug would manifest - yaml_util.load_yaml would fail + # to find secrets.yaml because CORE.config_path.parent pointed to the wrong place + config = yaml_util.load_yaml(device_config) + # If we get here, secret resolution worked! + assert "esphome" in config + assert config["esphome"]["name"] == "test-download-secrets"