mirror of
https://github.com/esphome/esphome.git
synced 2026-09-02 11:06:04 +00:00
Merge remote-tracking branch 'upstream/dev' into fast-millis-esp32
# Conflicts: # esphome/core/application.h
This commit is contained in:
@@ -291,12 +291,12 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.SplitDefault(
|
||||
CONF_MAX_CONNECTIONS,
|
||||
esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes
|
||||
esp32=8, # 520KB RAM available
|
||||
esp32=5, # 520KB RAM available
|
||||
rp2040=4, # 264KB RAM but LWIP constraints
|
||||
bk72xx=8, # Moderate RAM
|
||||
rtl87xx=8, # Moderate RAM
|
||||
bk72xx=5, # Moderate RAM
|
||||
rtl87xx=5, # Moderate RAM
|
||||
host=8, # Abundant resources
|
||||
ln882x=8, # Moderate RAM
|
||||
ln882x=5, # Moderate RAM
|
||||
): cv.int_range(min=1, max=20),
|
||||
# Maximum queued send buffers per connection before dropping connection
|
||||
# Each buffer uses ~8-12 bytes overhead plus actual message size
|
||||
@@ -336,8 +336,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(var.set_batch_delay(config[CONF_BATCH_DELAY]))
|
||||
if CONF_LISTEN_BACKLOG in config:
|
||||
cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG]))
|
||||
if CONF_MAX_CONNECTIONS in config:
|
||||
cg.add(var.set_max_connections(config[CONF_MAX_CONNECTIONS]))
|
||||
cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS])
|
||||
cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE])
|
||||
|
||||
# Set USE_API_USER_DEFINED_ACTIONS if any services are enabled
|
||||
|
||||
@@ -118,7 +118,7 @@ void APIServer::loop() {
|
||||
this->accept_new_connections_();
|
||||
}
|
||||
|
||||
if (this->clients_.empty()) {
|
||||
if (this->api_connection_count_ == 0) {
|
||||
// Check reboot timeout - done in loop to avoid scheduler heap churn
|
||||
// (cancelled scheduler items sit in heap memory until their scheduled time)
|
||||
if (this->reboot_timeout_ != 0) {
|
||||
@@ -135,15 +135,15 @@ void APIServer::loop() {
|
||||
// Check network connectivity once for all clients
|
||||
if (!network::is_connected()) {
|
||||
// Network is down - disconnect all clients
|
||||
for (auto &client : this->clients_) {
|
||||
for (auto &client : this->active_clients()) {
|
||||
client->on_fatal_error();
|
||||
client->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Network down; disconnect"));
|
||||
}
|
||||
// Continue to process and clean up the clients below
|
||||
}
|
||||
|
||||
size_t client_index = 0;
|
||||
while (client_index < this->clients_.size()) {
|
||||
uint8_t client_index = 0;
|
||||
while (client_index < this->api_connection_count_) {
|
||||
auto &client = this->clients_[client_index];
|
||||
|
||||
// Common case: process active client
|
||||
@@ -161,7 +161,7 @@ void APIServer::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
void APIServer::remove_client_(size_t client_index) {
|
||||
void APIServer::remove_client_(uint8_t client_index) {
|
||||
auto &client = this->clients_[client_index];
|
||||
|
||||
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
|
||||
@@ -179,14 +179,17 @@ void APIServer::remove_client_(size_t client_index) {
|
||||
// Close socket now (was deferred from on_fatal_error to allow getpeername)
|
||||
client->helper_->close();
|
||||
|
||||
// Swap with the last element and pop (avoids expensive vector shifts)
|
||||
if (client_index < this->clients_.size() - 1) {
|
||||
std::swap(this->clients_[client_index], this->clients_.back());
|
||||
// Swap-and-reset: move the removed client to the trailing slot and null it out so slots
|
||||
// [api_connection_count_, N) remain nullptr.
|
||||
const uint8_t last_index = this->api_connection_count_ - 1;
|
||||
if (client_index < last_index) {
|
||||
std::swap(this->clients_[client_index], this->clients_[last_index]);
|
||||
}
|
||||
this->clients_.pop_back();
|
||||
this->clients_[last_index].reset();
|
||||
this->api_connection_count_--;
|
||||
|
||||
// Last client disconnected - set warning and start tracking for reboot timeout
|
||||
if (this->clients_.empty() && this->reboot_timeout_ != 0) {
|
||||
if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0) {
|
||||
this->status_set_warning(LOG_STR("waiting for client connection"));
|
||||
this->last_connected_ = App.get_loop_component_start_time();
|
||||
}
|
||||
@@ -210,8 +213,8 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
|
||||
sock->getpeername_to(peername);
|
||||
|
||||
// Check if we're at the connection limit
|
||||
if (this->clients_.size() >= this->max_connections_) {
|
||||
ESP_LOGW(TAG, "Max connections (%d), rejecting %s", this->max_connections_, peername);
|
||||
if (this->api_connection_count_ >= MAX_API_CONNECTIONS) {
|
||||
ESP_LOGW(TAG, "Max connections (%d), rejecting %s", MAX_API_CONNECTIONS, peername);
|
||||
// Immediately close - socket destructor will handle cleanup
|
||||
sock.reset();
|
||||
continue;
|
||||
@@ -220,11 +223,11 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
|
||||
ESP_LOGD(TAG, "Accept %s", peername);
|
||||
|
||||
auto *conn = new APIConnection(std::move(sock), this);
|
||||
this->clients_.emplace_back(conn);
|
||||
this->clients_[this->api_connection_count_++].reset(conn);
|
||||
conn->start();
|
||||
|
||||
// First client connected - clear warning and update timestamp
|
||||
if (this->clients_.size() == 1 && this->reboot_timeout_ != 0) {
|
||||
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0) {
|
||||
this->status_clear_warning();
|
||||
this->last_connected_ = App.get_loop_component_start_time();
|
||||
}
|
||||
@@ -237,7 +240,7 @@ void APIServer::dump_config() {
|
||||
" Address: %s:%u\n"
|
||||
" Listen backlog: %u\n"
|
||||
" Max connections: %u",
|
||||
network::get_use_address(), this->port_, this->listen_backlog_, this->max_connections_);
|
||||
network::get_use_address(), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS);
|
||||
#ifdef USE_API_NOISE
|
||||
ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_.has_psk()));
|
||||
if (!this->noise_ctx_.has_psk()) {
|
||||
@@ -255,7 +258,7 @@ void APIServer::handle_disconnect(APIConnection *conn) {}
|
||||
void APIServer::on_##entity_name##_update(entity_type *obj) { /* NOLINT(bugprone-macro-parentheses) */ \
|
||||
if (obj->is_internal()) \
|
||||
return; \
|
||||
for (auto &c : this->clients_) { \
|
||||
for (auto &c : this->active_clients()) { \
|
||||
if (c->flags_.state_subscription) \
|
||||
c->send_##entity_name##_state(obj); \
|
||||
} \
|
||||
@@ -337,7 +340,7 @@ API_DISPATCH_UPDATE(water_heater::WaterHeater, water_heater)
|
||||
void APIServer::on_event(event::Event *obj) {
|
||||
if (obj->is_internal())
|
||||
return;
|
||||
for (auto &c : this->clients_) {
|
||||
for (auto &c : this->active_clients()) {
|
||||
if (c->flags_.state_subscription)
|
||||
c->send_event(obj);
|
||||
}
|
||||
@@ -349,7 +352,7 @@ void APIServer::on_event(event::Event *obj) {
|
||||
void APIServer::on_update(update::UpdateEntity *obj) {
|
||||
if (obj->is_internal())
|
||||
return;
|
||||
for (auto &c : this->clients_) {
|
||||
for (auto &c : this->active_clients()) {
|
||||
if (c->flags_.state_subscription)
|
||||
c->send_update_state(obj);
|
||||
}
|
||||
@@ -360,7 +363,7 @@ void APIServer::on_update(update::UpdateEntity *obj) {
|
||||
void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) {
|
||||
// We could add code to manage a second subscription type, but, since this message type is
|
||||
// very infrequent and small, we simply send it to all clients
|
||||
for (auto &c : this->clients_)
|
||||
for (auto &c : this->active_clients())
|
||||
c->send_message(msg);
|
||||
}
|
||||
#endif
|
||||
@@ -375,7 +378,7 @@ void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_
|
||||
resp.key = key;
|
||||
resp.timings = timings;
|
||||
|
||||
for (auto &c : this->clients_)
|
||||
for (auto &c : this->active_clients())
|
||||
c->send_infrared_rf_receive_event(resp);
|
||||
}
|
||||
#endif
|
||||
@@ -392,7 +395,7 @@ void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = bat
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) {
|
||||
for (auto &client : this->clients_) {
|
||||
for (auto &client : this->active_clients()) {
|
||||
client->send_homeassistant_action(call);
|
||||
}
|
||||
}
|
||||
@@ -532,7 +535,7 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString
|
||||
return;
|
||||
}
|
||||
ESP_LOGW(TAG, "Disconnecting all clients to reset PSK");
|
||||
for (auto &c : this->clients_) {
|
||||
for (auto &c : this->active_clients()) {
|
||||
DisconnectRequest req;
|
||||
c->send_message(req);
|
||||
}
|
||||
@@ -583,7 +586,7 @@ bool APIServer::clear_noise_psk(bool make_active) {
|
||||
|
||||
#ifdef USE_HOMEASSISTANT_TIME
|
||||
void APIServer::request_time() {
|
||||
for (auto &client : this->clients_) {
|
||||
for (auto &client : this->active_clients()) {
|
||||
if (!client->flags_.remove && client->is_authenticated()) {
|
||||
client->send_time_request();
|
||||
return; // Only request from one client to avoid clock conflicts
|
||||
@@ -593,8 +596,8 @@ void APIServer::request_time() {
|
||||
#endif
|
||||
|
||||
bool APIServer::is_connected_with_state_subscription() const {
|
||||
for (const auto &client : this->clients_) {
|
||||
if (client->flags_.state_subscription) {
|
||||
for (uint8_t i = 0; i < this->api_connection_count_; i++) {
|
||||
if (this->clients_[i]->flags_.state_subscription) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -609,7 +612,7 @@ void APIServer::on_log(uint8_t level, const char *tag, const char *message, size
|
||||
// we would be filling a buffer we are trying to clear
|
||||
return;
|
||||
}
|
||||
for (auto &c : this->clients_) {
|
||||
for (auto &c : this->active_clients()) {
|
||||
if (!c->flags_.remove && c->get_log_subscription_level() >= level)
|
||||
c->try_send_log_message(level, tag, message, message_len);
|
||||
}
|
||||
@@ -618,7 +621,7 @@ void APIServer::on_log(uint8_t level, const char *tag, const char *message, size
|
||||
|
||||
#ifdef USE_CAMERA
|
||||
void APIServer::on_camera_image(const std::shared_ptr<camera::CameraImage> &image) {
|
||||
for (auto &c : this->clients_) {
|
||||
for (auto &c : this->active_clients()) {
|
||||
if (!c->flags_.remove)
|
||||
c->set_camera_state(image);
|
||||
}
|
||||
@@ -635,7 +638,7 @@ void APIServer::on_shutdown() {
|
||||
this->batch_delay_ = 5;
|
||||
|
||||
// Send disconnect requests to all connected clients
|
||||
for (auto &c : this->clients_) {
|
||||
for (auto &c : this->active_clients()) {
|
||||
DisconnectRequest req;
|
||||
if (!c->send_message(req)) {
|
||||
// If we can't send the disconnect request directly (tx_buffer full),
|
||||
@@ -653,7 +656,7 @@ bool APIServer::teardown() {
|
||||
this->loop();
|
||||
|
||||
// Return true only when all clients have been torn down
|
||||
return this->clients_.empty();
|
||||
return this->api_connection_count_ == 0;
|
||||
}
|
||||
|
||||
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
#include "esphome/components/camera/camera.h"
|
||||
#endif
|
||||
|
||||
#include <array>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
namespace esphome::api {
|
||||
@@ -63,7 +65,6 @@ class APIServer final : public Component,
|
||||
void set_batch_delay(uint16_t batch_delay);
|
||||
uint16_t get_batch_delay() const { return batch_delay_; }
|
||||
void set_listen_backlog(uint8_t listen_backlog) { this->listen_backlog_ = listen_backlog; }
|
||||
void set_max_connections(uint8_t max_connections) { this->max_connections_ = max_connections; }
|
||||
|
||||
// Get reference to shared buffer for API connections
|
||||
APIBuffer &get_shared_buffer_ref() { return shared_write_buffer_; }
|
||||
@@ -186,9 +187,26 @@ class APIServer final : public Component,
|
||||
void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector<int32_t> *timings);
|
||||
#endif
|
||||
|
||||
bool is_connected() const { return !this->clients_.empty(); }
|
||||
bool is_connected() const { return this->api_connection_count_ != 0; }
|
||||
bool is_connected_with_state_subscription() const;
|
||||
|
||||
// Range-for view over the populated slice [0, api_connection_count_). Read-only with respect
|
||||
// to ownership — callers get `const unique_ptr&` so they can invoke non-const methods on the
|
||||
// APIConnection but cannot reset/move the slot and break the count invariant.
|
||||
using APIConnectionPtr = std::unique_ptr<APIConnection>;
|
||||
class ActiveClientsView {
|
||||
const APIConnectionPtr *begin_;
|
||||
const APIConnectionPtr *end_;
|
||||
|
||||
public:
|
||||
ActiveClientsView(const APIConnectionPtr *b, const APIConnectionPtr *e) : begin_(b), end_(e) {}
|
||||
const APIConnectionPtr *begin() const { return this->begin_; }
|
||||
const APIConnectionPtr *end() const { return this->end_; }
|
||||
};
|
||||
ActiveClientsView active_clients() const {
|
||||
return {this->clients_.data(), this->clients_.data() + this->api_connection_count_};
|
||||
}
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_STATES
|
||||
struct HomeAssistantStateSubscription {
|
||||
const char *entity_id; // Pointer to flash (internal) or heap (external)
|
||||
@@ -234,8 +252,8 @@ class APIServer final : public Component,
|
||||
protected:
|
||||
// Accept incoming socket connections. Only called when socket has pending connections.
|
||||
void __attribute__((noinline)) accept_new_connections_();
|
||||
// Remove a disconnected client by index. Swaps with last element and pops.
|
||||
void __attribute__((noinline)) remove_client_(size_t client_index);
|
||||
// Remove a disconnected client by index. Swaps with the last populated slot and resets it.
|
||||
void __attribute__((noinline)) remove_client_(uint8_t client_index);
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg,
|
||||
@@ -273,8 +291,9 @@ class APIServer final : public Component,
|
||||
uint32_t reboot_timeout_{300000};
|
||||
uint32_t last_connected_{0};
|
||||
|
||||
// Slots [0, api_connection_count_) are populated; trailing slots are always nullptr.
|
||||
std::array<std::unique_ptr<APIConnection>, MAX_API_CONNECTIONS> clients_{};
|
||||
// Vectors and strings (12 bytes each on 32-bit)
|
||||
std::vector<std::unique_ptr<APIConnection>> clients_;
|
||||
// Shared proto write buffer for all connections.
|
||||
// Not pre-allocated: all send paths call prepare_first_message_buffer() which
|
||||
// reserves the exact needed size. Pre-allocating here would cause heap fragmentation
|
||||
@@ -309,10 +328,10 @@ class APIServer final : public Component,
|
||||
uint16_t port_{6053};
|
||||
uint16_t batch_delay_{100};
|
||||
// Connection limits - these defaults will be overridden by config values
|
||||
// from cv.SplitDefault in __init__.py which sets platform-specific defaults
|
||||
// from cv.SplitDefault in __init__.py which sets platform-specific defaults.
|
||||
uint8_t listen_backlog_{4};
|
||||
uint8_t max_connections_{8};
|
||||
bool shutting_down_ = false;
|
||||
uint8_t api_connection_count_{0};
|
||||
// 7 bytes used, 1 byte padding
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
|
||||
@@ -30,7 +30,7 @@ void DebugComponent::dump_config() {
|
||||
|
||||
char device_info_buffer[DEVICE_INFO_BUFFER_SIZE];
|
||||
ESP_LOGD(TAG, "ESPHome version %s", ESPHOME_VERSION);
|
||||
size_t pos = buf_append_printf(device_info_buffer, DEVICE_INFO_BUFFER_SIZE, 0, "%s", ESPHOME_VERSION);
|
||||
size_t pos = buf_append_str(device_info_buffer, DEVICE_INFO_BUFFER_SIZE, 0, ESPHOME_VERSION);
|
||||
|
||||
this->free_heap_ = get_free_heap_();
|
||||
ESP_LOGD(TAG, "Free Heap Size: %" PRIu32 " bytes", this->free_heap_);
|
||||
|
||||
@@ -224,17 +224,21 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
|
||||
const char *model = ESPHOME_VARIANT;
|
||||
|
||||
// Build features string
|
||||
pos = buf_append_printf(buf, size, pos, "|Chip: %s Features:", model);
|
||||
pos = buf_append_str(buf, size, pos, "|Chip: ");
|
||||
pos = buf_append_str(buf, size, pos, model);
|
||||
pos = buf_append_str(buf, size, pos, " Features:");
|
||||
bool first_feature = true;
|
||||
for (const auto &feature : CHIP_FEATURES) {
|
||||
if (info.features & feature.bit) {
|
||||
pos = buf_append_printf(buf, size, pos, "%s%s", first_feature ? "" : ", ", feature.name);
|
||||
pos = buf_append_str(buf, size, pos, first_feature ? "" : ", ");
|
||||
pos = buf_append_str(buf, size, pos, feature.name);
|
||||
first_feature = false;
|
||||
info.features &= ~feature.bit;
|
||||
}
|
||||
}
|
||||
if (info.features != 0) {
|
||||
pos = buf_append_printf(buf, size, pos, "%sOther:0x%" PRIx32, first_feature ? "" : ", ", info.features);
|
||||
pos = buf_append_str(buf, size, pos, first_feature ? "" : ", ");
|
||||
pos = buf_append_printf(buf, size, pos, "Other:0x%" PRIx32, info.features);
|
||||
}
|
||||
pos = buf_append_printf(buf, size, pos, " Cores:%u Revision:%u", info.cores, info.revision);
|
||||
|
||||
@@ -267,17 +271,20 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
|
||||
// Framework detection
|
||||
#ifdef USE_ARDUINO
|
||||
ESP_LOGD(TAG, " Framework: Arduino");
|
||||
pos = buf_append_printf(buf, size, pos, "|Framework: Arduino");
|
||||
pos = buf_append_str(buf, size, pos, "|Framework: Arduino");
|
||||
#else
|
||||
ESP_LOGD(TAG, " Framework: ESP-IDF");
|
||||
pos = buf_append_printf(buf, size, pos, "|Framework: ESP-IDF");
|
||||
pos = buf_append_str(buf, size, pos, "|Framework: ESP-IDF");
|
||||
#endif
|
||||
|
||||
pos = buf_append_printf(buf, size, pos, "|ESP-IDF: %s", esp_get_idf_version());
|
||||
pos = buf_append_str(buf, size, pos, "|ESP-IDF: ");
|
||||
pos = buf_append_str(buf, size, pos, esp_get_idf_version());
|
||||
pos = buf_append_printf(buf, size, pos, "|EFuse MAC: %02X:%02X:%02X:%02X:%02X:%02X", mac[0], mac[1], mac[2], mac[3],
|
||||
mac[4], mac[5]);
|
||||
pos = buf_append_printf(buf, size, pos, "|Reset: %s", reset_reason);
|
||||
pos = buf_append_printf(buf, size, pos, "|Wakeup: %s", wakeup_cause);
|
||||
pos = buf_append_str(buf, size, pos, "|Reset: ");
|
||||
pos = buf_append_str(buf, size, pos, reset_reason);
|
||||
pos = buf_append_str(buf, size, pos, "|Wakeup: ");
|
||||
pos = buf_append_str(buf, size, pos, wakeup_cause);
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
@@ -38,9 +38,12 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
|
||||
lt_get_version(), lt_cpu_get_model_name(), lt_cpu_get_model(), lt_cpu_get_freq_mhz(), mac_id,
|
||||
lt_get_board_code(), flash_kib, ram_kib, reset_reason);
|
||||
|
||||
pos = buf_append_printf(buf, size, pos, "|Version: %s", LT_BANNER_STR + 10);
|
||||
pos = buf_append_printf(buf, size, pos, "|Reset Reason: %s", reset_reason);
|
||||
pos = buf_append_printf(buf, size, pos, "|Chip Name: %s", lt_cpu_get_model_name());
|
||||
pos = buf_append_str(buf, size, pos, "|Version: ");
|
||||
pos = buf_append_str(buf, size, pos, LT_BANNER_STR + 10);
|
||||
pos = buf_append_str(buf, size, pos, "|Reset Reason: ");
|
||||
pos = buf_append_str(buf, size, pos, reset_reason);
|
||||
pos = buf_append_str(buf, size, pos, "|Chip Name: ");
|
||||
pos = buf_append_str(buf, size, pos, lt_cpu_get_model_name());
|
||||
pos = buf_append_printf(buf, size, pos, "|Chip ID: 0x%06" PRIX32, mac_id);
|
||||
pos = buf_append_printf(buf, size, pos, "|Flash: %" PRIu32 " KiB", flash_kib);
|
||||
pos = buf_append_printf(buf, size, pos, "|RAM: %" PRIu32 " KiB", ram_kib);
|
||||
|
||||
@@ -162,14 +162,18 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
|
||||
const char *supply_status =
|
||||
(nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_NORMAL) ? "Normal voltage." : "High voltage.";
|
||||
ESP_LOGD(TAG, "Main supply status: %s", supply_status);
|
||||
pos = buf_append_printf(buf, size, pos, "|Main supply status: %s", supply_status);
|
||||
pos = buf_append_str(buf, size, pos, "|Main supply status: ");
|
||||
pos = buf_append_str(buf, size, pos, supply_status);
|
||||
|
||||
// Regulator stage 0
|
||||
if (nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_HIGH) {
|
||||
const char *reg0_type = nrf_power_dcdcen_vddh_get(NRF_POWER) ? "DC/DC" : "LDO";
|
||||
const char *reg0_voltage = regout0_to_str((NRF_UICR->REGOUT0 & UICR_REGOUT0_VOUT_Msk) >> UICR_REGOUT0_VOUT_Pos);
|
||||
ESP_LOGD(TAG, "Regulator stage 0: %s, %s", reg0_type, reg0_voltage);
|
||||
pos = buf_append_printf(buf, size, pos, "|Regulator stage 0: %s, %s", reg0_type, reg0_voltage);
|
||||
pos = buf_append_str(buf, size, pos, "|Regulator stage 0: ");
|
||||
pos = buf_append_str(buf, size, pos, reg0_type);
|
||||
pos = buf_append_str(buf, size, pos, ", ");
|
||||
pos = buf_append_str(buf, size, pos, reg0_voltage);
|
||||
#ifdef USE_NRF52_REG0_VOUT
|
||||
if ((NRF_UICR->REGOUT0 & UICR_REGOUT0_VOUT_Msk) >> UICR_REGOUT0_VOUT_Pos != USE_NRF52_REG0_VOUT) {
|
||||
ESP_LOGE(TAG, "Regulator stage 0: expected %s", regout0_to_str(USE_NRF52_REG0_VOUT));
|
||||
@@ -177,13 +181,14 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
|
||||
#endif
|
||||
} else {
|
||||
ESP_LOGD(TAG, "Regulator stage 0: disabled");
|
||||
pos = buf_append_printf(buf, size, pos, "|Regulator stage 0: disabled");
|
||||
pos = buf_append_str(buf, size, pos, "|Regulator stage 0: disabled");
|
||||
}
|
||||
|
||||
// Regulator stage 1
|
||||
const char *reg1_type = nrf_power_dcdcen_get(NRF_POWER) ? "DC/DC" : "LDO";
|
||||
ESP_LOGD(TAG, "Regulator stage 1: %s", reg1_type);
|
||||
pos = buf_append_printf(buf, size, pos, "|Regulator stage 1: %s", reg1_type);
|
||||
pos = buf_append_str(buf, size, pos, "|Regulator stage 1: ");
|
||||
pos = buf_append_str(buf, size, pos, reg1_type);
|
||||
|
||||
// USB power state
|
||||
const char *usb_state;
|
||||
@@ -197,7 +202,8 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
|
||||
usb_state = "disconnected";
|
||||
}
|
||||
ESP_LOGD(TAG, "USB power state: %s", usb_state);
|
||||
pos = buf_append_printf(buf, size, pos, "|USB power state: %s", usb_state);
|
||||
pos = buf_append_str(buf, size, pos, "|USB power state: ");
|
||||
pos = buf_append_str(buf, size, pos, usb_state);
|
||||
|
||||
// Power-fail comparator
|
||||
bool enabled;
|
||||
@@ -302,14 +308,18 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
|
||||
break;
|
||||
}
|
||||
ESP_LOGD(TAG, "Power-fail comparator: %s, VDDH: %s", pof_voltage, vddh_voltage);
|
||||
pos = buf_append_printf(buf, size, pos, "|Power-fail comparator: %s, VDDH: %s", pof_voltage, vddh_voltage);
|
||||
pos = buf_append_str(buf, size, pos, "|Power-fail comparator: ");
|
||||
pos = buf_append_str(buf, size, pos, pof_voltage);
|
||||
pos = buf_append_str(buf, size, pos, ", VDDH: ");
|
||||
pos = buf_append_str(buf, size, pos, vddh_voltage);
|
||||
} else {
|
||||
ESP_LOGD(TAG, "Power-fail comparator: %s", pof_voltage);
|
||||
pos = buf_append_printf(buf, size, pos, "|Power-fail comparator: %s", pof_voltage);
|
||||
pos = buf_append_str(buf, size, pos, "|Power-fail comparator: ");
|
||||
pos = buf_append_str(buf, size, pos, pof_voltage);
|
||||
}
|
||||
} else {
|
||||
ESP_LOGD(TAG, "Power-fail comparator: disabled");
|
||||
pos = buf_append_printf(buf, size, pos, "|Power-fail comparator: disabled");
|
||||
pos = buf_append_str(buf, size, pos, "|Power-fail comparator: disabled");
|
||||
}
|
||||
|
||||
auto package = [](uint32_t value) {
|
||||
|
||||
@@ -766,32 +766,38 @@ void LD2412Component::get_distance_resolution_() { this->send_command_(CMD_QUERY
|
||||
void LD2412Component::query_light_control_() { this->send_command_(CMD_QUERY_LIGHT_CONTROL, nullptr, 0); }
|
||||
|
||||
void LD2412Component::set_basic_config() {
|
||||
uint8_t min_gate = 1;
|
||||
uint8_t max_gate = TOTAL_GATES;
|
||||
uint16_t timeout = DEFAULT_PRESENCE_TIMEOUT;
|
||||
uint8_t out_pin_level = 0x01;
|
||||
|
||||
#ifdef USE_NUMBER
|
||||
if (!this->min_distance_gate_number_->has_state() || !this->max_distance_gate_number_->has_state() ||
|
||||
!this->timeout_number_->has_state()) {
|
||||
return;
|
||||
if (this->min_distance_gate_number_ != nullptr) {
|
||||
if (!this->min_distance_gate_number_->has_state())
|
||||
return;
|
||||
min_gate = static_cast<int>(this->min_distance_gate_number_->state);
|
||||
}
|
||||
if (this->max_distance_gate_number_ != nullptr) {
|
||||
if (!this->max_distance_gate_number_->has_state())
|
||||
return;
|
||||
max_gate = static_cast<int>(this->max_distance_gate_number_->state) + 1;
|
||||
}
|
||||
if (this->timeout_number_ != nullptr) {
|
||||
if (!this->timeout_number_->has_state())
|
||||
return;
|
||||
timeout = static_cast<int>(this->timeout_number_->state);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SELECT
|
||||
if (!this->out_pin_level_select_->has_state()) {
|
||||
return;
|
||||
if (this->out_pin_level_select_ != nullptr) {
|
||||
if (!this->out_pin_level_select_->has_state())
|
||||
return;
|
||||
out_pin_level = find_uint8(OUT_PIN_LEVELS_BY_STR, this->out_pin_level_select_->current_option().c_str());
|
||||
}
|
||||
#endif
|
||||
|
||||
uint8_t value[5] = {
|
||||
#ifdef USE_NUMBER
|
||||
lowbyte(static_cast<int>(this->min_distance_gate_number_->state)),
|
||||
lowbyte(static_cast<int>(this->max_distance_gate_number_->state) + 1),
|
||||
lowbyte(static_cast<int>(this->timeout_number_->state)),
|
||||
highbyte(static_cast<int>(this->timeout_number_->state)),
|
||||
#else
|
||||
1, TOTAL_GATES, DEFAULT_PRESENCE_TIMEOUT, 0,
|
||||
#endif
|
||||
#ifdef USE_SELECT
|
||||
find_uint8(OUT_PIN_LEVELS_BY_STR, this->out_pin_level_select_->current_option().c_str()),
|
||||
#else
|
||||
0x01, // Default value if not using select
|
||||
#endif
|
||||
lowbyte(min_gate), lowbyte(max_gate), lowbyte(timeout), highbyte(timeout), out_pin_level,
|
||||
};
|
||||
this->set_config_mode_(true);
|
||||
this->send_command_(CMD_BASIC_CONF, value, sizeof(value));
|
||||
|
||||
@@ -35,9 +35,11 @@ LockStateForwarder = lock_ns.class_("LockStateForwarder")
|
||||
LockState = lock_ns.enum("LockState")
|
||||
|
||||
LOCK_STATES = {
|
||||
"OPEN": LockState.LOCK_STATE_OPEN,
|
||||
"LOCKED": LockState.LOCK_STATE_LOCKED,
|
||||
"UNLOCKED": LockState.LOCK_STATE_UNLOCKED,
|
||||
"JAMMED": LockState.LOCK_STATE_JAMMED,
|
||||
"OPENING": LockState.LOCK_STATE_OPENING,
|
||||
"LOCKING": LockState.LOCK_STATE_LOCKING,
|
||||
"UNLOCKING": LockState.LOCK_STATE_UNLOCKING,
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ namespace esphome::lock {
|
||||
|
||||
static const char *const TAG = "lock";
|
||||
|
||||
// Lock state strings indexed by LockState enum (0-5): NONE(UNKNOWN), LOCKED, UNLOCKED, JAMMED, LOCKING, UNLOCKING
|
||||
// Lock state strings indexed by LockState enum.
|
||||
// Index 0 is UNKNOWN (for LOCK_STATE_NONE), also used as fallback for out-of-range
|
||||
PROGMEM_STRING_TABLE(LockStateStrings, "UNKNOWN", "LOCKED", "UNLOCKED", "JAMMED", "LOCKING", "UNLOCKING");
|
||||
PROGMEM_STRING_TABLE(LockStateStrings, "UNKNOWN", "LOCKED", "UNLOCKED", "JAMMED", "LOCKING", "UNLOCKING", "OPENING",
|
||||
"OPEN");
|
||||
|
||||
const LogString *lock_state_to_string(LockState state) {
|
||||
return LockStateStrings::get_log_str(static_cast<uint8_t>(state), 0);
|
||||
@@ -74,12 +75,16 @@ LockCall &LockCall::set_state(optional<LockState> state) {
|
||||
return *this;
|
||||
}
|
||||
LockCall &LockCall::set_state(const char *state) {
|
||||
if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("LOCKED")) == 0) {
|
||||
if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("OPEN")) == 0) {
|
||||
this->set_state(LOCK_STATE_OPEN);
|
||||
} else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("LOCKED")) == 0) {
|
||||
this->set_state(LOCK_STATE_LOCKED);
|
||||
} else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("UNLOCKED")) == 0) {
|
||||
this->set_state(LOCK_STATE_UNLOCKED);
|
||||
} else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("JAMMED")) == 0) {
|
||||
this->set_state(LOCK_STATE_JAMMED);
|
||||
} else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("OPENING")) == 0) {
|
||||
this->set_state(LOCK_STATE_OPENING);
|
||||
} else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("LOCKING")) == 0) {
|
||||
this->set_state(LOCK_STATE_LOCKING);
|
||||
} else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("UNLOCKING")) == 0) {
|
||||
|
||||
@@ -26,7 +26,9 @@ enum LockState : uint8_t {
|
||||
LOCK_STATE_UNLOCKED = 2,
|
||||
LOCK_STATE_JAMMED = 3,
|
||||
LOCK_STATE_LOCKING = 4,
|
||||
LOCK_STATE_UNLOCKING = 5
|
||||
LOCK_STATE_UNLOCKING = 5,
|
||||
LOCK_STATE_OPENING = 6,
|
||||
LOCK_STATE_OPEN = 7,
|
||||
};
|
||||
const LogString *lock_state_to_string(LockState state);
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ static const uint8_t QMC5883L_REGISTER_CONTROL_1 = 0x09;
|
||||
static const uint8_t QMC5883L_REGISTER_CONTROL_2 = 0x0A;
|
||||
static const uint8_t QMC5883L_REGISTER_PERIOD = 0x0B;
|
||||
|
||||
void IRAM_ATTR QMC5883LComponent::gpio_intr(QMC5883LComponent *arg) { arg->enable_loop_soon_any_context(); }
|
||||
|
||||
void QMC5883LComponent::setup() {
|
||||
// Soft Reset
|
||||
if (!this->write_byte(QMC5883L_REGISTER_CONTROL_2, 1 << 7)) {
|
||||
@@ -35,6 +37,12 @@ void QMC5883LComponent::setup() {
|
||||
|
||||
if (this->drdy_pin_) {
|
||||
this->drdy_pin_->setup();
|
||||
if (this->drdy_pin_->is_internal()) {
|
||||
static_cast<InternalGPIOPin *>(this->drdy_pin_)
|
||||
->attach_interrupt(&QMC5883LComponent::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE);
|
||||
this->drdy_use_isr_ = true;
|
||||
this->stop_poller();
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t control_1 = 0;
|
||||
@@ -65,8 +73,8 @@ void QMC5883LComponent::setup() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this->get_update_interval() < App.get_loop_interval()) {
|
||||
high_freq_.start();
|
||||
if (!this->drdy_use_isr_ && this->get_update_interval() < App.get_loop_interval()) {
|
||||
this->high_freq_.start();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,16 +92,32 @@ void QMC5883LComponent::dump_config() {
|
||||
LOG_SENSOR(" ", "Heading", this->heading_sensor_);
|
||||
LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
|
||||
LOG_PIN(" DRDY Pin: ", this->drdy_pin_);
|
||||
if (this->drdy_pin_ != nullptr) {
|
||||
ESP_LOGCONFIG(TAG, " DRDY mode: %s",
|
||||
this->drdy_use_isr_ ? LOG_STR_LITERAL("interrupt") : LOG_STR_LITERAL("polling"));
|
||||
}
|
||||
}
|
||||
|
||||
void QMC5883LComponent::update() {
|
||||
i2c::ErrorCode err;
|
||||
uint8_t status = false;
|
||||
|
||||
// If DRDY pin is configured and the data is not ready return.
|
||||
// If DRDY is on an external expander we keep the polling path and early-return
|
||||
// if data is not ready yet. Internal DRDY pins take the ISR path via loop().
|
||||
if (this->drdy_pin_ && !this->drdy_pin_->digital_read()) {
|
||||
return;
|
||||
}
|
||||
this->read_sensor_();
|
||||
}
|
||||
|
||||
void QMC5883LComponent::loop() {
|
||||
this->disable_loop();
|
||||
if (!this->drdy_use_isr_ || !this->drdy_pin_->digital_read()) {
|
||||
return;
|
||||
}
|
||||
this->read_sensor_();
|
||||
}
|
||||
|
||||
void QMC5883LComponent::read_sensor_() {
|
||||
i2c::ErrorCode err;
|
||||
uint8_t status = false;
|
||||
|
||||
// Status byte gets cleared when data is read, so we have to read this first.
|
||||
// If status and two axes are desired, it's possible to save one byte of traffic by enabling
|
||||
|
||||
@@ -32,6 +32,7 @@ class QMC5883LComponent : public PollingComponent, public i2c::I2CDevice {
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
void update() override;
|
||||
void loop() override;
|
||||
|
||||
void set_drdy_pin(GPIOPin *pin) { drdy_pin_ = pin; }
|
||||
void set_datarate(QMC5883LDatarate datarate) { datarate_ = datarate; }
|
||||
@@ -44,6 +45,9 @@ class QMC5883LComponent : public PollingComponent, public i2c::I2CDevice {
|
||||
void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; }
|
||||
|
||||
protected:
|
||||
static void IRAM_ATTR gpio_intr(QMC5883LComponent *arg);
|
||||
void read_sensor_();
|
||||
|
||||
QMC5883LDatarate datarate_{QMC5883L_DATARATE_10_HZ};
|
||||
QMC5883LRange range_{QMC5883L_RANGE_200_UT};
|
||||
QMC5883LOversampling oversampling_{QMC5883L_SAMPLING_512};
|
||||
@@ -53,6 +57,7 @@ class QMC5883LComponent : public PollingComponent, public i2c::I2CDevice {
|
||||
sensor::Sensor *heading_sensor_{nullptr};
|
||||
sensor::Sensor *temperature_sensor_{nullptr};
|
||||
GPIOPin *drdy_pin_{nullptr};
|
||||
bool drdy_use_isr_{false};
|
||||
enum ErrorCode {
|
||||
NONE = 0,
|
||||
COMMUNICATION_FAILED,
|
||||
|
||||
@@ -137,11 +137,12 @@ bool RuntimeStatsCollector::compare_total_time(Component *a, Component *b) {
|
||||
return a->runtime_stats_.total_time_us > b->runtime_stats_.total_time_us;
|
||||
}
|
||||
|
||||
void RuntimeStatsCollector::process_pending_stats(uint32_t current_time) {
|
||||
if ((int32_t) (current_time - this->next_log_time_) >= 0) {
|
||||
this->log_stats_();
|
||||
this->next_log_time_ = current_time + this->log_interval_;
|
||||
}
|
||||
// Slow path for process_pending_stats — gate already checked by the inline
|
||||
// wrapper in runtime_stats.h. Out-of-line keeps the log_stats_ machinery out
|
||||
// of Application::loop().
|
||||
void RuntimeStatsCollector::process_pending_stats_slow_(uint32_t current_time) {
|
||||
this->log_stats_();
|
||||
this->next_log_time_ = current_time + this->log_interval_;
|
||||
}
|
||||
|
||||
} // namespace runtime_stats
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#include <cstdint>
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome {
|
||||
@@ -26,14 +27,24 @@ class RuntimeStatsCollector {
|
||||
}
|
||||
uint32_t get_log_interval() const { return this->log_interval_; }
|
||||
|
||||
// Process any pending stats printing (should be called after component loop)
|
||||
void process_pending_stats(uint32_t current_time);
|
||||
// Process any pending stats printing. Called on every Application::loop()
|
||||
// tick, so the common "not yet time to log" path must be cheap — inline
|
||||
// the gate check and keep the actual logging work out-of-line.
|
||||
void ESPHOME_ALWAYS_INLINE process_pending_stats(uint32_t current_time) {
|
||||
if ((int32_t) (current_time - this->next_log_time_) >= 0) [[unlikely]] {
|
||||
this->process_pending_stats_slow_(current_time);
|
||||
}
|
||||
}
|
||||
|
||||
// Record the wall time of one main loop iteration excluding the yield/sleep.
|
||||
// Called once per loop from Application::loop().
|
||||
// active_us = total time between loop start and just before yield.
|
||||
// before_us = time spent in before_loop_tasks_ (scheduler + ISR enable_loop).
|
||||
// tail_us = time spent in after_loop_tasks_ + the trailing record/stats prefix.
|
||||
// before_us = time spent in Phase A (scheduler tick) excluding time
|
||||
// already attributed to per-component stats.
|
||||
// tail_us = time spent in after_component_phase_ + the trailing record/stats
|
||||
// prefix. Only meaningful on component-phase ticks; reported
|
||||
// as 0 on Phase A-only ticks (no component phase ran, so any
|
||||
// overhead between Phase A and stats belongs to "residual").
|
||||
// Residual overhead at log time = active − Σ(component) − before − tail,
|
||||
// which captures per-iteration inter-component bookkeeping (set_current_component,
|
||||
// WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls,
|
||||
@@ -55,6 +66,7 @@ class RuntimeStatsCollector {
|
||||
}
|
||||
|
||||
protected:
|
||||
void process_pending_stats_slow_(uint32_t current_time);
|
||||
void log_stats_();
|
||||
// Static comparators — member functions have friend access, lambdas do not
|
||||
static bool compare_period_time(Component *a, Component *b);
|
||||
|
||||
@@ -3,6 +3,7 @@ import esphome.codegen as cg
|
||||
from esphome.components import text
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_INITIAL_VALUE,
|
||||
CONF_LAMBDA,
|
||||
CONF_MAX_LENGTH,
|
||||
@@ -12,6 +13,7 @@ from esphome.const import (
|
||||
CONF_RESTORE_VALUE,
|
||||
CONF_SET_ACTION,
|
||||
)
|
||||
from esphome.core import ID
|
||||
|
||||
from .. import template_ns
|
||||
|
||||
@@ -84,8 +86,15 @@ async def to_code(config):
|
||||
if initial_value_config := config.get(CONF_INITIAL_VALUE):
|
||||
cg.add(var.set_initial_value(initial_value_config))
|
||||
if config[CONF_RESTORE_VALUE]:
|
||||
args = cg.TemplateArguments(config[CONF_MAX_LENGTH])
|
||||
saver = TextSaverTemplate.template(args).new()
|
||||
saver_id = ID(
|
||||
f"{config[CONF_ID].id}_value_saver",
|
||||
is_declaration=True,
|
||||
type=TextSaverBase,
|
||||
)
|
||||
saver_type = TextSaverTemplate.template(
|
||||
cg.TemplateArguments(config[CONF_MAX_LENGTH])
|
||||
)
|
||||
saver = cg.Pvariable(saver_id, saver_type.new())
|
||||
cg.add(var.set_value_saver(saver))
|
||||
|
||||
if CONF_SET_ACTION in config:
|
||||
|
||||
@@ -101,8 +101,10 @@ void ZWaveProxy::loop() {
|
||||
this->status_clear_warning();
|
||||
}
|
||||
|
||||
void ZWaveProxy::process_uart_() {
|
||||
while (this->available()) {
|
||||
void ZWaveProxy::process_uart_slow_() {
|
||||
// Caller (inline process_uart_) has already confirmed available() > 0, so use do/while to
|
||||
// drain bytes — available() is still checked at the tail, but not redundantly on entry.
|
||||
do {
|
||||
uint8_t byte;
|
||||
if (!this->read_byte(&byte)) {
|
||||
this->status_set_warning(LOG_STR("UART read failed"));
|
||||
@@ -137,7 +139,7 @@ void ZWaveProxy::process_uart_() {
|
||||
this->api_connection_->send_message(this->outgoing_proto_msg_);
|
||||
}
|
||||
}
|
||||
}
|
||||
} while (this->available());
|
||||
}
|
||||
|
||||
void ZWaveProxy::dump_config() {
|
||||
@@ -414,7 +416,7 @@ void ZWaveProxy::parse_start_(uint8_t byte) {
|
||||
}
|
||||
}
|
||||
|
||||
bool ZWaveProxy::response_handler_() {
|
||||
bool ZWaveProxy::response_handler_slow_() {
|
||||
switch (this->parsing_state_) {
|
||||
case ZWAVE_PARSING_STATE_SEND_ACK:
|
||||
this->last_response_ = ZWAVE_FRAME_TYPE_ACK;
|
||||
|
||||
@@ -38,6 +38,13 @@ enum ZWaveParsingState : uint8_t {
|
||||
ZWAVE_PARSING_STATE_READ_BL_MENU,
|
||||
};
|
||||
|
||||
// response_handler_()'s inline fast-path relies on SEND_ACK/CAN/NAK being contiguous in this
|
||||
// enum so a single range check (state - SEND_ACK < 3) is equivalent to three equality checks.
|
||||
static_assert(ZWAVE_PARSING_STATE_SEND_CAN == ZWAVE_PARSING_STATE_SEND_ACK + 1,
|
||||
"SEND_CAN must immediately follow SEND_ACK for response_handler_ fast-path");
|
||||
static_assert(ZWAVE_PARSING_STATE_SEND_NAK == ZWAVE_PARSING_STATE_SEND_ACK + 2,
|
||||
"SEND_NAK must immediately follow SEND_CAN for response_handler_ fast-path");
|
||||
|
||||
enum ZWaveProxyFeature : uint32_t {
|
||||
FEATURE_ZWAVE_PROXY_ENABLED = 1 << 0,
|
||||
};
|
||||
@@ -72,8 +79,31 @@ class ZWaveProxy : public uart::UARTDevice, public Component {
|
||||
void send_simple_command_(uint8_t command_id);
|
||||
bool parse_byte_(uint8_t byte); // Returns true if frame parsing was completed (a frame is ready in the buffer)
|
||||
void parse_start_(uint8_t byte);
|
||||
bool response_handler_();
|
||||
void process_uart_(); // Process all available UART data
|
||||
// Inline fast-path: most calls happen with parsing_state_ outside the SEND_* range, so skip the
|
||||
// out-of-line call entirely in the hot path (e.g. every loop() tick) and only pay for the real
|
||||
// work when a response is actually pending. ESPHOME_ALWAYS_INLINE is required because with -Os
|
||||
// gcc otherwise clones the wrapper into a shared $isra$ outline and keeps the call8.
|
||||
ESPHOME_ALWAYS_INLINE bool response_handler_() {
|
||||
if (this->parsing_state_ < ZWAVE_PARSING_STATE_SEND_ACK || this->parsing_state_ > ZWAVE_PARSING_STATE_SEND_NAK) {
|
||||
return false;
|
||||
}
|
||||
return this->response_handler_slow_();
|
||||
}
|
||||
bool response_handler_slow_();
|
||||
// Inline fast-path: UART::available() is cheap (ring-buffer head/tail compare on most backends).
|
||||
// On an idle loop tick we want to skip the call to process_uart_ entirely. When bytes are
|
||||
// pending we fall into the slow path, which drains the UART with a do/while so available() is
|
||||
// only checked once per byte — no redundant re-check on entry.
|
||||
ESPHOME_ALWAYS_INLINE void process_uart_() {
|
||||
if (!this->available()) {
|
||||
return;
|
||||
}
|
||||
this->process_uart_slow_();
|
||||
}
|
||||
// Precondition: caller must guarantee available() > 0 before invoking (see inline
|
||||
// process_uart_ above). The slow path uses do/while and would otherwise set a spurious UART
|
||||
// warning on entry if called with no bytes pending.
|
||||
void process_uart_slow_();
|
||||
|
||||
// Pre-allocated message - always ready to send
|
||||
api::ZWaveProxyFrame outgoing_proto_msg_;
|
||||
|
||||
@@ -93,8 +93,11 @@ void Application::setup() {
|
||||
do {
|
||||
uint32_t now = MillisInternal::get();
|
||||
|
||||
// Process pending loop enables to handle GPIO interrupts during setup
|
||||
this->before_loop_tasks_(now);
|
||||
// Service scheduler and process pending loop enables to handle GPIO
|
||||
// interrupts during setup. During setup we always run the component
|
||||
// phase (no loop_interval_ gate), so call both helpers unconditionally.
|
||||
this->scheduler_tick_(now);
|
||||
this->before_component_phase_();
|
||||
|
||||
for (uint32_t j = 0; j <= i; j++) {
|
||||
// Update loop_component_start_time_ right before calling each component
|
||||
@@ -103,7 +106,7 @@ void Application::setup() {
|
||||
this->feed_wdt();
|
||||
}
|
||||
|
||||
this->after_loop_tasks_();
|
||||
this->after_component_phase_();
|
||||
yield();
|
||||
} while (!component->can_proceed() && !component->is_failed());
|
||||
}
|
||||
|
||||
+104
-49
@@ -426,8 +426,9 @@ class Application {
|
||||
void enable_component_loop_(Component *component);
|
||||
void enable_pending_loops_();
|
||||
void activate_looping_component_(uint16_t index);
|
||||
inline uint32_t ESPHOME_ALWAYS_INLINE before_loop_tasks_(uint32_t loop_start_time);
|
||||
inline void ESPHOME_ALWAYS_INLINE after_loop_tasks_() { this->in_loop_ = false; }
|
||||
inline uint32_t ESPHOME_ALWAYS_INLINE scheduler_tick_(uint32_t now);
|
||||
inline void ESPHOME_ALWAYS_INLINE before_component_phase_();
|
||||
inline void ESPHOME_ALWAYS_INLINE after_component_phase_() { this->in_loop_ = false; }
|
||||
|
||||
/// Process dump_config output one component per loop iteration.
|
||||
/// Extracted from loop() to keep cold startup/reconnect logging out of the hot path.
|
||||
@@ -582,16 +583,25 @@ inline void Application::drain_wake_notifications_() {
|
||||
}
|
||||
#endif // USE_HOST
|
||||
|
||||
inline uint32_t ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_start_time) {
|
||||
// Phase A: drain wake notifications and run the scheduler. Invoked on every
|
||||
// Application::loop() tick regardless of whether a component phase runs, so
|
||||
// scheduler items fire at their requested cadence even when the caller has
|
||||
// raised loop_interval_ for power savings (see Application::loop()).
|
||||
// Returns the timestamp of the last scheduler item that ran (or `now`
|
||||
// unchanged if none ran), so the caller's WDT feed stays monotonic with the
|
||||
// per-item feeds inside scheduler.call() without an extra millis().
|
||||
inline uint32_t ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now) {
|
||||
#ifdef USE_HOST
|
||||
// Drain wake notifications first to clear socket for next wake
|
||||
this->drain_wake_notifications_();
|
||||
#endif
|
||||
return this->scheduler.call(now);
|
||||
}
|
||||
|
||||
// Scheduler::call feeds the WDT per item and returns the timestamp of the
|
||||
// last fired item, or the input unchanged when nothing ran.
|
||||
uint32_t last_op_end_time = this->scheduler.call(loop_start_time);
|
||||
|
||||
// Phase B entry: only invoked when a component loop phase is about to run.
|
||||
// Processes pending enable_loop requests from ISRs and marks in_loop_ so
|
||||
// reentrant modifications during component.loop() are safe.
|
||||
inline void ESPHOME_ALWAYS_INLINE Application::before_component_phase_() {
|
||||
// Process any pending enable_loop requests from ISRs
|
||||
// This must be done before marking in_loop_ = true to avoid race conditions
|
||||
if (this->has_pending_enable_loop_requests_) {
|
||||
@@ -608,7 +618,6 @@ inline uint32_t ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t l
|
||||
|
||||
// Mark that we're in the loop for safe reentrant modifications
|
||||
this->in_loop_ = true;
|
||||
return last_op_end_time;
|
||||
}
|
||||
|
||||
inline void ESPHOME_ALWAYS_INLINE Application::loop() {
|
||||
@@ -623,46 +632,77 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
|
||||
// so charging it again to "before" would double-count.
|
||||
uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us;
|
||||
#endif
|
||||
// Get the initial loop time at the start
|
||||
uint32_t last_op_end_time = MillisInternal::get();
|
||||
// Phase A: always service the scheduler. Decouples scheduler cadence from
|
||||
// loop_interval_ so raised intervals (for power savings) don't drag scheduled
|
||||
// items forward. A tick that only runs the scheduler is cheap.
|
||||
// scheduler_tick_ returns the timestamp of the last scheduler item that ran
|
||||
// (advanced by its per-item feeds) or `now` unchanged. We adopt it as `now`
|
||||
// so the gate check and WDT feed both reflect actual elapsed time after
|
||||
// scheduler dispatch, without an extra millis() call.
|
||||
uint32_t now = this->scheduler_tick_(MillisInternal::get());
|
||||
// Guarantee one WDT feed per tick even when the scheduler had nothing to
|
||||
// dispatch and the component phase is gated out — covers configs with no
|
||||
// looping components and no scheduler work (setup() has its own
|
||||
// per-component feed_wdt calls, so only do this here, not in scheduler_tick_).
|
||||
this->feed_wdt_with_time(now);
|
||||
|
||||
// Returned timestamp keeps us monotonic with last_wdt_feed_ (advanced by
|
||||
// the scheduler's per-item feeds) without an extra millis() call.
|
||||
last_op_end_time = this->before_loop_tasks_(last_op_end_time);
|
||||
// Guarantee a WDT touch every tick — covers configs with no looping
|
||||
// components and no scheduler work, where the per-item / per-component
|
||||
// feeds never fire. Rate-limited inline fast path, ~free when unneeded.
|
||||
this->feed_wdt_with_time(last_op_end_time);
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
uint32_t loop_before_end_us = micros();
|
||||
uint64_t loop_before_scheduled_us = ComponentRuntimeStats::global_recorded_us - loop_recorded_snap;
|
||||
// Only meaningful when do_component_phase is true; initialized to 0 so the
|
||||
// tail bucket receives 0 on Phase A-only ticks (no component tail happened,
|
||||
// the gate-check / stats-prefix overhead belongs to "residual", not "tail").
|
||||
uint32_t loop_tail_start_us = 0;
|
||||
#endif
|
||||
|
||||
for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_;
|
||||
this->current_loop_index_++) {
|
||||
Component *component = this->looping_components_[this->current_loop_index_];
|
||||
// Gate the component phase on loop_interval_, an active high-frequency
|
||||
// request, or an explicit wake from a background producer. A scheduler-only
|
||||
// wake (e.g. set_interval firing under a raised loop_interval_) leaves the
|
||||
// component phase gated; an external producer that called wake_loop_*
|
||||
// (MQTT RX, USB RX, BLE event, etc.) needs the component phase to actually
|
||||
// run so its component's loop() can drain the queued work — that is the
|
||||
// long-standing semantic of wake_loop_threadsafe(), and the wake_request
|
||||
// flag preserves it. wake_request_take() exchange-clears the flag; wakes
|
||||
// that arrive during Phase B re-set it and run Phase B again on the next
|
||||
// iteration.
|
||||
const bool high_frequency = HighFrequencyLoopRequester::is_high_frequency();
|
||||
const uint32_t elapsed = now - this->last_loop_;
|
||||
const bool woke = esphome::wake_request_take();
|
||||
const bool do_component_phase = high_frequency || woke || (elapsed >= this->loop_interval_);
|
||||
|
||||
// Update the cached time before each component runs
|
||||
this->loop_component_start_time_ = last_op_end_time;
|
||||
if (do_component_phase) {
|
||||
this->before_component_phase_();
|
||||
|
||||
{
|
||||
this->set_current_component(component);
|
||||
WarnIfComponentBlockingGuard guard{component, last_op_end_time};
|
||||
component->loop();
|
||||
// Use the finish method to get the current time as the end time
|
||||
last_op_end_time = guard.finish();
|
||||
uint32_t last_op_end_time = now;
|
||||
for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_;
|
||||
this->current_loop_index_++) {
|
||||
Component *component = this->looping_components_[this->current_loop_index_];
|
||||
|
||||
// Update the cached time before each component runs
|
||||
this->loop_component_start_time_ = last_op_end_time;
|
||||
|
||||
{
|
||||
this->set_current_component(component);
|
||||
WarnIfComponentBlockingGuard guard{component, last_op_end_time};
|
||||
component->loop();
|
||||
// Use the finish method to get the current time as the end time
|
||||
last_op_end_time = guard.finish();
|
||||
}
|
||||
this->feed_wdt_with_time(last_op_end_time);
|
||||
}
|
||||
this->feed_wdt_with_time(last_op_end_time);
|
||||
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
loop_tail_start_us = micros();
|
||||
#endif
|
||||
this->last_loop_ = last_op_end_time;
|
||||
now = last_op_end_time;
|
||||
this->after_component_phase_();
|
||||
}
|
||||
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
uint32_t loop_tail_start_us = micros();
|
||||
#endif
|
||||
this->after_loop_tasks_();
|
||||
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
// Process any pending runtime stats printing after all components have run
|
||||
// This ensures stats printing doesn't affect component timing measurements
|
||||
// Record per-tick timing on every loop, not just component-phase ticks.
|
||||
// record_loop_active is a small accumulator; process_pending_stats is an
|
||||
// inline gate check that early-outs unless now >= next_log_time_.
|
||||
if (global_runtime_stats != nullptr) {
|
||||
uint32_t loop_now_us = micros();
|
||||
// Subtract scheduled-component time from the "before" bucket so it is
|
||||
@@ -671,25 +711,40 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
|
||||
uint32_t loop_before_overhead_us = loop_before_wall_us > loop_before_scheduled_us
|
||||
? loop_before_wall_us - static_cast<uint32_t>(loop_before_scheduled_us)
|
||||
: 0;
|
||||
global_runtime_stats->record_loop_active(loop_now_us - loop_active_start_us, loop_before_overhead_us,
|
||||
loop_now_us - loop_tail_start_us);
|
||||
global_runtime_stats->process_pending_stats(last_op_end_time);
|
||||
// tail_us is only defined when Phase B ran; 0 on Phase A-only ticks so the
|
||||
// stats bucket keeps its "component-phase trailing overhead" meaning.
|
||||
uint32_t loop_tail_us = do_component_phase ? (loop_now_us - loop_tail_start_us) : 0;
|
||||
global_runtime_stats->record_loop_active(loop_now_us - loop_active_start_us, loop_before_overhead_us, loop_tail_us);
|
||||
global_runtime_stats->process_pending_stats(now);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Use the last component's end time instead of calling millis() again
|
||||
// Compute sleep: bounded by time-until-next-component-phase and the
|
||||
// scheduler's next deadline. When a scheduler timer fires it re-enters
|
||||
// loop(), Phase A services it, and the component phase stays gated by
|
||||
// loop_interval_. When a background producer calls wake_loop_threadsafe()
|
||||
// it sets the wake_request flag and wakes select() / the task notification;
|
||||
// the gate above sees the flag and runs Phase B too so the producer's
|
||||
// component can drain its queued work without waiting up to loop_interval_.
|
||||
//
|
||||
// Re-read HighFrequencyLoopRequester::is_high_frequency() here instead of
|
||||
// reusing the cached `high_frequency` captured above: a component calling
|
||||
// HighFrequencyLoopRequester::start() from within its loop() would
|
||||
// otherwise sit under the stale value and sleep for up to loop_interval_
|
||||
// before the request took effect. That was fine pre-decoupling (the old
|
||||
// main loop also called the function fresh at the sleep point) but now
|
||||
// matters much more — loop_interval_ is a power-saving knob documented
|
||||
// to accept multi-second values, so the stale path could add seconds of
|
||||
// latency on an HF request. The call is a trivial atomic read.
|
||||
uint32_t delay_time = 0;
|
||||
auto elapsed = last_op_end_time - this->last_loop_;
|
||||
if (elapsed < this->loop_interval_ && !HighFrequencyLoopRequester::is_high_frequency()) {
|
||||
delay_time = this->loop_interval_ - elapsed;
|
||||
uint32_t next_schedule = this->scheduler.next_schedule_in(last_op_end_time).value_or(delay_time);
|
||||
// next_schedule is max 0.5*delay_time
|
||||
// otherwise interval=0 schedules result in constant looping with almost no sleep
|
||||
next_schedule = std::max(next_schedule, delay_time / 2);
|
||||
delay_time = std::min(next_schedule, delay_time);
|
||||
if (!HighFrequencyLoopRequester::is_high_frequency()) {
|
||||
const uint32_t elapsed_since_phase = now - this->last_loop_;
|
||||
const uint32_t until_phase =
|
||||
(elapsed_since_phase >= this->loop_interval_) ? 0 : (this->loop_interval_ - elapsed_since_phase);
|
||||
const uint32_t until_sched = this->scheduler.next_schedule_in(now).value_or(until_phase);
|
||||
delay_time = std::min(until_phase, until_sched);
|
||||
}
|
||||
this->yield_with_select_(delay_time);
|
||||
this->last_loop_ = last_op_end_time;
|
||||
|
||||
if (this->dump_config_at_ < this->components_.size()) {
|
||||
this->process_dump_config_();
|
||||
|
||||
@@ -177,6 +177,7 @@
|
||||
#define USE_API_USER_DEFINED_ACTION_RESPONSES
|
||||
#define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
|
||||
#define API_MAX_SEND_QUEUE 8
|
||||
#define MAX_API_CONNECTIONS 6
|
||||
#define USE_MD5
|
||||
#define USE_SHA256
|
||||
#define USE_MQTT
|
||||
|
||||
@@ -1117,7 +1117,10 @@ inline size_t buf_append_str(char *buf, size_t size, size_t pos, const char *str
|
||||
return size;
|
||||
}
|
||||
size_t remaining = size - pos - 1; // reserve space for null terminator
|
||||
size_t len = strnlen(str, remaining);
|
||||
size_t len = 0;
|
||||
while (len < remaining && str[len] != '\0') {
|
||||
len++;
|
||||
}
|
||||
memcpy(buf + pos, str, len);
|
||||
pos += len;
|
||||
buf[pos] = '\0';
|
||||
|
||||
@@ -1,28 +1,14 @@
|
||||
#include "esphome/core/util.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/version.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#ifdef USE_API
|
||||
#include "esphome/components/api/api_server.h"
|
||||
#endif
|
||||
|
||||
#ifdef USE_MQTT
|
||||
#include "esphome/components/mqtt/mqtt_client.h"
|
||||
#endif
|
||||
|
||||
namespace esphome {
|
||||
|
||||
bool api_is_connected() {
|
||||
#ifdef USE_API
|
||||
if (api::global_api_server != nullptr) {
|
||||
return api::global_api_server->is_connected();
|
||||
}
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
bool mqtt_is_connected() {
|
||||
#ifdef USE_MQTT
|
||||
if (mqtt::global_mqtt_client != nullptr) {
|
||||
|
||||
+20
-2
@@ -1,10 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#ifdef USE_API
|
||||
#include "esphome/components/api/api_server.h"
|
||||
#endif
|
||||
|
||||
namespace esphome {
|
||||
|
||||
/// Return whether the node has at least one client connected to the native API
|
||||
bool api_is_connected();
|
||||
/// Return whether the node has at least one client connected to the native API.
|
||||
///
|
||||
/// Inline so that hot-path callers (e.g. component loop() ticks that check connectivity every
|
||||
/// iteration) can skip the call8/return pair. With USE_API disabled this trivially returns false
|
||||
/// and collapses at compile time.
|
||||
#ifdef USE_API
|
||||
ESPHOME_ALWAYS_INLINE inline bool api_is_connected() {
|
||||
return api::global_api_server != nullptr && api::global_api_server->is_connected();
|
||||
}
|
||||
#else
|
||||
ESPHOME_ALWAYS_INLINE inline bool api_is_connected() { return false; }
|
||||
#endif
|
||||
|
||||
/// Return whether the node has an active connection to an MQTT broker
|
||||
bool mqtt_is_connected();
|
||||
|
||||
@@ -12,9 +12,22 @@
|
||||
|
||||
namespace esphome {
|
||||
|
||||
// === Wake-requested flag storage ===
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
std::atomic<uint8_t> g_wake_requested{0};
|
||||
#else
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
volatile uint8_t g_wake_requested = 0;
|
||||
#endif
|
||||
|
||||
// === ESP32 / LibreTiny — IRAM_ATTR entry points ===
|
||||
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
|
||||
void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) {
|
||||
// ISR-safe: set flag before notify so the wake is visible on the next gate
|
||||
// check. wake_request_set() is just an aligned 8-bit store / atomic store
|
||||
// and is safe from IRAM.
|
||||
wake_request_set();
|
||||
esphome_main_task_notify_from_isr(px_higher_priority_task_woken);
|
||||
}
|
||||
void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); }
|
||||
@@ -72,6 +85,9 @@ void wakeable_delay(uint32_t ms) {
|
||||
// === Host (UDP loopback socket) ===
|
||||
#ifdef USE_HOST
|
||||
void wake_loop_threadsafe() {
|
||||
// Set flag before sending so the consumer's gate check on the next loop()
|
||||
// entry observes the wake regardless of select() scheduling.
|
||||
wake_request_set();
|
||||
if (App.wake_socket_fd_ >= 0) {
|
||||
const char dummy = 1;
|
||||
::send(App.wake_socket_fd_, &dummy, 1, 0);
|
||||
|
||||
+50
-1
@@ -7,6 +7,10 @@
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/hal.h"
|
||||
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
#include <atomic>
|
||||
#endif
|
||||
|
||||
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
|
||||
#include "esphome/core/main_task.h"
|
||||
#endif
|
||||
@@ -25,12 +29,48 @@ namespace esphome {
|
||||
extern volatile bool g_main_loop_woke;
|
||||
#endif
|
||||
|
||||
// === wake_request flag — signals Application::loop() that a producer queued
|
||||
// work for some component's loop() to drain (MQTT RX, USB RX, BLE event, etc.)
|
||||
// and the component phase should run this tick instead of being held off by
|
||||
// the loop_interval_ gate. Set by every wake_loop_* entry point; consumed
|
||||
// (via exchange-and-clear) at the gate in Application::loop(). ===
|
||||
//
|
||||
// std::atomic<uint8_t> rather than std::atomic<bool> because GCC on Xtensa
|
||||
// generates an indirect function call for atomic<bool> ops instead of inlining
|
||||
// them — same workaround applied in scheduler.h for the SchedulerItem::remove
|
||||
// flag. On non-atomic platforms a volatile uint8_t suffices: 8-bit aligned
|
||||
// loads/stores are atomic on every supported MCU, and the platform signal
|
||||
// that follows wake_request_set() (FreeRTOS task-notify, esp_schedule, socket
|
||||
// send) provides the cross-thread/cross-core memory barrier.
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
extern std::atomic<uint8_t> g_wake_requested;
|
||||
|
||||
__attribute__((always_inline)) inline void wake_request_set() { g_wake_requested.store(1, std::memory_order_release); }
|
||||
__attribute__((always_inline)) inline bool wake_request_take() {
|
||||
return g_wake_requested.exchange(0, std::memory_order_acquire) != 0;
|
||||
}
|
||||
#else
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
extern volatile uint8_t g_wake_requested;
|
||||
|
||||
__attribute__((always_inline)) inline void wake_request_set() { g_wake_requested = 1; }
|
||||
__attribute__((always_inline)) inline bool wake_request_take() {
|
||||
uint8_t v = g_wake_requested;
|
||||
g_wake_requested = 0;
|
||||
return v != 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
// === ESP32 / LibreTiny (FreeRTOS) ===
|
||||
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
|
||||
|
||||
/// Wake the main loop from any context (ISR or task).
|
||||
/// always_inline so callers placed in IRAM keep the whole wake path in IRAM.
|
||||
__attribute__((always_inline)) inline void wake_main_task_any_context() {
|
||||
// Set the wake-requested flag BEFORE the task notification so the consumer
|
||||
// (Application::loop() gate) is guaranteed to see it on its next gate check.
|
||||
wake_request_set();
|
||||
if (in_isr_context()) {
|
||||
BaseType_t px_higher_priority_task_woken = pdFALSE;
|
||||
esphome_main_task_notify_from_isr(&px_higher_priority_task_woken);
|
||||
@@ -50,7 +90,10 @@ __attribute__((always_inline)) inline void wake_main_task_any_context() {
|
||||
void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken);
|
||||
void wake_loop_any_context();
|
||||
|
||||
inline void wake_loop_threadsafe() { esphome_main_task_notify(); }
|
||||
inline void wake_loop_threadsafe() {
|
||||
wake_request_set();
|
||||
esphome_main_task_notify();
|
||||
}
|
||||
|
||||
namespace internal {
|
||||
inline void wakeable_delay(uint32_t ms) {
|
||||
@@ -67,6 +110,9 @@ inline void wakeable_delay(uint32_t ms) {
|
||||
|
||||
/// Inline implementation — IRAM callers inline this directly.
|
||||
inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() {
|
||||
// Set the wake-requested flag BEFORE esp_schedule so the consumer is
|
||||
// guaranteed to see it on its next gate check.
|
||||
wake_request_set();
|
||||
g_main_loop_woke = true;
|
||||
esp_schedule();
|
||||
}
|
||||
@@ -98,6 +144,9 @@ inline void wakeable_delay(uint32_t ms) {
|
||||
#elif defined(USE_RP2040)
|
||||
|
||||
inline void wake_loop_any_context() {
|
||||
// Set the wake-requested flag BEFORE the SEV so the consumer is guaranteed
|
||||
// to see it on its next gate check.
|
||||
wake_request_set();
|
||||
g_main_loop_woke = true;
|
||||
__sev();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
host:
|
||||
|
||||
text:
|
||||
- platform: template
|
||||
name: "Test Text Restore"
|
||||
id: test_text_restore
|
||||
optimistic: true
|
||||
max_length: 10
|
||||
mode: text
|
||||
initial_value: "hello"
|
||||
restore_value: true
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Tests for the template text component."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_template_text_saver_uses_placement_new_with_templated_subclass(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Regression test for template text restore saver using placement new.
|
||||
|
||||
When ``restore_value: true``, the saver is its own Pvariable with
|
||||
placement new: storage is sized for ``TextSaver<MAX_LENGTH>``, the
|
||||
declared pointer stays at ``TemplateTextSaverBase *`` for polymorphism,
|
||||
and the templated subclass constructor runs. A regression would either
|
||||
reintroduce the heap ``new TextSaver<...>()`` expression or size the
|
||||
storage for the base class and silently skip the subclass ctor.
|
||||
"""
|
||||
main_cpp = generate_main(component_config_path("template_text_restore.yaml"))
|
||||
|
||||
# Storage is sized and aligned for the templated subclass.
|
||||
assert "sizeof(template_::TextSaver<10>)" in main_cpp
|
||||
assert "alignas(template_::TextSaver<10>)" in main_cpp
|
||||
# Pointer declared as base type for polymorphism.
|
||||
assert (
|
||||
"static template_::TemplateTextSaverBase *const test_text_restore_value_saver"
|
||||
in main_cpp
|
||||
)
|
||||
# Placement new runs the templated subclass constructor.
|
||||
assert "new(test_text_restore_value_saver) template_::TextSaver<10>()" in main_cpp
|
||||
# Base-class default ctor must NOT be used.
|
||||
assert (
|
||||
"new(test_text_restore_value_saver) template_::TemplateTextSaverBase()"
|
||||
not in main_cpp
|
||||
)
|
||||
# No heap `new TextSaver<...>()` left over — the pre-fix pattern.
|
||||
assert "new template_::TextSaver<" not in main_cpp
|
||||
# Saver is wired into the text component.
|
||||
assert (
|
||||
"test_text_restore->set_value_saver(test_text_restore_value_saver)" in main_cpp
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
|
||||
CODEOWNERS = ["@esphome/tests"]
|
||||
|
||||
wake_test_component_ns = cg.esphome_ns.namespace("wake_test_component")
|
||||
WakeTestComponent = wake_test_component_ns.class_("WakeTestComponent", cg.Component)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(WakeTestComponent),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
#include "wake_test_component.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
|
||||
namespace esphome::wake_test_component {
|
||||
|
||||
static const char *const TAG = "wake_test_component";
|
||||
|
||||
void WakeTestComponent::start_async_wake() {
|
||||
ESP_LOGI(TAG, "Spawning async wake thread (50ms delay)");
|
||||
std::thread([] {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
App.wake_loop_threadsafe();
|
||||
}).detach();
|
||||
}
|
||||
|
||||
} // namespace esphome::wake_test_component
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include <atomic>
|
||||
|
||||
namespace esphome::wake_test_component {
|
||||
|
||||
class WakeTestComponent : public Component {
|
||||
public:
|
||||
void setup() override {}
|
||||
void loop() override { this->loop_count_.fetch_add(1, std::memory_order_relaxed); }
|
||||
|
||||
int get_loop_count() const { return this->loop_count_.load(std::memory_order_relaxed); }
|
||||
|
||||
// Spawn a detached thread that sleeps briefly then calls
|
||||
// App.wake_loop_threadsafe(). Used by the integration test to verify a
|
||||
// cross-thread wake forces a component-phase iteration even when
|
||||
// loop_interval_ has been raised high enough to gate it off otherwise.
|
||||
void start_async_wake();
|
||||
|
||||
float get_setup_priority() const override { return setup_priority::DATA; }
|
||||
|
||||
protected:
|
||||
std::atomic<int> loop_count_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::wake_test_component
|
||||
@@ -0,0 +1,60 @@
|
||||
esphome:
|
||||
name: loop-interval-decouple
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- lambda: |-
|
||||
// Raise loop_interval_ to 500ms. With the decoupling fix the
|
||||
// component phase should run ~twice per second while the 50ms
|
||||
// scheduler interval below still fires at its requested cadence.
|
||||
App.set_loop_interval(500);
|
||||
# Start measurement after 1s so boot transients settle.
|
||||
- delay: 1000ms
|
||||
- lambda: |-
|
||||
id(loop_at_start) = id(loop_counter)->get_loop_count();
|
||||
id(sched_at_start) = id(sched_count);
|
||||
ESP_LOGI("test", "MEASUREMENT_STARTED loop=%d sched=%d",
|
||||
id(loop_at_start), id(sched_at_start));
|
||||
# Observe for 2s.
|
||||
- delay: 2000ms
|
||||
- lambda: |-
|
||||
int loop_delta = id(loop_counter)->get_loop_count() - id(loop_at_start);
|
||||
int sched_delta = id(sched_count) - id(sched_at_start);
|
||||
ESP_LOGI("test", "MEASUREMENT_DONE loop_delta=%d sched_delta=%d",
|
||||
loop_delta, sched_delta);
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: INFO
|
||||
logs:
|
||||
loop_test_component: WARN # Silence per-loop log spam
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
globals:
|
||||
- id: sched_count
|
||||
type: int
|
||||
initial_value: "0"
|
||||
- id: loop_at_start
|
||||
type: int
|
||||
initial_value: "0"
|
||||
- id: sched_at_start
|
||||
type: int
|
||||
initial_value: "0"
|
||||
|
||||
loop_test_component:
|
||||
components:
|
||||
- id: loop_counter
|
||||
name: loop_counter
|
||||
|
||||
interval:
|
||||
# Fast scheduler interval — with the decoupling fix this should fire at
|
||||
# its requested 50ms cadence regardless of loop_interval_.
|
||||
- interval: 50ms
|
||||
then:
|
||||
- lambda: |-
|
||||
id(sched_count) += 1;
|
||||
@@ -0,0 +1,51 @@
|
||||
esphome:
|
||||
name: loop-default-not-pulled
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
# Leave loop_interval_ at its default (16 ms → ~62 Hz). Do NOT call
|
||||
# set_loop_interval here. The fast scheduler interval below used to
|
||||
# pull the component phase forward to ~128 Hz via the old
|
||||
# std::max(next_schedule, delay_time / 2) floor.
|
||||
# Start measurement after 1s so boot transients settle.
|
||||
- delay: 1000ms
|
||||
- lambda: |-
|
||||
id(loop_at_start) = id(loop_counter)->get_loop_count();
|
||||
ESP_LOGI("test", "MEASUREMENT_STARTED loop=%d", id(loop_at_start));
|
||||
# Observe for 2s.
|
||||
- delay: 2000ms
|
||||
- lambda: |-
|
||||
int loop_delta = id(loop_counter)->get_loop_count() - id(loop_at_start);
|
||||
ESP_LOGI("test", "MEASUREMENT_DONE loop_delta=%d", loop_delta);
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: INFO
|
||||
logs:
|
||||
loop_test_component: WARN # Silence per-loop log spam
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
globals:
|
||||
- id: loop_at_start
|
||||
type: int
|
||||
initial_value: "0"
|
||||
|
||||
loop_test_component:
|
||||
components:
|
||||
- id: loop_counter
|
||||
name: loop_counter
|
||||
|
||||
interval:
|
||||
# Fast scheduler interval (well under loop_interval_/2 = 8ms). In the
|
||||
# pre-decoupling code this would have pulled the component phase forward
|
||||
# to ~128 Hz. After the decoupling fix the component phase stays at
|
||||
# ~62 Hz regardless.
|
||||
- interval: 5ms
|
||||
then:
|
||||
- lambda: |-
|
||||
// No-op; the presence of a due scheduler item is what matters.
|
||||
@@ -0,0 +1,52 @@
|
||||
esphome:
|
||||
name: wake-loop-phase-b
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- lambda: |-
|
||||
// Raise loop_interval_ to 2000ms. Without the wake-request flag,
|
||||
// a wake_loop_threadsafe() call would only run Phase A (scheduler)
|
||||
// and leave the component phase gated for ~2s.
|
||||
App.set_loop_interval(2000);
|
||||
# Let boot transients settle.
|
||||
- delay: 1000ms
|
||||
- lambda: |-
|
||||
// Snapshot the loop counter, then ask the component to spawn a
|
||||
// background thread that calls App.wake_loop_threadsafe() after
|
||||
// ~50ms. With the fix, that wake forces Phase B on the next tick
|
||||
// and the counter increments well within the 500ms observation
|
||||
// window below.
|
||||
id(count_at_start) = id(wake_counter)->get_loop_count();
|
||||
id(start_time) = millis();
|
||||
id(wake_counter)->start_async_wake();
|
||||
ESP_LOGI("test", "WAKE_STARTED count=%d", id(count_at_start));
|
||||
# Observation window must be much shorter than loop_interval_ (2000ms)
|
||||
# so a "false pass" isn't possible by simply waiting out the gate.
|
||||
- delay: 500ms
|
||||
- lambda: |-
|
||||
int count_now = id(wake_counter)->get_loop_count();
|
||||
int delta = count_now - id(count_at_start);
|
||||
uint32_t elapsed = millis() - id(start_time);
|
||||
ESP_LOGI("test", "WAKE_RESULT delta=%d elapsed=%u", delta, elapsed);
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: INFO
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
components: [wake_test_component]
|
||||
|
||||
globals:
|
||||
- id: count_at_start
|
||||
type: int
|
||||
initial_value: "0"
|
||||
- id: start_time
|
||||
type: uint32_t
|
||||
initial_value: "0"
|
||||
|
||||
wake_test_component:
|
||||
id: wake_counter
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Test that loop_interval_ no longer clamps scheduler cadence.
|
||||
|
||||
Regression test for the decoupling of Application::loop() component-phase
|
||||
cadence from scheduler wake timing.
|
||||
|
||||
Setup:
|
||||
- App.set_loop_interval(500) — raised for power-savings style cadence
|
||||
- Scheduler interval at 50ms — should fire at 50ms regardless of loop_interval_
|
||||
- Component loop (LoopTestComponent) — should run at 500ms cadence
|
||||
|
||||
Before the decoupling fix the old `std::max(next_schedule, delay_time / 2)`
|
||||
floor clamped the sleep to ~250ms, so the 50ms scheduler only fired ~8 times
|
||||
per 2s (vs the ~40 expected). After the fix the scheduler fires close to its
|
||||
requested cadence while the component phase stays gated at loop_interval_.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loop_interval_decoupling(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Raised loop_interval_ must not clamp scheduler item cadence."""
|
||||
loop = asyncio.get_running_loop()
|
||||
measurement_done: asyncio.Future[tuple[int, int]] = loop.create_future()
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
match = re.search(r"MEASUREMENT_DONE loop_delta=(\d+) sched_delta=(\d+)", line)
|
||||
if match and not measurement_done.done():
|
||||
measurement_done.set_result((int(match.group(1)), int(match.group(2))))
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=on_log_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
device_info = await client.device_info()
|
||||
assert device_info is not None
|
||||
assert device_info.name == "loop-interval-decouple"
|
||||
|
||||
try:
|
||||
loop_delta, sched_delta = await asyncio.wait_for(
|
||||
measurement_done, timeout=10.0
|
||||
)
|
||||
except TimeoutError:
|
||||
pytest.fail("MEASUREMENT_DONE marker never appeared")
|
||||
|
||||
# Observation window = 2s, loop_interval_ = 500ms.
|
||||
# Component phase should fire ~4 times in 2s. The upper bound must be
|
||||
# less than 8: the pre-decoupling behavior clamped to ~250ms cadence
|
||||
# giving ~8 loops/2s, so allowing 8 would let the old behavior pass.
|
||||
# Lower bound 3 (not 2) keeps the test honest: a >30% slowdown from
|
||||
# the ~4 nominal is not normal CI jitter and should fail.
|
||||
assert 3 <= loop_delta <= 6, (
|
||||
f"Component loop should fire ~4 times in 2s at loop_interval=500ms, "
|
||||
f"got {loop_delta}"
|
||||
)
|
||||
|
||||
# Scheduler interval = 50ms → ~40 fires in 2s. Before the decoupling
|
||||
# fix this clamped to ~8 fires. Assert >= 20 to catch the old clamped
|
||||
# behavior with comfortable jitter headroom for slow CI hosts.
|
||||
assert sched_delta >= 20, (
|
||||
f"50ms scheduler interval should fire ~40 times in 2s but only "
|
||||
f"fired {sched_delta}. This indicates loop_interval_ is still "
|
||||
f"clamping scheduler cadence."
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Test that a fast scheduler item does not pull the component phase forward.
|
||||
|
||||
Regression test for the original ~128 Hz → ~62 Hz bug fixed by decoupling
|
||||
Application::loop() component-phase cadence from scheduler wake timing.
|
||||
|
||||
Setup:
|
||||
- loop_interval_ left at its default (16 ms → ~62 Hz component phase).
|
||||
- Scheduler interval at 5 ms (well under the old loop_interval_/2 = 8 ms floor).
|
||||
|
||||
Before the decoupling fix the ``std::max(next_schedule, delay_time / 2)`` floor
|
||||
clamped the sleep to ~8 ms whenever any scheduler item was due sooner than
|
||||
loop_interval_/2. That pulled the component phase forward to ~128 Hz — twice
|
||||
what the documented ~62 Hz default promised. After the fix the component
|
||||
phase stays at ~62 Hz regardless of scheduler activity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loop_interval_default_not_pulled_forward(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Fast scheduler item must not pull component phase past default ~62 Hz."""
|
||||
loop = asyncio.get_running_loop()
|
||||
measurement_done: asyncio.Future[int] = loop.create_future()
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
match = re.search(r"MEASUREMENT_DONE loop_delta=(\d+)", line)
|
||||
if match and not measurement_done.done():
|
||||
measurement_done.set_result(int(match.group(1)))
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=on_log_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
device_info = await client.device_info()
|
||||
assert device_info is not None
|
||||
assert device_info.name == "loop-default-not-pulled"
|
||||
|
||||
try:
|
||||
loop_delta = await asyncio.wait_for(measurement_done, timeout=10.0)
|
||||
except TimeoutError:
|
||||
pytest.fail("MEASUREMENT_DONE marker never appeared")
|
||||
|
||||
# Observation window = 2s, loop_interval_ default = 16ms → ~62 Hz →
|
||||
# ~125 component-phase iterations expected.
|
||||
# Pre-fix behavior: the 5 ms scheduler interval tripped the old
|
||||
# delay_time/2 = 8 ms floor, pulling the phase to ~128 Hz → ~256.
|
||||
# Upper bound 180 is comfortably below the ~256 pre-fix rate but
|
||||
# above the ~125 nominal with CI jitter.
|
||||
# Lower bound 80 covers very slow CI hosts without permitting a
|
||||
# complete regression.
|
||||
assert 80 <= loop_delta <= 180, (
|
||||
f"Component loop at default loop_interval_ should fire ~125 times "
|
||||
f"in 2s (≈62 Hz × 2s); got {loop_delta}. Values >200 indicate the "
|
||||
f"scheduler is again pulling the component phase forward."
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Test that wake_loop_threadsafe() forces a component-phase iteration.
|
||||
|
||||
Regression test for the wake-request flag added to Application::loop()'s
|
||||
Phase A / Phase B gate. Background producers (MQTT RX, USB RX, BLE event,
|
||||
etc.) call App.wake_loop_threadsafe() expecting their component's loop()
|
||||
to drain queued work; if the component phase stays gated by loop_interval_,
|
||||
the work waits up to loop_interval_ ms instead of running on the next tick.
|
||||
|
||||
Setup:
|
||||
- App.set_loop_interval(2000) — a wide gate that would clearly mask the bug.
|
||||
- A test component spawns a detached std::thread that sleeps 50 ms and then
|
||||
calls App.wake_loop_threadsafe() from a non-main thread.
|
||||
- The on_boot block snapshots the component's loop counter before/after a
|
||||
500 ms observation window.
|
||||
|
||||
Without the fix, delta=0 (the gate holds Phase B for ~2 s).
|
||||
With the fix, delta>=1 (the wake forces Phase B within one tick of the wake).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wake_loop_forces_phase_b(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""A wake_loop_threadsafe() call from a background thread must trigger the
|
||||
component phase within the next tick, even when loop_interval_ is raised
|
||||
well above the observation window."""
|
||||
external_components_path = str(
|
||||
Path(__file__).parent / "fixtures" / "external_components"
|
||||
)
|
||||
yaml_config = yaml_config.replace(
|
||||
"EXTERNAL_COMPONENT_PATH", external_components_path
|
||||
)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
result: asyncio.Future[tuple[int, int]] = loop.create_future()
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
match = re.search(r"WAKE_RESULT delta=(\d+) elapsed=(\d+)", line)
|
||||
if match and not result.done():
|
||||
result.set_result((int(match.group(1)), int(match.group(2))))
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=on_log_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
device_info = await client.device_info()
|
||||
assert device_info is not None
|
||||
assert device_info.name == "wake-loop-phase-b"
|
||||
|
||||
try:
|
||||
delta, elapsed = await asyncio.wait_for(result, timeout=15.0)
|
||||
except TimeoutError:
|
||||
pytest.fail("WAKE_RESULT marker never appeared")
|
||||
|
||||
# Without the fix, delta would be 0 — loop_interval_=2000ms held
|
||||
# Phase B off for the full 500ms observation window. With the fix
|
||||
# the wake from the background thread (~50ms after start) forces
|
||||
# Phase B on the next tick, so the counter increments at least once.
|
||||
assert delta >= 1, (
|
||||
f"wake_loop_threadsafe() from a background thread should force "
|
||||
f"Phase B within the next tick; observed delta={delta} after "
|
||||
f"{elapsed}ms with loop_interval_=2000ms"
|
||||
)
|
||||
Reference in New Issue
Block a user