Compare commits

..

2 Commits

Author SHA1 Message Date
Kevin Ahrendt
37eaf10f75 [audio] Bump esp-audio-libs to 2.0.3 (#13346) 2026-01-21 07:40:41 -05:00
J. Nick Koston
0b60fd0c8c [core] Avoid heap allocation in str_equals_case_insensitive with string literals (#13312) 2026-01-20 21:49:14 -10:00
12 changed files with 68 additions and 210 deletions

View File

@@ -1 +1 @@
d15ae81646ac0ee76b2586716fe697f187281523ee6db566aed26542a9f98d1a
a34e66dce18eaf9d2d094fedf6b9799bbd9ec1e734f76a29544e3f6d61786c3e

View File

@@ -1,4 +1,5 @@
import esphome.codegen as cg
from esphome.components.esp32 import add_idf_component
import esphome.config_validation as cv
from esphome.const import CONF_BITS_PER_SAMPLE, CONF_NUM_CHANNELS, CONF_SAMPLE_RATE
import esphome.final_validate as fv
@@ -165,4 +166,7 @@ def final_validate_audio_schema(
async def to_code(config):
cg.add_library("esphome/esp-audio-libs", "2.0.1")
add_idf_component(
name="esphome/esp-audio-libs",
ref="2.0.3",
)

View File

@@ -300,7 +300,7 @@ FileDecoderState AudioDecoder::decode_mp3_() {
// Advance read pointer to match the offset for the syncword
this->input_transfer_buffer_->decrease_buffer_length(offset);
uint8_t *buffer_start = this->input_transfer_buffer_->get_buffer_start();
const uint8_t *buffer_start = this->input_transfer_buffer_->get_buffer_start();
buffer_length = (int) this->input_transfer_buffer_->available();
int err = esp_audio_libs::helix_decoder::MP3Decode(this->mp3_decoder_, &buffer_start, &buffer_length,

View File

@@ -39,10 +39,6 @@
#include "esphome/components/esp32_improv/esp32_improv_component.h"
#endif
#ifdef USE_IMPROV_SERIAL
#include "esphome/components/improv_serial/improv_serial_component.h"
#endif
namespace esphome::wifi {
static const char *const TAG = "wifi";
@@ -369,75 +365,6 @@ bool WiFiComponent::ssid_was_seen_in_scan_(const std::string &ssid) const {
return false;
}
bool WiFiComponent::needs_full_scan_results_() const {
// Components that require full scan results (for example, scan result listeners)
// are expected to call request_wifi_scan_results(), which sets keep_scan_results_.
if (this->keep_scan_results_) {
return true;
}
#ifdef USE_CAPTIVE_PORTAL
// Captive portal needs full results when active (showing network list to user)
if (captive_portal::global_captive_portal != nullptr && captive_portal::global_captive_portal->is_active()) {
return true;
}
#endif
#ifdef USE_IMPROV_SERIAL
// Improv serial needs results during provisioning (before connected)
if (improv_serial::global_improv_serial_component != nullptr && !this->is_connected()) {
return true;
}
#endif
#ifdef USE_IMPROV
// BLE improv also needs results during provisioning
if (esp32_improv::global_improv_component != nullptr && esp32_improv::global_improv_component->is_active()) {
return true;
}
#endif
return false;
}
bool WiFiComponent::matches_configured_network_(const char *ssid, const uint8_t *bssid) const {
// Hidden networks in scan results have empty SSIDs - skip them
if (ssid[0] == '\0') {
return false;
}
for (const auto &sta : this->sta_) {
// Skip hidden network configs (they don't appear in normal scans)
if (sta.get_hidden()) {
continue;
}
// For BSSID-only configs (empty SSID), match by BSSID
if (sta.get_ssid().empty()) {
if (sta.has_bssid() && std::memcmp(sta.get_bssid().data(), bssid, 6) == 0) {
return true;
}
continue;
}
// Match by SSID
if (sta.get_ssid() == ssid) {
return true;
}
}
return false;
}
void WiFiComponent::log_discarded_scan_result_(const char *ssid, const uint8_t *bssid, int8_t rssi, uint8_t channel) {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
// Skip logging during roaming scans to avoid log buffer overflow
// (roaming scans typically find many networks but only care about same-SSID APs)
if (this->roaming_state_ == RoamingState::SCANNING) {
return;
}
char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
format_mac_addr_upper(bssid, bssid_s);
ESP_LOGV(TAG, "- " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") " %ddB Ch:%u", ssid, bssid_s, rssi, channel);
#endif
}
int8_t WiFiComponent::find_next_hidden_sta_(int8_t start_index) {
// Find next SSID to try in RETRY_HIDDEN phase.
//
@@ -726,12 +653,8 @@ void WiFiComponent::loop() {
ESP_LOGI(TAG, "Starting fallback AP");
this->setup_ap_config_();
#ifdef USE_CAPTIVE_PORTAL
if (captive_portal::global_captive_portal != nullptr) {
// Reset so we force one full scan after captive portal starts
// (previous scans were filtered because captive portal wasn't active yet)
this->has_completed_scan_after_captive_portal_start_ = false;
if (captive_portal::global_captive_portal != nullptr)
captive_portal::global_captive_portal->start();
}
#endif
}
}
@@ -1248,7 +1171,7 @@ template<typename VectorType> static void insertion_sort_scan_results(VectorType
// has overhead from UART transmission, so combining INFO+DEBUG into one line halves
// the blocking time. Do NOT split this into separate ESP_LOGI/ESP_LOGD calls.
__attribute__((noinline)) static void log_scan_result(const WiFiScanResult &res) {
char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
char bssid_s[18];
auto bssid = res.get_bssid();
format_mac_addr_upper(bssid.data(), bssid_s);
@@ -1264,6 +1187,18 @@ __attribute__((noinline)) static void log_scan_result(const WiFiScanResult &res)
#endif
}
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
// Helper function to log non-matching scan results at verbose level
__attribute__((noinline)) static void log_scan_result_non_matching(const WiFiScanResult &res) {
char bssid_s[18];
auto bssid = res.get_bssid();
format_mac_addr_upper(bssid.data(), bssid_s);
ESP_LOGV(TAG, "- " LOG_SECRET("'%s'") " " LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), bssid_s,
LOG_STR_ARG(get_signal_bars(res.get_rssi())));
}
#endif
void WiFiComponent::check_scanning_finished() {
if (!this->scan_done_) {
if (millis() - this->action_started_ > WIFI_SCAN_TIMEOUT_MS) {
@@ -1273,8 +1208,6 @@ void WiFiComponent::check_scanning_finished() {
return;
}
this->scan_done_ = false;
this->has_completed_scan_after_captive_portal_start_ =
true; // Track that we've done a scan since captive portal started
this->retry_hidden_mode_ = RetryHiddenMode::SCAN_BASED;
if (this->scan_result_.empty()) {
@@ -1302,12 +1235,21 @@ void WiFiComponent::check_scanning_finished() {
// Sort scan results using insertion sort for better memory efficiency
insertion_sort_scan_results(this->scan_result_);
// Log matching networks (non-matching already logged at VERBOSE in scan callback)
size_t non_matching_count = 0;
for (auto &res : this->scan_result_) {
if (res.get_matches()) {
log_scan_result(res);
} else {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
log_scan_result_non_matching(res);
#else
non_matching_count++;
#endif
}
}
if (non_matching_count > 0) {
ESP_LOGD(TAG, "- %zu non-matching (VERBOSE to show)", non_matching_count);
}
// 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
@@ -1571,10 +1513,7 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() {
if (this->went_through_explicit_hidden_phase_()) {
return WiFiRetryPhase::EXPLICIT_HIDDEN;
}
// Skip scanning when captive portal/improv is active to avoid disrupting AP,
// BUT only if we've already completed at least one scan AFTER the portal started.
// When captive portal first starts, scan results may be filtered/stale, so we need
// to do one full scan to populate available networks for the captive portal UI.
// Skip scanning when captive portal/improv is active to avoid disrupting AP.
//
// WHY SCANNING DISRUPTS AP MODE:
// WiFi scanning requires the radio to leave the AP's channel and hop through
@@ -1591,16 +1530,7 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() {
//
// This allows users to configure WiFi via captive portal while the device keeps
// attempting to connect to all configured networks in sequence.
// Captive portal needs scan results to show available networks.
// If captive portal is active, only skip scanning if we've done a scan after it started.
// If only improv is active (no captive portal), skip scanning since improv doesn't need results.
if (this->is_captive_portal_active_()) {
if (this->has_completed_scan_after_captive_portal_start_) {
return WiFiRetryPhase::RETRY_HIDDEN;
}
// Need to scan for captive portal
} else if (this->is_esp32_improv_active_()) {
// Improv doesn't need scan results
if (this->is_captive_portal_active_() || this->is_esp32_improv_active_()) {
return WiFiRetryPhase::RETRY_HIDDEN;
}
return WiFiRetryPhase::SCAN_CONNECTING;
@@ -2149,7 +2079,7 @@ void WiFiComponent::clear_roaming_state_() {
void WiFiComponent::release_scan_results_() {
if (!this->keep_scan_results_) {
#if defined(USE_RP2040) || defined(USE_ESP32)
#ifdef USE_RP2040
// std::vector - use swap trick since shrink_to_fit is non-binding
decltype(this->scan_result_)().swap(this->scan_result_);
#else

View File

@@ -161,12 +161,9 @@ struct EAPAuth {
using bssid_t = std::array<uint8_t, 6>;
/// Initial reserve size for filtered scan results (typical: 1-3 matching networks per SSID)
static constexpr size_t WIFI_SCAN_RESULT_FILTERED_RESERVE = 8;
// Use std::vector for RP2040 (callback-based) and ESP32 (destructive scan API)
// Use FixedVector for ESP8266 and LibreTiny where two-pass exact allocation is possible
#if defined(USE_RP2040) || defined(USE_ESP32)
// Use std::vector for RP2040 since scan count is unknown (callback-based)
// Use FixedVector for other platforms where count is queried first
#ifdef USE_RP2040
template<typename T> using wifi_scan_vector_t = std::vector<T>;
#else
template<typename T> using wifi_scan_vector_t = FixedVector<T>;
@@ -542,13 +539,6 @@ class WiFiComponent : public Component {
/// 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;
/// Check if full scan results are needed (captive portal active, improv, listeners)
bool needs_full_scan_results_() const;
/// Check if network matches any configured network (for scan result filtering)
/// Matches by SSID when configured, or by BSSID for BSSID-only configs
bool matches_configured_network_(const char *ssid, const uint8_t *bssid) const;
/// Log a discarded scan result at VERBOSE level (skipped during roaming scans to avoid log overflow)
void log_discarded_scan_result_(const char *ssid, const uint8_t *bssid, int8_t rssi, uint8_t channel);
/// 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)
@@ -720,8 +710,6 @@ class WiFiComponent : public Component {
bool enable_on_boot_{true};
bool got_ipv4_address_{false};
bool keep_scan_results_{false};
bool has_completed_scan_after_captive_portal_start_{
false}; // Tracks if we've completed a scan after captive portal started
RetryHiddenMode retry_hidden_mode_{RetryHiddenMode::BLIND_RETRY};
bool skip_cooldown_next_cycle_{false};
bool post_connect_roaming_{true}; // Enabled by default

View File

@@ -756,35 +756,20 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) {
return;
}
// Count the number of results first
auto *head = reinterpret_cast<bss_info *>(arg);
bool needs_full = this->needs_full_scan_results_();
// First pass: count matching networks (linked list is non-destructive)
size_t total = 0;
size_t count = 0;
for (bss_info *it = head; it != nullptr; it = STAILQ_NEXT(it, next)) {
total++;
const char *ssid_cstr = reinterpret_cast<const char *>(it->ssid);
if (needs_full || this->matches_configured_network_(ssid_cstr, it->bssid)) {
count++;
}
count++;
}
this->scan_result_.init(count); // Exact allocation
// Second pass: store matching networks
this->scan_result_.init(count);
for (bss_info *it = head; it != nullptr; it = STAILQ_NEXT(it, next)) {
const char *ssid_cstr = reinterpret_cast<const char *>(it->ssid);
if (needs_full || this->matches_configured_network_(ssid_cstr, it->bssid)) {
this->scan_result_.emplace_back(
bssid_t{it->bssid[0], it->bssid[1], it->bssid[2], it->bssid[3], it->bssid[4], it->bssid[5]},
std::string(ssid_cstr, it->ssid_len), it->channel, it->rssi, it->authmode != AUTH_OPEN, it->is_hidden != 0);
} else {
this->log_discarded_scan_result_(ssid_cstr, it->bssid, it->rssi, it->channel);
}
this->scan_result_.emplace_back(
bssid_t{it->bssid[0], it->bssid[1], it->bssid[2], it->bssid[3], it->bssid[4], it->bssid[5]},
std::string(reinterpret_cast<char *>(it->ssid), it->ssid_len), it->channel, it->rssi, it->authmode != AUTH_OPEN,
it->is_hidden != 0);
}
ESP_LOGV(TAG, "Scan complete: %zu found, %zu stored%s", total, this->scan_result_.size(),
needs_full ? "" : " (filtered)");
this->scan_done_ = true;
#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
for (auto *listener : global_wifi_component->scan_results_listeners_) {

View File

@@ -827,14 +827,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
}
uint16_t number = it.number;
bool needs_full = this->needs_full_scan_results_();
// Smart reserve: full capacity if needed, small reserve otherwise
if (needs_full) {
this->scan_result_.reserve(number);
} else {
this->scan_result_.reserve(WIFI_SCAN_RESULT_FILTERED_RESERVE);
}
scan_result_.init(number);
// Process one record at a time to avoid large buffer allocation
wifi_ap_record_t record;
@@ -845,23 +838,12 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
esp_wifi_clear_ap_list(); // Free remaining records not yet retrieved
break;
}
// Check C string first - avoid std::string construction for non-matching networks
const char *ssid_cstr = reinterpret_cast<const char *>(record.ssid);
// Only construct std::string and store if needed
if (needs_full || this->matches_configured_network_(ssid_cstr, record.bssid)) {
bssid_t bssid;
std::copy(record.bssid, record.bssid + 6, bssid.begin());
std::string ssid(ssid_cstr);
this->scan_result_.emplace_back(bssid, std::move(ssid), record.primary, record.rssi,
record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0');
} else {
this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary);
}
bssid_t bssid;
std::copy(record.bssid, record.bssid + 6, bssid.begin());
std::string ssid(reinterpret_cast<const char *>(record.ssid));
scan_result_.emplace_back(bssid, ssid, record.primary, record.rssi, record.authmode != WIFI_AUTH_OPEN,
ssid.empty());
}
ESP_LOGV(TAG, "Scan complete: %u found, %zu stored%s", number, this->scan_result_.size(),
needs_full ? "" : " (filtered)");
#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
for (auto *listener : this->scan_results_listeners_) {
listener->on_wifi_scan_results(this->scan_result_);

View File

@@ -664,39 +664,18 @@ void WiFiComponent::wifi_scan_done_callback_() {
if (num < 0)
return;
bool needs_full = this->needs_full_scan_results_();
// Access scan results directly via WiFi.scan struct to avoid Arduino String allocations
// WiFi.scan is public in LibreTiny for WiFiEvents & WiFiScan static handlers
auto *scan = WiFi.scan;
// First pass: count matching networks
size_t count = 0;
this->scan_result_.init(static_cast<unsigned int>(num));
for (int i = 0; i < num; i++) {
const char *ssid_cstr = scan->ap[i].ssid;
if (needs_full || this->matches_configured_network_(ssid_cstr, scan->ap[i].bssid.addr)) {
count++;
}
}
String ssid = WiFi.SSID(i);
wifi_auth_mode_t authmode = WiFi.encryptionType(i);
int32_t rssi = WiFi.RSSI(i);
uint8_t *bssid = WiFi.BSSID(i);
int32_t channel = WiFi.channel(i);
this->scan_result_.init(count); // Exact allocation
// Second pass: store matching networks
for (int i = 0; i < num; i++) {
const char *ssid_cstr = scan->ap[i].ssid;
if (needs_full || this->matches_configured_network_(ssid_cstr, scan->ap[i].bssid.addr)) {
auto &ap = scan->ap[i];
this->scan_result_.emplace_back(bssid_t{ap.bssid.addr[0], ap.bssid.addr[1], ap.bssid.addr[2], ap.bssid.addr[3],
ap.bssid.addr[4], ap.bssid.addr[5]},
std::string(ssid_cstr), ap.channel, ap.rssi, ap.auth != WIFI_AUTH_OPEN,
ssid_cstr[0] == '\0');
} else {
auto &ap = scan->ap[i];
this->log_discarded_scan_result_(ssid_cstr, ap.bssid.addr, ap.rssi, ap.channel);
}
this->scan_result_.emplace_back(bssid_t{bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]},
std::string(ssid.c_str()), channel, rssi, authmode != WIFI_AUTH_OPEN,
ssid.length() == 0);
}
ESP_LOGV(TAG, "Scan complete: %d found, %zu stored%s", num, this->scan_result_.size(),
needs_full ? "" : " (filtered)");
WiFi.scanDelete();
#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
for (auto *listener : this->scan_results_listeners_) {

View File

@@ -21,7 +21,6 @@ static const char *const TAG = "wifi_pico_w";
// Track previous state for detecting changes
static bool s_sta_was_connected = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static bool s_sta_had_ip = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static size_t s_scan_result_count = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
bool WiFiComponent::wifi_mode_(optional<bool> sta, optional<bool> ap) {
if (sta.has_value()) {
@@ -138,20 +137,10 @@ int WiFiComponent::s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *r
}
void WiFiComponent::wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result) {
s_scan_result_count++;
const char *ssid_cstr = reinterpret_cast<const char *>(result->ssid);
// Skip networks that don't match any configured network (unless full results needed)
if (!this->needs_full_scan_results_() && !this->matches_configured_network_(ssid_cstr, result->bssid)) {
this->log_discarded_scan_result_(ssid_cstr, result->bssid, result->rssi, result->channel);
return;
}
bssid_t bssid;
std::copy(result->bssid, result->bssid + 6, bssid.begin());
std::string ssid(ssid_cstr);
WiFiScanResult res(bssid, std::move(ssid), result->channel, result->rssi, result->auth_mode != CYW43_AUTH_OPEN,
ssid_cstr[0] == '\0');
std::string ssid(reinterpret_cast<const char *>(result->ssid));
WiFiScanResult res(bssid, ssid, result->channel, result->rssi, result->auth_mode != CYW43_AUTH_OPEN, ssid.empty());
if (std::find(this->scan_result_.begin(), this->scan_result_.end(), res) == this->scan_result_.end()) {
this->scan_result_.push_back(res);
}
@@ -160,7 +149,6 @@ void WiFiComponent::wifi_scan_result(void *env, const cyw43_ev_scan_result_t *re
bool WiFiComponent::wifi_scan_start_(bool passive) {
this->scan_result_.clear();
this->scan_done_ = false;
s_scan_result_count = 0;
cyw43_wifi_scan_options_t scan_options = {0};
scan_options.scan_type = passive ? 1 : 0;
int err = cyw43_wifi_scan(&cyw43_state, &scan_options, nullptr, &s_wifi_scan_result);
@@ -256,9 +244,7 @@ void WiFiComponent::wifi_loop_() {
// Handle scan completion
if (this->state_ == WIFI_COMPONENT_STATE_STA_SCANNING && !cyw43_wifi_scan_active(&cyw43_state)) {
this->scan_done_ = true;
bool needs_full = this->needs_full_scan_results_();
ESP_LOGV(TAG, "Scan complete: %zu found, %zu stored%s", s_scan_result_count, this->scan_result_.size(),
needs_full ? "" : " (filtered)");
ESP_LOGV(TAG, "Scan done");
#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
for (auto *listener : this->scan_results_listeners_) {
listener->on_wifi_scan_results(this->scan_result_);

View File

@@ -572,6 +572,10 @@ template<typename T> constexpr T convert_little_endian(T val) {
bool str_equals_case_insensitive(const std::string &a, const std::string &b);
/// Compare StringRefs for equality in case-insensitive manner.
bool str_equals_case_insensitive(StringRef a, StringRef b);
/// Compare C strings for equality in case-insensitive manner (no heap allocation).
inline bool str_equals_case_insensitive(const char *a, const char *b) { return strcasecmp(a, b) == 0; }
inline bool str_equals_case_insensitive(const std::string &a, const char *b) { return strcasecmp(a.c_str(), b) == 0; }
inline bool str_equals_case_insensitive(const char *a, const std::string &b) { return strcasecmp(a, b.c_str()) == 0; }
/// Check whether a string starts with a value.
bool str_startswith(const std::string &str, const std::string &start);

View File

@@ -1,6 +1,8 @@
dependencies:
bblanchon/arduinojson:
version: "7.4.2"
esphome/esp-audio-libs:
version: 2.0.3
espressif/esp-tflite-micro:
version: 1.3.3~1
espressif/esp32-camera:

View File

@@ -154,7 +154,6 @@ lib_deps =
makuna/NeoPixelBus@2.8.0 ; neopixelbus
esphome/ESP32-audioI2S@2.3.0 ; i2s_audio
droscy/esp_wireguard@0.4.2 ; wireguard
esphome/esp-audio-libs@2.0.1 ; audio
kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word
build_flags =
@@ -178,7 +177,6 @@ lib_deps =
${common:idf.lib_deps}
droscy/esp_wireguard@0.4.2 ; wireguard
kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word
esphome/esp-audio-libs@2.0.1 ; audio
build_flags =
${common:idf.build_flags}
-Wno-nonnull-compare