diff --git a/CODEOWNERS b/CODEOWNERS index 393774372f..e6970af47c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -396,6 +396,7 @@ esphome/components/rpi_dpi_rgb/* @clydebarrow esphome/components/rtl87xx/* @kuba2k2 esphome/components/rtttl/* @glmnet esphome/components/runtime_stats/* @bdraco +esphome/components/rx8130/* @beormund esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti esphome/components/scd4x/* @martgras @sjtrny esphome/components/script/* @esphome/core diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 861999d0b7..4df68a6386 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -1,6 +1,8 @@ +import importlib import logging +import pkgutil -from esphome.automation import build_automation, register_action, validate_automation +from esphome.automation import build_automation, validate_automation import esphome.codegen as cg from esphome.components.const import CONF_COLOR_DEPTH, CONF_DRAW_ROUNDING from esphome.components.display import Display @@ -25,8 +27,8 @@ from esphome.cpp_generator import MockObj from esphome.final_validate import full_config from esphome.helpers import write_file_if_changed -from . import defines as df, helpers, lv_validation as lvalid -from .automation import disp_update, focused_widgets, refreshed_widgets, update_to_code +from . import defines as df, helpers, lv_validation as lvalid, widgets +from .automation import disp_update, focused_widgets, refreshed_widgets from .defines import add_define from .encoders import ( ENCODERS_CONFIG, @@ -45,7 +47,6 @@ from .schemas import ( WIDGET_TYPES, any_widget_schema, container_schema, - create_modify_schema, obj_schema, ) from .styles import add_top_layer, styles_to_code, theme_to_code @@ -54,7 +55,6 @@ from .trigger import add_on_boot_triggers, generate_triggers from .types import ( FontEngine, IdleTrigger, - ObjUpdateAction, PlainTrigger, lv_font_t, lv_group_t, @@ -69,33 +69,23 @@ from .widgets import ( set_obj_properties, styles_used, ) -from .widgets.animimg import animimg_spec -from .widgets.arc import arc_spec -from .widgets.button import button_spec -from .widgets.buttonmatrix import buttonmatrix_spec -from .widgets.canvas import canvas_spec -from .widgets.checkbox import checkbox_spec -from .widgets.container import container_spec -from .widgets.dropdown import dropdown_spec -from .widgets.img import img_spec -from .widgets.keyboard import keyboard_spec -from .widgets.label import label_spec -from .widgets.led import led_spec -from .widgets.line import line_spec -from .widgets.lv_bar import bar_spec -from .widgets.meter import meter_spec + +# Import only what we actually use directly in this file from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code -from .widgets.obj import obj_spec -from .widgets.page import add_pages, generate_page_triggers, page_spec -from .widgets.qrcode import qr_code_spec -from .widgets.roller import roller_spec -from .widgets.slider import slider_spec -from .widgets.spinbox import spinbox_spec -from .widgets.spinner import spinner_spec -from .widgets.switch import switch_spec -from .widgets.tabview import tabview_spec -from .widgets.textarea import textarea_spec -from .widgets.tileview import tileview_spec +from .widgets.obj import obj_spec # Used in LVGL_SCHEMA +from .widgets.page import ( # page_spec used in LVGL_SCHEMA + add_pages, + generate_page_triggers, + page_spec, +) + +# Widget registration happens via WidgetType.__init__ in individual widget files +# The imports below trigger creation of the widget types +# Action registration (lvgl.{widget}.update) happens automatically +# in the WidgetType.__init__ method + +for module_info in pkgutil.iter_modules(widgets.__path__): + importlib.import_module(f".widgets.{module_info.name}", package=__package__) DOMAIN = "lvgl" DEPENDENCIES = ["display"] @@ -103,41 +93,6 @@ AUTO_LOAD = ["key_provider"] CODEOWNERS = ["@clydebarrow"] LOGGER = logging.getLogger(__name__) -for w_type in ( - label_spec, - obj_spec, - button_spec, - bar_spec, - slider_spec, - arc_spec, - line_spec, - spinner_spec, - led_spec, - animimg_spec, - checkbox_spec, - img_spec, - switch_spec, - tabview_spec, - buttonmatrix_spec, - meter_spec, - dropdown_spec, - roller_spec, - textarea_spec, - spinbox_spec, - keyboard_spec, - tileview_spec, - qr_code_spec, - canvas_spec, - container_spec, -): - WIDGET_TYPES[w_type.name] = w_type - -for w_type in WIDGET_TYPES.values(): - register_action( - f"lvgl.{w_type.name}.update", - ObjUpdateAction, - create_modify_schema(w_type), - )(update_to_code) SIMPLE_TRIGGERS = ( df.CONF_ON_PAUSE, @@ -402,6 +357,15 @@ def add_hello_world(config): return config +def _theme_schema(value): + return cv.Schema( + { + cv.Optional(name): obj_schema(w).extend(FULL_STYLE_SCHEMA) + for name, w in WIDGET_TYPES.items() + } + )(value) + + FINAL_VALIDATE_SCHEMA = final_validation LVGL_SCHEMA = cv.All( @@ -454,12 +418,7 @@ LVGL_SCHEMA = cv.All( cv.Optional( df.CONF_TRANSPARENCY_KEY, default=0x000400 ): lvalid.lv_color, - cv.Optional(df.CONF_THEME): cv.Schema( - { - cv.Optional(name): obj_schema(w).extend(FULL_STYLE_SCHEMA) - for name, w in WIDGET_TYPES.items() - } - ), + cv.Optional(df.CONF_THEME): _theme_schema, cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA, cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema, cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG, diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 0dcf420f24..6b77f66abb 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -411,6 +411,10 @@ def any_widget_schema(extras=None): Dynamically generate schemas for all possible LVGL widgets. This is what implements the ability to have a list of any kind of widget under the widgets: key. + This uses lazy evaluation - the schema is built when called during validation, + not at import time. This allows external components to register widgets + before schema validation begins. + :param extras: Additional schema to be applied to each generated one :return: A validator for the Widgets key """ diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index 8c33e13934..035320b6ac 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -1,8 +1,10 @@ import sys from esphome import automation, codegen as cg +from esphome.automation import register_action from esphome.config_validation import Schema from esphome.const import CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_TEXT, CONF_VALUE +from esphome.core import EsphomeError from esphome.cpp_generator import MockObj, MockObjClass from esphome.cpp_types import esphome_ns @@ -124,13 +126,16 @@ class WidgetType: schema=None, modify_schema=None, lv_name=None, + is_mock: bool = False, ): """ :param name: The widget name, e.g. "bar" :param w_type: The C type of the widget :param parts: What parts this widget supports :param schema: The config schema for defining a widget - :param modify_schema: A schema to update the widget + :param modify_schema: A schema to update the widget, defaults to the same as the schema + :param lv_name: The name of the LVGL widget in the LVGL library, if different from the name + :param is_mock: Whether this widget is a mock widget, i.e. not a real LVGL widget """ self.name = name self.lv_name = lv_name or name @@ -146,6 +151,22 @@ class WidgetType: self.modify_schema = modify_schema self.mock_obj = MockObj(f"lv_{self.lv_name}", "_") + # Local import to avoid circular import + from .automation import update_to_code + from .schemas import WIDGET_TYPES, create_modify_schema + + if not is_mock: + if self.name in WIDGET_TYPES: + raise EsphomeError(f"Duplicate definition of widget type '{self.name}'") + WIDGET_TYPES[self.name] = self + + # Register the update action automatically + register_action( + f"lvgl.{self.name}.update", + ObjUpdateAction, + create_modify_schema(self), + )(update_to_code) + @property def animated(self): return False diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index 7d9f9cb7de..187b5828c2 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -213,17 +213,14 @@ class LvScrActType(WidgetType): """ def __init__(self): - super().__init__("lv_scr_act()", lv_obj_t, ()) + super().__init__("lv_scr_act()", lv_obj_t, (), is_mock=True) async def to_code(self, w, config: dict): return [] -lv_scr_act_spec = LvScrActType() - - def get_scr_act(lv_comp: MockObj) -> Widget: - return Widget.create(None, lv_comp.get_scr_act(), lv_scr_act_spec, {}) + return Widget.create(None, lv_comp.get_scr_act(), LvScrActType(), {}) def get_widget_generator(wid): diff --git a/esphome/components/lvgl/widgets/spinbox.py b/esphome/components/lvgl/widgets/spinbox.py index 26ad149c6f..ac23ded723 100644 --- a/esphome/components/lvgl/widgets/spinbox.py +++ b/esphome/components/lvgl/widgets/spinbox.py @@ -2,7 +2,7 @@ from esphome import automation import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_RANGE_FROM, CONF_RANGE_TO, CONF_STEP, CONF_VALUE -from ..automation import action_to_code, update_to_code +from ..automation import action_to_code from ..defines import ( CONF_CURSOR, CONF_DECIMAL_PLACES, @@ -171,17 +171,3 @@ async def spinbox_decrement(config, action_id, template_arg, args): lv.spinbox_decrement(w.obj) return await action_to_code(widgets, do_increment, action_id, template_arg, args) - - -@automation.register_action( - "lvgl.spinbox.update", - ObjUpdateAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(lv_spinbox_t), - cv.Required(CONF_VALUE): lv_float, - } - ), -) -async def spinbox_update_to_code(config, action_id, template_arg, args): - return await update_to_code(config, action_id, template_arg, args) diff --git a/esphome/components/rx8130/__init__.py b/esphome/components/rx8130/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/rx8130/rx8130.cpp b/esphome/components/rx8130/rx8130.cpp new file mode 100644 index 0000000000..cf6ea3e6e6 --- /dev/null +++ b/esphome/components/rx8130/rx8130.cpp @@ -0,0 +1,127 @@ +#include "rx8130.h" +#include "esphome/core/log.h" + +// https://download.epsondevice.com/td/pdf/app/RX8130CE_en.pdf + +namespace esphome { +namespace rx8130 { + +static const uint8_t RX8130_REG_SEC = 0x10; +static const uint8_t RX8130_REG_MIN = 0x11; +static const uint8_t RX8130_REG_HOUR = 0x12; +static const uint8_t RX8130_REG_WDAY = 0x13; +static const uint8_t RX8130_REG_MDAY = 0x14; +static const uint8_t RX8130_REG_MONTH = 0x15; +static const uint8_t RX8130_REG_YEAR = 0x16; +static const uint8_t RX8130_REG_EXTEN = 0x1C; +static const uint8_t RX8130_REG_FLAG = 0x1D; +static const uint8_t RX8130_REG_CTRL0 = 0x1E; +static const uint8_t RX8130_REG_CTRL1 = 0x1F; +static const uint8_t RX8130_REG_DIG_OFFSET = 0x30; +static const uint8_t RX8130_BIT_CTRL_STOP = 0x40; +static const uint8_t RX8130_BAT_FLAGS = 0x30; +static const uint8_t RX8130_CLEAR_FLAGS = 0x00; + +static const char *const TAG = "rx8130"; + +constexpr uint8_t bcd2dec(uint8_t val) { return (val >> 4) * 10 + (val & 0x0f); } +constexpr uint8_t dec2bcd(uint8_t val) { return ((val / 10) << 4) + (val % 10); } + +void RX8130Component::setup() { + // Set digital offset to disabled with no offset + if (this->write_register(RX8130_REG_DIG_OFFSET, &RX8130_CLEAR_FLAGS, 1) != i2c::ERROR_OK) { + this->mark_failed(); + return; + } + // Disable wakeup timers + if (this->write_register(RX8130_REG_EXTEN, &RX8130_CLEAR_FLAGS, 1) != i2c::ERROR_OK) { + this->mark_failed(); + return; + } + // Clear VLF flag in case there has been data loss + if (this->write_register(RX8130_REG_FLAG, &RX8130_CLEAR_FLAGS, 1) != i2c::ERROR_OK) { + this->mark_failed(); + return; + } + // Clear test flag and disable interrupts + if (this->write_register(RX8130_REG_CTRL0, &RX8130_CLEAR_FLAGS, 1) != i2c::ERROR_OK) { + this->mark_failed(); + return; + } + // Enable battery charging and switching + if (this->write_register(RX8130_REG_CTRL1, &RX8130_BAT_FLAGS, 1) != i2c::ERROR_OK) { + this->mark_failed(); + return; + } + // Clear STOP bit + this->stop_(false); +} + +void RX8130Component::update() { this->read_time(); } + +void RX8130Component::dump_config() { + ESP_LOGCONFIG(TAG, "RX8130:"); + LOG_I2C_DEVICE(this); +} + +void RX8130Component::read_time() { + uint8_t date[7]; + if (this->read_register(RX8130_REG_SEC, date, 7) != i2c::ERROR_OK) { + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + return; + } + ESPTime rtc_time{ + .second = bcd2dec(date[0] & 0x7f), + .minute = bcd2dec(date[1] & 0x7f), + .hour = bcd2dec(date[2] & 0x3f), + .day_of_week = bcd2dec(date[3] & 0x7f), + .day_of_month = bcd2dec(date[4] & 0x3f), + .day_of_year = 1, // ignored by recalc_timestamp_utc(false) + .month = bcd2dec(date[5] & 0x1f), + .year = static_cast(bcd2dec(date[6]) + 2000), + .is_dst = false, // not used + .timestamp = 0 // overwritten by recalc_timestamp_utc(false) + }; + rtc_time.recalc_timestamp_utc(false); + if (!rtc_time.is_valid()) { + ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); + return; + } + ESP_LOGD(TAG, "Read UTC time: %04d-%02d-%02d %02d:%02d:%02d", rtc_time.year, rtc_time.month, rtc_time.day_of_month, + rtc_time.hour, rtc_time.minute, rtc_time.second); + time::RealTimeClock::synchronize_epoch_(rtc_time.timestamp); +} + +void RX8130Component::write_time() { + auto now = time::RealTimeClock::utcnow(); + if (!now.is_valid()) { + ESP_LOGE(TAG, "Invalid system time, not syncing to RTC."); + return; + } + uint8_t buff[7]; + buff[0] = dec2bcd(now.second); + buff[1] = dec2bcd(now.minute); + buff[2] = dec2bcd(now.hour); + buff[3] = dec2bcd(now.day_of_week); + buff[4] = dec2bcd(now.day_of_month); + buff[5] = dec2bcd(now.month); + buff[6] = dec2bcd(now.year % 100); + this->stop_(true); + if (this->write_register(RX8130_REG_SEC, buff, 7) != i2c::ERROR_OK) { + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + } else { + ESP_LOGD(TAG, "Wrote UTC time: %04d-%02d-%02d %02d:%02d:%02d", now.year, now.month, now.day_of_month, now.hour, + now.minute, now.second); + } + this->stop_(false); +} + +void RX8130Component::stop_(bool stop) { + const uint8_t data = stop ? RX8130_BIT_CTRL_STOP : RX8130_CLEAR_FLAGS; + if (this->write_register(RX8130_REG_CTRL0, &data, 1) != i2c::ERROR_OK) { + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + } +} + +} // namespace rx8130 +} // namespace esphome diff --git a/esphome/components/rx8130/rx8130.h b/esphome/components/rx8130/rx8130.h new file mode 100644 index 0000000000..6694c763cd --- /dev/null +++ b/esphome/components/rx8130/rx8130.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/time/real_time_clock.h" + +namespace esphome { +namespace rx8130 { + +class RX8130Component : public time::RealTimeClock, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + void read_time(); + void write_time(); + /// Ensure RTC is initialized at the correct time in the setup sequence + float get_setup_priority() const override { return setup_priority::DATA; } + + protected: + void stop_(bool stop); +}; + +template class WriteAction : public Action, public Parented { + public: + void play(const Ts... x) override { this->parent_->write_time(); } +}; + +template class ReadAction : public Action, public Parented { + public: + void play(const Ts... x) override { this->parent_->read_time(); } +}; + +} // namespace rx8130 +} // namespace esphome diff --git a/esphome/components/rx8130/time.py b/esphome/components/rx8130/time.py new file mode 100644 index 0000000000..cb0402bd32 --- /dev/null +++ b/esphome/components/rx8130/time.py @@ -0,0 +1,56 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import i2c, time +import esphome.config_validation as cv +from esphome.const import CONF_ID + +CODEOWNERS = ["@beormund"] +DEPENDENCIES = ["i2c"] +rx8130_ns = cg.esphome_ns.namespace("rx8130") +RX8130Component = rx8130_ns.class_("RX8130Component", time.RealTimeClock, i2c.I2CDevice) +WriteAction = rx8130_ns.class_("WriteAction", automation.Action) +ReadAction = rx8130_ns.class_("ReadAction", automation.Action) + + +CONFIG_SCHEMA = time.TIME_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(RX8130Component), + } +).extend(i2c.i2c_device_schema(0x32)) + + +@automation.register_action( + "rx8130.write_time", + WriteAction, + cv.Schema( + { + cv.GenerateID(): cv.use_id(RX8130Component), + } + ), +) +async def rx8130_write_time_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +@automation.register_action( + "rx8130.read_time", + ReadAction, + automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(RX8130Component), + } + ), +) +async def rx8130_read_time_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + await time.register_time(var, config) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 5f4190a933..0bd2e158e8 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -1,3 +1,5 @@ +import logging + from esphome import automation from esphome.automation import Condition import esphome.codegen as cg @@ -42,6 +44,7 @@ from esphome.const import ( CONF_TTLS_PHASE_2, CONF_USE_ADDRESS, CONF_USERNAME, + Platform, PlatformFramework, ) from esphome.core import CORE, CoroPriority, HexInt, coroutine_with_priority @@ -49,10 +52,13 @@ import esphome.final_validate as fv from . import wpa2_eap +_LOGGER = logging.getLogger(__name__) + AUTO_LOAD = ["network"] NO_WIFI_VARIANTS = [const.VARIANT_ESP32H2, const.VARIANT_ESP32P4] CONF_SAVE = "save" +CONF_MIN_AUTH_MODE = "min_auth_mode" # Maximum number of WiFi networks that can be configured # Limited to 127 because selected_sta_index_ is int8_t in C++ @@ -70,6 +76,14 @@ WIFI_POWER_SAVE_MODES = { "LIGHT": WiFiPowerSaveMode.WIFI_POWER_SAVE_LIGHT, "HIGH": WiFiPowerSaveMode.WIFI_POWER_SAVE_HIGH, } + +WifiMinAuthMode = wifi_ns.enum("WifiMinAuthMode") +WIFI_MIN_AUTH_MODES = { + "WPA": WifiMinAuthMode.WIFI_MIN_AUTH_MODE_WPA, + "WPA2": WifiMinAuthMode.WIFI_MIN_AUTH_MODE_WPA2, + "WPA3": WifiMinAuthMode.WIFI_MIN_AUTH_MODE_WPA3, +} +VALIDATE_WIFI_MIN_AUTH_MODE = cv.enum(WIFI_MIN_AUTH_MODES, upper=True) WiFiConnectedCondition = wifi_ns.class_("WiFiConnectedCondition", Condition) WiFiEnabledCondition = wifi_ns.class_("WiFiEnabledCondition", Condition) WiFiEnableAction = wifi_ns.class_("WiFiEnableAction", automation.Action) @@ -187,6 +201,27 @@ def validate_variant(_): raise cv.Invalid(f"WiFi requires component esp32_hosted on {variant}") +def _apply_min_auth_mode_default(config): + """Apply platform-specific default for min_auth_mode and warn ESP8266 users.""" + # Only apply defaults for platforms that support min_auth_mode + if CONF_MIN_AUTH_MODE not in config and (CORE.is_esp8266 or CORE.is_esp32): + if CORE.is_esp8266: + _LOGGER.warning( + "The minimum WiFi authentication mode (wifi -> min_auth_mode) is not set. " + "This controls the weakest encryption your device will accept when connecting to WiFi. " + "Currently defaults to WPA (less secure), but will change to WPA2 (more secure) in 2026.6.0. " + "WPA uses TKIP encryption which has known security vulnerabilities and should be avoided. " + "WPA2 uses AES encryption which is significantly more secure. " + "To silence this warning, explicitly set min_auth_mode under 'wifi:'. " + "If your router supports WPA2 or WPA3, set 'min_auth_mode: WPA2'. " + "If your router only supports WPA, set 'min_auth_mode: WPA'." + ) + config[CONF_MIN_AUTH_MODE] = VALIDATE_WIFI_MIN_AUTH_MODE("WPA") + elif CORE.is_esp32: + config[CONF_MIN_AUTH_MODE] = VALIDATE_WIFI_MIN_AUTH_MODE("WPA2") + return config + + def final_validate(config): has_sta = bool(config.get(CONF_NETWORKS, True)) has_ap = CONF_AP in config @@ -287,6 +322,10 @@ CONFIG_SCHEMA = cv.All( ): cv.enum(WIFI_POWER_SAVE_MODES, upper=True), cv.Optional(CONF_FAST_CONNECT, default=False): cv.boolean, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, + cv.Optional(CONF_MIN_AUTH_MODE): cv.All( + VALIDATE_WIFI_MIN_AUTH_MODE, + cv.only_on([Platform.ESP32, Platform.ESP8266]), + ), cv.SplitDefault(CONF_OUTPUT_POWER, esp8266=20.0): cv.All( cv.decibel, cv.float_range(min=8.5, max=20.5) ), @@ -311,6 +350,7 @@ CONFIG_SCHEMA = cv.All( ), } ), + _apply_min_auth_mode_default, _validate, ) @@ -420,6 +460,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])) + if CONF_MIN_AUTH_MODE in config: + cg.add(var.set_min_auth_mode(config[CONF_MIN_AUTH_MODE])) if config[CONF_FAST_CONNECT]: cg.add_define("USE_WIFI_FAST_CONNECT") cg.add(var.set_passive_scan(config[CONF_PASSIVE_SCAN])) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 789c22bae1..885288f46a 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -42,6 +42,258 @@ namespace wifi { static const char *const TAG = "wifi"; +/// WiFi Retry Logic - Priority-Based BSSID Selection +/// +/// The WiFi component uses a state machine with priority degradation to handle connection failures +/// and automatically cycle through different BSSIDs in mesh networks or multiple configured networks. +/// +/// Connection Flow: +/// ┌──────────────────────────────────────────────────────────────────────┐ +/// │ Fast Connect Path (Optional) │ +/// ├──────────────────────────────────────────────────────────────────────┤ +/// │ Entered if: configuration has 'fast_connect: true' │ +/// │ Optimization to skip scanning when possible: │ +/// │ │ +/// │ 1. INITIAL_CONNECT → Try one of: │ +/// │ a) Saved BSSID+channel (from previous boot) │ +/// │ b) First configured non-hidden network (any BSSID) │ +/// │ ↓ │ +/// │ [FAILED] → Check if more configured networks available │ +/// │ ↓ │ +/// │ 2. FAST_CONNECT_CYCLING_APS → Try remaining configured networks │ +/// │ (1 attempt each, any BSSID) │ +/// │ ↓ │ +/// │ [All Failed] → Fall through to explicit hidden or scanning │ +/// │ │ +/// │ Note: Fast connect data saved from previous successful connection │ +/// └──────────────────────────────────────────────────────────────────────┘ +/// ↓ +/// ┌──────────────────────────────────────────────────────────────────────┐ +/// │ Explicit Hidden Networks Path (Optional) │ +/// ├──────────────────────────────────────────────────────────────────────┤ +/// │ Entered if: first configured network has 'hidden: true' │ +/// │ │ +/// │ 1. EXPLICIT_HIDDEN → Try consecutive hidden networks (1 attempt) │ +/// │ Stop when visible network reached │ +/// │ ↓ │ +/// │ Example: Hidden1, Hidden2, Visible1, Hidden3, Visible2 │ +/// │ Try: Hidden1, Hidden2 (stop at Visible1) │ +/// │ ↓ │ +/// │ [All Failed] → Fall back to scan-based connection │ +/// │ │ +/// │ Note: Fast connect saves BSSID after first successful connection, │ +/// │ so subsequent boots use fast path instead of hidden mode │ +/// └──────────────────────────────────────────────────────────────────────┘ +/// ↓ +/// ┌──────────────────────────────────────────────────────────────────────┐ +/// │ Scan-Based Connection Path │ +/// ├──────────────────────────────────────────────────────────────────────┤ +/// │ │ +/// │ 1. SCAN → Sort by priority (highest first), then RSSI │ +/// │ ┌─────────────────────────────────────────────────┐ │ +/// │ │ scan_result_[0] = Best BSSID (highest priority) │ │ +/// │ │ scan_result_[1] = Second best │ │ +/// │ │ scan_result_[2] = Third best │ │ +/// │ └─────────────────────────────────────────────────┘ │ +/// │ ↓ │ +/// │ 2. SCAN_CONNECTING → Try scan_result_[0] (2 attempts) │ +/// │ (Visible1, Visible2 from example above) │ +/// │ ↓ │ +/// │ 3. FAILED → Decrease priority: 0.0 → -1.0 → -2.0 │ +/// │ (stored in persistent sta_priorities_) │ +/// │ ↓ │ +/// │ 4. Check for hidden networks: │ +/// │ - If found → RETRY_HIDDEN (try SSIDs not in scan, 1 attempt) │ +/// │ Skip hidden networks before first visible one │ +/// │ (Skip Hidden1/Hidden2, try Hidden3 from example) │ +/// │ - If none → Skip RETRY_HIDDEN, go to step 5 │ +/// │ ↓ │ +/// │ 5. FAILED → RESTARTING_ADAPTER (skipped if AP/improv active) │ +/// │ ↓ │ +/// │ 6. Loop back to start: │ +/// │ - If first network is hidden → EXPLICIT_HIDDEN (retry cycle) │ +/// │ - Otherwise → SCAN_CONNECTING (rescan) │ +/// │ ↓ │ +/// │ 7. RESCAN → Apply stored priorities, sort again │ +/// │ ┌─────────────────────────────────────────────────┐ │ +/// │ │ scan_result_[0] = BSSID B (priority 0.0) ← NEW │ │ +/// │ │ scan_result_[1] = BSSID C (priority 0.0) │ │ +/// │ │ scan_result_[2] = BSSID A (priority -2.0) ← OLD │ │ +/// │ └─────────────────────────────────────────────────┘ │ +/// │ ↓ │ +/// │ 8. SCAN_CONNECTING → Try scan_result_[0] (next best) │ +/// │ │ +/// │ Key: Priority system cycles through BSSIDs ACROSS scan cycles │ +/// │ Full retry cycle: EXPLICIT_HIDDEN → SCAN → RETRY_HIDDEN │ +/// │ Always try best available BSSID (scan_result_[0]) │ +/// └──────────────────────────────────────────────────────────────────────┘ +/// +/// Retry Phases: +/// - INITIAL_CONNECT: Try saved BSSID+channel (fast_connect), or fall back to normal flow +/// - FAST_CONNECT_CYCLING_APS: Cycle through remaining configured networks (1 attempt each, fast_connect only) +/// - EXPLICIT_HIDDEN: Try consecutive networks marked hidden:true before scanning (1 attempt per SSID) +/// - SCAN_CONNECTING: Connect using scan results (2 attempts per BSSID) +/// - RETRY_HIDDEN: Try networks not found in scan (1 attempt per SSID, skipped if none found) +/// - RESTARTING_ADAPTER: Restart WiFi adapter to clear stuck state +/// +/// Hidden Network Handling: +/// - Networks marked 'hidden: true' before first non-hidden → Tried in EXPLICIT_HIDDEN phase +/// - Networks marked 'hidden: true' after first non-hidden → Tried in RETRY_HIDDEN phase +/// - After successful connection, fast_connect saves BSSID → subsequent boots use fast path +/// - Networks not in scan results → Tried in RETRY_HIDDEN phase +/// - Networks visible in scan + not marked hidden → Skipped in RETRY_HIDDEN phase +/// - Networks marked 'hidden: true' always use hidden mode, even if broadcasting SSID + +static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) { + switch (phase) { + case WiFiRetryPhase::INITIAL_CONNECT: + return LOG_STR("INITIAL_CONNECT"); +#ifdef USE_WIFI_FAST_CONNECT + case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS: + return LOG_STR("FAST_CONNECT_CYCLING"); +#endif + case WiFiRetryPhase::EXPLICIT_HIDDEN: + return LOG_STR("EXPLICIT_HIDDEN"); + case WiFiRetryPhase::SCAN_CONNECTING: + return LOG_STR("SCAN_CONNECTING"); + case WiFiRetryPhase::RETRY_HIDDEN: + return LOG_STR("RETRY_HIDDEN"); + case WiFiRetryPhase::RESTARTING_ADAPTER: + return LOG_STR("RESTARTING"); + default: + return LOG_STR("UNKNOWN"); + } +} + +bool WiFiComponent::went_through_explicit_hidden_phase_() const { + // If first configured network is marked hidden, we went through EXPLICIT_HIDDEN phase + // This means those networks were already tried and should be skipped in RETRY_HIDDEN + return !this->sta_.empty() && this->sta_[0].get_hidden(); +} + +int8_t WiFiComponent::find_first_non_hidden_index_() const { + // Find the first network that is NOT marked hidden:true + // This is where EXPLICIT_HIDDEN phase would have stopped + for (size_t i = 0; i < this->sta_.size(); i++) { + if (!this->sta_[i].get_hidden()) { + return static_cast(i); + } + } + return -1; // All networks are hidden +} + +// 2 attempts per BSSID in SCAN_CONNECTING phase +// Rationale: This is the ONLY phase where we decrease BSSID priority, so we must be very sure. +// Auth failures are common immediately after scan due to WiFi stack state transitions. +// Trying twice filters out false positives and prevents unnecessarily marking a good BSSID as bad. +// After 2 genuine failures, priority degradation ensures we skip this BSSID on subsequent scans. +static constexpr uint8_t WIFI_RETRY_COUNT_PER_BSSID = 2; + +// 1 attempt per SSID in RETRY_HIDDEN phase +// Rationale: Try hidden mode once, then rescan to get next best BSSID via priority system +static constexpr uint8_t WIFI_RETRY_COUNT_PER_SSID = 1; + +// 1 attempt per AP in fast_connect mode (INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS) +// Rationale: Fast connect prioritizes speed - try each AP once to find a working one quickly +static constexpr uint8_t WIFI_RETRY_COUNT_PER_AP = 1; + +static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) { + switch (phase) { + case WiFiRetryPhase::INITIAL_CONNECT: +#ifdef USE_WIFI_FAST_CONNECT + case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS: +#endif + // INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS both use 1 attempt per AP (fast_connect mode) + return WIFI_RETRY_COUNT_PER_AP; + case WiFiRetryPhase::EXPLICIT_HIDDEN: + // Explicitly hidden network: 1 attempt (user marked as hidden, try once then scan) + return WIFI_RETRY_COUNT_PER_SSID; + case WiFiRetryPhase::SCAN_CONNECTING: + // Scan-based phase: 2 attempts per BSSID (handles transient auth failures after scan) + return WIFI_RETRY_COUNT_PER_BSSID; + case WiFiRetryPhase::RETRY_HIDDEN: + // Hidden network mode: 1 attempt per SSID + return WIFI_RETRY_COUNT_PER_SSID; + default: + return WIFI_RETRY_COUNT_PER_BSSID; + } +} + +static void apply_scan_result_to_params(WiFiAP ¶ms, const WiFiScanResult &scan) { + params.set_hidden(false); + params.set_ssid(scan.get_ssid()); + params.set_bssid(scan.get_bssid()); + params.set_channel(scan.get_channel()); +} + +bool WiFiComponent::needs_scan_results_() const { + // Only SCAN_CONNECTING phase needs scan results + if (this->retry_phase_ != WiFiRetryPhase::SCAN_CONNECTING) { + return false; + } + // Need scan if we have no results or no matching networks + return this->scan_result_.empty() || !this->scan_result_[0].get_matches(); +} + +bool WiFiComponent::ssid_was_seen_in_scan_(const std::string &ssid) const { + // Check if this SSID is configured as hidden + // If explicitly marked hidden, we should always try hidden mode regardless of scan results + for (const auto &conf : this->sta_) { + if (conf.get_ssid() == ssid && conf.get_hidden()) { + return false; // Treat as not seen - force hidden mode attempt + } + } + + // Otherwise, check if we saw it in scan results + for (const auto &scan : this->scan_result_) { + if (scan.get_ssid() == ssid) { + return true; + } + } + return false; +} + +int8_t WiFiComponent::find_next_hidden_sta_(int8_t start_index, bool include_explicit_hidden) { + // Find next SSID that wasn't in scan results (might be hidden) + // Start searching from start_index + 1 + for (size_t i = start_index + 1; i < this->sta_.size(); i++) { + const auto &sta = this->sta_[i]; + + // Skip networks that were already tried in EXPLICIT_HIDDEN phase + // Those are: networks marked hidden:true that appear before the first non-hidden network + if (!include_explicit_hidden && sta.get_hidden()) { + int8_t first_non_hidden_idx = this->find_first_non_hidden_index_(); + if (first_non_hidden_idx >= 0 && static_cast(i) < first_non_hidden_idx) { + ESP_LOGD(TAG, "Skipping " LOG_SECRET("'%s'") " (explicit hidden, already tried)", sta.get_ssid().c_str()); + continue; + } + } + + if (!this->ssid_was_seen_in_scan_(sta.get_ssid())) { + ESP_LOGD(TAG, "Hidden candidate " LOG_SECRET("'%s'") " at index %d", sta.get_ssid().c_str(), static_cast(i)); + return static_cast(i); + } + ESP_LOGD(TAG, "Skipping " LOG_SECRET("'%s'") " (visible in scan)", sta.get_ssid().c_str()); + } + // No hidden SSIDs found + return -1; +} + +void WiFiComponent::start_initial_connection_() { + // If first network (highest priority) is explicitly marked hidden, try it first before scanning + // This respects user's priority order when they explicitly configure hidden networks + if (!this->sta_.empty() && this->sta_[0].get_hidden()) { + ESP_LOGI(TAG, "Starting with explicit hidden network (highest priority)"); + this->selected_sta_index_ = 0; + this->retry_phase_ = WiFiRetryPhase::EXPLICIT_HIDDEN; + WiFiAP params = this->build_params_for_current_phase_(); + this->start_connecting(params, false); + } else { + ESP_LOGI(TAG, "Starting scan"); + this->start_scanning(); + } +} + #if defined(USE_ESP32) && defined(USE_WIFI_WPA2_EAP) && ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE static const char *eap_phase2_to_str(esp_eap_ttls_phase2_types type) { switch (type) { @@ -109,18 +361,28 @@ void WiFiComponent::start() { ESP_LOGV(TAG, "Setting Power Save Option failed"); } + this->transition_to_phase_(WiFiRetryPhase::INITIAL_CONNECT); #ifdef USE_WIFI_FAST_CONNECT WiFiAP params; - this->trying_loaded_ap_ = this->load_fast_connect_settings_(params); - if (!this->trying_loaded_ap_) { - // FAST CONNECT FALLBACK: No saved settings available - // Use first config (will use SSID from config) + bool loaded_fast_connect = this->load_fast_connect_settings_(params); + // Fast connect optimization: only use when we have saved BSSID+channel data + // Without saved data, try first configured network or use normal flow + if (loaded_fast_connect) { + ESP_LOGI(TAG, "Starting fast_connect (saved) " LOG_SECRET("'%s'"), params.get_ssid().c_str()); + this->start_connecting(params, false); + } else if (!this->sta_.empty() && !this->sta_[0].get_hidden()) { + // No saved data, but have configured networks - try first non-hidden network + ESP_LOGI(TAG, "Starting fast_connect (config) " LOG_SECRET("'%s'"), this->sta_[0].get_ssid().c_str()); this->selected_sta_index_ = 0; - params = this->build_wifi_ap_from_selected_(); + params = this->build_params_for_current_phase_(); + this->start_connecting(params, false); + } else { + // No saved data and (no networks OR first is hidden) - use normal flow + this->start_initial_connection_(); } - this->start_connecting(params, false); #else - this->start_scanning(); + // Without fast_connect: go straight to scanning (or hidden mode if all networks are hidden) + this->start_initial_connection_(); #endif #ifdef USE_WIFI_AP } else if (this->has_ap()) { @@ -150,8 +412,7 @@ void WiFiComponent::restart_adapter() { ESP_LOGW(TAG, "Restarting adapter"); this->wifi_mode_(false, {}); delay(100); // NOLINT - this->num_retried_ = 0; - this->retry_hidden_ = false; + // Don't set retry_phase_ or num_retried_ here - state machine handles transitions } void WiFiComponent::loop() { @@ -172,21 +433,19 @@ void WiFiComponent::loop() { case WIFI_COMPONENT_STATE_COOLDOWN: { this->status_set_warning(LOG_STR("waiting to reconnect")); if (millis() - this->action_started_ > 5000) { -#ifdef USE_WIFI_FAST_CONNECT - // Safety check: Ensure selected_sta_index_ is valid before retrying - // (should already be set by retry_connect(), but check for robustness) + // After cooldown, connect based on current retry phase this->reset_selected_ap_to_first_if_invalid_(); - WiFiAP params = this->build_wifi_ap_from_selected_(); - this->start_connecting(params, false); -#else - if (this->retry_hidden_) { - this->reset_selected_ap_to_first_if_invalid_(); - WiFiAP params = this->build_wifi_ap_from_selected_(); - this->start_connecting(params, false); - } else { + + // Check if we need to trigger a scan first + if (this->needs_scan_results_() && !this->all_networks_hidden_()) { + // Need scan results or no matching networks found - scan/rescan + ESP_LOGD(TAG, "Scanning required for phase %s", LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_))); this->start_scanning(); + } else { + // Have everything we need to connect (or all networks are hidden, skip scanning) + WiFiAP params = this->build_params_for_current_phase_(); + this->start_connecting(params, false); } -#endif } break; } @@ -344,30 +603,44 @@ void WiFiComponent::set_sta(const WiFiAP &ap) { this->selected_sta_index_ = 0; } -WiFiAP WiFiComponent::build_wifi_ap_from_selected_() const { - // PRECONDITION: selected_sta_index_ must be valid (ensured by all callers) +WiFiAP WiFiComponent::build_params_for_current_phase_() { const WiFiAP *config = this->get_selected_sta_(); - assert(config != nullptr); + if (config == nullptr) { + ESP_LOGE(TAG, "No valid network config (selected_sta_index_=%d, sta_.size()=%zu)", + static_cast(this->selected_sta_index_), this->sta_.size()); + // Return empty params - caller should handle this gracefully + return WiFiAP(); + } + WiFiAP params = *config; - // SYNCHRONIZATION: selected_sta_index_ and scan_result_[0] are kept in sync after wifi_scan_done(): - // - wifi_scan_done() sorts all scan results by priority/RSSI (best first) - // - It then finds which sta_[i] config matches scan_result_[0] - // - Sets selected_sta_index_ = i to record that matching config - // This sync holds until scan_result_ is cleared (e.g., after connection or in reset_for_next_ap_attempt_()) - if (!this->scan_result_.empty()) { - // Override with scan data - network is visible - const WiFiScanResult &scan = this->scan_result_[0]; - params.set_hidden(false); - params.set_ssid(scan.get_ssid()); - params.set_bssid(scan.get_bssid()); - params.set_channel(scan.get_channel()); - } else if (params.get_hidden()) { - // Hidden network - clear BSSID and channel even if set in config - // There might be multiple hidden networks with same SSID but we can't know which is correct - // Rely on probe-req with just SSID. Empty channel triggers ALL_CHANNEL_SCAN. - params.set_bssid(optional{}); - params.set_channel(optional{}); + switch (this->retry_phase_) { + case WiFiRetryPhase::INITIAL_CONNECT: +#ifdef USE_WIFI_FAST_CONNECT + case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS: +#endif + // Fast connect phases: use config-only (no scan results) + // BSSID/channel from config if user specified them, otherwise empty + break; + + case WiFiRetryPhase::EXPLICIT_HIDDEN: + case WiFiRetryPhase::RETRY_HIDDEN: + // Hidden network mode: clear BSSID/channel to trigger probe request + // (both explicit hidden and retry hidden use same behavior) + params.set_bssid(optional{}); + params.set_channel(optional{}); + break; + + case WiFiRetryPhase::SCAN_CONNECTING: + // Scan-based phase: always use best scan result (index 0 - highest priority after sorting) + if (!this->scan_result_.empty()) { + apply_scan_result_to_params(params, this->scan_result_[0]); + } + break; + + case WiFiRetryPhase::RESTARTING_ADAPTER: + // Should not be building params during restart + break; } return params; @@ -392,7 +665,21 @@ void WiFiComponent::save_wifi_sta(const std::string &ssid, const std::string &pa } void WiFiComponent::start_connecting(const WiFiAP &ap, bool two) { - ESP_LOGI(TAG, "Connecting to '%s'", ap.get_ssid().c_str()); + // Log connection attempt at INFO level with priority + std::string bssid_formatted; + float priority = 0.0f; + + if (ap.get_bssid().has_value()) { + bssid_formatted = format_mac_address_pretty(ap.get_bssid().value().data()); + priority = this->get_sta_priority(ap.get_bssid().value()); + } + + ESP_LOGI(TAG, + "Connecting to " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") " (priority %.1f, attempt %u/%u in phase %s)...", + ap.get_ssid().c_str(), ap.get_bssid().has_value() ? bssid_formatted.c_str() : LOG_STR_LITERAL("any"), + priority, this->num_retried_ + 1, get_max_retries_for_phase(this->retry_phase_), + LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_))); + #ifdef ESPHOME_LOG_HAS_VERBOSE ESP_LOGV(TAG, "Connection Params:"); ESP_LOGV(TAG, " SSID: '%s'", ap.get_ssid().c_str()); @@ -565,8 +852,39 @@ void WiFiComponent::start_scanning() { this->state_ = WIFI_COMPONENT_STATE_STA_SCANNING; } -// Helper function for WiFi scan result comparison -// Returns true if 'a' should be placed before 'b' in the sorted order +/// Comparator for WiFi scan result sorting - determines which network should be tried first +/// Returns true if 'a' should be placed before 'b' in the sorted order (a is "better" than b) +/// +/// Sorting logic (in priority order): +/// 1. Matching networks always ranked before non-matching networks +/// 2. For matching networks: Priority first (CRITICAL - tracks failure history) +/// 3. RSSI as tiebreaker for equal priority or non-matching networks +/// +/// WHY PRIORITY MUST BE CHECKED FIRST: +/// The priority field tracks connection failure history via priority degradation: +/// - Initial priority: 0.0 (from config or default) +/// - Each connection failure: priority -= 1.0 (becomes -1.0, -2.0, -3.0, etc.) +/// - Failed BSSIDs sorted lower → naturally try different BSSID on next scan +/// +/// This enables automatic BSSID cycling for various real-world failure scenarios: +/// - Crashed/hung AP (visible but not responding) +/// - Misconfigured mesh node (accepts auth but no DHCP/routing) +/// - Capacity limits (AP refuses new clients) +/// - Rogue AP (same SSID, wrong password or malicious) +/// - Intermittent hardware issues (flaky radio, overheating) +/// +/// Example mesh network: 3 APs with same SSID "home", all at priority 0.0 initially +/// - Try strongest BSSID A (sorted by RSSI) → fails → priority A becomes -1.0 +/// - Next scan: BSSID B and C (priority 0.0) sorted BEFORE A (priority -1.0) +/// - Try next strongest BSSID B → succeeds or fails and gets deprioritized +/// - System naturally cycles through all BSSIDs via priority degradation +/// - Eventually finds working AP or tries all options before restarting adapter +/// +/// If we checked RSSI first (Bug in PR #9963): +/// - Same failed BSSID would keep being selected if it has strongest signal +/// - Device stuck connecting to crashed AP with -30dBm while working AP at -50dBm ignored +/// - Priority degradation would be useless +/// - Mesh networks would never recover from single AP failure [[nodiscard]] inline static bool wifi_scan_result_is_better(const WiFiScanResult &a, const WiFiScanResult &b) { // Matching networks always come before non-matching if (a.get_matches() && !b.get_matches()) @@ -574,21 +892,13 @@ void WiFiComponent::start_scanning() { if (!a.get_matches() && b.get_matches()) return false; - if (a.get_matches() && b.get_matches()) { - // For APs with the same SSID, always prefer stronger signal - // This helps with mesh networks and multiple APs - if (a.get_ssid() == b.get_ssid()) { - return a.get_rssi() > b.get_rssi(); - } - - // For different SSIDs, check priority first - if (a.get_priority() != b.get_priority()) - return a.get_priority() > b.get_priority(); - // If priorities are equal, prefer stronger signal - return a.get_rssi() > b.get_rssi(); + // Both matching: check priority first (tracks connection failures via priority degradation) + // Priority is decreased when a BSSID fails to connect, so lower priority = previously failed + if (a.get_matches() && b.get_matches() && a.get_priority() != b.get_priority()) { + return a.get_priority() > b.get_priority(); } - // Both don't match - sort by signal strength + // Use RSSI as tiebreaker (for equal-priority matching networks or all non-matching networks) return a.get_rssi() > b.get_rssi(); } @@ -623,10 +933,8 @@ __attribute__((noinline)) static void log_scan_result(const WiFiScanResult &res) ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), res.get_is_hidden() ? LOG_STR_LITERAL("(HIDDEN) ") : LOG_STR_LITERAL(""), bssid_s, LOG_STR_ARG(get_signal_bars(res.get_rssi()))); - ESP_LOGD(TAG, - " Channel: %u\n" - " RSSI: %d dB", - res.get_channel(), res.get_rssi()); + ESP_LOGD(TAG, " Channel: %2u, RSSI: %3d dB, Priority: %4.1f", res.get_channel(), res.get_rssi(), + res.get_priority()); } else { ESP_LOGD(TAG, "- " LOG_SECRET("'%s'") " " LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), bssid_s, LOG_STR_ARG(get_signal_bars(res.get_rssi()))); @@ -675,34 +983,36 @@ void WiFiComponent::check_scanning_finished() { // SYNCHRONIZATION POINT: Establish link between scan_result_[0] and selected_sta_index_ // After sorting, scan_result_[0] contains the best network. Now find which sta_[i] config // matches that network and record it in selected_sta_index_. This keeps the two indices - // synchronized so build_wifi_ap_from_selected_() can safely use both to build connection parameters. + // synchronized so build_params_for_current_phase_() can safely use both to build connection parameters. const WiFiScanResult &scan_res = this->scan_result_[0]; - if (!scan_res.get_matches()) { - ESP_LOGW(TAG, "No matching network found"); - this->retry_connect(); - return; - } - bool found_match = false; - for (size_t i = 0; i < this->sta_.size(); i++) { - if (scan_res.matches(this->sta_[i])) { - // Safe cast: sta_.size() limited to MAX_WIFI_NETWORKS (127) in __init__.py validation - // No overflow check needed - YAML validation prevents >127 networks - this->selected_sta_index_ = static_cast(i); // Links scan_result_[0] with sta_[i] - found_match = true; - break; + if (scan_res.get_matches()) { + for (size_t i = 0; i < this->sta_.size(); i++) { + if (scan_res.matches(this->sta_[i])) { + // Safe cast: sta_.size() limited to MAX_WIFI_NETWORKS (127) in __init__.py validation + // No overflow check needed - YAML validation prevents >127 networks + this->selected_sta_index_ = static_cast(i); // Links scan_result_[0] with sta_[i] + found_match = true; + break; + } } } if (!found_match) { ESP_LOGW(TAG, "No matching network found"); - this->retry_connect(); - return; + // No scan results matched our configured networks - transition directly to hidden mode + // Don't call retry_connect() since we never attempted a connection (no BSSID to penalize) + this->transition_to_phase_(WiFiRetryPhase::RETRY_HIDDEN); + // Now start connection attempt in hidden mode + } else if (this->transition_to_phase_(WiFiRetryPhase::SCAN_CONNECTING)) { + return; // scan started, wait for next loop iteration } yield(); - WiFiAP params = this->build_wifi_ap_from_selected_(); + WiFiAP params = this->build_params_for_current_phase_(); + // Ensure we're in SCAN_CONNECTING phase when connecting with scan results + // (needed when scan was started directly without transition_to_phase_, e.g., initial scan) this->start_connecting(params, false); } @@ -724,11 +1034,14 @@ void WiFiComponent::check_connecting_finished() { ESP_LOGI(TAG, "Connected"); // Warn if we had to retry with hidden network mode for a network that's not marked hidden // Only warn if we actually connected without scan data (SSID only), not if scan succeeded on retry - if (const WiFiAP *config = this->get_selected_sta_(); - this->retry_hidden_ && config && !config->get_hidden() && this->scan_result_.empty()) { - ESP_LOGW(TAG, "Network '%s' should be marked as hidden", config->get_ssid().c_str()); + if (const WiFiAP *config = this->get_selected_sta_(); this->retry_phase_ == WiFiRetryPhase::RETRY_HIDDEN && + config && !config->get_hidden() && + this->scan_result_.empty()) { + ESP_LOGW(TAG, LOG_SECRET("'%s'") " should be marked hidden", config->get_ssid().c_str()); } - this->retry_hidden_ = false; + // Reset to initial phase on successful connection (don't log transition, just reset state) + this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT; + this->num_retried_ = 0; this->print_connect_params_(); @@ -796,58 +1109,334 @@ void WiFiComponent::check_connecting_finished() { this->retry_connect(); } -void WiFiComponent::retry_connect() { - if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_bssid()) { - auto bssid = *config->get_bssid(); - float priority = this->get_sta_priority(bssid); - this->set_sta_priority(bssid, priority - 1.0f); +/// Determine the next retry phase based on current state and failure conditions +/// This function examines the current retry phase, number of retries, and failure reasons +/// to decide what phase to move to next. It does not modify any state - it only returns +/// the recommended next phase. +/// +/// @return The next WiFiRetryPhase to transition to (may be same as current phase if should retry) +WiFiRetryPhase WiFiComponent::determine_next_phase_() { + switch (this->retry_phase_) { + case WiFiRetryPhase::INITIAL_CONNECT: +#ifdef USE_WIFI_FAST_CONNECT + case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS: + // INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS: no retries, try next AP or fall back to scan + if (this->selected_sta_index_ < static_cast(this->sta_.size()) - 1) { + return WiFiRetryPhase::FAST_CONNECT_CYCLING_APS; // Move to next AP + } +#endif + // No more APs to try, fall back to scan + return WiFiRetryPhase::SCAN_CONNECTING; + + case WiFiRetryPhase::EXPLICIT_HIDDEN: { + // Try all explicitly hidden networks before scanning + if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_SSID) { + return WiFiRetryPhase::EXPLICIT_HIDDEN; // Keep retrying same SSID + } + + // Exhausted retries on current SSID - check for more explicitly hidden networks + // Stop when we reach a visible network (proceed to scanning) + size_t next_index = this->selected_sta_index_ + 1; + if (next_index < this->sta_.size() && this->sta_[next_index].get_hidden()) { + // Found another explicitly hidden network + return WiFiRetryPhase::EXPLICIT_HIDDEN; + } + + // No more consecutive explicitly hidden networks - proceed to scanning + return WiFiRetryPhase::SCAN_CONNECTING; + } + + case WiFiRetryPhase::SCAN_CONNECTING: + // If scan found no matching networks, skip to hidden network mode + if (!this->scan_result_.empty() && !this->scan_result_[0].get_matches()) { + return WiFiRetryPhase::RETRY_HIDDEN; + } + + if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_BSSID) { + return WiFiRetryPhase::SCAN_CONNECTING; // Keep retrying same BSSID + } + + // Exhausted retries on current BSSID (scan_result_[0]) + // Its priority has been decreased, so on next scan it will be sorted lower + // and we'll try the next best BSSID. + // Check if there are any potentially hidden networks to try + if (this->find_next_hidden_sta_(-1, !this->went_through_explicit_hidden_phase_()) >= 0) { + return WiFiRetryPhase::RETRY_HIDDEN; // Found hidden networks to try + } + // No hidden networks - skip directly to restart/rescan + if (this->is_captive_portal_active_() || this->is_esp32_improv_active_()) { + return this->went_through_explicit_hidden_phase_() ? WiFiRetryPhase::EXPLICIT_HIDDEN + : WiFiRetryPhase::SCAN_CONNECTING; + } + return WiFiRetryPhase::RESTARTING_ADAPTER; + + case WiFiRetryPhase::RETRY_HIDDEN: + // If no hidden SSIDs to try (selected_sta_index_ == -1), skip directly to rescan + if (this->selected_sta_index_ >= 0) { + if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_SSID) { + return WiFiRetryPhase::RETRY_HIDDEN; // Keep retrying same SSID + } + + // Exhausted retries on current SSID - check if there are more potentially hidden SSIDs to try + if (this->selected_sta_index_ < static_cast(this->sta_.size()) - 1) { + // More SSIDs available - stay in RETRY_HIDDEN, advance will happen in retry_connect() + return WiFiRetryPhase::RETRY_HIDDEN; + } + } + // Exhausted all potentially hidden SSIDs - rescan to try next BSSID + // If captive portal/improv is active, skip adapter restart and go back to start + // Otherwise restart adapter to clear any stuck state + if (this->is_captive_portal_active_() || this->is_esp32_improv_active_()) { + // Go back to explicit hidden if we went through it initially, otherwise scan + return this->went_through_explicit_hidden_phase_() ? WiFiRetryPhase::EXPLICIT_HIDDEN + : WiFiRetryPhase::SCAN_CONNECTING; + } + + // Restart adapter + return WiFiRetryPhase::RESTARTING_ADAPTER; + + case WiFiRetryPhase::RESTARTING_ADAPTER: + // After restart, go back to explicit hidden if we went through it initially, otherwise scan + return this->went_through_explicit_hidden_phase_() ? WiFiRetryPhase::EXPLICIT_HIDDEN + : WiFiRetryPhase::SCAN_CONNECTING; } - delay(10); - if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_() && - (this->num_retried_ > 3 || this->error_from_callback_)) { -#ifdef USE_WIFI_FAST_CONNECT - // No empty check needed - YAML validation requires at least one network for fast_connect - if (this->trying_loaded_ap_) { - this->trying_loaded_ap_ = false; - this->selected_sta_index_ = 0; // Retry from the first configured AP - this->reset_for_next_ap_attempt_(); - } else if (this->selected_sta_index_ >= static_cast(this->sta_.size()) - 1) { - // Safe cast: sta_.size() limited to MAX_WIFI_NETWORKS (127) in __init__.py validation - // Exhausted all configured APs, restart adapter and cycle back to first - // Restart clears any stuck WiFi driver state - // Each AP is tried with config data only (SSID + optional BSSID/channel if user configured them) - // Typically SSID only, which triggers ESP-IDF internal scanning - ESP_LOGW(TAG, "No more APs to try"); - this->selected_sta_index_ = 0; - this->reset_for_next_ap_attempt_(); - this->restart_adapter(); - } else { - // Try next AP - this->selected_sta_index_++; - this->reset_for_next_ap_attempt_(); - } -#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_++; + // Should never reach here + return WiFiRetryPhase::SCAN_CONNECTING; +} + +/// Transition from current retry phase to a new phase with logging and phase-specific setup +/// This function handles the actual state change, including: +/// - Logging the phase transition +/// - Resetting the retry counter +/// - Performing phase-specific initialization (e.g., advancing AP index, starting scans) +/// +/// @param new_phase The phase we're transitioning TO +/// @return true if an async scan was started (caller should wait for completion) +/// false if no scan started (caller can proceed with connection attempt) +bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { + WiFiRetryPhase old_phase = this->retry_phase_; + + // No-op if staying in same phase + if (old_phase == new_phase) { + return false; } + + ESP_LOGD(TAG, "Retry phase: %s → %s", LOG_STR_ARG(retry_phase_to_log_string(old_phase)), + LOG_STR_ARG(retry_phase_to_log_string(new_phase))); + + this->retry_phase_ = new_phase; + this->num_retried_ = 0; // Reset retry counter on phase change + + // Phase-specific setup + switch (new_phase) { +#ifdef USE_WIFI_FAST_CONNECT + case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS: + // Move to next configured AP - clear old scan data so new AP is tried with config only + this->selected_sta_index_++; + this->scan_result_.clear(); + break; +#endif + + case WiFiRetryPhase::EXPLICIT_HIDDEN: + // Starting explicit hidden phase - reset to first network + this->selected_sta_index_ = 0; + break; + + case WiFiRetryPhase::SCAN_CONNECTING: + // Transitioning to scan-based connection +#ifdef USE_WIFI_FAST_CONNECT + if (old_phase == WiFiRetryPhase::FAST_CONNECT_CYCLING_APS) { + ESP_LOGI(TAG, "Fast connect exhausted, falling back to scan"); + } +#endif + // Trigger scan if we don't have scan results OR if transitioning from phases that need fresh scan + if (this->scan_result_.empty() || old_phase == WiFiRetryPhase::EXPLICIT_HIDDEN || + old_phase == WiFiRetryPhase::RETRY_HIDDEN || old_phase == WiFiRetryPhase::RESTARTING_ADAPTER) { + this->selected_sta_index_ = -1; // Will be set after scan completes + this->start_scanning(); + return true; // Started scan, wait for completion + } + // Already have scan results - selected_sta_index_ should already be synchronized + // (set in check_scanning_finished() when scan completed) + // No need to reset it here + break; + + case WiFiRetryPhase::RETRY_HIDDEN: + // Starting hidden mode - find first SSID that wasn't in scan results + if (old_phase == WiFiRetryPhase::SCAN_CONNECTING) { + // Keep scan results so we can skip SSIDs that were visible in the scan + // Don't clear scan_result_ - we need it to know which SSIDs are NOT hidden + + // If first network is marked hidden, we went through EXPLICIT_HIDDEN phase + // In that case, skip networks marked hidden:true (already tried) + // Otherwise, include them (they haven't been tried yet) + this->selected_sta_index_ = this->find_next_hidden_sta_(-1, !this->went_through_explicit_hidden_phase_()); + + if (this->selected_sta_index_ == -1) { + ESP_LOGD(TAG, "All SSIDs visible or already tried, skipping hidden mode"); + } + } + break; + + case WiFiRetryPhase::RESTARTING_ADAPTER: + this->restart_adapter(); + // Return true to indicate we should wait (go to COOLDOWN) instead of immediately connecting + return true; + + default: + break; + } + + return false; // Did not start scan, can proceed with connection +} + +/// Log failed connection attempt and decrease BSSID priority to avoid repeated failures +/// This function identifies which BSSID was attempted (from scan results or config), +/// decreases its priority by 1.0 to discourage future attempts, and logs the change. +/// +/// The priority degradation system ensures that failed BSSIDs are automatically sorted +/// lower in subsequent scans, naturally cycling through different APs without explicit +/// BSSID tracking within a scan cycle. +/// +/// Priority sources: +/// - SCAN_CONNECTING phase: Uses BSSID from scan_result_[0] (best match after sorting) +/// - Other phases: Uses BSSID from config if explicitly specified by user or fast_connect +/// +/// If no BSSID is available (SSID-only connection), priority adjustment is skipped. +void WiFiComponent::log_and_adjust_priority_for_failed_connect_() { + // Determine which BSSID we tried to connect to + optional failed_bssid; + + if (this->retry_phase_ == WiFiRetryPhase::SCAN_CONNECTING && !this->scan_result_.empty()) { + // Scan-based phase: always use best result (index 0) + failed_bssid = this->scan_result_[0].get_bssid(); + } else if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_bssid()) { + // Config has specific BSSID (fast_connect or user-specified) + failed_bssid = *config->get_bssid(); + } + + if (!failed_bssid.has_value()) { + return; // No BSSID to penalize + } + + // Decrease priority to avoid repeatedly trying the same failed BSSID + float old_priority = this->get_sta_priority(failed_bssid.value()); + float new_priority = old_priority - 1.0f; + this->set_sta_priority(failed_bssid.value(), new_priority); + + // Get SSID for logging + std::string ssid; + if (this->retry_phase_ == WiFiRetryPhase::SCAN_CONNECTING && !this->scan_result_.empty()) { + ssid = this->scan_result_[0].get_ssid(); + } else if (const WiFiAP *config = this->get_selected_sta_()) { + ssid = config->get_ssid(); + } + + ESP_LOGD(TAG, "Failed " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") ", priority %.1f → %.1f", ssid.c_str(), + format_mac_address_pretty(failed_bssid.value().data()).c_str(), old_priority, new_priority); +} + +/// Handle target advancement or retry counter increment when staying in the same phase +/// This function is called when a connection attempt fails and determine_next_phase_() indicates +/// we should stay in the current phase. It decides whether to: +/// - Advance to the next target (AP in fast_connect, SSID in hidden mode) +/// - Or increment the retry counter to try the same target again +/// +/// Phase-specific behavior: +/// - FAST_CONNECT_CYCLING_APS: Always advance to next AP (no retries per AP) +/// - RETRY_HIDDEN: Advance to next SSID after exhausting retries on current SSID +/// - Other phases: Increment retry counter (will retry same target) +void WiFiComponent::advance_to_next_target_or_increment_retry_() { + WiFiRetryPhase current_phase = this->retry_phase_; + + // Check if we need to advance to next AP/SSID within the same phase +#ifdef USE_WIFI_FAST_CONNECT + if (current_phase == WiFiRetryPhase::FAST_CONNECT_CYCLING_APS) { + // Fast connect: always advance to next AP (no retries per AP) + this->selected_sta_index_++; + this->num_retried_ = 0; + ESP_LOGD(TAG, "Next AP in %s", LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_))); + return; + } +#endif + + if (current_phase == WiFiRetryPhase::EXPLICIT_HIDDEN && this->num_retried_ + 1 >= WIFI_RETRY_COUNT_PER_SSID) { + // Explicit hidden: exhausted retries on current SSID, find next explicitly hidden network + // Stop when we reach a visible network (proceed to scanning) + size_t next_index = this->selected_sta_index_ + 1; + if (next_index < this->sta_.size() && this->sta_[next_index].get_hidden()) { + this->selected_sta_index_ = static_cast(next_index); + this->num_retried_ = 0; + ESP_LOGD(TAG, "Next explicit hidden network at index %d", static_cast(next_index)); + return; + } + // No more consecutive explicit hidden networks found - fall through to trigger phase change + } + + if (current_phase == WiFiRetryPhase::RETRY_HIDDEN && this->num_retried_ + 1 >= WIFI_RETRY_COUNT_PER_SSID) { + // Hidden mode: exhausted retries on current SSID, find next potentially hidden SSID + // If first network is marked hidden, we went through EXPLICIT_HIDDEN phase + // In that case, skip networks marked hidden:true (already tried) + // Otherwise, include them (they haven't been tried yet) + int8_t next_index = + this->find_next_hidden_sta_(this->selected_sta_index_, !this->went_through_explicit_hidden_phase_()); + if (next_index != -1) { + // Found another potentially hidden SSID + this->selected_sta_index_ = next_index; + this->num_retried_ = 0; + return; + } + // No more potentially hidden SSIDs - set selected_sta_index_ to -1 to trigger phase change + // This ensures determine_next_phase_() will skip the RETRY_HIDDEN logic and transition out + this->selected_sta_index_ = -1; + // Return early - phase change will happen on next wifi_loop() iteration + return; + } + + // Don't increment retry counter if we're in a scan phase with no valid targets + if (this->needs_scan_results_()) { + return; + } + + // Increment retry counter to try the same target again + this->num_retried_++; + ESP_LOGD(TAG, "Retry attempt %u/%u in phase %s", this->num_retried_ + 1, + get_max_retries_for_phase(this->retry_phase_), LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_))); +} + +void WiFiComponent::retry_connect() { + this->log_and_adjust_priority_for_failed_connect_(); + + delay(10); + + // Determine next retry phase based on current state + WiFiRetryPhase current_phase = this->retry_phase_; + WiFiRetryPhase next_phase = this->determine_next_phase_(); + + // Handle phase transitions (transition_to_phase_ handles same-phase no-op internally) + if (this->transition_to_phase_(next_phase)) { + return; // Wait for scan to complete + } + + if (next_phase == current_phase) { + this->advance_to_next_target_or_increment_retry_(); + } + this->error_from_callback_ = false; + if (this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTING) { yield(); - this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING_2; - WiFiAP params = this->build_wifi_ap_from_selected_(); - this->start_connecting(params, true); - return; + // Check if we have a valid target before building params + // After exhausting all networks in a phase, selected_sta_index_ may be -1 + // In that case, skip connection and let next wifi_loop() handle phase transition + if (this->selected_sta_index_ >= 0) { + this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING_2; + WiFiAP params = this->build_params_for_current_phase_(); + this->start_connecting(params, true); + return; + } + // No valid target - fall through to set state to allow phase transition } this->state_ = WIFI_COMPONENT_STATE_COOLDOWN; diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index cb75edf5a0..6c07df0c3f 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -94,6 +94,24 @@ enum class WiFiSTAConnectStatus : int { ERROR_CONNECT_FAILED, }; +/// Tracks the current retry strategy/phase for WiFi connection attempts +enum class WiFiRetryPhase : uint8_t { + /// Initial connection attempt (varies based on fast_connect setting) + INITIAL_CONNECT, +#ifdef USE_WIFI_FAST_CONNECT + /// Fast connect mode: cycling through configured APs (config-only, no scan) + FAST_CONNECT_CYCLING_APS, +#endif + /// Explicitly hidden networks (user marked as hidden, try before scanning) + EXPLICIT_HIDDEN, + /// Scan-based: connecting to best AP from scan results + SCAN_CONNECTING, + /// Retry networks not found in scan (might be hidden) + RETRY_HIDDEN, + /// Restarting WiFi adapter to clear stuck state + RESTARTING_ADAPTER, +}; + /// Struct for setting static IPs in WiFiComponent. struct ManualIP { network::IPAddress static_ip; @@ -207,6 +225,12 @@ enum WiFiPowerSaveMode : uint8_t { WIFI_POWER_SAVE_HIGH, }; +enum WifiMinAuthMode : uint8_t { + WIFI_MIN_AUTH_MODE_WPA = 0, + WIFI_MIN_AUTH_MODE_WPA2, + WIFI_MIN_AUTH_MODE_WPA3, +}; + #ifdef USE_ESP32 struct IDFWiFiEvent; #endif @@ -258,6 +282,7 @@ class WiFiComponent : public Component { bool is_connected(); void set_power_save_mode(WiFiPowerSaveMode power_save); + void set_min_auth_mode(WifiMinAuthMode min_auth_mode) { min_auth_mode_ = min_auth_mode; } void set_output_power(float output_power) { output_power_ = output_power; } void set_passive_scan(bool passive); @@ -341,8 +366,37 @@ class WiFiComponent : public Component { #endif // USE_WIFI_AP void print_connect_params_(); - WiFiAP build_wifi_ap_from_selected_() const; + WiFiAP build_params_for_current_phase_(); + /// Determine next retry phase based on current state and failure conditions + WiFiRetryPhase determine_next_phase_(); + /// Transition to a new retry phase with logging + /// Returns true if a scan was started (caller should wait), false otherwise + bool transition_to_phase_(WiFiRetryPhase new_phase); + /// Check if we need valid scan results for the current phase but don't have any + /// Returns true if the phase requires scan results but they're missing or don't match + bool needs_scan_results_() const; + /// Check if we went through EXPLICIT_HIDDEN phase (first network is marked hidden) + /// Used in RETRY_HIDDEN to determine whether to skip explicitly hidden networks + bool went_through_explicit_hidden_phase_() const; + /// Find the index of the first non-hidden network + /// Returns where EXPLICIT_HIDDEN phase would have stopped, or -1 if all networks are hidden + int8_t find_first_non_hidden_index_() const; + /// Check if an SSID was seen in the most recent scan results + /// Used to skip hidden mode for SSIDs we know are visible + bool ssid_was_seen_in_scan_(const std::string &ssid) const; + /// Find next SSID that wasn't in scan results (might be hidden) + /// Returns index of next potentially hidden SSID, or -1 if none found + /// @param start_index Start searching from index after this (-1 to start from beginning) + /// @param include_explicit_hidden If true, include SSIDs marked hidden:true. If false, only find truly hidden SSIDs. + int8_t find_next_hidden_sta_(int8_t start_index, bool include_explicit_hidden = true); + /// Log failed connection and decrease BSSID priority to avoid repeated attempts + void log_and_adjust_priority_for_failed_connect_(); + /// Advance to next target (AP/SSID) within current phase, or increment retry counter + /// Called when staying in the same phase after a failed connection attempt + void advance_to_next_target_or_increment_retry_(); + /// Start initial connection - either scan or connect directly to hidden networks + void start_initial_connection_(); const WiFiAP *get_selected_sta_() const { if (this->selected_sta_index_ >= 0 && static_cast(this->selected_sta_index_) < this->sta_.size()) { return &this->sta_[this->selected_sta_index_]; @@ -356,14 +410,15 @@ class WiFiComponent : public Component { } } -#ifdef USE_WIFI_FAST_CONNECT - // Reset state for next fast connect AP attempt - // Clears old scan data so the new AP is tried with config only (SSID without specific BSSID/channel) - void reset_for_next_ap_attempt_() { - this->num_retried_ = 0; - this->scan_result_.clear(); + bool all_networks_hidden_() const { + if (this->sta_.empty()) + return false; + for (const auto &ap : this->sta_) { + if (!ap.get_hidden()) + return false; + } + return true; } -#endif void wifi_loop_(); bool wifi_mode_(optional sta, optional ap); @@ -443,20 +498,19 @@ class WiFiComponent : public Component { // Group all 8-bit values together WiFiComponentState state_{WIFI_COMPONENT_STATE_OFF}; WiFiPowerSaveMode power_save_{WIFI_POWER_SAVE_NONE}; + WifiMinAuthMode min_auth_mode_{WIFI_MIN_AUTH_MODE_WPA2}; + WiFiRetryPhase retry_phase_{WiFiRetryPhase::INITIAL_CONNECT}; uint8_t num_retried_{0}; // Index into sta_ array for the currently selected AP configuration (-1 = none selected) // Used to access password, manual_ip, priority, EAP settings, and hidden flag // int8_t limits to 127 APs (enforced in __init__.py via MAX_WIFI_NETWORKS) int8_t selected_sta_index_{-1}; + #if USE_NETWORK_IPV6 uint8_t num_ipv6_addresses_{0}; #endif /* USE_NETWORK_IPV6 */ // Group all boolean values together -#ifdef USE_WIFI_FAST_CONNECT - bool trying_loaded_ap_{false}; -#endif - bool retry_hidden_{false}; bool has_ap_{false}; bool handled_connected_state_{false}; bool error_from_callback_{false}; diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 4e17c42f41..56e071404b 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -258,8 +258,17 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { if (ap.get_password().empty()) { conf.threshold.authmode = AUTH_OPEN; } else { - // Only allow auth modes with at least WPA - conf.threshold.authmode = AUTH_WPA_PSK; + // Set threshold based on configured minimum auth mode + // Note: ESP8266 doesn't support WPA3 + switch (this->min_auth_mode_) { + case WIFI_MIN_AUTH_MODE_WPA: + conf.threshold.authmode = AUTH_WPA_PSK; + break; + case WIFI_MIN_AUTH_MODE_WPA2: + case WIFI_MIN_AUTH_MODE_WPA3: // Fall back to WPA2 for ESP8266 + conf.threshold.authmode = AUTH_WPA2_PSK; + break; + } } conf.threshold.rssi = -127; #endif diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 08ecba3598..d3088c9a10 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -308,7 +308,18 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { if (ap.get_password().empty()) { conf.sta.threshold.authmode = WIFI_AUTH_OPEN; } else { - conf.sta.threshold.authmode = WIFI_AUTH_WPA_WPA2_PSK; + // Set threshold based on configured minimum auth mode + switch (this->min_auth_mode_) { + case WIFI_MIN_AUTH_MODE_WPA: + conf.sta.threshold.authmode = WIFI_AUTH_WPA_PSK; + break; + case WIFI_MIN_AUTH_MODE_WPA2: + conf.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; + break; + case WIFI_MIN_AUTH_MODE_WPA3: + conf.sta.threshold.authmode = WIFI_AUTH_WPA3_PSK; + break; + } } #ifdef USE_WIFI_WPA2_EAP @@ -347,8 +358,6 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { // The minimum rssi to accept in the fast scan mode conf.sta.threshold.rssi = -127; - conf.sta.threshold.authmode = WIFI_AUTH_OPEN; - wifi_config_t current_conf; esp_err_t err; err = esp_wifi_get_config(WIFI_IF_STA, ¤t_conf); diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 38d1f8c2b7..27a36f889f 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -86,6 +86,7 @@ ISOLATED_COMPONENTS = { "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", + "tinyusb": "Conflicts with usb_host component - cannot be used together", } diff --git a/tests/components/chsc6x/test.rp2040-ard.yaml b/tests/components/chsc6x/test.rp2040-ard.yaml index 89cc1b7477..2e3613a4a3 100644 --- a/tests/components/chsc6x/test.rp2040-ard.yaml +++ b/tests/components/chsc6x/test.rp2040-ard.yaml @@ -16,5 +16,6 @@ display: touchscreen: - platform: chsc6x + i2c_id: i2c_bus display: ili9xxx_display interrupt_pin: 22 diff --git a/tests/components/hlk_fm22x/common.yaml b/tests/components/hlk_fm22x/common.yaml new file mode 100644 index 0000000000..6fcd9af594 --- /dev/null +++ b/tests/components/hlk_fm22x/common.yaml @@ -0,0 +1,41 @@ +esphome: + on_boot: + then: + - hlk_fm22x.enroll: + name: "Test" + direction: 1 + - hlk_fm22x.delete_all: + +hlk_fm22x: + on_face_scan_matched: + - logger.log: test_hlk_22x_face_scan_matched + on_face_scan_unmatched: + - logger.log: test_hlk_22x_face_scan_unmatched + on_face_scan_invalid: + - logger.log: test_hlk_22x_face_scan_invalid + on_face_info: + - logger.log: test_hlk_22x_face_info + on_enrollment_done: + - logger.log: test_hlk_22x_enrollment_done + on_enrollment_failed: + - logger.log: test_hlk_22x_enrollment_failed + +sensor: + - platform: hlk_fm22x + face_count: + name: "Face Count" + last_face_id: + name: "Last Face ID" + status: + name: "Face Status" + +binary_sensor: + - platform: hlk_fm22x + name: "Face Enrolling" + +text_sensor: + - platform: hlk_fm22x + version: + name: "HLK Version" + last_face_name: + name: "Last Face Name" diff --git a/tests/components/hlk_fm22x/test.esp32-idf.yaml b/tests/components/hlk_fm22x/test.esp32-idf.yaml index 5e7cbde664..2d29656c94 100644 --- a/tests/components/hlk_fm22x/test.esp32-idf.yaml +++ b/tests/components/hlk_fm22x/test.esp32-idf.yaml @@ -1,47 +1,4 @@ -esphome: - on_boot: - then: - - hlk_fm22x.enroll: - name: "Test" - direction: 1 - - hlk_fm22x.delete_all: +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml -uart: - - id: uart_hlk_fm22x - tx_pin: 17 - rx_pin: 16 - baud_rate: 115200 - -hlk_fm22x: - on_face_scan_matched: - - logger.log: test_hlk_22x_face_scan_matched - on_face_scan_unmatched: - - logger.log: test_hlk_22x_face_scan_unmatched - on_face_scan_invalid: - - logger.log: test_hlk_22x_face_scan_invalid - on_face_info: - - logger.log: test_hlk_22x_face_info - on_enrollment_done: - - logger.log: test_hlk_22x_enrollment_done - on_enrollment_failed: - - logger.log: test_hlk_22x_enrollment_failed - -sensor: - - platform: hlk_fm22x - face_count: - name: "Face Count" - last_face_id: - name: "Last Face ID" - status: - name: "Face Status" - -binary_sensor: - - platform: hlk_fm22x - name: "Face Enrolling" - -text_sensor: - - platform: hlk_fm22x - version: - name: "HLK Version" - last_face_name: - name: "Last Face Name" +<<: !include common.yaml diff --git a/tests/components/hlk_fm22x/test.esp8266-ard.yaml b/tests/components/hlk_fm22x/test.esp8266-ard.yaml index 680047834c..5a05efa259 100644 --- a/tests/components/hlk_fm22x/test.esp8266-ard.yaml +++ b/tests/components/hlk_fm22x/test.esp8266-ard.yaml @@ -1,47 +1,4 @@ -esphome: - on_boot: - then: - - hlk_fm22x.enroll: - name: "Test" - direction: 1 - - hlk_fm22x.delete_all: +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml -uart: - - id: uart_hlk_fm22x - tx_pin: 4 - rx_pin: 5 - baud_rate: 115200 - -hlk_fm22x: - on_face_scan_matched: - - logger.log: test_hlk_22x_face_scan_matched - on_face_scan_unmatched: - - logger.log: test_hlk_22x_face_scan_unmatched - on_face_scan_invalid: - - logger.log: test_hlk_22x_face_scan_invalid - on_face_info: - - logger.log: test_hlk_22x_face_info - on_enrollment_done: - - logger.log: test_hlk_22x_enrollment_done - on_enrollment_failed: - - logger.log: test_hlk_22x_enrollment_failed - -sensor: - - platform: hlk_fm22x - face_count: - name: "Face Count" - last_face_id: - name: "Last Face ID" - status: - name: "Face Status" - -binary_sensor: - - platform: hlk_fm22x - name: "Face Enrolling" - -text_sensor: - - platform: hlk_fm22x - version: - name: "HLK Version" - last_face_name: - name: "Last Face Name" +<<: !include common.yaml diff --git a/tests/components/hlk_fm22x/test.rp2040-ard.yaml b/tests/components/hlk_fm22x/test.rp2040-ard.yaml index 680047834c..f1df2daf83 100644 --- a/tests/components/hlk_fm22x/test.rp2040-ard.yaml +++ b/tests/components/hlk_fm22x/test.rp2040-ard.yaml @@ -1,47 +1,4 @@ -esphome: - on_boot: - then: - - hlk_fm22x.enroll: - name: "Test" - direction: 1 - - hlk_fm22x.delete_all: +packages: + uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml -uart: - - id: uart_hlk_fm22x - tx_pin: 4 - rx_pin: 5 - baud_rate: 115200 - -hlk_fm22x: - on_face_scan_matched: - - logger.log: test_hlk_22x_face_scan_matched - on_face_scan_unmatched: - - logger.log: test_hlk_22x_face_scan_unmatched - on_face_scan_invalid: - - logger.log: test_hlk_22x_face_scan_invalid - on_face_info: - - logger.log: test_hlk_22x_face_info - on_enrollment_done: - - logger.log: test_hlk_22x_enrollment_done - on_enrollment_failed: - - logger.log: test_hlk_22x_enrollment_failed - -sensor: - - platform: hlk_fm22x - face_count: - name: "Face Count" - last_face_id: - name: "Last Face ID" - status: - name: "Face Status" - -binary_sensor: - - platform: hlk_fm22x - name: "Face Enrolling" - -text_sensor: - - platform: hlk_fm22x - version: - name: "HLK Version" - last_face_name: - name: "Last Face Name" +<<: !include common.yaml diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index b122d10f04..d7c342b16e 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -700,6 +700,10 @@ lvgl: width: 100% height: 10% align: top_mid + on_value: + - lvgl.spinbox.update: + id: spinbox_id + value: !lambda return x; - button: styles: spin_button id: spin_up diff --git a/tests/components/rx8130/common.yaml b/tests/components/rx8130/common.yaml new file mode 100644 index 0000000000..e6b849e25b --- /dev/null +++ b/tests/components/rx8130/common.yaml @@ -0,0 +1,8 @@ +esphome: + on_boot: + - rx8130.read_time + - rx8130.write_time + +time: + - platform: rx8130 + i2c_id: i2c_bus diff --git a/tests/components/rx8130/test.esp32-idf.yaml b/tests/components/rx8130/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/rx8130/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/rx8130/test.esp8266-ard.yaml b/tests/components/rx8130/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/rx8130/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/rx8130/test.rp2040-ard.yaml b/tests/components/rx8130/test.rp2040-ard.yaml new file mode 100644 index 0000000000..319a7c71a6 --- /dev/null +++ b/tests/components/rx8130/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/speaker/common.yaml b/tests/components/speaker/common.yaml index fa54fa7e39..9aaf639162 100644 --- a/tests/components/speaker/common.yaml +++ b/tests/components/speaker/common.yaml @@ -11,26 +11,42 @@ esphome: on_boot: then: - speaker.mute_on: + id: speaker_id - speaker.mute_off: + id: speaker_id - if: - condition: speaker.is_stopped + condition: + speaker.is_stopped: + id: speaker_id then: - - speaker.play: [0, 1, 2, 3] - - speaker.volume_set: 0.9 + - speaker.play: + id: speaker_id + data: [0, 1, 2, 3] + - speaker.volume_set: + id: speaker_id + volume: 0.9 - if: - condition: speaker.is_playing + condition: + speaker.is_playing: + id: speaker_id then: - speaker.finish: + id: speaker_id - speaker.stop: + id: speaker_id button: - platform: template name: "Speaker Button" on_press: then: - - speaker.play: [0x10, 0x20, 0x30, 0x40] - - speaker.play: !lambda |- - return {0x01, 0x02, (uint8_t)id(my_number).state}; + - speaker.play: + id: speaker_id + data: [0x10, 0x20, 0x30, 0x40] + - speaker.play: + id: speaker_id + data: !lambda |- + return {0x01, 0x02, (uint8_t)id(my_number).state}; i2s_audio: i2s_lrclk_pin: ${i2s_bclk_pin} diff --git a/tests/components/toshiba/common_ras2819t.yaml b/tests/components/toshiba/common_ras2819t.yaml index 32081fca98..157456ba81 100644 --- a/tests/components/toshiba/common_ras2819t.yaml +++ b/tests/components/toshiba/common_ras2819t.yaml @@ -1,11 +1,3 @@ -remote_transmitter: - pin: ${tx_pin} - carrier_duty_percent: 50% - -remote_receiver: - id: rcvr - pin: ${rx_pin} - climate: - platform: toshiba name: "RAS-2819T Climate" diff --git a/tests/components/toshiba/test_ras2819t.esp32-ard.yaml b/tests/components/toshiba/test_ras2819t.esp32-ard.yaml index 00805baa01..d82ba54897 100644 --- a/tests/components/toshiba/test_ras2819t.esp32-ard.yaml +++ b/tests/components/toshiba/test_ras2819t.esp32-ard.yaml @@ -1,5 +1,5 @@ -substitutions: - tx_pin: GPIO5 - rx_pin: GPIO4 +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-ard.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-ard.yaml <<: !include common_ras2819t.yaml diff --git a/tests/components/toshiba/test_ras2819t.esp32-c3-ard.yaml b/tests/components/toshiba/test_ras2819t.esp32-c3-ard.yaml index 00805baa01..6858dd587f 100644 --- a/tests/components/toshiba/test_ras2819t.esp32-c3-ard.yaml +++ b/tests/components/toshiba/test_ras2819t.esp32-c3-ard.yaml @@ -1,5 +1,5 @@ -substitutions: - tx_pin: GPIO5 - rx_pin: GPIO4 +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-ard.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-c3-ard.yaml <<: !include common_ras2819t.yaml diff --git a/tests/components/toshiba/test_ras2819t.esp32-idf.yaml b/tests/components/toshiba/test_ras2819t.esp32-idf.yaml index 00805baa01..3facc5bbb3 100644 --- a/tests/components/toshiba/test_ras2819t.esp32-idf.yaml +++ b/tests/components/toshiba/test_ras2819t.esp32-idf.yaml @@ -1,5 +1,5 @@ -substitutions: - tx_pin: GPIO5 - rx_pin: GPIO4 +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml <<: !include common_ras2819t.yaml diff --git a/tests/components/toshiba/test_ras2819t.esp8266-ard.yaml b/tests/components/toshiba/test_ras2819t.esp8266-ard.yaml index 00805baa01..3976dcc739 100644 --- a/tests/components/toshiba/test_ras2819t.esp8266-ard.yaml +++ b/tests/components/toshiba/test_ras2819t.esp8266-ard.yaml @@ -1,5 +1,5 @@ -substitutions: - tx_pin: GPIO5 - rx_pin: GPIO4 +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml <<: !include common_ras2819t.yaml diff --git a/tests/components/wifi/test.esp32-idf.yaml b/tests/components/wifi/test.esp32-idf.yaml index 91e235b9ce..827e4b17f7 100644 --- a/tests/components/wifi/test.esp32-idf.yaml +++ b/tests/components/wifi/test.esp32-idf.yaml @@ -2,6 +2,7 @@ psram: wifi: use_psram: true + min_auth_mode: WPA packages: - !include common.yaml diff --git a/tests/components/wifi/test.esp8266-ard.yaml b/tests/components/wifi/test.esp8266-ard.yaml index dade44d145..9cb0e3cf48 100644 --- a/tests/components/wifi/test.esp8266-ard.yaml +++ b/tests/components/wifi/test.esp8266-ard.yaml @@ -1 +1,5 @@ -<<: !include common.yaml +wifi: + min_auth_mode: WPA2 + +packages: + - !include common.yaml diff --git a/tests/test_build_components/common/remote_receiver/esp32-ard.yaml b/tests/test_build_components/common/remote_receiver/esp32-ard.yaml new file mode 100644 index 0000000000..af5c2f2409 --- /dev/null +++ b/tests/test_build_components/common/remote_receiver/esp32-ard.yaml @@ -0,0 +1,12 @@ +# Common remote_receiver configuration for ESP32 Arduino tests +# Provides a shared remote receiver that all components can use +# Components will auto-use this receiver if they don't specify receiver_id + +substitutions: + remote_receiver_pin: GPIO32 + +remote_receiver: + - id: rcvr + pin: ${remote_receiver_pin} + dump: all + tolerance: 25% diff --git a/tests/test_build_components/common/remote_receiver/esp32-c3-ard.yaml b/tests/test_build_components/common/remote_receiver/esp32-c3-ard.yaml new file mode 100644 index 0000000000..26b288b427 --- /dev/null +++ b/tests/test_build_components/common/remote_receiver/esp32-c3-ard.yaml @@ -0,0 +1,12 @@ +# Common remote_receiver configuration for ESP32-C3 Arduino tests +# Provides a shared remote receiver that all components can use +# Components will auto-use this receiver if they don't specify receiver_id + +substitutions: + remote_receiver_pin: GPIO10 + +remote_receiver: + - id: rcvr + pin: ${remote_receiver_pin} + dump: all + tolerance: 25%