mirror of
https://github.com/esphome/esphome.git
synced 2025-11-10 03:51:52 +00:00
Compare commits
165 Commits
memory_api
...
try_next_b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
531c7571a0 | ||
|
|
6f65de0275 | ||
|
|
3cabe9a650 | ||
|
|
fb516f1665 | ||
|
|
2f0b861ea8 | ||
|
|
59a6bf1176 | ||
|
|
1e3b3f0565 | ||
|
|
968a67be73 | ||
|
|
f9e9def1bb | ||
|
|
503b2cf57d | ||
|
|
c8af20950f | ||
|
|
adbb86cec0 | ||
|
|
cc12aa101a | ||
|
|
8227257333 | ||
|
|
f56ce570a2 | ||
|
|
88588cecb6 | ||
|
|
52f6227b9e | ||
|
|
f350faf733 | ||
|
|
856b457600 | ||
|
|
ff68fc3fc8 | ||
|
|
d0127e28c9 | ||
|
|
75a3e49455 | ||
|
|
00cf3f9c1c | ||
|
|
cb98263d34 | ||
|
|
4e6627a4f5 | ||
|
|
8e8397031a | ||
|
|
e0d91d87ee | ||
|
|
3b2847ffa6 | ||
|
|
c0f7ec763a | ||
|
|
2f61d2746c | ||
|
|
b0ddf64a05 | ||
|
|
b5a24f5b70 | ||
|
|
609855f5a7 | ||
|
|
efe33b0c2a | ||
|
|
367aade78d | ||
|
|
1f32baa3c3 | ||
|
|
3824577d6a | ||
|
|
ff7eb2f434 | ||
|
|
eb7b7e61c3 | ||
|
|
39c6e5d656 | ||
|
|
7b35954c72 | ||
|
|
7a6128a1f1 | ||
|
|
7abb6d4998 | ||
|
|
3831f4084a | ||
|
|
1660509285 | ||
|
|
9b3b422b9e | ||
|
|
fd51dc9697 | ||
|
|
37279cb224 | ||
|
|
3b264ad5a1 | ||
|
|
fca2871c4d | ||
|
|
5ccdd6c77b | ||
|
|
e46809a1c0 | ||
|
|
abf37a7493 | ||
|
|
a5e40156c6 | ||
|
|
7890803e9f | ||
|
|
6a59e4a993 | ||
|
|
b232aafaa3 | ||
|
|
57220305a9 | ||
|
|
1dabe83d04 | ||
|
|
2945b31365 | ||
|
|
0d735dc259 | ||
|
|
9d6a17d5b0 | ||
|
|
7a6a485330 | ||
|
|
579659dc85 | ||
|
|
7b86e1feb0 | ||
|
|
addfbd7079 | ||
|
|
d516627957 | ||
|
|
fb1c67490a | ||
|
|
8b9600b930 | ||
|
|
5bb7ab9960 | ||
|
|
2480663b87 | ||
|
|
cbb98c4050 | ||
|
|
e7ff56f1cd | ||
|
|
7705a5de06 | ||
|
|
77ab096b59 | ||
|
|
e84d459f46 | ||
|
|
26a3ec41d6 | ||
|
|
3bcbfe8d97 | ||
|
|
870b2c4f84 | ||
|
|
89d64b972d | ||
|
|
5f9c7a70ff | ||
|
|
f7179d4255 | ||
|
|
eb0558ca3f | ||
|
|
5585355263 | ||
|
|
f7626128f6 | ||
|
|
2c76db2132 | ||
|
|
e468ca4881 | ||
|
|
4c078dea2c | ||
|
|
783dbd1e6b | ||
|
|
b49619d9bf | ||
|
|
a290b88cd6 | ||
|
|
579e0dd095 | ||
|
|
e0e2063569 | ||
|
|
0c4add95e9 | ||
|
|
73bcefb928 | ||
|
|
63a8a85ddb | ||
|
|
a27af820ea | ||
|
|
f8c5c26958 | ||
|
|
307cd0cc83 | ||
|
|
be9056b20c | ||
|
|
9514dedd95 | ||
|
|
b2739ee8c1 | ||
|
|
87d01e7ff2 | ||
|
|
0a1c40e327 | ||
|
|
1674e7a1c7 | ||
|
|
89c769a313 | ||
|
|
6d429e8d2b | ||
|
|
7d64aa4d7a | ||
|
|
e20b9ae561 | ||
|
|
cccdb805b8 | ||
|
|
f960dd1ac8 | ||
|
|
0654747f69 | ||
|
|
db044c9f0c | ||
|
|
15f20caef5 | ||
|
|
db7cc46afb | ||
|
|
5975babcb2 | ||
|
|
7bf2f03668 | ||
|
|
f3ad670278 | ||
|
|
6e040dc6a4 | ||
|
|
9b4dbf8c1c | ||
|
|
36a030cd56 | ||
|
|
bd92487cf3 | ||
|
|
94a09ffe2b | ||
|
|
ed4bf9914b | ||
|
|
5cd5717234 | ||
|
|
94b74be5f4 | ||
|
|
842706d830 | ||
|
|
c1a28f99e2 | ||
|
|
587d530bb0 | ||
|
|
8cbbee53d7 | ||
|
|
40d2850983 | ||
|
|
a85e6efb29 | ||
|
|
4a911cdf4b | ||
|
|
714ec127c8 | ||
|
|
ef8dcb394b | ||
|
|
5c5518e4c8 | ||
|
|
5220e4ac00 | ||
|
|
3c66ddcc70 | ||
|
|
639cdb8002 | ||
|
|
b7a905844c | ||
|
|
abc4a1475e | ||
|
|
2a732d1e9a | ||
|
|
601bf6129a | ||
|
|
88346aa801 | ||
|
|
23b35cfc8f | ||
|
|
f34aa529f9 | ||
|
|
629cd6cf7c | ||
|
|
ec866c20bd | ||
|
|
f106e9ac15 | ||
|
|
3110c2071e | ||
|
|
6cde5b15d3 | ||
|
|
20400ba848 | ||
|
|
d993eff743 | ||
|
|
a295cc017f | ||
|
|
f794b88bae | ||
|
|
b63c799ad9 | ||
|
|
c754b0f71d | ||
|
|
c1240ce876 | ||
|
|
f76caf3dda | ||
|
|
f92d4e5ce3 | ||
|
|
8612efad53 | ||
|
|
5d846c2909 | ||
|
|
bdad85a101 | ||
|
|
29e0601849 | ||
|
|
bf31620104 |
@@ -741,13 +741,6 @@ def command_vscode(args: ArgsProtocol) -> int | None:
|
||||
|
||||
|
||||
def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
# Set memory analysis options in config
|
||||
if args.analyze_memory:
|
||||
config.setdefault(CONF_ESPHOME, {})["analyze_memory"] = True
|
||||
|
||||
if args.memory_report:
|
||||
config.setdefault(CONF_ESPHOME, {})["memory_report_file"] = args.memory_report
|
||||
|
||||
exit_code = write_cpp(config)
|
||||
if exit_code != 0:
|
||||
return exit_code
|
||||
@@ -1209,17 +1202,6 @@ def parse_args(argv):
|
||||
help="Only generate source code, do not compile.",
|
||||
action="store_true",
|
||||
)
|
||||
parser_compile.add_argument(
|
||||
"--analyze-memory",
|
||||
help="Analyze and display memory usage by component after compilation.",
|
||||
action="store_true",
|
||||
)
|
||||
parser_compile.add_argument(
|
||||
"--memory-report",
|
||||
help="Save memory analysis report to a file (supports .json or .txt).",
|
||||
type=str,
|
||||
metavar="FILE",
|
||||
)
|
||||
|
||||
parser_upload = subparsers.add_parser(
|
||||
"upload",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""CLI interface for memory analysis with report generation."""
|
||||
|
||||
from collections import defaultdict
|
||||
import json
|
||||
import sys
|
||||
|
||||
from . import (
|
||||
@@ -284,28 +283,6 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Export analysis results as JSON."""
|
||||
data = {
|
||||
"components": {
|
||||
name: {
|
||||
"text": mem.text_size,
|
||||
"rodata": mem.rodata_size,
|
||||
"data": mem.data_size,
|
||||
"bss": mem.bss_size,
|
||||
"flash_total": mem.flash_total,
|
||||
"ram_total": mem.ram_total,
|
||||
"symbol_count": mem.symbol_count,
|
||||
}
|
||||
for name, mem in self.components.items()
|
||||
},
|
||||
"totals": {
|
||||
"flash": sum(c.flash_total for c in self.components.values()),
|
||||
"ram": sum(c.ram_total for c in self.components.values()),
|
||||
},
|
||||
}
|
||||
return json.dumps(data, indent=2)
|
||||
|
||||
def dump_uncategorized_symbols(self, output_file: str | None = None) -> None:
|
||||
"""Dump uncategorized symbols for analysis."""
|
||||
# Sort by size descending
|
||||
|
||||
@@ -227,6 +227,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
esp32=8, # More RAM, can buffer more
|
||||
rp2040=5, # Limited RAM
|
||||
bk72xx=8, # Moderate RAM
|
||||
nrf52=8, # Moderate RAM
|
||||
rtl87xx=8, # Moderate RAM
|
||||
host=16, # Abundant resources
|
||||
ln882x=8, # Moderate RAM
|
||||
|
||||
@@ -1467,6 +1467,8 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) {
|
||||
static constexpr auto MANUFACTURER = StringRef::from_lit("Beken");
|
||||
#elif defined(USE_LN882X)
|
||||
static constexpr auto MANUFACTURER = StringRef::from_lit("Lightning");
|
||||
#elif defined(USE_NRF52)
|
||||
static constexpr auto MANUFACTURER = StringRef::from_lit("Nordic Semiconductor");
|
||||
#elif defined(USE_RTL87XX)
|
||||
static constexpr auto MANUFACTURER = StringRef::from_lit("Realtek");
|
||||
#elif defined(USE_HOST)
|
||||
|
||||
@@ -9,7 +9,7 @@ static const char *const TAG = "bl0940.number";
|
||||
void CalibrationNumber::setup() {
|
||||
float value = 0.0f;
|
||||
if (this->restore_value_) {
|
||||
this->pref_ = global_preferences->make_preference<float>(this->get_object_id_hash());
|
||||
this->pref_ = global_preferences->make_preference<float>(this->get_preference_hash());
|
||||
if (!this->pref_.load(&value)) {
|
||||
value = 0.0f;
|
||||
}
|
||||
|
||||
@@ -96,10 +96,6 @@ void ESP32BLE::advertising_set_service_data(const std::vector<uint8_t> &data) {
|
||||
}
|
||||
|
||||
void ESP32BLE::advertising_set_manufacturer_data(const std::vector<uint8_t> &data) {
|
||||
this->advertising_set_manufacturer_data(std::span<const uint8_t>(data));
|
||||
}
|
||||
|
||||
void ESP32BLE::advertising_set_manufacturer_data(std::span<const uint8_t> data) {
|
||||
this->advertising_init_();
|
||||
this->advertising_->set_manufacturer_data(data);
|
||||
this->advertising_start();
|
||||
|
||||
@@ -118,7 +118,6 @@ class ESP32BLE : public Component {
|
||||
void advertising_start();
|
||||
void advertising_set_service_data(const std::vector<uint8_t> &data);
|
||||
void advertising_set_manufacturer_data(const std::vector<uint8_t> &data);
|
||||
void advertising_set_manufacturer_data(std::span<const uint8_t> data);
|
||||
void advertising_set_appearance(uint16_t appearance) { this->appearance_ = appearance; }
|
||||
void advertising_set_service_data_and_name(std::span<const uint8_t> data, bool include_name);
|
||||
void advertising_add_service_uuid(ESPBTUUID uuid);
|
||||
|
||||
@@ -59,10 +59,6 @@ void BLEAdvertising::set_service_data(const std::vector<uint8_t> &data) {
|
||||
}
|
||||
|
||||
void BLEAdvertising::set_manufacturer_data(const std::vector<uint8_t> &data) {
|
||||
this->set_manufacturer_data(std::span<const uint8_t>(data));
|
||||
}
|
||||
|
||||
void BLEAdvertising::set_manufacturer_data(std::span<const uint8_t> data) {
|
||||
delete[] this->advertising_data_.p_manufacturer_data;
|
||||
this->advertising_data_.p_manufacturer_data = nullptr;
|
||||
this->advertising_data_.manufacturer_len = data.size();
|
||||
|
||||
@@ -37,7 +37,6 @@ class BLEAdvertising {
|
||||
void set_scan_response(bool scan_response) { this->scan_response_ = scan_response; }
|
||||
void set_min_preferred_interval(uint16_t interval) { this->advertising_data_.min_interval = interval; }
|
||||
void set_manufacturer_data(const std::vector<uint8_t> &data);
|
||||
void set_manufacturer_data(std::span<const uint8_t> data);
|
||||
void set_appearance(uint16_t appearance) { this->advertising_data_.appearance = appearance; }
|
||||
void set_service_data(const std::vector<uint8_t> &data);
|
||||
void set_service_data(std::span<const uint8_t> data);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#include "esp32_ble_beacon.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
|
||||
@@ -15,10 +15,7 @@ Trigger<std::vector<uint8_t>, uint16_t> *BLETriggers::create_characteristic_on_w
|
||||
Trigger<std::vector<uint8_t>, uint16_t> *on_write_trigger = // NOLINT(cppcoreguidelines-owning-memory)
|
||||
new Trigger<std::vector<uint8_t>, uint16_t>();
|
||||
characteristic->on_write([on_write_trigger](std::span<const uint8_t> data, uint16_t id) {
|
||||
// Convert span to vector for trigger - copy is necessary because:
|
||||
// 1. Trigger stores the data for use in automation actions that execute later
|
||||
// 2. The span is only valid during this callback (points to temporary BLE stack data)
|
||||
// 3. User lambdas in automations need persistent data they can access asynchronously
|
||||
// Convert span to vector for trigger
|
||||
on_write_trigger->trigger(std::vector<uint8_t>(data.begin(), data.end()), id);
|
||||
});
|
||||
return on_write_trigger;
|
||||
@@ -30,10 +27,7 @@ Trigger<std::vector<uint8_t>, uint16_t> *BLETriggers::create_descriptor_on_write
|
||||
Trigger<std::vector<uint8_t>, uint16_t> *on_write_trigger = // NOLINT(cppcoreguidelines-owning-memory)
|
||||
new Trigger<std::vector<uint8_t>, uint16_t>();
|
||||
descriptor->on_write([on_write_trigger](std::span<const uint8_t> data, uint16_t id) {
|
||||
// Convert span to vector for trigger - copy is necessary because:
|
||||
// 1. Trigger stores the data for use in automation actions that execute later
|
||||
// 2. The span is only valid during this callback (points to temporary BLE stack data)
|
||||
// 3. User lambdas in automations need persistent data they can access asynchronously
|
||||
// Convert span to vector for trigger
|
||||
on_write_trigger->trigger(std::vector<uint8_t>(data.begin(), data.end()), id);
|
||||
});
|
||||
return on_write_trigger;
|
||||
|
||||
@@ -45,22 +45,6 @@ void Event::set_event_types(const std::vector<const char *> &event_types) {
|
||||
this->last_event_type_ = nullptr; // Reset when types change
|
||||
}
|
||||
|
||||
void Event::set_event_types(const FixedVector<const char *> &event_types) {
|
||||
this->types_.init(event_types.size());
|
||||
for (const char *type : event_types) {
|
||||
this->types_.push_back(type);
|
||||
}
|
||||
this->last_event_type_ = nullptr; // Reset when types change
|
||||
}
|
||||
|
||||
void Event::set_event_types(const std::vector<const char *> &event_types) {
|
||||
this->types_.init(event_types.size());
|
||||
for (const char *type : event_types) {
|
||||
this->types_.push_back(type);
|
||||
}
|
||||
this->last_event_type_ = nullptr; // Reset when types change
|
||||
}
|
||||
|
||||
void Event::add_on_event_callback(std::function<void(const std::string &event_type)> &&callback) {
|
||||
this->event_callback_.add(std::move(callback));
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ void IDFI2CBus::dump_config() {
|
||||
if (s.second) {
|
||||
ESP_LOGCONFIG(TAG, "Found device at address 0x%02X", s.first);
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, "Unknown error at address 0x%02X", s.first);
|
||||
ESP_LOGE(TAG, "Unknown error at address 0x%02X", s.first);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ void MCP23016::pin_mode(uint8_t pin, gpio::Flags flags) {
|
||||
this->update_reg_(pin, false, iodir);
|
||||
}
|
||||
}
|
||||
float MCP23016::get_setup_priority() const { return setup_priority::IO; }
|
||||
float MCP23016::get_setup_priority() const { return setup_priority::HARDWARE; }
|
||||
bool MCP23016::read_reg_(uint8_t reg, uint8_t *value) {
|
||||
if (this->is_failed())
|
||||
return false;
|
||||
|
||||
@@ -226,17 +226,18 @@ optional<ProntoData> ProntoProtocol::decode(RemoteReceiveData src) {
|
||||
}
|
||||
|
||||
void ProntoProtocol::dump(const ProntoData &data) {
|
||||
std::string rest = data.data;
|
||||
ESP_LOGI(TAG, "Received Pronto: data=");
|
||||
|
||||
const char *ptr = data.data.c_str();
|
||||
size_t remaining = data.data.size();
|
||||
|
||||
// Log in chunks, always logging at least once (even for empty string)
|
||||
do {
|
||||
size_t chunk_size = rest.size() > PRONTO_LOG_CHUNK_SIZE ? PRONTO_LOG_CHUNK_SIZE : rest.size();
|
||||
ESP_LOGI(TAG, "%.*s", (int) chunk_size, rest.c_str());
|
||||
if (rest.size() > PRONTO_LOG_CHUNK_SIZE) {
|
||||
rest.erase(0, PRONTO_LOG_CHUNK_SIZE);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} while (true);
|
||||
size_t chunk_size = remaining < PRONTO_LOG_CHUNK_SIZE ? remaining : PRONTO_LOG_CHUNK_SIZE;
|
||||
ESP_LOGI(TAG, "%.*s", (int) chunk_size, ptr);
|
||||
ptr += chunk_size;
|
||||
remaining -= chunk_size;
|
||||
} while (remaining > 0);
|
||||
}
|
||||
|
||||
} // namespace remote_base
|
||||
|
||||
@@ -77,21 +77,23 @@ class Select : public EntityBase {
|
||||
|
||||
void add_on_state_callback(std::function<void(std::string, size_t)> &&callback);
|
||||
|
||||
/** Set the value of the select by index, this is an optional virtual method.
|
||||
*
|
||||
* This method is called by the SelectCall when the index is already known.
|
||||
* Default implementation converts to string and calls control().
|
||||
* Override this to work directly with indices and avoid string conversions.
|
||||
*
|
||||
* @param index The index as validated by the SelectCall.
|
||||
*/
|
||||
virtual void control(size_t index) { this->control(this->option_at(index)); }
|
||||
|
||||
protected:
|
||||
friend class SelectCall;
|
||||
|
||||
size_t active_index_{0};
|
||||
|
||||
/** Set the value of the select by index, this is an optional virtual method.
|
||||
*
|
||||
* IMPORTANT: At least ONE of the two control() methods must be overridden by derived classes.
|
||||
* Overriding this index-based version is PREFERRED as it avoids string conversions.
|
||||
*
|
||||
* This method is called by the SelectCall when the index is already known.
|
||||
* Default implementation converts to string and calls control(const std::string&).
|
||||
*
|
||||
* @param index The index as validated by the SelectCall.
|
||||
*/
|
||||
virtual void control(size_t index) { this->control(this->option_at(index)); }
|
||||
|
||||
/** Set the value of the select, this is a virtual method that each select integration can implement.
|
||||
*
|
||||
* IMPORTANT: At least ONE of the two control() methods must be overridden by derived classes.
|
||||
|
||||
@@ -353,9 +353,8 @@ void AsyncWebServerResponse::addHeader(const char *name, const char *value) {
|
||||
void AsyncResponseStream::print(float value) {
|
||||
// Use stack buffer to avoid temporary string allocation
|
||||
// Size: sign (1) + digits (10) + decimal (1) + precision (6) + exponent (5) + null (1) = 24, use 32 for safety
|
||||
constexpr size_t float_buf_size = 32;
|
||||
char buf[float_buf_size];
|
||||
int len = snprintf(buf, float_buf_size, "%f", value);
|
||||
char buf[32];
|
||||
int len = snprintf(buf, sizeof(buf), "%f", value);
|
||||
this->content_.append(buf, len);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<int8_t>(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<int8_t>(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<int>(i));
|
||||
return static_cast<int8_t>(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<int>(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<bssid_t>{});
|
||||
params.set_channel(optional<uint8_t>{});
|
||||
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<bssid_t>{});
|
||||
params.set_channel(optional<uint8_t>{});
|
||||
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<int8_t>(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<int8_t>(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<int8_t>(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<int8_t>(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<int8_t>(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<bssid_t> 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<int8_t>(next_index);
|
||||
this->num_retried_ = 0;
|
||||
ESP_LOGD(TAG, "Next explicit hidden network at index %d", static_cast<int>(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;
|
||||
|
||||
@@ -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;
|
||||
@@ -341,8 +359,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<size_t>(this->selected_sta_index_) < this->sta_.size()) {
|
||||
return &this->sta_[this->selected_sta_index_];
|
||||
@@ -356,14 +403,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<bool> sta, optional<bool> ap);
|
||||
@@ -443,20 +491,18 @@ class WiFiComponent : public Component {
|
||||
// Group all 8-bit values together
|
||||
WiFiComponentState state_{WIFI_COMPONENT_STATE_OFF};
|
||||
WiFiPowerSaveMode power_save_{WIFI_POWER_SAVE_NONE};
|
||||
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};
|
||||
|
||||
@@ -710,15 +710,6 @@ class EsphomeCore:
|
||||
def relative_piolibdeps_path(self, *path: str | Path) -> Path:
|
||||
return self.relative_build_path(".piolibdeps", *path)
|
||||
|
||||
@property
|
||||
def platformio_cache_dir(self) -> str:
|
||||
"""Get the PlatformIO cache directory path."""
|
||||
# Check if running in Docker/HA addon with custom cache dir
|
||||
if (cache_dir := os.environ.get("PLATFORMIO_CACHE_DIR")) and cache_dir.strip():
|
||||
return cache_dir
|
||||
# Default PlatformIO cache location
|
||||
return os.path.expanduser("~/.platformio/.cache")
|
||||
|
||||
@property
|
||||
def firmware_bin(self) -> Path:
|
||||
if self.is_libretiny:
|
||||
|
||||
@@ -414,8 +414,10 @@ int8_t step_to_accuracy_decimals(float step) {
|
||||
return str.length() - dot_pos - 1;
|
||||
}
|
||||
|
||||
// Store BASE64 characters as array - automatically placed in flash/ROM on embedded platforms
|
||||
static const char BASE64_CHARS[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
// Use C-style string constant to store in ROM instead of RAM (saves 24 bytes)
|
||||
static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
"abcdefghijklmnopqrstuvwxyz"
|
||||
"0123456789+/";
|
||||
|
||||
// Helper function to find the index of a base64 character in the lookup table.
|
||||
// Returns the character's position (0-63) if found, or 0 if not found.
|
||||
@@ -425,8 +427,8 @@ static const char BASE64_CHARS[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqr
|
||||
// stops processing at the first invalid character due to the is_base64() check in its
|
||||
// while loop condition, making this edge case harmless in practice.
|
||||
static inline uint8_t base64_find_char(char c) {
|
||||
const void *ptr = memchr(BASE64_CHARS, c, sizeof(BASE64_CHARS));
|
||||
return ptr ? (static_cast<const char *>(ptr) - BASE64_CHARS) : 0;
|
||||
const char *pos = strchr(BASE64_CHARS, c);
|
||||
return pos ? (pos - BASE64_CHARS) : 0;
|
||||
}
|
||||
|
||||
static inline bool is_base64(char c) { return (isalnum(c) || (c == '+') || (c == '/')); }
|
||||
|
||||
@@ -145,9 +145,6 @@ template<typename T, size_t N> class StaticVector {
|
||||
size_t size() const { return count_; }
|
||||
bool empty() const { return count_ == 0; }
|
||||
|
||||
// Direct access to size counter for efficient in-place construction
|
||||
size_t &count() { return count_; }
|
||||
|
||||
T &operator[](size_t i) { return data_[i]; }
|
||||
const T &operator[](size_t i) const { return data_[i]; }
|
||||
|
||||
|
||||
@@ -94,9 +94,10 @@ class Scheduler {
|
||||
} name_;
|
||||
uint32_t interval;
|
||||
// Split time to handle millis() rollover. The scheduler combines the 32-bit millis()
|
||||
// with a 16-bit rollover counter to create a 48-bit time space (stored as 64-bit
|
||||
// for compatibility). With 49.7 days per 32-bit rollover, the 16-bit counter
|
||||
// supports 49.7 days × 65536 = ~8900 years. This ensures correct scheduling
|
||||
// with a 16-bit rollover counter to create a 48-bit time space (using 32+16 bits).
|
||||
// This is intentionally limited to 48 bits, not stored as a full 64-bit value.
|
||||
// With 49.7 days per 32-bit rollover, the 16-bit counter supports
|
||||
// 49.7 days × 65536 = ~8900 years. This ensures correct scheduling
|
||||
// even when devices run for months. Split into two fields for better memory
|
||||
// alignment on 32-bit systems.
|
||||
uint32_t next_execution_low_; // Lower 32 bits of execution time (millis value)
|
||||
|
||||
@@ -145,16 +145,7 @@ def run_compile(config, verbose):
|
||||
args = []
|
||||
if CONF_COMPILE_PROCESS_LIMIT in config[CONF_ESPHOME]:
|
||||
args += [f"-j{config[CONF_ESPHOME][CONF_COMPILE_PROCESS_LIMIT]}"]
|
||||
result = run_platformio_cli_run(config, verbose, *args)
|
||||
|
||||
# Run memory analysis if enabled
|
||||
if config.get(CONF_ESPHOME, {}).get("analyze_memory", False):
|
||||
try:
|
||||
analyze_memory_usage(config)
|
||||
except Exception as e:
|
||||
_LOGGER.warning("Failed to analyze memory usage: %s", e)
|
||||
|
||||
return result
|
||||
return run_platformio_cli_run(config, verbose, *args)
|
||||
|
||||
|
||||
def _run_idedata(config):
|
||||
@@ -403,74 +394,3 @@ class IDEData:
|
||||
if path.endswith(".exe")
|
||||
else f"{path[:-3]}readelf"
|
||||
)
|
||||
|
||||
|
||||
def analyze_memory_usage(config: dict[str, Any]) -> None:
|
||||
"""Analyze memory usage by component after compilation."""
|
||||
# Lazy import to avoid overhead when not needed
|
||||
from esphome.analyze_memory.cli import MemoryAnalyzerCLI
|
||||
from esphome.analyze_memory.helpers import get_esphome_components
|
||||
|
||||
idedata = get_idedata(config)
|
||||
|
||||
# Get paths to tools
|
||||
elf_path = idedata.firmware_elf_path
|
||||
objdump_path = idedata.objdump_path
|
||||
readelf_path = idedata.readelf_path
|
||||
|
||||
# Debug logging
|
||||
_LOGGER.debug("ELF path from idedata: %s", elf_path)
|
||||
|
||||
# Check if file exists
|
||||
if not Path(elf_path).exists():
|
||||
# Try alternate path
|
||||
alt_path = Path(CORE.relative_build_path(".pioenvs", CORE.name, "firmware.elf"))
|
||||
if alt_path.exists():
|
||||
elf_path = str(alt_path)
|
||||
_LOGGER.debug("Using alternate ELF path: %s", elf_path)
|
||||
else:
|
||||
_LOGGER.warning("ELF file not found at %s or %s", elf_path, alt_path)
|
||||
return
|
||||
|
||||
# Extract external components from config
|
||||
external_components = set()
|
||||
|
||||
# Get the list of built-in ESPHome components
|
||||
builtin_components = get_esphome_components()
|
||||
|
||||
# Special non-component keys that appear in configs
|
||||
NON_COMPONENT_KEYS = {
|
||||
CONF_ESPHOME,
|
||||
"substitutions",
|
||||
"packages",
|
||||
"globals",
|
||||
"<<",
|
||||
}
|
||||
|
||||
# Check all top-level keys in config
|
||||
for key in config:
|
||||
if key not in builtin_components and key not in NON_COMPONENT_KEYS:
|
||||
# This is an external component
|
||||
external_components.add(key)
|
||||
|
||||
_LOGGER.debug("Detected external components: %s", external_components)
|
||||
|
||||
# Create analyzer and run analysis
|
||||
analyzer = MemoryAnalyzerCLI(
|
||||
elf_path, objdump_path, readelf_path, external_components
|
||||
)
|
||||
analyzer.analyze()
|
||||
|
||||
# Generate and print report
|
||||
report = analyzer.generate_report()
|
||||
_LOGGER.info("\n%s", report)
|
||||
|
||||
# Optionally save to file
|
||||
if config.get(CONF_ESPHOME, {}).get("memory_report_file"):
|
||||
report_file = Path(config[CONF_ESPHOME]["memory_report_file"])
|
||||
if report_file.suffix == ".json":
|
||||
report_file.write_text(analyzer.to_json())
|
||||
_LOGGER.info("Memory report saved to %s", report_file)
|
||||
else:
|
||||
report_file.write_text(report)
|
||||
_LOGGER.info("Memory report saved to %s", report_file)
|
||||
|
||||
@@ -66,6 +66,5 @@ def test_text_config_lamda_is_set(generate_main):
|
||||
main_cpp = generate_main("tests/component_tests/text/test_text.yaml")
|
||||
|
||||
# Then
|
||||
# Stateless lambda optimization: empty capture list allows function pointer conversion
|
||||
assert "it_4->set_template([]() -> esphome::optional<std::string> {" in main_cpp
|
||||
assert 'return std::string{"Hello"};' in main_cpp
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
esphome:
|
||||
name: test-user-services-union
|
||||
friendly_name: Test User Services Union Storage
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
ssid: "test"
|
||||
password: "password"
|
||||
|
||||
api:
|
||||
actions:
|
||||
# Test service with no arguments
|
||||
- action: test_no_args
|
||||
then:
|
||||
- logger.log: "No args service called"
|
||||
|
||||
# Test service with one argument
|
||||
- action: test_one_arg
|
||||
variables:
|
||||
value: int
|
||||
then:
|
||||
- logger.log:
|
||||
format: "One arg service: %d"
|
||||
args: [value]
|
||||
|
||||
# Test service with multiple arguments of different types
|
||||
- action: test_multi_args
|
||||
variables:
|
||||
int_val: int
|
||||
float_val: float
|
||||
str_val: string
|
||||
bool_val: bool
|
||||
then:
|
||||
- logger.log:
|
||||
format: "Multi args: %d, %.2f, %s, %d"
|
||||
args: [int_val, float_val, str_val.c_str(), bool_val]
|
||||
|
||||
# Test service with max typical arguments
|
||||
- action: test_many_args
|
||||
variables:
|
||||
arg1: int
|
||||
arg2: int
|
||||
arg3: int
|
||||
arg4: string
|
||||
arg5: float
|
||||
then:
|
||||
- logger.log: "Many args service called"
|
||||
|
||||
binary_sensor:
|
||||
- platform: template
|
||||
name: "Test Binary Sensor"
|
||||
id: test_sensor
|
||||
@@ -670,45 +670,3 @@ class TestEsphomeCore:
|
||||
os.environ.pop("ESPHOME_IS_HA_ADDON", None)
|
||||
os.environ.pop("ESPHOME_DATA_DIR", None)
|
||||
assert target.data_dir == Path(expected_default)
|
||||
|
||||
def test_platformio_cache_dir_with_env_var(self):
|
||||
"""Test platformio_cache_dir when PLATFORMIO_CACHE_DIR env var is set."""
|
||||
target = core.EsphomeCore()
|
||||
test_cache_dir = "/custom/cache/dir"
|
||||
|
||||
with patch.dict(os.environ, {"PLATFORMIO_CACHE_DIR": test_cache_dir}):
|
||||
assert target.platformio_cache_dir == test_cache_dir
|
||||
|
||||
def test_platformio_cache_dir_without_env_var(self):
|
||||
"""Test platformio_cache_dir defaults to ~/.platformio/.cache."""
|
||||
target = core.EsphomeCore()
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
# Ensure env var is not set
|
||||
os.environ.pop("PLATFORMIO_CACHE_DIR", None)
|
||||
expected = os.path.expanduser("~/.platformio/.cache")
|
||||
assert target.platformio_cache_dir == expected
|
||||
|
||||
def test_platformio_cache_dir_empty_env_var(self):
|
||||
"""Test platformio_cache_dir with empty env var falls back to default."""
|
||||
target = core.EsphomeCore()
|
||||
|
||||
with patch.dict(os.environ, {"PLATFORMIO_CACHE_DIR": ""}):
|
||||
expected = os.path.expanduser("~/.platformio/.cache")
|
||||
assert target.platformio_cache_dir == expected
|
||||
|
||||
def test_platformio_cache_dir_whitespace_env_var(self):
|
||||
"""Test platformio_cache_dir with whitespace-only env var falls back to default."""
|
||||
target = core.EsphomeCore()
|
||||
|
||||
with patch.dict(os.environ, {"PLATFORMIO_CACHE_DIR": " "}):
|
||||
expected = os.path.expanduser("~/.platformio/.cache")
|
||||
assert target.platformio_cache_dir == expected
|
||||
|
||||
def test_platformio_cache_dir_docker_addon_path(self):
|
||||
"""Test platformio_cache_dir in Docker/HA addon environment."""
|
||||
target = core.EsphomeCore()
|
||||
addon_cache = "/data/cache/platformio"
|
||||
|
||||
with patch.dict(os.environ, {"PLATFORMIO_CACHE_DIR": addon_cache}):
|
||||
assert target.platformio_cache_dir == addon_cache
|
||||
|
||||
@@ -355,7 +355,6 @@ def test_clean_build(
|
||||
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
|
||||
mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir
|
||||
mock_core.relative_build_path.return_value = dependencies_lock
|
||||
mock_core.platformio_cache_dir = str(platformio_cache_dir)
|
||||
|
||||
# Verify all exist before
|
||||
assert pioenvs_dir.exists()
|
||||
|
||||
Reference in New Issue
Block a user