Compare commits

..

26 Commits

Author SHA1 Message Date
J. Nick Koston
bd958c5859 [api] Use shared static string for reboot timeout scheduler name 2025-11-28 19:27:21 -06:00
J. Nick Koston
bc50be6053 [logger] Conditionally compile log level change listener (#12168)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-28 22:14:00 +00:00
J. Nick Koston
ca599b25c2 [espnow] Initialize LwIP stack when running without WiFi component (#12169) 2025-11-28 16:33:28 -05:00
J. Nick Koston
2e55296640 [sensor] Replace timeout filter scheduler with loop-based implementation (#11922) 2025-11-28 20:43:11 +00:00
Javier Peletier
d6ca01775e [packages] Restore remote shorthand vars and !remove in early package contents validation (#12158)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-11-28 18:24:09 +00:00
Javier Peletier
e15f3a08ae [tests] Remote packages with substitutions (#12145) 2025-11-28 12:15:55 -06:00
J. Nick Koston
fb82362e9c [api] Eliminate rx_buf heap churn and release buffers after initial sync (#12133) 2025-11-28 12:13:29 -06:00
J. Nick Koston
26e979d3d5 [wifi] Replace std::function callbacks with listener interfaces (#12155) 2025-11-28 11:27:17 -06:00
J. Nick Koston
60ffa0e52e [esp32_ble_tracker] Replace scanner state callback with listener interface (#12156) 2025-11-28 11:27:08 -06:00
J. Nick Koston
e1ec6146c0 [wifi] Save 112 bytes BSS on ESP8266 by calling SDK directly for BSSID (#12137)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2025-11-27 22:09:41 -06:00
J. Nick Koston
450065fdae [light] Replace sparse enum switch with linear search to save 156 bytes RAM (#12140) 2025-11-27 22:09:27 -06:00
J. Nick Koston
71dc402a30 [logger] Replace std::function callbacks with LogListener interface (#12153) 2025-11-28 04:00:33 +00:00
Jonathan Swoboda
9bd148dfd1 Merge branch 'release' into dev 2025-11-27 18:19:20 -05:00
Jonathan Swoboda
50c1720c16 Merge pull request #12149 from esphome/bump-2025.11.2
2025.11.2
2025-11-27 18:19:05 -05:00
J. Nick Koston
4c549798bc [usb_uart] Wake main loop immediately when USB data arrives (#12148) 2025-11-27 16:33:08 -06:00
Jonathan Swoboda
4115dd7222 Bump version to 2025.11.2 2025-11-27 17:23:28 -05:00
J. Nick Koston
d5e2543751 [scheduler] Fix use-after-move crash in heap operations (#12124) 2025-11-27 17:23:28 -05:00
Clyde Stubbs
b4b34aee13 [wifi] Restore blocking setup until connected for RP2040 (#12142) 2025-11-27 17:23:28 -05:00
Jonathan Swoboda
6645994700 [esp32] Fix hosted update when there is no wifi (#12123) 2025-11-27 17:23:28 -05:00
Clyde Stubbs
ae140f52e3 [lvgl] Fix position of errors in widget config (#12111)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-11-27 17:23:28 -05:00
Clyde Stubbs
46ae6d35a2 [lvgl] Allow multiple widgets per grid cell (#12091) 2025-11-27 17:23:27 -05:00
J. Nick Koston
278f12fb99 [script] Fix script.wait hanging when triggered from on_boot (#12102) 2025-11-27 17:23:27 -05:00
Jonathan Swoboda
acdcd56395 [esp32] Fix platformio flash size print (#12099)
Co-authored-by: J. Nick Koston <nick+github@koston.org>
2025-11-27 17:23:27 -05:00
Edward Firmo
9289fc36f7 [nextion] Do not set alternative baud rate when not specified or <= 0 (#12097) 2025-11-27 17:23:27 -05:00
J. Nick Koston
1fadd1227d [scheduler] Fix use-after-move crash in heap operations (#12124) 2025-11-27 10:50:21 -06:00
Clyde Stubbs
91df0548ef [wifi] Restore blocking setup until connected for RP2040 (#12142) 2025-11-27 10:30:03 -05:00
60 changed files with 792 additions and 429 deletions

View File

@@ -169,8 +169,7 @@ void APIConnection::loop() {
} else {
this->last_traffic_ = now;
// read a packet
this->read_message(buffer.data_len, buffer.type,
buffer.data_len > 0 ? &buffer.container[buffer.data_offset] : nullptr);
this->read_message(buffer.data_len, buffer.type, buffer.data);
if (this->flags_.remove)
return;
}
@@ -195,6 +194,9 @@ void APIConnection::loop() {
}
// Now that everything is sent, enable immediate sending for future state changes
this->flags_.should_try_send_immediately = true;
// Release excess memory from buffers that grew during initial sync
this->deferred_batch_.release_buffer();
this->helper_->release_buffers();
}
}

View File

@@ -554,10 +554,8 @@ class APIConnection final : public APIServerConnection {
std::vector<BatchItem> items;
uint32_t batch_start_time{0};
DeferredBatch() {
// Pre-allocate capacity for typical batch sizes to avoid reallocation
items.reserve(8);
}
// No pre-allocation - log connections never use batching, and for
// connections that do, buffers are released after initial sync anyway
// Add item to the batch
void add_item(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size);
@@ -576,6 +574,15 @@ class APIConnection final : public APIServerConnection {
bool empty() const { return items.empty(); }
size_t size() const { return items.size(); }
const BatchItem &operator[](size_t index) const { return items[index]; }
// Release excess capacity - only releases if items already empty
void release_buffer() {
// Safe to call: batch is processed before release_buffer is called,
// and if any items remain (partial processing), we must not clear them.
// Use swap trick since shrink_to_fit() is non-binding and may be ignored.
if (items.empty()) {
std::vector<BatchItem>().swap(items);
}
}
};
// DeferredBatch here (16 bytes, 4-byte aligned)

View File

@@ -35,10 +35,9 @@ struct ClientInfo;
class ProtoWriteBuffer;
struct ReadPacketBuffer {
std::vector<uint8_t> container;
uint16_t type;
uint16_t data_offset;
const uint8_t *data; // Points directly into frame helper's rx_buf_ (valid until next read_packet call)
uint16_t data_len;
uint16_t type;
};
// Packed packet info structure to minimize memory usage
@@ -119,6 +118,22 @@ class APIFrameHelper {
uint8_t frame_footer_size() const { return frame_footer_size_; }
// Check if socket has data ready to read
bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); }
// Release excess memory from internal buffers after initial sync
void release_buffers() {
// rx_buf_: Safe to clear only if no partial read in progress.
// rx_buf_len_ tracks bytes read so far; if non-zero, we're mid-frame
// and clearing would lose partially received data.
if (this->rx_buf_len_ == 0) {
// Use swap trick since shrink_to_fit() is non-binding and may be ignored
std::vector<uint8_t>().swap(this->rx_buf_);
}
// reusable_iovs_: Safe to release unconditionally.
// Only used within write_protobuf_packets() calls - cleared at start,
// populated with pointers, used for writev(), then function returns.
// The iovecs contain stale pointers after the call (data was either sent
// or copied to tx_buf_), and are cleared on next write_protobuf_packets().
std::vector<struct iovec>().swap(this->reusable_iovs_);
}
protected:
// Buffer containing data to be sent

View File

@@ -407,8 +407,7 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) {
return APIError::BAD_DATA_PACKET;
}
buffer->container = std::move(this->rx_buf_);
buffer->data_offset = 4;
buffer->data = msg_data + 4; // Skip 4-byte header (type + length)
buffer->data_len = data_len;
buffer->type = type;
return APIError::OK;

View File

@@ -210,8 +210,7 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) {
return aerr;
}
buffer->container = std::move(this->rx_buf_);
buffer->data_offset = 0;
buffer->data = this->rx_buf_.data();
buffer->data_len = this->rx_header_parsed_len_;
buffer->type = this->rx_header_parsed_type_;
return APIError::OK;

View File

@@ -13,7 +13,7 @@ void APIServerConnectionBase::log_send_message_(const char *name, const std::str
}
#endif
void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) {
void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) {
switch (msg_type) {
case HelloRequest::MESSAGE_TYPE: {
HelloRequest msg;
@@ -827,7 +827,7 @@ void APIServerConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) { th
void APIServerConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { this->zwave_proxy_request(msg); }
#endif
void APIServerConnection::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) {
void APIServerConnection::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) {
// Check authentication/connection requirements for messages
switch (msg_type) {
case HelloRequest::MESSAGE_TYPE: // No setup required

View File

@@ -218,7 +218,7 @@ class APIServerConnectionBase : public ProtoService {
virtual void on_z_wave_proxy_request(const ZWaveProxyRequest &value){};
#endif
protected:
void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override;
void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override;
};
class APIServerConnection : public APIServerConnectionBase {
@@ -480,7 +480,7 @@ class APIServerConnection : public APIServerConnectionBase {
#ifdef USE_ZWAVE_PROXY
void on_z_wave_proxy_request(const ZWaveProxyRequest &msg) override;
#endif
void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override;
void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override;
};
} // namespace esphome::api

View File

@@ -101,19 +101,7 @@ void APIServer::setup() {
#ifdef USE_LOGGER
if (logger::global_logger != nullptr) {
logger::global_logger->add_on_log_callback(
[this](int level, const char *tag, const char *message, size_t message_len) {
if (this->shutting_down_) {
// Don't try to send logs during shutdown
// as it could result in a recursion and
// we would be filling a buffer we are trying to clear
return;
}
for (auto &c : this->clients_) {
if (!c->flags_.remove && c->get_log_subscription_level() >= level)
c->try_send_log_message(level, tag, message, message_len);
}
});
logger::global_logger->add_log_listener(this);
}
#endif
@@ -129,9 +117,11 @@ void APIServer::setup() {
#endif
}
static const char *const REBOOT_TIMEOUT = "reboot";
void APIServer::schedule_reboot_timeout_() {
this->status_set_warning();
this->set_timeout("api_reboot", this->reboot_timeout_, []() {
this->set_timeout(REBOOT_TIMEOUT, this->reboot_timeout_, []() {
if (!global_api_server->is_connected()) {
ESP_LOGE(TAG, "No clients; rebooting");
App.reboot();
@@ -167,7 +157,7 @@ void APIServer::loop() {
// Clear warning status and cancel reboot when first client connects
if (this->clients_.size() == 1 && this->reboot_timeout_ != 0) {
this->status_clear_warning();
this->cancel_timeout("api_reboot");
this->cancel_timeout(REBOOT_TIMEOUT);
}
}
}
@@ -541,6 +531,21 @@ bool APIServer::is_connected(bool state_subscription_only) const {
return false;
}
#ifdef USE_LOGGER
void APIServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) {
if (this->shutting_down_) {
// Don't try to send logs during shutdown
// as it could result in a recursion and
// we would be filling a buffer we are trying to clear
return;
}
for (auto &c : this->clients_) {
if (!c->flags_.remove && c->get_log_subscription_level() >= level)
c->try_send_log_message(level, tag, message, message_len);
}
}
#endif
void APIServer::on_shutdown() {
this->shutting_down_ = true;

View File

@@ -15,6 +15,9 @@
#ifdef USE_API_USER_DEFINED_ACTIONS
#include "user_services.h"
#endif
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
#endif
#include <map>
#include <vector>
@@ -27,7 +30,13 @@ struct SavedNoisePsk {
} PACKED; // NOLINT
#endif
class APIServer : public Component, public Controller {
class APIServer : public Component,
public Controller
#ifdef USE_LOGGER
,
public logger::LogListener
#endif
{
public:
APIServer();
void setup() override;
@@ -37,6 +46,9 @@ class APIServer : public Component, public Controller {
void dump_config() override;
void on_shutdown() override;
bool teardown() override;
#ifdef USE_LOGGER
void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) override;
#endif
#ifdef USE_API_PASSWORD
bool check_password(const uint8_t *password_data, size_t password_len) const;
void set_password(const std::string &password);

View File

@@ -846,7 +846,7 @@ class ProtoService {
*/
virtual ProtoWriteBuffer create_buffer(uint32_t reserve_size) = 0;
virtual bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) = 0;
virtual void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) = 0;
virtual void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) = 0;
// Optimized method that pre-allocates buffer based on message size
bool send_message_(const ProtoMessage &msg, uint8_t message_type) {

View File

@@ -87,17 +87,21 @@ void BLENUS::setup() {
global_ble_nus = this;
#ifdef USE_LOGGER
if (logger::global_logger != nullptr && this->expose_log_) {
logger::global_logger->add_on_log_callback(
[this](int level, const char *tag, const char *message, size_t message_len) {
this->write_array(reinterpret_cast<const uint8_t *>(message), message_len);
const char c = '\n';
this->write_array(reinterpret_cast<const uint8_t *>(&c), 1);
});
logger::global_logger->add_log_listener(this);
}
#endif
}
#ifdef USE_LOGGER
void BLENUS::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) {
(void) level;
(void) tag;
this->write_array(reinterpret_cast<const uint8_t *>(message), message_len);
const char c = '\n';
this->write_array(reinterpret_cast<const uint8_t *>(&c), 1);
}
#endif
void BLENUS::dump_config() {
ESP_LOGCONFIG(TAG, "ble nus:");
ESP_LOGCONFIG(TAG, " log: %s", YESNO(this->expose_log_));

View File

@@ -2,12 +2,20 @@
#ifdef USE_ZEPHYR
#include "esphome/core/defines.h"
#include "esphome/core/component.h"
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
#endif
#include <shell/shell_bt_nus.h>
#include <atomic>
namespace esphome::ble_nus {
class BLENUS : public Component {
class BLENUS : public Component
#ifdef USE_LOGGER
,
public logger::LogListener
#endif
{
enum TxStatus {
TX_DISABLED,
TX_ENABLED,
@@ -20,6 +28,9 @@ class BLENUS : public Component {
void loop() override;
size_t write_array(const uint8_t *data, size_t len);
void set_expose_log(bool expose_log) { this->expose_log_ = expose_log; }
#ifdef USE_LOGGER
void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) override;
#endif
protected:
static void send_enabled_callback(bt_nus_send_status status);

View File

@@ -27,11 +27,13 @@ void BluetoothProxy::setup() {
// Capture the configured scan mode from YAML before any API changes
this->configured_scan_active_ = this->parent_->get_scan_active();
this->parent_->add_scanner_state_callback([this](esp32_ble_tracker::ScannerState state) {
if (this->api_connection_ != nullptr) {
this->send_bluetooth_scanner_state_(state);
}
});
this->parent_->add_scanner_state_listener(this);
}
void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) {
if (this->api_connection_ != nullptr) {
this->send_bluetooth_scanner_state_(state);
}
}
void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state) {

View File

@@ -52,7 +52,9 @@ enum BluetoothProxySubscriptionFlag : uint32_t {
SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0,
};
class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, public Component {
class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
public esp32_ble_tracker::BLEScannerStateListener,
public Component {
friend class BluetoothConnection; // Allow connection to update connections_free_response_
public:
BluetoothProxy();
@@ -108,6 +110,9 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, publ
void set_active(bool active) { this->active_ = active; }
bool has_active() { return this->active_; }
/// BLEScannerStateListener interface
void on_scanner_state(esp32_ble_tracker::ScannerState state) override;
uint32_t get_legacy_version() const {
if (this->active_) {
return LEGACY_ACTIVE_CONNECTIONS_VERSION;

View File

@@ -373,7 +373,9 @@ void ESP32BLETracker::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_i
void ESP32BLETracker::set_scanner_state_(ScannerState state) {
this->scanner_state_ = state;
this->scanner_state_callbacks_.call(state);
for (auto *listener : this->scanner_state_listeners_) {
listener->on_scanner_state(state);
}
}
#ifdef USE_ESP32_BLE_DEVICE

View File

@@ -180,6 +180,16 @@ enum class ScannerState {
STOPPING,
};
/** Listener interface for BLE scanner state changes.
*
* Components can implement this interface to receive scanner state updates
* without the overhead of std::function callbacks.
*/
class BLEScannerStateListener {
public:
virtual void on_scanner_state(ScannerState state) = 0;
};
// Helper function to convert ClientState to string
const char *client_state_to_string(ClientState state);
@@ -264,8 +274,9 @@ class ESP32BLETracker : public Component,
void gap_scan_event_handler(const BLEScanResult &scan_result) override;
void ble_before_disabled_event_handler() override;
void add_scanner_state_callback(std::function<void(ScannerState)> &&callback) {
this->scanner_state_callbacks_.add(std::move(callback));
/// Add a listener for scanner state changes
void add_scanner_state_listener(BLEScannerStateListener *listener) {
this->scanner_state_listeners_.push_back(listener);
}
ScannerState get_scanner_state() const { return this->scanner_state_; }
@@ -322,14 +333,14 @@ class ESP32BLETracker : public Component,
return counts;
}
// Group 1: Large objects (12+ bytes) - vectors and callback manager
// Group 1: Large objects (12+ bytes) - vectors
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
StaticVector<ESPBTDeviceListener *, ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT> listeners_;
#endif
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
StaticVector<ESPBTClient *, ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT> clients_;
#endif
CallbackManager<void(ScannerState)> scanner_state_callbacks_;
std::vector<BLEScannerStateListener *> scanner_state_listeners_;
#ifdef USE_ESP32_BLE_DEVICE
/// Vector of addresses that have already been printed in print_bt_device_info
std::vector<uint64_t> already_discovered_;

View File

@@ -10,6 +10,7 @@
#include <esp_event.h>
#include <esp_mac.h>
#include <esp_netif.h>
#include <esp_now.h>
#include <esp_random.h>
#include <esp_wifi.h>
@@ -157,6 +158,12 @@ bool ESPNowComponent::is_wifi_enabled() {
}
void ESPNowComponent::setup() {
#ifndef USE_WIFI
// Initialize LwIP stack for wake_loop_threadsafe() socket support
// When WiFi component is present, it handles esp_netif_init()
ESP_ERROR_CHECK(esp_netif_init());
#endif
if (this->enable_on_boot_) {
this->enable_();
} else {

View File

@@ -7,30 +7,29 @@ namespace esphome::light {
// See https://www.home-assistant.io/integrations/light.mqtt/#json-schema for documentation on the schema
// Lookup table for color mode strings
static constexpr const char *get_color_mode_json_str(ColorMode mode) {
switch (mode) {
case ColorMode::ON_OFF:
return "onoff";
case ColorMode::BRIGHTNESS:
return "brightness";
case ColorMode::WHITE:
return "white"; // not supported by HA in MQTT
case ColorMode::COLOR_TEMPERATURE:
return "color_temp";
case ColorMode::COLD_WARM_WHITE:
return "cwww"; // not supported by HA
case ColorMode::RGB:
return "rgb";
case ColorMode::RGB_WHITE:
return "rgbw";
case ColorMode::RGB_COLOR_TEMPERATURE:
return "rgbct"; // not supported by HA
case ColorMode::RGB_COLD_WARM_WHITE:
return "rgbww";
default:
return nullptr;
// Get JSON string for color mode using linear search (avoids large switch jump table)
static const char *get_color_mode_json_str(ColorMode mode) {
// Parallel arrays: mode values and their corresponding strings
// Uses less RAM than a switch jump table on sparse enum values
static constexpr ColorMode MODES[] = {
ColorMode::ON_OFF,
ColorMode::BRIGHTNESS,
ColorMode::WHITE,
ColorMode::COLOR_TEMPERATURE,
ColorMode::COLD_WARM_WHITE,
ColorMode::RGB,
ColorMode::RGB_WHITE,
ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::RGB_COLD_WARM_WHITE,
};
static constexpr const char *STRINGS[] = {
"onoff", "brightness", "white", "color_temp", "cwww", "rgb", "rgbw", "rgbct", "rgbww",
};
for (size_t i = 0; i < sizeof(MODES) / sizeof(MODES[0]); i++) {
if (MODES[i] == mode)
return STRINGS[i];
}
return nullptr;
}
void LightJSONSchema::dump_json(LightState &state, JsonObject root) {

View File

@@ -406,6 +406,8 @@ async def to_code(config):
conf,
)
CORE.add_job(final_step)
def validate_printf(value):
# https://stackoverflow.com/questions/30011379/how-can-i-parse-a-c-format-string-in-python
@@ -506,3 +508,24 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
},
}
)
# Keys for CORE.data storage
DOMAIN = "logger"
KEY_LEVEL_LISTENERS = "level_listeners"
def request_logger_level_listeners() -> None:
"""Request that logger level listeners be compiled in.
Components that need to be notified about log level changes should call this
function during their code generation. This enables the add_level_listener()
method and compiles in the listener vector.
"""
CORE.data.setdefault(DOMAIN, {})[KEY_LEVEL_LISTENERS] = True
@coroutine_with_priority(CoroPriority.FINAL)
async def final_step():
"""Final code generation step to configure optional logger features."""
if CORE.data.get(DOMAIN, {}).get(KEY_LEVEL_LISTENERS, False):
cg.add_define("USE_LOGGER_LEVEL_LISTENERS")

View File

@@ -140,8 +140,9 @@ void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __Flas
uint16_t msg_length =
this->tx_buffer_at_ - msg_start; // Don't subtract 1 - tx_buffer_at_ is already at the null terminator position
// Callbacks get message first (before console write)
this->log_callback_.call(level, tag, this->tx_buffer_ + msg_start, msg_length);
// Listeners get message first (before console write)
for (auto *listener : this->log_listeners_)
listener->on_log(level, tag, this->tx_buffer_ + msg_start, msg_length);
// Write to console starting at the msg_start
this->write_tx_buffer_to_console_(msg_start, &msg_length);
@@ -203,7 +204,8 @@ void Logger::process_messages_() {
this->write_footer_to_buffer_(this->tx_buffer_, &this->tx_buffer_at_, this->tx_buffer_size_);
this->tx_buffer_[this->tx_buffer_at_] = '\0';
size_t msg_len = this->tx_buffer_at_; // We already know the length from tx_buffer_at_
this->log_callback_.call(message->level, message->tag, this->tx_buffer_, msg_len);
for (auto *listener : this->log_listeners_)
listener->on_log(message->level, message->tag, this->tx_buffer_, msg_len);
// At this point all the data we need from message has been transferred to the tx_buffer
// so we can release the message to allow other tasks to use it as soon as possible.
this->log_buffer_->release_message_main_loop(received_token);
@@ -231,9 +233,6 @@ void Logger::set_log_level(const char *tag, uint8_t log_level) { this->log_level
UARTSelection Logger::get_uart() const { return this->uart_; }
#endif
void Logger::add_on_log_callback(std::function<void(uint8_t, const char *, const char *, size_t)> &&callback) {
this->log_callback_.add(std::move(callback));
}
float Logger::get_setup_priority() const { return setup_priority::BUS + 500.0f; }
#ifdef USE_STORE_LOG_STR_IN_FLASH
@@ -289,7 +288,10 @@ void Logger::set_log_level(uint8_t level) {
ESP_LOGW(TAG, "Cannot set log level higher than pre-compiled %s", LOG_STR_ARG(LOG_LEVELS[ESPHOME_LOG_LEVEL]));
}
this->current_level_ = level;
this->level_callback_.call(level);
#ifdef USE_LOGGER_LEVEL_LISTENERS
for (auto *listener : this->level_listeners_)
listener->on_log_level_change(level);
#endif
}
Logger *global_logger = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)

View File

@@ -36,6 +36,52 @@ struct device;
namespace esphome::logger {
/** Interface for receiving log messages without std::function overhead.
*
* Components can implement this interface instead of using lambdas with std::function
* to reduce flash usage from std::function type erasure machinery.
*
* Usage:
* class MyComponent : public Component, public LogListener {
* public:
* void setup() override {
* if (logger::global_logger != nullptr)
* logger::global_logger->add_log_listener(this);
* }
* void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) override {
* // Handle log message
* }
* };
*/
class LogListener {
public:
virtual void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) = 0;
};
#ifdef USE_LOGGER_LEVEL_LISTENERS
/** Interface for receiving log level changes without std::function overhead.
*
* Components can implement this interface instead of using lambdas with std::function
* to reduce flash usage from std::function type erasure machinery.
*
* Usage:
* class MyComponent : public Component, public LoggerLevelListener {
* public:
* void setup() override {
* if (logger::global_logger != nullptr)
* logger::global_logger->add_logger_level_listener(this);
* }
* void on_log_level_change(uint8_t level) override {
* // Handle log level change
* }
* };
*/
class LoggerLevelListener {
public:
virtual void on_log_level_change(uint8_t level) = 0;
};
#endif
#ifdef USE_LOGGER_RUNTIME_TAG_LEVELS
// Comparison function for const char* keys in log_levels_ map
struct CStrCompare {
@@ -168,11 +214,13 @@ class Logger : public Component {
inline uint8_t level_for(const char *tag);
/// Register a callback that will be called for every log message sent
void add_on_log_callback(std::function<void(uint8_t, const char *, const char *, size_t)> &&callback);
/// Register a log listener to receive log messages
void add_log_listener(LogListener *listener) { this->log_listeners_.push_back(listener); }
// add a listener for log level changes
void add_listener(std::function<void(uint8_t)> &&callback) { this->level_callback_.add(std::move(callback)); }
#ifdef USE_LOGGER_LEVEL_LISTENERS
/// Register a listener for log level changes
void add_level_listener(LoggerLevelListener *listener) { this->level_listeners_.push_back(listener); }
#endif
float get_setup_priority() const override;
@@ -240,7 +288,7 @@ class Logger : public Component {
}
}
// Helper to format and send a log message to both console and callbacks
// Helper to format and send a log message to both console and listeners
inline void HOT log_message_to_buffer_and_send_(uint8_t level, const char *tag, int line, const char *format,
va_list args) {
// Format to tx_buffer and prepare for output
@@ -248,8 +296,9 @@ class Logger : public Component {
this->format_log_to_buffer_with_terminator_(level, tag, line, format, args, this->tx_buffer_, &this->tx_buffer_at_,
this->tx_buffer_size_);
// Callbacks get message WITHOUT newline (for API/MQTT/syslog)
this->log_callback_.call(level, tag, this->tx_buffer_, this->tx_buffer_at_);
// Listeners get message WITHOUT newline (for API/MQTT/syslog)
for (auto *listener : this->log_listeners_)
listener->on_log(level, tag, this->tx_buffer_, this->tx_buffer_at_);
// Console gets message WITH newline (if platform needs it)
this->write_tx_buffer_to_console_();
@@ -301,8 +350,10 @@ class Logger : public Component {
#ifdef USE_LOGGER_RUNTIME_TAG_LEVELS
std::map<const char *, uint8_t, CStrCompare> log_levels_{};
#endif
CallbackManager<void(uint8_t, const char *, const char *, size_t)> log_callback_{};
CallbackManager<void(uint8_t)> level_callback_{};
std::vector<LogListener *> log_listeners_; // Log message listeners (API, MQTT, syslog, etc.)
#ifdef USE_LOGGER_LEVEL_LISTENERS
std::vector<LoggerLevelListener *> level_listeners_; // Log level change listeners
#endif
#ifdef USE_ESPHOME_TASK_LOG_BUFFER
std::unique_ptr<logger::TaskLogBuffer> log_buffer_; // Will be initialized with init_log_buffer
#endif
@@ -496,15 +547,15 @@ class Logger : public Component {
};
extern Logger *global_logger; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
class LoggerMessageTrigger : public Trigger<uint8_t, const char *, const char *> {
class LoggerMessageTrigger : public Trigger<uint8_t, const char *, const char *>, public LogListener {
public:
explicit LoggerMessageTrigger(Logger *parent, uint8_t level) {
this->level_ = level;
parent->add_on_log_callback([this](uint8_t level, const char *tag, const char *message, size_t message_len) {
if (level <= this->level_) {
this->trigger(level, tag, message);
}
});
explicit LoggerMessageTrigger(Logger *parent, uint8_t level) : level_(level) { parent->add_log_listener(this); }
void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) override {
(void) message_len;
if (level <= this->level_) {
this->trigger(level, tag, message);
}
}
protected:

View File

@@ -5,7 +5,13 @@ from esphome.const import CONF_LEVEL, CONF_LOGGER, ENTITY_CATEGORY_CONFIG, ICON_
from esphome.core import CORE
from esphome.cpp_helpers import register_component, register_parented
from .. import CONF_LOGGER_ID, LOG_LEVELS, Logger, logger_ns
from .. import (
CONF_LOGGER_ID,
LOG_LEVELS,
Logger,
logger_ns,
request_logger_level_listeners,
)
CODEOWNERS = ["@clydebarrow"]
@@ -21,6 +27,7 @@ CONFIG_SCHEMA = select.select_schema(
async def to_code(config):
request_logger_level_listeners()
parent = await cg.get_variable(config[CONF_LOGGER_ID])
levels = list(LOG_LEVELS)
index = levels.index(CORE.data[CONF_LOGGER][CONF_LEVEL])

View File

@@ -2,7 +2,7 @@
namespace esphome::logger {
void LoggerLevelSelect::publish_state(int level) {
void LoggerLevelSelect::on_log_level_change(uint8_t level) {
auto index = level_to_index(level);
if (!this->has_index(index))
return;
@@ -10,8 +10,8 @@ void LoggerLevelSelect::publish_state(int level) {
}
void LoggerLevelSelect::setup() {
this->parent_->add_listener([this](int level) { this->publish_state(level); });
this->publish_state(this->parent_->get_log_level());
this->parent_->add_level_listener(this);
this->on_log_level_change(this->parent_->get_log_level());
}
void LoggerLevelSelect::control(size_t index) { this->parent_->set_log_level(index_to_level(index)); }

View File

@@ -5,12 +5,17 @@
#include "esphome/components/logger/logger.h"
namespace esphome::logger {
class LoggerLevelSelect : public Component, public select::Select, public Parented<Logger> {
class LoggerLevelSelect final : public Component,
public select::Select,
public Parented<Logger>,
public LoggerLevelListener {
public:
void publish_state(int level);
void setup() override;
void control(size_t index) override;
// LoggerLevelListener interface
void on_log_level_change(uint8_t level) override;
protected:
// Convert log level to option index (skip CONFIG at level 4)
static uint8_t level_to_index(uint8_t level) { return (level > ESPHOME_LOG_LEVEL_CONFIG) ? level - 1 : level; }

View File

@@ -57,15 +57,7 @@ void MQTTClientComponent::setup() {
});
#ifdef USE_LOGGER
if (this->is_log_message_enabled() && logger::global_logger != nullptr) {
logger::global_logger->add_on_log_callback(
[this](int level, const char *tag, const char *message, size_t message_len) {
if (level <= this->log_level_ && this->is_connected()) {
this->publish({.topic = this->log_message_.topic,
.payload = std::string(message, message_len),
.qos = this->log_message_.qos,
.retain = this->log_message_.retain});
}
});
logger::global_logger->add_log_listener(this);
}
#endif
@@ -148,6 +140,18 @@ void MQTTClientComponent::send_device_info_() {
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
}
#ifdef USE_LOGGER
void MQTTClientComponent::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) {
(void) tag;
if (level <= this->log_level_ && this->is_connected()) {
this->publish({.topic = this->log_message_.topic,
.payload = std::string(message, message_len),
.qos = this->log_message_.qos,
.retain = this->log_message_.retain});
}
}
#endif
void MQTTClientComponent::dump_config() {
ESP_LOGCONFIG(TAG,
"MQTT:\n"

View File

@@ -10,6 +10,9 @@
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
#endif
#if defined(USE_ESP32)
#include "mqtt_backend_esp32.h"
#elif defined(USE_ESP8266)
@@ -97,7 +100,12 @@ enum MQTTClientState {
class MQTTComponent;
class MQTTClientComponent : public Component {
class MQTTClientComponent : public Component
#ifdef USE_LOGGER
,
public logger::LogListener
#endif
{
public:
MQTTClientComponent();
@@ -238,6 +246,10 @@ class MQTTClientComponent : public Component {
/// MQTT client setup priority
float get_setup_priority() const override;
#ifdef USE_LOGGER
void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) override;
#endif
void on_message(const std::string &topic, const std::string &payload);
bool can_proceed() override;

View File

@@ -2,7 +2,8 @@ import logging
from pathlib import Path
from esphome import git, yaml_util
from esphome.config_helpers import merge_config
from esphome.components.substitutions.jinja import has_jinja
from esphome.config_helpers import Remove, merge_config
import esphome.config_validation as cv
from esphome.const import (
CONF_ESPHOME,
@@ -39,10 +40,15 @@ def valid_package_contents(package_config: dict):
for k, v in package_config.items():
if not isinstance(k, str):
raise cv.Invalid("Package content keys must be strings")
if isinstance(v, (dict, list)):
continue # e.g. script: [] or logger: {level: debug}
if isinstance(v, (dict, list, Remove)):
continue # e.g. script: [], psram: !remove, logger: {level: debug}
if v is None:
continue # e.g. web_server:
if isinstance(v, str) and has_jinja(v):
# e.g: remote package shorthand:
# package_name: github://esphome/repo/file.yaml@${ branch }
continue
raise cv.Invalid("Invalid component content in package definition")
return package_config

View File

@@ -270,7 +270,9 @@ ThrottleFilter = sensor_ns.class_("ThrottleFilter", Filter)
ThrottleWithPriorityFilter = sensor_ns.class_(
"ThrottleWithPriorityFilter", ValueListFilter
)
TimeoutFilter = sensor_ns.class_("TimeoutFilter", Filter, cg.Component)
TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component)
TimeoutFilterLast = sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase)
TimeoutFilterConfigured = sensor_ns.class_("TimeoutFilterConfigured", TimeoutFilterBase)
DebounceFilter = sensor_ns.class_("DebounceFilter", Filter, cg.Component)
HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter, cg.Component)
DeltaFilter = sensor_ns.class_("DeltaFilter", Filter)
@@ -681,11 +683,16 @@ TIMEOUT_SCHEMA = cv.maybe_simple_value(
)
@FILTER_REGISTRY.register("timeout", TimeoutFilter, TIMEOUT_SCHEMA)
@FILTER_REGISTRY.register("timeout", TimeoutFilterBase, TIMEOUT_SCHEMA)
async def timeout_filter_to_code(config, filter_id):
filter_id = filter_id.copy()
if config[CONF_VALUE] == "last":
# Use TimeoutFilterLast for "last" mode (smaller, more common - LD2450, LD2412, etc.)
filter_id.type = TimeoutFilterLast
var = cg.new_Pvariable(filter_id, config[CONF_TIMEOUT])
else:
# Use TimeoutFilterConfigured for configured value mode
filter_id.type = TimeoutFilterConfigured
template_ = await cg.templatable(config[CONF_VALUE], [], float)
var = cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], template_)
await cg.register_component(var, {})

View File

@@ -339,20 +339,43 @@ void OrFilter::initialize(Sensor *parent, Filter *next) {
this->phi_.initialize(parent, nullptr);
}
// TimeoutFilter
optional<float> TimeoutFilter::new_value(float value) {
if (this->value_.has_value()) {
this->set_timeout("timeout", this->time_period_, [this]() { this->output(this->value_.value().value()); });
} else {
this->set_timeout("timeout", this->time_period_, [this, value]() { this->output(value); });
// TimeoutFilterBase - shared loop logic
void TimeoutFilterBase::loop() {
// Check if timeout period has elapsed
// Use cached loop start time to avoid repeated millis() calls
const uint32_t now = App.get_loop_component_start_time();
if (now - this->timeout_start_time_ >= this->time_period_) {
// Timeout fired - get output value from derived class and output it
this->output(this->get_output_value());
// Disable loop until next value arrives
this->disable_loop();
}
}
float TimeoutFilterBase::get_setup_priority() const { return setup_priority::HARDWARE; }
// TimeoutFilterLast - "last" mode implementation
optional<float> TimeoutFilterLast::new_value(float value) {
// Store the value to output when timeout fires
this->pending_value_ = value;
// Record when timeout started and enable loop
this->timeout_start_time_ = millis();
this->enable_loop();
return value;
}
TimeoutFilter::TimeoutFilter(uint32_t time_period) : time_period_(time_period) {}
TimeoutFilter::TimeoutFilter(uint32_t time_period, const TemplatableValue<float> &new_value)
: time_period_(time_period), value_(new_value) {}
float TimeoutFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
// TimeoutFilterConfigured - configured value mode implementation
optional<float> TimeoutFilterConfigured::new_value(float value) {
// Record when timeout started and enable loop
// Note: we don't store the incoming value since we have a configured value
this->timeout_start_time_ = millis();
this->enable_loop();
return value;
}
// DebounceFilter
optional<float> DebounceFilter::new_value(float value) {

View File

@@ -380,18 +380,46 @@ class ThrottleWithPriorityFilter : public ValueListFilter {
uint32_t min_time_between_inputs_;
};
class TimeoutFilter : public Filter, public Component {
// Base class for timeout filters - contains common loop logic
class TimeoutFilterBase : public Filter, public Component {
public:
explicit TimeoutFilter(uint32_t time_period);
explicit TimeoutFilter(uint32_t time_period, const TemplatableValue<float> &new_value);
optional<float> new_value(float value) override;
void loop() override;
float get_setup_priority() const override;
protected:
uint32_t time_period_;
optional<TemplatableValue<float>> value_;
explicit TimeoutFilterBase(uint32_t time_period) : time_period_(time_period) { this->disable_loop(); }
virtual float get_output_value() = 0;
uint32_t time_period_; // 4 bytes (timeout duration in ms)
uint32_t timeout_start_time_{0}; // 4 bytes (when the timeout was started)
// Total base: 8 bytes
};
// Timeout filter for "last" mode - outputs the last received value after timeout
class TimeoutFilterLast : public TimeoutFilterBase {
public:
explicit TimeoutFilterLast(uint32_t time_period) : TimeoutFilterBase(time_period) {}
optional<float> new_value(float value) override;
protected:
float get_output_value() override { return this->pending_value_; }
float pending_value_{0}; // 4 bytes (value to output when timeout fires)
// Total: 8 (base) + 4 = 12 bytes + vtable ptr + Component overhead
};
// Timeout filter with configured value - evaluates TemplatableValue after timeout
class TimeoutFilterConfigured : public TimeoutFilterBase {
public:
explicit TimeoutFilterConfigured(uint32_t time_period, const TemplatableValue<float> &new_value)
: TimeoutFilterBase(time_period), value_(new_value) {}
optional<float> new_value(float value) override;
protected:
float get_output_value() override { return this->value_.value(); }
TemplatableValue<float> value_; // 16 bytes (configured output value, can be lambda)
// Total: 8 (base) + 16 = 24 bytes + vtable ptr + Component overhead
};
class DebounceFilter : public Filter, public Component {

View File

@@ -19,11 +19,10 @@ constexpr int LOG_LEVEL_TO_SYSLOG_SEVERITY[] = {
7 // VERY_VERBOSE
};
void Syslog::setup() {
logger::global_logger->add_on_log_callback(
[this](int level, const char *tag, const char *message, size_t message_len) {
this->log_(level, tag, message, message_len);
});
void Syslog::setup() { logger::global_logger->add_log_listener(this); }
void Syslog::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) {
this->log_(level, tag, message, message_len);
}
void Syslog::log_(const int level, const char *tag, const char *message, size_t message_len) const {

View File

@@ -2,16 +2,18 @@
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/components/logger/logger.h"
#include "esphome/components/udp/udp_component.h"
#include "esphome/components/time/real_time_clock.h"
#ifdef USE_NETWORK
namespace esphome {
namespace syslog {
class Syslog : public Component, public Parented<udp::UDPComponent> {
class Syslog : public Component, public Parented<udp::UDPComponent>, public logger::LogListener {
public:
Syslog(int level, time::RealTimeClock *time) : log_level_(level), time_(time) {}
void setup() override;
void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) override;
void set_strip(bool strip) { this->strip_ = strip; }
void set_facility(int facility) { this->facility_ = facility; }

View File

@@ -1,4 +1,5 @@
import esphome.codegen as cg
from esphome.components import socket
from esphome.components.uart import (
CONF_DATA_BITS,
CONF_PARITY,
@@ -17,7 +18,7 @@ from esphome.const import (
)
from esphome.cpp_types import Component
AUTO_LOAD = ["uart", "usb_host", "bytebuffer"]
AUTO_LOAD = ["uart", "usb_host", "bytebuffer", "socket"]
CODEOWNERS = ["@clydebarrow"]
usb_uart_ns = cg.esphome_ns.namespace("usb_uart")
@@ -116,6 +117,10 @@ CONFIG_SCHEMA = cv.ensure_list(
async def to_code(config):
# Enable wake_loop_threadsafe for low-latency USB data processing
# The USB task queues data events that need immediate processing
socket.require_wake_loop_threadsafe()
for device in config:
var = await register_usb_client(device)
for index, channel in enumerate(device[CONF_CHANNELS]):

View File

@@ -2,6 +2,7 @@
#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4)
#include "usb_uart.h"
#include "esphome/core/log.h"
#include "esphome/core/application.h"
#include "esphome/components/uart/uart_debugger.h"
#include <cinttypes>
@@ -262,6 +263,11 @@ void USBUartComponent::start_input(USBUartChannel *channel) {
// Push to lock-free queue for main loop processing
// Push always succeeds because pool size == queue size
this->usb_data_queue_.push(chunk);
// Wake main loop immediately to process USB data instead of waiting for select() timeout
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
App.wake_loop_threadsafe();
#endif
}
// On success, restart input immediately from USB task for performance

View File

@@ -301,12 +301,7 @@ void WebServer::setup() {
#ifdef USE_LOGGER
if (logger::global_logger != nullptr && this->expose_log_) {
logger::global_logger->add_on_log_callback(
// logs are not deferred, the memory overhead would be too large
[this](int level, const char *tag, const char *message, size_t message_len) {
(void) message_len;
this->events_.try_send_nodefer(message, "log", millis());
});
logger::global_logger->add_log_listener(this);
}
#endif
@@ -322,6 +317,16 @@ void WebServer::setup() {
this->set_interval(10000, [this]() { this->events_.try_send_nodefer("", "ping", millis(), 30000); });
}
void WebServer::loop() { this->events_.loop(); }
#ifdef USE_LOGGER
void WebServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) {
(void) level;
(void) tag;
(void) message_len;
this->events_.try_send_nodefer(message, "log", millis());
}
#endif
void WebServer::dump_config() {
ESP_LOGCONFIG(TAG,
"Web Server:\n"

View File

@@ -7,6 +7,9 @@
#include "esphome/core/component.h"
#include "esphome/core/controller.h"
#include "esphome/core/entity_base.h"
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
#endif
#include <functional>
#include <list>
@@ -170,7 +173,14 @@ class DeferredUpdateEventSourceList : public std::list<DeferredUpdateEventSource
* under the '/light/...', '/sensor/...', ... URLs. A full documentation for this API
* can be found under https://esphome.io/web-api/index.html.
*/
class WebServer : public Controller, public Component, public AsyncWebHandler {
class WebServer : public Controller,
public Component,
public AsyncWebHandler
#ifdef USE_LOGGER
,
public logger::LogListener
#endif
{
#if !defined(USE_ESP32) && defined(USE_ARDUINO)
friend class DeferredUpdateEventSourceList;
#endif
@@ -230,6 +240,10 @@ class WebServer : public Controller, public Component, public AsyncWebHandler {
void dump_config() override;
#ifdef USE_LOGGER
void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) override;
#endif
/// MQTT setup priority.
float get_setup_priority() const override;

View File

@@ -608,7 +608,7 @@ async def wifi_disable_to_code(config, action_id, template_arg, args):
KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results"
RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save"
WIFI_CALLBACKS_KEY = "wifi_callbacks"
WIFI_LISTENERS_KEY = "wifi_listeners"
def request_wifi_scan_results():
@@ -634,15 +634,15 @@ def enable_runtime_power_save_control():
CORE.data[RUNTIME_POWER_SAVE_KEY] = True
def request_wifi_callbacks() -> None:
"""Request that WiFi callbacks be compiled in.
def request_wifi_listeners() -> None:
"""Request that WiFi state listeners be compiled in.
Components that need to be notified about WiFi state changes (IP address changes,
scan results, connection state) should call this function during their code generation.
This enables the add_on_ip_state_callback(), add_on_wifi_scan_state_callback(),
and add_on_wifi_connect_state_callback() APIs.
This enables the add_ip_state_listener(), add_scan_results_listener(),
and add_connect_state_listener() APIs.
"""
CORE.data[WIFI_CALLBACKS_KEY] = True
CORE.data[WIFI_LISTENERS_KEY] = True
@coroutine_with_priority(CoroPriority.FINAL)
@@ -654,8 +654,8 @@ async def final_step():
)
if CORE.data.get(RUNTIME_POWER_SAVE_KEY, False):
cg.add_define("USE_WIFI_RUNTIME_POWER_SAVE")
if CORE.data.get(WIFI_CALLBACKS_KEY, False):
cg.add_define("USE_WIFI_CALLBACKS")
if CORE.data.get(WIFI_LISTENERS_KEY, False):
cg.add_define("USE_WIFI_LISTENERS")
@automation.register_action(

View File

@@ -1612,6 +1612,20 @@ void WiFiComponent::retry_connect() {
}
}
#ifdef USE_RP2040
// RP2040's mDNS library (LEAmDNS) relies on LwipIntf::stateUpCB() to restart
// mDNS when the network interface reconnects. However, this callback is disabled
// in the arduino-pico framework. As a workaround, we block component setup until
// WiFi is connected, ensuring mDNS.begin() is called with an active connection.
bool WiFiComponent::can_proceed() {
if (!this->has_sta() || this->state_ == WIFI_COMPONENT_STATE_DISABLED || this->ap_setup_) {
return true;
}
return this->is_connected();
}
#endif
void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; }
bool WiFiComponent::is_connected() {
return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED &&

View File

@@ -242,6 +242,37 @@ enum WifiMinAuthMode : uint8_t {
struct IDFWiFiEvent;
#endif
/** Listener interface for WiFi IP state changes.
*
* Components can implement this interface to receive IP address updates
* without the overhead of std::function callbacks.
*/
class WiFiIPStateListener {
public:
virtual void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1,
const network::IPAddress &dns2) = 0;
};
/** Listener interface for WiFi scan results.
*
* Components can implement this interface to receive scan results
* without the overhead of std::function callbacks.
*/
class WiFiScanResultsListener {
public:
virtual void on_wifi_scan_results(const wifi_scan_vector_t<WiFiScanResult> &results) = 0;
};
/** Listener interface for WiFi connection state changes.
*
* Components can implement this interface to receive connection updates
* without the overhead of std::function callbacks.
*/
class WiFiConnectStateListener {
public:
virtual void on_wifi_connect_state(const std::string &ssid, const bssid_t &bssid) = 0;
};
/// This component is responsible for managing the ESP WiFi interface.
class WiFiComponent : public Component {
public:
@@ -284,6 +315,10 @@ class WiFiComponent : public Component {
void retry_connect();
#ifdef USE_RP2040
bool can_proceed() override;
#endif
void set_reboot_timeout(uint32_t reboot_timeout);
bool is_connected();
@@ -369,26 +404,22 @@ class WiFiComponent : public Component {
int32_t get_wifi_channel();
#ifdef USE_WIFI_CALLBACKS
/// Add a callback that will be called on configuration changes (IP change, SSID change, etc.)
/// @param callback The callback to be called; template arguments are:
/// - IP addresses
/// - DNS address 1
/// - DNS address 2
void add_on_ip_state_callback(
std::function<void(network::IPAddresses, network::IPAddress, network::IPAddress)> &&callback) {
this->ip_state_callback_.add(std::move(callback));
#ifdef USE_WIFI_LISTENERS
/** Add a listener for IP state changes.
* Listener receives: IP addresses, DNS address 1, DNS address 2
*/
void add_ip_state_listener(WiFiIPStateListener *listener) { this->ip_state_listeners_.push_back(listener); }
/// Add a listener for WiFi scan results
void add_scan_results_listener(WiFiScanResultsListener *listener) {
this->scan_results_listeners_.push_back(listener);
}
/// - Wi-Fi scan results
void add_on_wifi_scan_state_callback(std::function<void(wifi_scan_vector_t<WiFiScanResult> &)> &&callback) {
this->wifi_scan_state_callback_.add(std::move(callback));
/** Add a listener for WiFi connection state changes.
* Listener receives: SSID, BSSID
*/
void add_connect_state_listener(WiFiConnectStateListener *listener) {
this->connect_state_listeners_.push_back(listener);
}
/// - Wi-Fi SSID
/// - Wi-Fi BSSID
void add_on_wifi_connect_state_callback(std::function<void(std::string, wifi::bssid_t)> &&callback) {
this->wifi_connect_state_callback_.add(std::move(callback));
}
#endif // USE_WIFI_CALLBACKS
#endif // USE_WIFI_LISTENERS
#ifdef USE_WIFI_RUNTIME_POWER_SAVE
/** Request high-performance mode (no power saving) for improved WiFi latency.
@@ -546,11 +577,11 @@ class WiFiComponent : public Component {
WiFiAP ap_;
#endif
optional<float> output_power_;
#ifdef USE_WIFI_CALLBACKS
CallbackManager<void(network::IPAddresses, network::IPAddress, network::IPAddress)> ip_state_callback_;
CallbackManager<void(wifi_scan_vector_t<WiFiScanResult> &)> wifi_scan_state_callback_;
CallbackManager<void(std::string, wifi::bssid_t)> wifi_connect_state_callback_;
#endif // USE_WIFI_CALLBACKS
#ifdef USE_WIFI_LISTENERS
std::vector<WiFiIPStateListener *> ip_state_listeners_;
std::vector<WiFiScanResultsListener *> scan_results_listeners_;
std::vector<WiFiConnectStateListener *> connect_state_listeners_;
#endif // USE_WIFI_LISTENERS
ESPPreferenceObject pref_;
#ifdef USE_WIFI_FAST_CONNECT
ESPPreferenceObject fast_connect_pref_;

View File

@@ -513,9 +513,10 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
ESP_LOGV(TAG, "Connected ssid='%s' bssid=%s channel=%u", buf, format_mac_address_pretty(it.bssid).c_str(),
it.channel);
s_sta_connected = true;
#ifdef USE_WIFI_CALLBACKS
global_wifi_component->wifi_connect_state_callback_.call(global_wifi_component->wifi_ssid(),
global_wifi_component->wifi_bssid());
#ifdef USE_WIFI_LISTENERS
for (auto *listener : global_wifi_component->connect_state_listeners_) {
listener->on_wifi_connect_state(global_wifi_component->wifi_ssid(), global_wifi_component->wifi_bssid());
}
#endif
break;
}
@@ -536,8 +537,10 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
}
s_sta_connected = false;
s_sta_connecting = false;
#ifdef USE_WIFI_CALLBACKS
global_wifi_component->wifi_connect_state_callback_.call("", bssid_t({0, 0, 0, 0, 0, 0}));
#ifdef USE_WIFI_LISTENERS
for (auto *listener : global_wifi_component->connect_state_listeners_) {
listener->on_wifi_connect_state("", bssid_t({0, 0, 0, 0, 0, 0}));
}
#endif
break;
}
@@ -561,10 +564,11 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
ESP_LOGV(TAG, "static_ip=%s gateway=%s netmask=%s", format_ip_addr(it.ip).c_str(), format_ip_addr(it.gw).c_str(),
format_ip_addr(it.mask).c_str());
s_sta_got_ip = true;
#ifdef USE_WIFI_CALLBACKS
global_wifi_component->ip_state_callback_.call(global_wifi_component->wifi_sta_ip_addresses(),
global_wifi_component->get_dns_address(0),
global_wifi_component->get_dns_address(1));
#ifdef USE_WIFI_LISTENERS
for (auto *listener : global_wifi_component->ip_state_listeners_) {
listener->on_ip_state(global_wifi_component->wifi_sta_ip_addresses(), global_wifi_component->get_dns_address(0),
global_wifi_component->get_dns_address(1));
}
#endif
break;
}
@@ -740,8 +744,10 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) {
it->is_hidden != 0);
}
this->scan_done_ = true;
#ifdef USE_WIFI_CALLBACKS
global_wifi_component->wifi_scan_state_callback_.call(global_wifi_component->scan_result_);
#ifdef USE_WIFI_LISTENERS
for (auto *listener : global_wifi_component->scan_results_listeners_) {
listener->on_wifi_scan_results(global_wifi_component->scan_result_);
}
#endif
}
@@ -878,10 +884,9 @@ network::IPAddress WiFiComponent::wifi_soft_ap_ip() {
bssid_t WiFiComponent::wifi_bssid() {
bssid_t bssid{};
uint8_t *raw_bssid = WiFi.BSSID();
if (raw_bssid != nullptr) {
for (size_t i = 0; i < bssid.size(); i++)
bssid[i] = raw_bssid[i];
struct station_config conf {};
if (wifi_station_get_config(&conf)) {
std::copy_n(conf.bssid, bssid.size(), bssid.begin());
}
return bssid;
}

View File

@@ -727,8 +727,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
ESP_LOGV(TAG, "Connected ssid='%s' bssid=" LOG_SECRET("%s") " channel=%u, authmode=%s", buf,
format_mac_address_pretty(it.bssid).c_str(), it.channel, get_auth_mode_str(it.authmode));
s_sta_connected = true;
#ifdef USE_WIFI_CALLBACKS
this->wifi_connect_state_callback_.call(this->wifi_ssid(), this->wifi_bssid());
#ifdef USE_WIFI_LISTENERS
for (auto *listener : this->connect_state_listeners_) {
listener->on_wifi_connect_state(this->wifi_ssid(), this->wifi_bssid());
}
#endif
} else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_DISCONNECTED) {
@@ -753,8 +755,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
s_sta_connected = false;
s_sta_connecting = false;
error_from_callback_ = true;
#ifdef USE_WIFI_CALLBACKS
this->wifi_connect_state_callback_.call("", bssid_t({0, 0, 0, 0, 0, 0}));
#ifdef USE_WIFI_LISTENERS
for (auto *listener : this->connect_state_listeners_) {
listener->on_wifi_connect_state("", bssid_t({0, 0, 0, 0, 0, 0}));
}
#endif
} else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_STA_GOT_IP) {
@@ -764,8 +768,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
#endif /* USE_NETWORK_IPV6 */
ESP_LOGV(TAG, "static_ip=" IPSTR " gateway=" IPSTR, IP2STR(&it.ip_info.ip), IP2STR(&it.ip_info.gw));
this->got_ipv4_address_ = true;
#ifdef USE_WIFI_CALLBACKS
this->ip_state_callback_.call(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
#ifdef USE_WIFI_LISTENERS
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
#endif
#if USE_NETWORK_IPV6
@@ -773,8 +779,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
const auto &it = data->data.ip_got_ip6;
ESP_LOGV(TAG, "IPv6 address=" IPV6STR, IPV62STR(it.ip6_info.ip));
this->num_ipv6_addresses_++;
#ifdef USE_WIFI_CALLBACKS
this->ip_state_callback_.call(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
#ifdef USE_WIFI_LISTENERS
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
#endif
#endif /* USE_NETWORK_IPV6 */
@@ -815,8 +823,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
scan_result_.emplace_back(bssid, ssid, record.primary, record.rssi, record.authmode != WIFI_AUTH_OPEN,
ssid.empty());
}
#ifdef USE_WIFI_CALLBACKS
this->wifi_scan_state_callback_.call(this->scan_result_);
#ifdef USE_WIFI_LISTENERS
for (auto *listener : this->scan_results_listeners_) {
listener->on_wifi_scan_results(this->scan_result_);
}
#endif
} else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_START) {

View File

@@ -287,8 +287,10 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_
buf[it.ssid_len] = '\0';
ESP_LOGV(TAG, "Connected ssid='%s' bssid=" LOG_SECRET("%s") " channel=%u, authmode=%s", buf,
format_mac_address_pretty(it.bssid).c_str(), it.channel, get_auth_mode_str(it.authmode));
#ifdef USE_WIFI_CALLBACKS
this->wifi_connect_state_callback_.call(this->wifi_ssid(), this->wifi_bssid());
#ifdef USE_WIFI_LISTENERS
for (auto *listener : this->connect_state_listeners_) {
listener->on_wifi_connect_state(this->wifi_ssid(), this->wifi_bssid());
}
#endif
break;
}
@@ -315,8 +317,10 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_
}
s_sta_connecting = false;
#ifdef USE_WIFI_CALLBACKS
this->wifi_connect_state_callback_.call("", bssid_t({0, 0, 0, 0, 0, 0}));
#ifdef USE_WIFI_LISTENERS
for (auto *listener : this->connect_state_listeners_) {
listener->on_wifi_connect_state("", bssid_t({0, 0, 0, 0, 0, 0}));
}
#endif
break;
}
@@ -339,16 +343,20 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_
ESP_LOGV(TAG, "static_ip=%s gateway=%s", format_ip4_addr(WiFi.localIP()).c_str(),
format_ip4_addr(WiFi.gatewayIP()).c_str());
s_sta_connecting = false;
#ifdef USE_WIFI_CALLBACKS
this->ip_state_callback_.call(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
#ifdef USE_WIFI_LISTENERS
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
#endif
break;
}
case ESPHOME_EVENT_ID_WIFI_STA_GOT_IP6: {
// auto it = info.got_ip.ip_info;
ESP_LOGV(TAG, "Got IPv6");
#ifdef USE_WIFI_CALLBACKS
this->ip_state_callback_.call(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
#ifdef USE_WIFI_LISTENERS
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
#endif
break;
}
@@ -443,8 +451,10 @@ void WiFiComponent::wifi_scan_done_callback_() {
}
WiFi.scanDelete();
this->scan_done_ = true;
#ifdef USE_WIFI_CALLBACKS
this->wifi_scan_state_callback_.call(this->scan_result_);
#ifdef USE_WIFI_LISTENERS
for (auto *listener : this->scan_results_listeners_) {
listener->on_wifi_scan_results(this->scan_result_);
}
#endif
}

View File

@@ -225,8 +225,10 @@ void WiFiComponent::wifi_loop_() {
if (this->state_ == WIFI_COMPONENT_STATE_STA_SCANNING && !cyw43_wifi_scan_active(&cyw43_state)) {
this->scan_done_ = true;
ESP_LOGV(TAG, "Scan done");
#ifdef USE_WIFI_CALLBACKS
this->wifi_scan_state_callback_.call(this->scan_result_);
#ifdef USE_WIFI_LISTENERS
for (auto *listener : this->scan_results_listeners_) {
listener->on_wifi_scan_results(this->scan_result_);
}
#endif
}
@@ -241,16 +243,20 @@ void WiFiComponent::wifi_loop_() {
// Just connected
s_sta_was_connected = true;
ESP_LOGV(TAG, "Connected");
#ifdef USE_WIFI_CALLBACKS
this->wifi_connect_state_callback_.call(this->wifi_ssid(), this->wifi_bssid());
#ifdef USE_WIFI_LISTENERS
for (auto *listener : this->connect_state_listeners_) {
listener->on_wifi_connect_state(this->wifi_ssid(), this->wifi_bssid());
}
#endif
} else if (!is_connected && s_sta_was_connected) {
// Just disconnected
s_sta_was_connected = false;
s_sta_had_ip = false;
ESP_LOGV(TAG, "Disconnected");
#ifdef USE_WIFI_CALLBACKS
this->wifi_connect_state_callback_.call("", bssid_t({0, 0, 0, 0, 0, 0}));
#ifdef USE_WIFI_LISTENERS
for (auto *listener : this->connect_state_listeners_) {
listener->on_wifi_connect_state("", bssid_t({0, 0, 0, 0, 0, 0}));
}
#endif
}
@@ -267,8 +273,10 @@ void WiFiComponent::wifi_loop_() {
// Just got IP address
s_sta_had_ip = true;
ESP_LOGV(TAG, "Got IP address");
#ifdef USE_WIFI_CALLBACKS
this->ip_state_callback_.call(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
#ifdef USE_WIFI_LISTENERS
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
#endif
}
}

View File

@@ -61,7 +61,7 @@ CONFIG_SCHEMA = cv.Schema(
}
)
# Keys that require WiFi callbacks
# Keys that require WiFi listeners
_NETWORK_INFO_KEYS = {
CONF_SSID,
CONF_BSSID,
@@ -79,9 +79,9 @@ async def setup_conf(config, key):
async def to_code(config):
# Request WiFi callbacks for any sensor that needs them
# Request WiFi listeners for any sensor that needs them
if _NETWORK_INFO_KEYS.intersection(config):
wifi.request_wifi_callbacks()
wifi.request_wifi_listeners()
await setup_conf(config, CONF_SSID)
await setup_conf(config, CONF_BSSID)

View File

@@ -12,16 +12,12 @@ static constexpr size_t MAX_STATE_LENGTH = 255;
* IPAddressWiFiInfo
*******************/
void IPAddressWiFiInfo::setup() {
wifi::global_wifi_component->add_on_ip_state_callback(
[this](const network::IPAddresses &ips, const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip) {
this->state_callback_(ips);
});
}
void IPAddressWiFiInfo::setup() { wifi::global_wifi_component->add_ip_state_listener(this); }
void IPAddressWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "IP Address", this); }
void IPAddressWiFiInfo::state_callback_(const network::IPAddresses &ips) {
void IPAddressWiFiInfo::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1,
const network::IPAddress &dns2) {
this->publish_state(ips[0].str());
uint8_t sensor = 0;
for (const auto &ip : ips) {
@@ -38,17 +34,13 @@ void IPAddressWiFiInfo::state_callback_(const network::IPAddresses &ips) {
* DNSAddressWifiInfo
********************/
void DNSAddressWifiInfo::setup() {
wifi::global_wifi_component->add_on_ip_state_callback(
[this](const network::IPAddresses &ips, const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip) {
this->state_callback_(dns1_ip, dns2_ip);
});
}
void DNSAddressWifiInfo::setup() { wifi::global_wifi_component->add_ip_state_listener(this); }
void DNSAddressWifiInfo::dump_config() { LOG_TEXT_SENSOR("", "DNS Address", this); }
void DNSAddressWifiInfo::state_callback_(const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip) {
std::string dns_results = dns1_ip.str() + " " + dns2_ip.str();
void DNSAddressWifiInfo::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1,
const network::IPAddress &dns2) {
std::string dns_results = dns1.str() + " " + dns2.str();
this->publish_state(dns_results);
}
@@ -56,14 +48,11 @@ void DNSAddressWifiInfo::state_callback_(const network::IPAddress &dns1_ip, cons
* ScanResultsWiFiInfo
*********************/
void ScanResultsWiFiInfo::setup() {
wifi::global_wifi_component->add_on_wifi_scan_state_callback(
[this](const wifi::wifi_scan_vector_t<wifi::WiFiScanResult> &results) { this->state_callback_(results); });
}
void ScanResultsWiFiInfo::setup() { wifi::global_wifi_component->add_scan_results_listener(this); }
void ScanResultsWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "Scan Results", this); }
void ScanResultsWiFiInfo::state_callback_(const wifi::wifi_scan_vector_t<wifi::WiFiScanResult> &results) {
void ScanResultsWiFiInfo::on_wifi_scan_results(const wifi::wifi_scan_vector_t<wifi::WiFiScanResult> &results) {
std::string scan_results;
for (const auto &scan : results) {
if (scan.get_is_hidden())
@@ -85,33 +74,30 @@ void ScanResultsWiFiInfo::state_callback_(const wifi::wifi_scan_vector_t<wifi::W
* SSIDWiFiInfo
**************/
void SSIDWiFiInfo::setup() {
wifi::global_wifi_component->add_on_wifi_connect_state_callback(
[this](const std::string &ssid, const wifi::bssid_t &bssid) { this->state_callback_(ssid); });
}
void SSIDWiFiInfo::setup() { wifi::global_wifi_component->add_connect_state_listener(this); }
void SSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "SSID", this); }
void SSIDWiFiInfo::state_callback_(const std::string &ssid) { this->publish_state(ssid); }
void SSIDWiFiInfo::on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) {
this->publish_state(ssid);
}
/****************
* BSSIDWiFiInfo
***************/
void BSSIDWiFiInfo::setup() {
wifi::global_wifi_component->add_on_wifi_connect_state_callback(
[this](const std::string &ssid, const wifi::bssid_t &bssid) { this->state_callback_(bssid); });
}
void BSSIDWiFiInfo::setup() { wifi::global_wifi_component->add_connect_state_listener(this); }
void BSSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "BSSID", this); }
void BSSIDWiFiInfo::state_callback_(const wifi::bssid_t &bssid) {
void BSSIDWiFiInfo::on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) {
char buf[18] = "unknown";
if (mac_address_is_valid(bssid.data())) {
format_mac_addr_upper(bssid.data(), buf);
}
this->publish_state(buf);
}
/*********************
* MacAddressWifiInfo
********************/

View File

@@ -9,55 +9,61 @@
namespace esphome::wifi_info {
class IPAddressWiFiInfo : public Component, public text_sensor::TextSensor {
class IPAddressWiFiInfo final : public Component, public text_sensor::TextSensor, public wifi::WiFiIPStateListener {
public:
void setup() override;
void dump_config() override;
void add_ip_sensors(uint8_t index, text_sensor::TextSensor *s) { this->ip_sensors_[index] = s; }
// WiFiIPStateListener interface
void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1,
const network::IPAddress &dns2) override;
protected:
void state_callback_(const network::IPAddresses &ips);
std::array<text_sensor::TextSensor *, 5> ip_sensors_;
};
class DNSAddressWifiInfo : public Component, public text_sensor::TextSensor {
class DNSAddressWifiInfo final : public Component, public text_sensor::TextSensor, public wifi::WiFiIPStateListener {
public:
void setup() override;
void dump_config() override;
protected:
void state_callback_(const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip);
// WiFiIPStateListener interface
void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1,
const network::IPAddress &dns2) override;
};
class ScanResultsWiFiInfo : public Component, public text_sensor::TextSensor {
class ScanResultsWiFiInfo final : public Component,
public text_sensor::TextSensor,
public wifi::WiFiScanResultsListener {
public:
void setup() override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
void dump_config() override;
protected:
void state_callback_(const wifi::wifi_scan_vector_t<wifi::WiFiScanResult> &results);
// WiFiScanResultsListener interface
void on_wifi_scan_results(const wifi::wifi_scan_vector_t<wifi::WiFiScanResult> &results) override;
};
class SSIDWiFiInfo : public Component, public text_sensor::TextSensor {
class SSIDWiFiInfo final : public Component, public text_sensor::TextSensor, public wifi::WiFiConnectStateListener {
public:
void setup() override;
void dump_config() override;
protected:
void state_callback_(const std::string &ssid);
// WiFiConnectStateListener interface
void on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) override;
};
class BSSIDWiFiInfo : public Component, public text_sensor::TextSensor {
class BSSIDWiFiInfo final : public Component, public text_sensor::TextSensor, public wifi::WiFiConnectStateListener {
public:
void setup() override;
void dump_config() override;
protected:
void state_callback_(const wifi::bssid_t &bssid);
// WiFiConnectStateListener interface
void on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) override;
};
class MacAddressWifiInfo : public Component, public text_sensor::TextSensor {
class MacAddressWifiInfo final : public Component, public text_sensor::TextSensor {
public:
void setup() override {
char mac_s[18];

View File

@@ -51,6 +51,7 @@
#define USE_LIGHT
#define USE_LOCK
#define USE_LOGGER
#define USE_LOGGER_LEVEL_LISTENERS
#define USE_LOGGER_RUNTIME_TAG_LEVELS
#define USE_LVGL
#define USE_LVGL_ANIMIMG
@@ -210,7 +211,7 @@
#define USE_WEBSERVER_SORTING
#define USE_WIFI_11KV_SUPPORT
#define USE_WIFI_FAST_CONNECT
#define USE_WIFI_CALLBACKS
#define USE_WIFI_LISTENERS
#define USE_WIFI_RUNTIME_POWER_SAVE
#define USB_HOST_MAX_REQUESTS 16

View File

@@ -359,8 +359,7 @@ void HOT Scheduler::call(uint32_t now) {
std::unique_ptr<SchedulerItem> item;
{
LockGuard guard{this->lock_};
item = std::move(this->items_[0]);
this->pop_raw_();
item = this->pop_raw_locked_();
}
const char *name = item->get_name();
@@ -401,7 +400,7 @@ void HOT Scheduler::call(uint32_t now) {
// Don't run on failed components
if (item->component != nullptr && item->component->is_failed()) {
LockGuard guard{this->lock_};
this->pop_raw_();
this->recycle_item_(this->pop_raw_locked_());
continue;
}
@@ -414,7 +413,7 @@ void HOT Scheduler::call(uint32_t now) {
{
LockGuard guard{this->lock_};
if (is_item_removed_(item.get())) {
this->pop_raw_();
this->recycle_item_(this->pop_raw_locked_());
this->to_remove_--;
continue;
}
@@ -423,7 +422,7 @@ void HOT Scheduler::call(uint32_t now) {
// Single-threaded or multi-threaded with atomics: can check without lock
if (is_item_removed_(item.get())) {
LockGuard guard{this->lock_};
this->pop_raw_();
this->recycle_item_(this->pop_raw_locked_());
this->to_remove_--;
continue;
}
@@ -443,14 +442,14 @@ void HOT Scheduler::call(uint32_t now) {
LockGuard guard{this->lock_};
auto executed_item = std::move(this->items_[0]);
// Only pop after function call, this ensures we were reachable
// during the function call and know if we were cancelled.
this->pop_raw_();
auto executed_item = this->pop_raw_locked_();
if (executed_item->remove) {
// We were removed/cancelled in the function call, stop
// We were removed/cancelled in the function call, recycle and continue
this->to_remove_--;
this->recycle_item_(std::move(executed_item));
continue;
}
@@ -497,7 +496,7 @@ size_t HOT Scheduler::cleanup_() {
return this->items_.size();
// We must hold the lock for the entire cleanup operation because:
// 1. We're modifying items_ (via pop_raw_) which requires exclusive access
// 1. We're modifying items_ (via pop_raw_locked_) which requires exclusive access
// 2. We're decrementing to_remove_ which is also modified by other threads
// (though all modifications are already under lock)
// 3. Other threads read items_ when searching for items to cancel in cancel_item_locked_()
@@ -510,17 +509,18 @@ size_t HOT Scheduler::cleanup_() {
if (!item->remove)
break;
this->to_remove_--;
this->pop_raw_();
this->recycle_item_(this->pop_raw_locked_());
}
return this->items_.size();
}
void HOT Scheduler::pop_raw_() {
std::unique_ptr<Scheduler::SchedulerItem> HOT Scheduler::pop_raw_locked_() {
std::pop_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp);
// Instead of destroying, recycle the item
this->recycle_item_(std::move(this->items_.back()));
// Move the item out before popping - this is the item that was at the front of the heap
auto item = std::move(this->items_.back());
this->items_.pop_back();
return item;
}
// Helper to execute a scheduler item

View File

@@ -219,7 +219,9 @@ class Scheduler {
// Returns the number of items remaining after cleanup
// IMPORTANT: This method should only be called from the main thread (loop task).
size_t cleanup_();
void pop_raw_();
// Remove and return the front item from the heap
// IMPORTANT: Caller must hold the scheduler lock before calling this function.
std::unique_ptr<SchedulerItem> pop_raw_locked_();
private:
// Helper to cancel items by name - must be called with lock held

View File

@@ -1,12 +1,8 @@
from collections.abc import Callable
import importlib
import logging
import os
from pathlib import Path
import re
import shutil
import stat
from types import TracebackType
from esphome import loader
from esphome.config import iter_component_configs, iter_components
@@ -305,24 +301,9 @@ def clean_cmake_cache():
pioenvs_cmake_path.unlink()
def _rmtree_error_handler(
func: Callable[[str], object],
path: str,
exc_info: tuple[type[BaseException], BaseException, TracebackType | None],
) -> None:
"""Error handler for shutil.rmtree to handle read-only files on Windows.
On Windows, git pack files and other files may be marked read-only,
causing shutil.rmtree to fail with "Access is denied". This handler
removes the read-only flag and retries the deletion.
"""
if os.access(path, os.W_OK):
raise exc_info[1].with_traceback(exc_info[2])
os.chmod(path, stat.S_IWUSR | stat.S_IRUSR)
func(path)
def clean_build(clear_pio_cache: bool = True):
import shutil
# Allow skipping cache cleaning for integration tests
if os.environ.get("ESPHOME_SKIP_CLEAN_BUILD"):
_LOGGER.warning("Skipping build cleaning (ESPHOME_SKIP_CLEAN_BUILD set)")
@@ -331,11 +312,11 @@ def clean_build(clear_pio_cache: bool = True):
pioenvs = CORE.relative_pioenvs_path()
if pioenvs.is_dir():
_LOGGER.info("Deleting %s", pioenvs)
shutil.rmtree(pioenvs, onerror=_rmtree_error_handler)
shutil.rmtree(pioenvs)
piolibdeps = CORE.relative_piolibdeps_path()
if piolibdeps.is_dir():
_LOGGER.info("Deleting %s", piolibdeps)
shutil.rmtree(piolibdeps, onerror=_rmtree_error_handler)
shutil.rmtree(piolibdeps)
dependencies_lock = CORE.relative_build_path("dependencies.lock")
if dependencies_lock.is_file():
_LOGGER.info("Deleting %s", dependencies_lock)
@@ -356,10 +337,12 @@ def clean_build(clear_pio_cache: bool = True):
cache_dir = Path(config.get("platformio", "cache_dir"))
if cache_dir.is_dir():
_LOGGER.info("Deleting PlatformIO cache %s", cache_dir)
shutil.rmtree(cache_dir, onerror=_rmtree_error_handler)
shutil.rmtree(cache_dir)
def clean_all(configuration: list[str]):
import shutil
data_dirs = []
for config in configuration:
item = Path(config)
@@ -381,7 +364,7 @@ def clean_all(configuration: list[str]):
if item.is_file() and not item.name.endswith(".json"):
item.unlink()
elif item.is_dir() and item.name != "storage":
shutil.rmtree(item, onerror=_rmtree_error_handler)
shutil.rmtree(item)
# Clean PlatformIO project files
try:
@@ -395,7 +378,7 @@ def clean_all(configuration: list[str]):
path = Path(config.get("platformio", pio_dir))
if path.is_dir():
_LOGGER.info("Deleting PlatformIO %s %s", pio_dir, path)
shutil.rmtree(path, onerror=_rmtree_error_handler)
shutil.rmtree(path)
GITIGNORE_CONTENT = """# Gitignore settings for ESPHome

View File

@@ -2769,8 +2769,8 @@ static const char *const TAG = "api.service";
cases = list(RECEIVE_CASES.items())
cases.sort()
hpp += " protected:\n"
hpp += " void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override;\n"
out = f"void {class_name}::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) {{\n"
hpp += " void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override;\n"
out = f"void {class_name}::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) {{\n"
out += " switch (msg_type) {\n"
for i, (case, ifdef, message_name) in cases:
if ifdef is not None:
@@ -2878,9 +2878,9 @@ static const char *const TAG = "api.service";
result += "#endif\n"
return result
hpp_protected += " void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override;\n"
hpp_protected += " void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override;\n"
cpp += f"\nvoid {class_name}::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) {{\n"
cpp += f"\nvoid {class_name}::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) {{\n"
cpp += " // Check authentication/connection requirements for messages\n"
cpp += " switch (msg_type) {\n"

View File

@@ -0,0 +1,30 @@
substitutions:
x: 10
y: 20
z: 30
values_from_repo1_main:
- package_name: package1
x: 3
y: 4
z: 5
volume: 60
- package_name: package2
x: 6
y: 7
z: 8
volume: 336
- package_name: default
x: 10
y: 20
z: 5
volume: 1000
- package_name: package4_from_repo2
x: 9
y: 10
z: 11
volume: 990
- package_name: default
x: 10
y: 20
z: 5
volume: 1000

View File

@@ -0,0 +1,43 @@
substitutions:
x: 10
y: 20
z: 30
packages:
package1:
url: https://github.com/esphome/repo1
files:
- path: file1.yaml
vars:
package_name: package1
x: 3
y: 4
ref: main
package2: !include # a package that just includes the given remote package
file: remote_package_proxy.yaml
vars:
url: https://github.com/esphome/repo1
ref: main
files:
- path: file1.yaml
vars:
package_name: package2
x: 6
y: 7
z: 8
package3: github://esphome/repo1/file1.yaml@main # a package that uses the shorthand syntax
package4: # include repo2, which itself includes repo1
url: https://github.com/esphome/repo2
files:
- path: file2.yaml
vars:
package_name: package4
a: 9
b: 10
c: 11
ref: main
package5: !include
file: remote_package_shorthand.yaml
vars:
repo: repo1
file: file1.yaml
ref: main

View File

@@ -0,0 +1,6 @@
# acts as a proxy to be able to include a remote package
# in which the url/ref/files come from a substitution
packages:
- url: ${url}
ref: ${ref}
files: ${files}

View File

@@ -0,0 +1,4 @@
# acts as a proxy to be able to include a remote package
# in which the shorthand comes from a substitution
packages:
- github://esphome/${repo}/${file}@${ref}

View File

@@ -0,0 +1,3 @@
This folder contains fake repos for remote packages testing
These are used by `test_substitutions.py`.
To add repos, create a folder and add its path to the `REMOTES` constant in `test_substitutions.py`.

View File

@@ -0,0 +1,9 @@
defaults:
z: 5
package_name: default
values_from_repo1_main:
- package_name: ${package_name}
x: ${x}
y: ${y}
z: ${z}
volume: ${x*y*z}

View File

@@ -0,0 +1,10 @@
packages:
- url: https://github.com/esphome/repo1
ref: main
files:
- path: file1.yaml
vars:
package_name: ${package_name}_from_repo2
x: ${a}
y: ${b}
z: ${c}

View File

@@ -2,6 +2,7 @@ import glob
import logging
from pathlib import Path
from typing import Any
from unittest.mock import patch
from esphome import config as config_module, yaml_util
from esphome.components import substitutions
@@ -84,11 +85,41 @@ def verify_database(value: Any, path: str = "") -> str | None:
return None
def test_substitutions_fixtures(fixture_path):
# Mapping of (url, ref) to local test repository path under fixtures/substitutions
REMOTES = {
("https://github.com/esphome/repo1", "main"): "remotes/repo1/main",
("https://github.com/esphome/repo2", "main"): "remotes/repo2/main",
}
@patch("esphome.git.clone_or_update")
def test_substitutions_fixtures(mock_clone_or_update, fixture_path):
base_dir = fixture_path / "substitutions"
sources = sorted(glob.glob(str(base_dir / "*.input.yaml")))
assert sources, f"No input YAML files found in {base_dir}"
def fake_clone_or_update(
*,
url: str,
ref: str | None = None,
refresh=None,
domain: str,
username: str | None = None,
password: str | None = None,
submodules: list[str] | None = None,
_recover_broken: bool = True,
) -> tuple[Path, None]:
path = REMOTES.get((url, ref))
if path is None:
path = REMOTES.get((url.rstrip(".git"), ref))
if path is None:
raise RuntimeError(
f"Cannot find test repository for {url} @ {ref}. Check the REMOTES mapping in test_substitutions.py"
)
return base_dir / path, None
mock_clone_or_update.side_effect = fake_clone_or_update
failures = []
for source_path in sources:
source_path = Path(source_path)

View File

@@ -1,9 +1,7 @@
"""Test writer module functionality."""
from collections.abc import Callable
import os
from pathlib import Path
import stat
from typing import Any
from unittest.mock import MagicMock, patch
@@ -1064,109 +1062,3 @@ def test_clean_all_preserves_json_files(
# Verify logging mentions cleaning
assert "Cleaning" in caplog.text
assert str(build_dir) in caplog.text
@patch("esphome.writer.CORE")
def test_clean_build_handles_readonly_files(
mock_core: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test clean_build handles read-only files (e.g., git pack files on Windows)."""
# Create directory structure with read-only files
pioenvs_dir = tmp_path / ".pioenvs"
pioenvs_dir.mkdir()
git_dir = pioenvs_dir / ".git" / "objects" / "pack"
git_dir.mkdir(parents=True)
# Create a read-only file (simulating git pack files on Windows)
readonly_file = git_dir / "pack-abc123.pack"
readonly_file.write_text("pack data")
os.chmod(readonly_file, stat.S_IRUSR) # Read-only
# Setup mocks
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock"
# Verify file is read-only
assert not os.access(readonly_file, os.W_OK)
# Call the function - should not crash
with caplog.at_level("INFO"):
clean_build()
# Verify directory was removed despite read-only files
assert not pioenvs_dir.exists()
@patch("esphome.writer.CORE")
def test_clean_all_handles_readonly_files(
mock_core: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test clean_all handles read-only files."""
from esphome.writer import clean_all
# Create config directory
config_dir = tmp_path / "config"
config_dir.mkdir()
build_dir = config_dir / ".esphome"
build_dir.mkdir()
# Create a subdirectory with read-only files
subdir = build_dir / "subdir"
subdir.mkdir()
readonly_file = subdir / "readonly.txt"
readonly_file.write_text("content")
os.chmod(readonly_file, stat.S_IRUSR) # Read-only
# Verify file is read-only
assert not os.access(readonly_file, os.W_OK)
# Call the function - should not crash
with caplog.at_level("INFO"):
clean_all([str(config_dir)])
# Verify directory was removed despite read-only files
assert not subdir.exists()
assert build_dir.exists() # .esphome dir itself is preserved
@patch("esphome.writer.CORE")
def test_clean_build_reraises_for_other_errors(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test clean_build re-raises errors that are not read-only permission issues."""
# Create directory structure with a read-only subdirectory
# This prevents file deletion and triggers the error handler
pioenvs_dir = tmp_path / ".pioenvs"
pioenvs_dir.mkdir()
subdir = pioenvs_dir / "subdir"
subdir.mkdir()
test_file = subdir / "test.txt"
test_file.write_text("content")
# Make subdir read-only so files inside can't be deleted
os.chmod(subdir, stat.S_IRUSR | stat.S_IXUSR)
# Setup mocks
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock"
try:
# Mock os.access in writer module to return True (writable)
# This simulates a case where the error is NOT due to read-only permissions
# so the error handler should re-raise instead of trying to fix permissions
with (
patch("esphome.writer.os.access", return_value=True),
pytest.raises(PermissionError),
):
clean_build()
finally:
# Cleanup - restore write permission so tmp_path cleanup works
os.chmod(subdir, stat.S_IRWXU)