1
0
mirror of https://github.com/esphome/esphome.git synced 2025-09-14 17:22:20 +01:00

Merge branch 'dev' into proxy_memory

This commit is contained in:
J. Nick Koston
2025-06-18 12:16:12 +02:00
committed by GitHub
70 changed files with 1255 additions and 312 deletions

View File

@@ -4,7 +4,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.11.10
rev: v0.12.0
hooks:
# Run the linter.
- id: ruff

View File

@@ -520,6 +520,7 @@ esphome/components/xiaomi_lywsd03mmc/* @ahpohl
esphome/components/xiaomi_mhoc303/* @drug123
esphome/components/xiaomi_mhoc401/* @vevsvevs
esphome/components/xiaomi_rtcgq02lm/* @jesserockz
esphome/components/xiaomi_xmwsdj04mmc/* @medusalix
esphome/components/xl9535/* @mreditor97
esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68
esphome/components/xxtea/* @clydebarrow

View File

@@ -17,7 +17,11 @@ void Anova::setup() {
this->current_request_ = 0;
}
void Anova::loop() {}
void Anova::loop() {
// Parent BLEClientNode has a loop() method, but this component uses
// polling via update() and BLE callbacks so loop isn't needed
this->disable_loop();
}
void Anova::control(const ClimateCall &call) {
if (call.get_mode().has_value()) {

View File

@@ -1643,6 +1643,7 @@ enum VoiceAssistantEvent {
VOICE_ASSISTANT_STT_VAD_END = 12;
VOICE_ASSISTANT_TTS_STREAM_START = 98;
VOICE_ASSISTANT_TTS_STREAM_END = 99;
VOICE_ASSISTANT_INTENT_PROGRESS = 100;
}
message VoiceAssistantEventData {

View File

@@ -516,6 +516,8 @@ template<> const char *proto_enum_to_string<enums::VoiceAssistantEvent>(enums::V
return "VOICE_ASSISTANT_TTS_STREAM_START";
case enums::VOICE_ASSISTANT_TTS_STREAM_END:
return "VOICE_ASSISTANT_TTS_STREAM_END";
case enums::VOICE_ASSISTANT_INTENT_PROGRESS:
return "VOICE_ASSISTANT_INTENT_PROGRESS";
default:
return "UNKNOWN";
}

View File

@@ -208,6 +208,7 @@ enum VoiceAssistantEvent : uint32_t {
VOICE_ASSISTANT_STT_VAD_END = 12,
VOICE_ASSISTANT_TTS_STREAM_START = 98,
VOICE_ASSISTANT_TTS_STREAM_END = 99,
VOICE_ASSISTANT_INTENT_PROGRESS = 100,
};
enum VoiceAssistantTimerEvent : uint32_t {
VOICE_ASSISTANT_TIMER_STARTED = 0,

View File

@@ -21,8 +21,8 @@ CONFIG_SCHEMA = cv.All(
@coroutine_with_priority(200.0)
async def to_code(config):
if CORE.is_esp32 or CORE.is_libretiny:
# https://github.com/esphome/AsyncTCP/blob/master/library.json
cg.add_library("esphome/AsyncTCP-esphome", "2.1.4")
# https://github.com/ESP32Async/AsyncTCP
cg.add_library("ESP32Async/AsyncTCP", "3.4.4")
elif CORE.is_esp8266:
# https://github.com/esphome/ESPAsyncTCP
cg.add_library("esphome/ESPAsyncTCP-esphome", "2.0.0")
# https://github.com/ESP32Async/ESPAsyncTCP
cg.add_library("ESP32Async/ESPAsyncTCP", "2.0.0")

View File

@@ -480,7 +480,11 @@ void BedJetHub::set_clock(uint8_t hour, uint8_t minute) {
/* Internal */
void BedJetHub::loop() {}
void BedJetHub::loop() {
// Parent BLEClientNode has a loop() method, but this component uses
// polling via update() and BLE callbacks so loop isn't needed
this->disable_loop();
}
void BedJetHub::update() { this->dispatch_status_(); }
void BedJetHub::dump_config() {

View File

@@ -83,7 +83,11 @@ void BedJetClimate::reset_state_() {
this->publish_state();
}
void BedJetClimate::loop() {}
void BedJetClimate::loop() {
// This component is controlled via the parent BedJetHub
// Empty loop not needed, disable to save CPU cycles
this->disable_loop();
}
void BedJetClimate::control(const ClimateCall &call) {
ESP_LOGD(TAG, "Received BedJetClimate::control");

View File

@@ -11,7 +11,11 @@ namespace ble_client {
static const char *const TAG = "ble_rssi_sensor";
void BLEClientRSSISensor::loop() {}
void BLEClientRSSISensor::loop() {
// Parent BLEClientNode has a loop() method, but this component uses
// polling via update() and BLE GAP callbacks so loop isn't needed
this->disable_loop();
}
void BLEClientRSSISensor::dump_config() {
LOG_SENSOR("", "BLE Client RSSI Sensor", this);

View File

@@ -11,7 +11,11 @@ namespace ble_client {
static const char *const TAG = "ble_sensor";
void BLESensor::loop() {}
void BLESensor::loop() {
// Parent BLEClientNode has a loop() method, but this component uses
// polling via update() and BLE callbacks so loop isn't needed
this->disable_loop();
}
void BLESensor::dump_config() {
LOG_SENSOR("", "BLE Sensor", this);

View File

@@ -14,7 +14,11 @@ static const char *const TAG = "ble_text_sensor";
static const std::string EMPTY = "";
void BLETextSensor::loop() {}
void BLETextSensor::loop() {
// Parent BLEClientNode has a loop() method, but this component uses
// polling via update() and BLE callbacks so loop isn't needed
this->disable_loop();
}
void BLETextSensor::dump_config() {
LOG_TEXT_SENSOR("", "BLE Text Sensor", this);

View File

@@ -12,8 +12,8 @@ from esphome.const import (
CONF_OVERSAMPLING,
CONF_PRESSURE,
CONF_TEMPERATURE,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_ATMOSPHERIC_PRESSURE,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
ICON_GAS_CYLINDER,
STATE_CLASS_MEASUREMENT,

View File

@@ -37,7 +37,12 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) {
request->redirect("/?save");
}
void CaptivePortal::setup() {}
void CaptivePortal::setup() {
#ifndef USE_ARDUINO
// No DNS server needed for non-Arduino frameworks
this->disable_loop();
#endif
}
void CaptivePortal::start() {
this->base_->init();
if (!this->initialized_) {
@@ -50,6 +55,8 @@ void CaptivePortal::start() {
this->dns_server_->setErrorReplyCode(DNSReplyCode::NoError);
network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip();
this->dns_server_->start(53, "*", ip);
// Re-enable loop() when DNS server is started
this->enable_loop();
#endif
this->base_->get_server()->onNotFound([this](AsyncWebServerRequest *req) {
@@ -68,7 +75,11 @@ void CaptivePortal::start() {
void CaptivePortal::handleRequest(AsyncWebServerRequest *req) {
if (req->url() == "/") {
#ifndef USE_ESP8266
auto *response = req->beginResponse(200, "text/html", INDEX_GZ, sizeof(INDEX_GZ));
#else
auto *response = req->beginResponse_P(200, "text/html", INDEX_GZ, sizeof(INDEX_GZ));
#endif
response->addHeader("Content-Encoding", "gzip");
req->send(response);
return;

View File

@@ -21,8 +21,11 @@ class CaptivePortal : public AsyncWebHandler, public Component {
void dump_config() override;
#ifdef USE_ARDUINO
void loop() override {
if (this->dns_server_ != nullptr)
if (this->dns_server_ != nullptr) {
this->dns_server_->processNextRequest();
} else {
this->disable_loop();
}
}
#endif
float get_setup_priority() const override;
@@ -37,7 +40,7 @@ class CaptivePortal : public AsyncWebHandler, public Component {
#endif
}
bool canHandle(AsyncWebServerRequest *request) override {
bool canHandle(AsyncWebServerRequest *request) const override {
if (!this->active_)
return false;

View File

@@ -1,6 +1,7 @@
#ifdef USE_ESP32
#include "ble.h"
#include "ble_event_pool.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
@@ -23,9 +24,6 @@ namespace esp32_ble {
static const char *const TAG = "esp32_ble";
static RAMAllocator<BLEEvent> EVENT_ALLOCATOR( // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
RAMAllocator<BLEEvent>::ALLOW_FAILURE | RAMAllocator<BLEEvent>::ALLOC_INTERNAL);
void ESP32BLE::setup() {
global_ble = this;
ESP_LOGCONFIG(TAG, "Running setup");
@@ -349,9 +347,8 @@ void ESP32BLE::loop() {
default:
break;
}
// Destructor will clean up external allocations for GATTC/GATTS
ble_event->~BLEEvent();
EVENT_ALLOCATOR.deallocate(ble_event, 1);
// Return the event to the pool
this->ble_event_pool_.release(ble_event);
ble_event = this->ble_events_.pop();
}
if (this->advertising_ != nullptr) {
@@ -359,37 +356,41 @@ void ESP32BLE::loop() {
}
// Log dropped events periodically
size_t dropped = this->ble_events_.get_and_reset_dropped_count();
uint16_t dropped = this->ble_events_.get_and_reset_dropped_count();
if (dropped > 0) {
ESP_LOGW(TAG, "Dropped %zu BLE events due to buffer overflow", dropped);
ESP_LOGW(TAG, "Dropped %u BLE events due to buffer overflow", dropped);
}
}
// Helper function to load new event data based on type
void load_ble_event(BLEEvent *event, esp_gap_ble_cb_event_t e, esp_ble_gap_cb_param_t *p) {
event->load_gap_event(e, p);
}
void load_ble_event(BLEEvent *event, esp_gattc_cb_event_t e, esp_gatt_if_t i, esp_ble_gattc_cb_param_t *p) {
event->load_gattc_event(e, i, p);
}
void load_ble_event(BLEEvent *event, esp_gatts_cb_event_t e, esp_gatt_if_t i, esp_ble_gatts_cb_param_t *p) {
event->load_gatts_event(e, i, p);
}
template<typename... Args> void enqueue_ble_event(Args... args) {
// Check if queue is full before allocating
if (global_ble->ble_events_.full()) {
// Queue is full, drop the event
// Allocate an event from the pool
BLEEvent *event = global_ble->ble_event_pool_.allocate();
if (event == nullptr) {
// No events available - queue is full or we're out of memory
global_ble->ble_events_.increment_dropped_count();
return;
}
BLEEvent *new_event = EVENT_ALLOCATOR.allocate(1);
if (new_event == nullptr) {
// Memory too fragmented to allocate new event. Can only drop it until memory comes back
global_ble->ble_events_.increment_dropped_count();
return;
}
new (new_event) BLEEvent(args...);
// Load new event data (replaces previous event)
load_ble_event(event, args...);
// Push the event - since we're the only producer and we checked full() above,
// this should always succeed unless we have a bug
if (!global_ble->ble_events_.push(new_event)) {
// This should not happen in SPSC queue with single producer
ESP_LOGE(TAG, "BLE queue push failed unexpectedly");
new_event->~BLEEvent();
EVENT_ALLOCATOR.deallocate(new_event, 1);
}
} // NOLINT(clang-analyzer-unix.Malloc)
// Push the event to the queue
global_ble->ble_events_.push(event);
// Push always succeeds because we're the only producer and the pool ensures we never exceed queue size
}
// Explicit template instantiations for the friend function
template void enqueue_ble_event(esp_gap_ble_cb_event_t, esp_ble_gap_cb_param_t *);

View File

@@ -12,6 +12,7 @@
#include "esphome/core/helpers.h"
#include "ble_event.h"
#include "ble_event_pool.h"
#include "queue.h"
#ifdef USE_ESP32
@@ -148,6 +149,7 @@ class ESP32BLE : public Component {
BLEComponentState state_{BLE_COMPONENT_STATE_OFF};
LockFreeQueue<BLEEvent, MAX_BLE_QUEUE_SIZE> ble_events_;
BLEEventPool<MAX_BLE_QUEUE_SIZE> ble_event_pool_;
BLEAdvertising *advertising_{};
esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE};
uint32_t advertising_cycle_time_{};

View File

@@ -51,6 +51,13 @@ static_assert(offsetof(esp_ble_gap_cb_param_t, scan_stop_cmpl.status) ==
// - GATTC/GATTS events: We heap-allocate and copy the entire param struct, ensuring
// the data remains valid even after the BLE callback returns. The original
// param pointer from ESP-IDF is only valid during the callback.
//
// CRITICAL DESIGN NOTE:
// The heap allocations for GATTC/GATTS events are REQUIRED for memory safety.
// DO NOT attempt to optimize by removing these allocations or storing pointers
// to the original ESP-IDF data. The ESP-IDF callback data has a different lifetime
// than our event processing, and accessing it after the callback returns would
// result in use-after-free bugs and crashes.
class BLEEvent {
public:
// NOLINTNEXTLINE(readability-identifier-naming)
@@ -63,125 +70,74 @@ class BLEEvent {
// Constructor for GAP events - no external allocations needed
BLEEvent(esp_gap_ble_cb_event_t e, esp_ble_gap_cb_param_t *p) {
this->type_ = GAP;
this->event_.gap.gap_event = e;
if (p == nullptr) {
return; // Invalid event, but we can't log in header file
}
// Only copy the data we actually use for each GAP event type
switch (e) {
case ESP_GAP_BLE_SCAN_RESULT_EVT:
// Copy only the fields we use from scan results
memcpy(this->event_.gap.scan_result.bda, p->scan_rst.bda, sizeof(esp_bd_addr_t));
this->event_.gap.scan_result.ble_addr_type = p->scan_rst.ble_addr_type;
this->event_.gap.scan_result.rssi = p->scan_rst.rssi;
this->event_.gap.scan_result.adv_data_len = p->scan_rst.adv_data_len;
this->event_.gap.scan_result.scan_rsp_len = p->scan_rst.scan_rsp_len;
this->event_.gap.scan_result.search_evt = p->scan_rst.search_evt;
memcpy(this->event_.gap.scan_result.ble_adv, p->scan_rst.ble_adv,
ESP_BLE_ADV_DATA_LEN_MAX + ESP_BLE_SCAN_RSP_DATA_LEN_MAX);
break;
case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT:
this->event_.gap.scan_complete.status = p->scan_param_cmpl.status;
break;
case ESP_GAP_BLE_SCAN_START_COMPLETE_EVT:
this->event_.gap.scan_complete.status = p->scan_start_cmpl.status;
break;
case ESP_GAP_BLE_SCAN_STOP_COMPLETE_EVT:
this->event_.gap.scan_complete.status = p->scan_stop_cmpl.status;
break;
default:
// We only handle 4 GAP event types, others are dropped
break;
}
this->init_gap_data_(e, p);
}
// Constructor for GATTC events - uses heap allocation
// Creates a copy of the param struct since the original is only valid during the callback
// IMPORTANT: The heap allocation is REQUIRED and must not be removed as an optimization.
// The param pointer from ESP-IDF is only valid during the callback execution.
// Since BLE events are processed asynchronously in the main loop, we must create
// our own copy to ensure the data remains valid until the event is processed.
BLEEvent(esp_gattc_cb_event_t e, esp_gatt_if_t i, esp_ble_gattc_cb_param_t *p) {
this->type_ = GATTC;
this->event_.gattc.gattc_event = e;
this->event_.gattc.gattc_if = i;
if (p == nullptr) {
this->event_.gattc.gattc_param = nullptr;
this->event_.gattc.data = nullptr;
return; // Invalid event, but we can't log in header file
}
// Heap-allocate param and data
// Heap allocation is used because GATTC/GATTS events are rare (<1% of events)
// while GAP events (99%) are stored inline to minimize memory usage
this->event_.gattc.gattc_param = new esp_ble_gattc_cb_param_t(*p);
// Copy data for events that need it
switch (e) {
case ESP_GATTC_NOTIFY_EVT:
this->event_.gattc.data = new std::vector<uint8_t>(p->notify.value, p->notify.value + p->notify.value_len);
this->event_.gattc.gattc_param->notify.value = this->event_.gattc.data->data();
break;
case ESP_GATTC_READ_CHAR_EVT:
case ESP_GATTC_READ_DESCR_EVT:
this->event_.gattc.data = new std::vector<uint8_t>(p->read.value, p->read.value + p->read.value_len);
this->event_.gattc.gattc_param->read.value = this->event_.gattc.data->data();
break;
default:
this->event_.gattc.data = nullptr;
break;
}
this->init_gattc_data_(e, i, p);
}
// Constructor for GATTS events - uses heap allocation
// Creates a copy of the param struct since the original is only valid during the callback
// IMPORTANT: The heap allocation is REQUIRED and must not be removed as an optimization.
// The param pointer from ESP-IDF is only valid during the callback execution.
// Since BLE events are processed asynchronously in the main loop, we must create
// our own copy to ensure the data remains valid until the event is processed.
BLEEvent(esp_gatts_cb_event_t e, esp_gatt_if_t i, esp_ble_gatts_cb_param_t *p) {
this->type_ = GATTS;
this->event_.gatts.gatts_event = e;
this->event_.gatts.gatts_if = i;
if (p == nullptr) {
this->event_.gatts.gatts_param = nullptr;
this->event_.gatts.data = nullptr;
return; // Invalid event, but we can't log in header file
}
// Heap-allocate param and data
// Heap allocation is used because GATTC/GATTS events are rare (<1% of events)
// while GAP events (99%) are stored inline to minimize memory usage
this->event_.gatts.gatts_param = new esp_ble_gatts_cb_param_t(*p);
// Copy data for events that need it
switch (e) {
case ESP_GATTS_WRITE_EVT:
this->event_.gatts.data = new std::vector<uint8_t>(p->write.value, p->write.value + p->write.len);
this->event_.gatts.gatts_param->write.value = this->event_.gatts.data->data();
break;
default:
this->event_.gatts.data = nullptr;
break;
}
this->init_gatts_data_(e, i, p);
}
// Destructor to clean up heap allocations
~BLEEvent() {
switch (this->type_) {
case GATTC:
~BLEEvent() { this->cleanup_heap_data(); }
// Default constructor for pre-allocation in pool
BLEEvent() : type_(GAP) {}
// Clean up any heap-allocated data
void cleanup_heap_data() {
if (this->type_ == GAP) {
return;
}
if (this->type_ == GATTC) {
delete this->event_.gattc.gattc_param;
delete this->event_.gattc.data;
break;
case GATTS:
this->event_.gattc.gattc_param = nullptr;
this->event_.gattc.data = nullptr;
return;
}
if (this->type_ == GATTS) {
delete this->event_.gatts.gatts_param;
delete this->event_.gatts.data;
break;
default:
break;
this->event_.gatts.gatts_param = nullptr;
this->event_.gatts.data = nullptr;
}
}
// Load new event data for reuse (replaces previous event data)
void load_gap_event(esp_gap_ble_cb_event_t e, esp_ble_gap_cb_param_t *p) {
this->cleanup_heap_data();
this->type_ = GAP;
this->init_gap_data_(e, p);
}
void load_gattc_event(esp_gattc_cb_event_t e, esp_gatt_if_t i, esp_ble_gattc_cb_param_t *p) {
this->cleanup_heap_data();
this->type_ = GATTC;
this->init_gattc_data_(e, i, p);
}
void load_gatts_event(esp_gatts_cb_event_t e, esp_gatt_if_t i, esp_ble_gatts_cb_param_t *p) {
this->cleanup_heap_data();
this->type_ = GATTS;
this->init_gatts_data_(e, i, p);
}
// Disable copy to prevent double-delete
BLEEvent(const BLEEvent &) = delete;
BLEEvent &operator=(const BLEEvent &) = delete;
@@ -224,6 +180,119 @@ class BLEEvent {
esp_gap_ble_cb_event_t gap_event_type() const { return event_.gap.gap_event; }
const BLEScanResult &scan_result() const { return event_.gap.scan_result; }
esp_bt_status_t scan_complete_status() const { return event_.gap.scan_complete.status; }
private:
// Initialize GAP event data
void init_gap_data_(esp_gap_ble_cb_event_t e, esp_ble_gap_cb_param_t *p) {
this->event_.gap.gap_event = e;
if (p == nullptr) {
return; // Invalid event, but we can't log in header file
}
// Copy data based on event type
switch (e) {
case ESP_GAP_BLE_SCAN_RESULT_EVT:
memcpy(this->event_.gap.scan_result.bda, p->scan_rst.bda, sizeof(esp_bd_addr_t));
this->event_.gap.scan_result.ble_addr_type = p->scan_rst.ble_addr_type;
this->event_.gap.scan_result.rssi = p->scan_rst.rssi;
this->event_.gap.scan_result.adv_data_len = p->scan_rst.adv_data_len;
this->event_.gap.scan_result.scan_rsp_len = p->scan_rst.scan_rsp_len;
this->event_.gap.scan_result.search_evt = p->scan_rst.search_evt;
memcpy(this->event_.gap.scan_result.ble_adv, p->scan_rst.ble_adv,
ESP_BLE_ADV_DATA_LEN_MAX + ESP_BLE_SCAN_RSP_DATA_LEN_MAX);
break;
case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT:
this->event_.gap.scan_complete.status = p->scan_param_cmpl.status;
break;
case ESP_GAP_BLE_SCAN_START_COMPLETE_EVT:
this->event_.gap.scan_complete.status = p->scan_start_cmpl.status;
break;
case ESP_GAP_BLE_SCAN_STOP_COMPLETE_EVT:
this->event_.gap.scan_complete.status = p->scan_stop_cmpl.status;
break;
default:
// We only handle 4 GAP event types, others are dropped
break;
}
}
// Initialize GATTC event data
void init_gattc_data_(esp_gattc_cb_event_t e, esp_gatt_if_t i, esp_ble_gattc_cb_param_t *p) {
this->event_.gattc.gattc_event = e;
this->event_.gattc.gattc_if = i;
if (p == nullptr) {
this->event_.gattc.gattc_param = nullptr;
this->event_.gattc.data = nullptr;
return; // Invalid event, but we can't log in header file
}
// Heap-allocate param and data
// Heap allocation is used because GATTC/GATTS events are rare (<1% of events)
// while GAP events (99%) are stored inline to minimize memory usage
// IMPORTANT: This heap allocation provides clear ownership semantics:
// - The BLEEvent owns the allocated memory for its lifetime
// - The data remains valid from the BLE callback context until processed in the main loop
// - Without this copy, we'd have use-after-free bugs as ESP-IDF reuses the callback memory
this->event_.gattc.gattc_param = new esp_ble_gattc_cb_param_t(*p);
// Copy data for events that need it
// The param struct contains pointers (e.g., notify.value) that point to temporary buffers.
// We must copy this data to ensure it remains valid when the event is processed later.
switch (e) {
case ESP_GATTC_NOTIFY_EVT:
this->event_.gattc.data = new std::vector<uint8_t>(p->notify.value, p->notify.value + p->notify.value_len);
this->event_.gattc.gattc_param->notify.value = this->event_.gattc.data->data();
break;
case ESP_GATTC_READ_CHAR_EVT:
case ESP_GATTC_READ_DESCR_EVT:
this->event_.gattc.data = new std::vector<uint8_t>(p->read.value, p->read.value + p->read.value_len);
this->event_.gattc.gattc_param->read.value = this->event_.gattc.data->data();
break;
default:
this->event_.gattc.data = nullptr;
break;
}
}
// Initialize GATTS event data
void init_gatts_data_(esp_gatts_cb_event_t e, esp_gatt_if_t i, esp_ble_gatts_cb_param_t *p) {
this->event_.gatts.gatts_event = e;
this->event_.gatts.gatts_if = i;
if (p == nullptr) {
this->event_.gatts.gatts_param = nullptr;
this->event_.gatts.data = nullptr;
return; // Invalid event, but we can't log in header file
}
// Heap-allocate param and data
// Heap allocation is used because GATTC/GATTS events are rare (<1% of events)
// while GAP events (99%) are stored inline to minimize memory usage
// IMPORTANT: This heap allocation provides clear ownership semantics:
// - The BLEEvent owns the allocated memory for its lifetime
// - The data remains valid from the BLE callback context until processed in the main loop
// - Without this copy, we'd have use-after-free bugs as ESP-IDF reuses the callback memory
this->event_.gatts.gatts_param = new esp_ble_gatts_cb_param_t(*p);
// Copy data for events that need it
// The param struct contains pointers (e.g., write.value) that point to temporary buffers.
// We must copy this data to ensure it remains valid when the event is processed later.
switch (e) {
case ESP_GATTS_WRITE_EVT:
this->event_.gatts.data = new std::vector<uint8_t>(p->write.value, p->write.value + p->write.len);
this->event_.gatts.gatts_param->write.value = this->event_.gatts.data->data();
break;
default:
this->event_.gatts.data = nullptr;
break;
}
}
};
// BLEEvent total size: 84 bytes (80 byte union + 1 byte type + 3 bytes padding)

View File

@@ -0,0 +1,72 @@
#pragma once
#ifdef USE_ESP32
#include <atomic>
#include <cstddef>
#include "ble_event.h"
#include "queue.h"
#include "esphome/core/helpers.h"
namespace esphome {
namespace esp32_ble {
// BLE Event Pool - On-demand pool of BLEEvent objects to avoid heap fragmentation
// Events are allocated on first use and reused thereafter, growing to peak usage
template<uint8_t SIZE> class BLEEventPool {
public:
BLEEventPool() : total_created_(0) {}
~BLEEventPool() {
// Clean up any remaining events in the free list
BLEEvent *event;
while ((event = this->free_list_.pop()) != nullptr) {
delete event;
}
}
// Allocate an event from the pool
// Returns nullptr if pool is full
BLEEvent *allocate() {
// Try to get from free list first
BLEEvent *event = this->free_list_.pop();
if (event != nullptr)
return event;
// Need to create a new event
if (this->total_created_ >= SIZE) {
// Pool is at capacity
return nullptr;
}
// Use internal RAM for better performance
RAMAllocator<BLEEvent> allocator(RAMAllocator<BLEEvent>::ALLOC_INTERNAL);
event = allocator.allocate(1);
if (event == nullptr) {
// Memory allocation failed
return nullptr;
}
// Placement new to construct the object
new (event) BLEEvent();
this->total_created_++;
return event;
}
// Return an event to the pool for reuse
void release(BLEEvent *event) {
if (event != nullptr) {
this->free_list_.push(event);
}
}
private:
LockFreeQueue<BLEEvent, SIZE> free_list_; // Free events ready for reuse
uint8_t total_created_; // Total events created (high water mark)
};
} // namespace esp32_ble
} // namespace esphome
#endif

View File

@@ -18,7 +18,7 @@
namespace esphome {
namespace esp32_ble {
template<class T, size_t SIZE> class LockFreeQueue {
template<class T, uint8_t SIZE> class LockFreeQueue {
public:
LockFreeQueue() : head_(0), tail_(0), dropped_count_(0) {}
@@ -26,8 +26,8 @@ template<class T, size_t SIZE> class LockFreeQueue {
if (element == nullptr)
return false;
size_t current_tail = tail_.load(std::memory_order_relaxed);
size_t next_tail = (current_tail + 1) % SIZE;
uint8_t current_tail = tail_.load(std::memory_order_relaxed);
uint8_t next_tail = (current_tail + 1) % SIZE;
if (next_tail == head_.load(std::memory_order_acquire)) {
// Buffer full
@@ -41,7 +41,7 @@ template<class T, size_t SIZE> class LockFreeQueue {
}
T *pop() {
size_t current_head = head_.load(std::memory_order_relaxed);
uint8_t current_head = head_.load(std::memory_order_relaxed);
if (current_head == tail_.load(std::memory_order_acquire)) {
return nullptr; // Empty
@@ -53,27 +53,30 @@ template<class T, size_t SIZE> class LockFreeQueue {
}
size_t size() const {
size_t tail = tail_.load(std::memory_order_acquire);
size_t head = head_.load(std::memory_order_acquire);
uint8_t tail = tail_.load(std::memory_order_acquire);
uint8_t head = head_.load(std::memory_order_acquire);
return (tail - head + SIZE) % SIZE;
}
size_t get_and_reset_dropped_count() { return dropped_count_.exchange(0, std::memory_order_relaxed); }
uint16_t get_and_reset_dropped_count() { return dropped_count_.exchange(0, std::memory_order_relaxed); }
void increment_dropped_count() { dropped_count_.fetch_add(1, std::memory_order_relaxed); }
bool empty() const { return head_.load(std::memory_order_acquire) == tail_.load(std::memory_order_acquire); }
bool full() const {
size_t next_tail = (tail_.load(std::memory_order_relaxed) + 1) % SIZE;
uint8_t next_tail = (tail_.load(std::memory_order_relaxed) + 1) % SIZE;
return next_tail == head_.load(std::memory_order_acquire);
}
protected:
T *buffer_[SIZE];
std::atomic<size_t> head_;
std::atomic<size_t> tail_;
std::atomic<size_t> dropped_count_;
// Atomic: written by producer (push/increment), read+reset by consumer (get_and_reset)
std::atomic<uint16_t> dropped_count_; // 65535 max - more than enough for drop tracking
// Atomic: written by consumer (pop), read by producer (push) to check if full
std::atomic<uint8_t> head_;
// Atomic: written by producer (push), read by consumer (pop) to check if empty
std::atomic<uint8_t> tail_;
};
} // namespace esp32_ble

View File

@@ -22,6 +22,16 @@ void BLEClientBase::setup() {
this->connection_index_ = connection_index++;
}
void BLEClientBase::set_state(espbt::ClientState st) {
ESP_LOGV(TAG, "[%d] [%s] Set state %d", this->connection_index_, this->address_str_.c_str(), (int) st);
ESPBTClient::set_state(st);
if (st == espbt::ClientState::READY_TO_CONNECT) {
// Enable loop when we need to connect
this->enable_loop();
}
}
void BLEClientBase::loop() {
if (!esp32_ble::global_ble->is_active()) {
this->set_state(espbt::ClientState::INIT);
@@ -37,9 +47,14 @@ void BLEClientBase::loop() {
}
// READY_TO_CONNECT means we have discovered the device
// and the scanner has been stopped by the tracker.
if (this->state_ == espbt::ClientState::READY_TO_CONNECT) {
else if (this->state_ == espbt::ClientState::READY_TO_CONNECT) {
this->connect();
}
// If its idle, we can disable the loop as set_state
// will enable it again when we need to connect.
else if (this->state_ == espbt::ClientState::IDLE) {
this->disable_loop();
}
}
float BLEClientBase::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; }

View File

@@ -93,6 +93,8 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
bool check_addr(esp_bd_addr_t &addr) { return memcmp(addr, this->remote_bda_, sizeof(esp_bd_addr_t)) == 0; }
void set_state(espbt::ClientState st) override;
protected:
// Memory optimized layout for 32-bit systems
// Group 1: 8-byte types

View File

@@ -1,25 +0,0 @@
#ifdef USE_ESP32
#include "esp32_hall.h"
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
#include <driver/adc.h>
namespace esphome {
namespace esp32_hall {
static const char *const TAG = "esp32_hall";
void ESP32HallSensor::update() {
adc1_config_width(ADC_WIDTH_BIT_12);
int value_int = hall_sensor_read();
float value = (value_int / 4095.0f) * 10000.0f;
ESP_LOGD(TAG, "'%s': Got reading %.0f µT", this->name_.c_str(), value);
this->publish_state(value);
}
std::string ESP32HallSensor::unique_id() { return get_mac_address() + "-hall"; }
void ESP32HallSensor::dump_config() { LOG_SENSOR("", "ESP32 Hall Sensor", this); }
} // namespace esp32_hall
} // namespace esphome
#endif

View File

@@ -1,23 +0,0 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
#ifdef USE_ESP32
namespace esphome {
namespace esp32_hall {
class ESP32HallSensor : public sensor::Sensor, public PollingComponent {
public:
void dump_config() override;
void update() override;
std::string unique_id() override;
};
} // namespace esp32_hall
} // namespace esphome
#endif

View File

@@ -1,24 +0,0 @@
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import ICON_MAGNET, STATE_CLASS_MEASUREMENT, UNIT_MICROTESLA
DEPENDENCIES = ["esp32"]
esp32_hall_ns = cg.esphome_ns.namespace("esp32_hall")
ESP32HallSensor = esp32_hall_ns.class_(
"ESP32HallSensor", sensor.Sensor, cg.PollingComponent
)
CONFIG_SCHEMA = sensor.sensor_schema(
ESP32HallSensor,
unit_of_measurement=UNIT_MICROTESLA,
icon=ICON_MAGNET,
accuracy_decimals=1,
state_class=STATE_CLASS_MEASUREMENT,
).extend(cv.polling_component_schema("60s"))
async def to_code(config):
var = await sensor.new_sensor(config)
await cg.register_component(var, config)

View File

@@ -168,6 +168,8 @@ void ESP32ImprovComponent::loop() {
case improv::STATE_PROVISIONED: {
this->incoming_data_.clear();
this->set_status_indicator_state_(false);
// Provisioning complete, no further loop execution needed
this->disable_loop();
break;
}
}
@@ -254,6 +256,7 @@ void ESP32ImprovComponent::start() {
ESP_LOGD(TAG, "Setting Improv to start");
this->should_start_ = true;
this->enable_loop();
}
void ESP32ImprovComponent::stop() {

View File

@@ -125,6 +125,6 @@ async def to_code(config):
cg.add(var.set_max_temperature(config[CONF_MAX_TEMPERATURE]))
cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE]))
cg.add_library("tonia/HeatpumpIR", "1.0.32")
cg.add_library("tonia/HeatpumpIR", "1.0.35")
if CORE.is_libretiny:
CORE.add_platformio_option("lib_ignore", "IRremoteESP8266")

View File

@@ -116,5 +116,5 @@ async def to_code(config):
cg.add_library("WiFiClientSecure", None)
cg.add_library("HTTPClient", None)
cg.add_library("esphome/ESP32-audioI2S", "2.2.0")
cg.add_library("esphome/ESP32-audioI2S", "2.3.0")
cg.add_build_flag("-DAUDIO_NO_SD_FS")

View File

@@ -17,7 +17,7 @@ namespace light {
class LightOutput;
enum LightRestoreMode {
enum LightRestoreMode : uint8_t {
LIGHT_RESTORE_DEFAULT_OFF,
LIGHT_RESTORE_DEFAULT_ON,
LIGHT_ALWAYS_OFF,
@@ -212,12 +212,18 @@ class LightState : public EntityBase, public Component {
/// Store the output to allow effects to have more access.
LightOutput *output_;
/// Value for storing the index of the currently active effect. 0 if no effect is active
uint32_t active_effect_index_{};
/// The currently active transformer for this light (transition/flash).
std::unique_ptr<LightTransformer> transformer_{nullptr};
/// Whether the light value should be written in the next cycle.
bool next_write_{true};
/// List of effects for this light.
std::vector<LightEffect *> effects_;
/// Value for storing the index of the currently active effect. 0 if no effect is active
uint32_t active_effect_index_{};
/// Default transition length for all transitions in ms.
uint32_t default_transition_length_{};
/// Transition length to use for flash transitions.
uint32_t flash_transition_length_{};
/// Gamma correction factor for the light.
float gamma_correct_{};
/// Object used to store the persisted values of the light.
ESPPreferenceObject rtc_;
@@ -236,19 +242,13 @@ class LightState : public EntityBase, public Component {
*/
CallbackManager<void()> target_state_reached_callback_{};
/// Default transition length for all transitions in ms.
uint32_t default_transition_length_{};
/// Transition length to use for flash transitions.
uint32_t flash_transition_length_{};
/// Gamma correction factor for the light.
float gamma_correct_{};
/// Restore mode of the light.
LightRestoreMode restore_mode_;
/// Initial state of the light.
optional<LightStateRTCState> initial_state_{};
/// List of effects for this light.
std::vector<LightEffect *> effects_;
/// Restore mode of the light.
LightRestoreMode restore_mode_;
/// Whether the light value should be written in the next cycle.
bool next_write_{true};
// for effects, true if a transformer (transition) is active.
bool is_transformer_active_ = false;
};

View File

@@ -178,18 +178,21 @@ void OnlineImage::update() {
if (this->format_ == ImageFormat::BMP) {
ESP_LOGD(TAG, "Allocating BMP decoder");
this->decoder_ = make_unique<BmpDecoder>(this);
this->enable_loop();
}
#endif // USE_ONLINE_IMAGE_BMP_SUPPORT
#ifdef USE_ONLINE_IMAGE_JPEG_SUPPORT
if (this->format_ == ImageFormat::JPEG) {
ESP_LOGD(TAG, "Allocating JPEG decoder");
this->decoder_ = esphome::make_unique<JpegDecoder>(this);
this->enable_loop();
}
#endif // USE_ONLINE_IMAGE_JPEG_SUPPORT
#ifdef USE_ONLINE_IMAGE_PNG_SUPPORT
if (this->format_ == ImageFormat::PNG) {
ESP_LOGD(TAG, "Allocating PNG decoder");
this->decoder_ = make_unique<PngDecoder>(this);
this->enable_loop();
}
#endif // USE_ONLINE_IMAGE_PNG_SUPPORT
@@ -212,6 +215,7 @@ void OnlineImage::update() {
void OnlineImage::loop() {
if (!this->decoder_) {
// Not decoding at the moment => nothing to do.
this->disable_loop();
return;
}
if (!this->downloader_ || this->decoder_->is_finished()) {

View File

@@ -12,6 +12,8 @@ class IntervalSyncer : public Component {
void setup() override {
if (this->write_interval_ != 0) {
set_interval(this->write_interval_, []() { global_preferences->sync(); });
// When using interval-based syncing, we don't need the loop
this->disable_loop();
}
}
void loop() override {

View File

@@ -40,7 +40,7 @@ class PrometheusHandler : public AsyncWebHandler, public Component {
*/
void add_label_name(EntityBase *obj, const std::string &value) { relabel_map_name_.insert({obj, value}); }
bool canHandle(AsyncWebServerRequest *request) override {
bool canHandle(AsyncWebServerRequest *request) const override {
if (request->method() == HTTP_GET) {
if (request->url() == "/metrics")
return true;

View File

@@ -142,8 +142,10 @@ void Rtttl::stop() {
}
void Rtttl::loop() {
if (this->note_duration_ == 0 || this->state_ == State::STATE_STOPPED)
if (this->note_duration_ == 0 || this->state_ == State::STATE_STOPPED) {
this->disable_loop();
return;
}
#ifdef USE_SPEAKER
if (this->speaker_ != nullptr) {
@@ -391,6 +393,11 @@ void Rtttl::set_state_(State state) {
this->state_ = state;
ESP_LOGV(TAG, "State changed from %s to %s", LOG_STR_ARG(state_to_string(old_state)),
LOG_STR_ARG(state_to_string(state)));
// Clear loop_done when transitioning from STOPPED to any other state
if (old_state == State::STATE_STOPPED && state != State::STATE_STOPPED) {
this->enable_loop();
}
}
} // namespace rtttl

View File

@@ -42,6 +42,8 @@ void SafeModeComponent::loop() {
ESP_LOGI(TAG, "Boot seems successful; resetting boot loop counter");
this->clean_rtc();
this->boot_successful_ = true;
// Disable loop since we no longer need to check
this->disable_loop();
}
}

View File

@@ -67,6 +67,12 @@ void SNTPComponent::loop() {
time.minute, time.second);
this->time_sync_callback_.call();
this->has_time_ = true;
#ifdef USE_ESP_IDF
// On ESP-IDF, time sync is permanent and update() doesn't force resync
// Time is now synchronized, no need to check anymore
this->disable_loop();
#endif
}
} // namespace sntp

View File

@@ -21,7 +21,7 @@ const int RESTORE_MODE_PERSISTENT_MASK = 0x02;
const int RESTORE_MODE_INVERTED_MASK = 0x04;
const int RESTORE_MODE_DISABLED_MASK = 0x08;
enum SwitchRestoreMode {
enum SwitchRestoreMode : uint8_t {
SWITCH_ALWAYS_OFF = !RESTORE_MODE_ON_MASK,
SWITCH_ALWAYS_ON = RESTORE_MODE_ON_MASK,
SWITCH_RESTORE_DEFAULT_OFF = RESTORE_MODE_PERSISTENT_MASK,
@@ -49,12 +49,12 @@ class Switch : public EntityBase, public EntityBase_DeviceClass {
*/
void publish_state(bool state);
/// The current reported state of the binary sensor.
bool state;
/// Indicates whether or not state is to be retrieved from flash and how
SwitchRestoreMode restore_mode{SWITCH_RESTORE_DEFAULT_OFF};
/// The current reported state of the binary sensor.
bool state;
/** Turn this switch on. This is called by the front-end.
*
* For implementing switches, please override write_state.
@@ -123,10 +123,16 @@ class Switch : public EntityBase, public EntityBase_DeviceClass {
*/
virtual void write_state(bool state) = 0;
CallbackManager<void(bool)> state_callback_{};
bool inverted_{false};
Deduplicator<bool> publish_dedup_;
// Pointer first (4 bytes)
ESPPreferenceObject rtc_;
// CallbackManager (12 bytes on 32-bit - contains vector)
CallbackManager<void(bool)> state_callback_{};
// Small types grouped together
Deduplicator<bool> publish_dedup_; // 2 bytes (bool has_value_ + bool last_value_)
bool inverted_{false}; // 1 byte
// Total: 3 bytes, 1 byte padding
};
#define LOG_SWITCH(prefix, type, obj) log_switch((TAG), (prefix), LOG_STR_LITERAL(type), (obj))

View File

@@ -24,8 +24,10 @@ void TLC5971::dump_config() {
}
void TLC5971::loop() {
if (!this->update_)
if (!this->update_) {
this->disable_loop();
return;
}
uint32_t command;
@@ -93,6 +95,7 @@ void TLC5971::set_channel_value(uint16_t channel, uint16_t value) {
return;
if (this->pwm_amounts_[channel] != value) {
this->update_ = true;
this->enable_loop();
}
this->pwm_amounts_[channel] = value;
}

View File

@@ -91,7 +91,7 @@ void DeferredUpdateEventSource::process_deferred_queue_() {
while (!deferred_queue_.empty()) {
DeferredEvent &de = deferred_queue_.front();
std::string message = de.message_generator_(web_server_, de.source_);
if (this->try_send(message.c_str(), "state")) {
if (this->send(message.c_str(), "state") != DISCARDED) {
// O(n) but memory efficiency is more important than speed here which is why std::vector was chosen
deferred_queue_.erase(deferred_queue_.begin());
} else {
@@ -131,7 +131,7 @@ void DeferredUpdateEventSource::deferrable_send_state(void *source, const char *
deq_push_back_with_dedup_(source, message_generator);
} else {
std::string message = message_generator(web_server_, source);
if (!this->try_send(message.c_str(), "state")) {
if (this->send(message.c_str(), "state") == DISCARDED) {
deq_push_back_with_dedup_(source, message_generator);
}
}
@@ -171,8 +171,8 @@ void DeferredUpdateEventSourceList::add_new_client(WebServer *ws, AsyncWebServer
ws->defer([this, ws, es]() { this->on_client_connect_(ws, es); });
});
es->onDisconnect([this, ws](AsyncEventSource *source, AsyncEventSourceClient *client) {
ws->defer([this, source]() { this->on_client_disconnect_((DeferredUpdateEventSource *) source); });
es->onDisconnect([this, ws, es](AsyncEventSourceClient *client) {
ws->defer([this, es]() { this->on_client_disconnect_((DeferredUpdateEventSource *) es); });
});
es->handleRequest(request);
@@ -291,14 +291,23 @@ float WebServer::get_setup_priority() const { return setup_priority::WIFI - 1.0f
#ifdef USE_WEBSERVER_LOCAL
void WebServer::handle_index_request(AsyncWebServerRequest *request) {
#ifndef USE_ESP8266
AsyncWebServerResponse *response = request->beginResponse(200, "text/html", INDEX_GZ, sizeof(INDEX_GZ));
#else
AsyncWebServerResponse *response = request->beginResponse_P(200, "text/html", INDEX_GZ, sizeof(INDEX_GZ));
#endif
response->addHeader("Content-Encoding", "gzip");
request->send(response);
}
#elif USE_WEBSERVER_VERSION >= 2
void WebServer::handle_index_request(AsyncWebServerRequest *request) {
#ifndef USE_ESP8266
AsyncWebServerResponse *response =
request->beginResponse(200, "text/html", ESPHOME_WEBSERVER_INDEX_HTML, ESPHOME_WEBSERVER_INDEX_HTML_SIZE);
#else
AsyncWebServerResponse *response =
request->beginResponse_P(200, "text/html", ESPHOME_WEBSERVER_INDEX_HTML, ESPHOME_WEBSERVER_INDEX_HTML_SIZE);
#endif
// No gzip header here because the HTML file is so small
request->send(response);
}
@@ -317,8 +326,13 @@ void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) {
#ifdef USE_WEBSERVER_CSS_INCLUDE
void WebServer::handle_css_request(AsyncWebServerRequest *request) {
#ifndef USE_ESP8266
AsyncWebServerResponse *response =
request->beginResponse(200, "text/css", ESPHOME_WEBSERVER_CSS_INCLUDE, ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE);
#else
AsyncWebServerResponse *response =
request->beginResponse_P(200, "text/css", ESPHOME_WEBSERVER_CSS_INCLUDE, ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE);
#endif
response->addHeader("Content-Encoding", "gzip");
request->send(response);
}
@@ -326,8 +340,13 @@ void WebServer::handle_css_request(AsyncWebServerRequest *request) {
#ifdef USE_WEBSERVER_JS_INCLUDE
void WebServer::handle_js_request(AsyncWebServerRequest *request) {
#ifndef USE_ESP8266
AsyncWebServerResponse *response =
request->beginResponse(200, "text/javascript", ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE);
#else
AsyncWebServerResponse *response =
request->beginResponse_P(200, "text/javascript", ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE);
#endif
response->addHeader("Content-Encoding", "gzip");
request->send(response);
}
@@ -1837,7 +1856,7 @@ std::string WebServer::update_json(update::UpdateEntity *obj, JsonDetail start_c
}
#endif
bool WebServer::canHandle(AsyncWebServerRequest *request) {
bool WebServer::canHandle(AsyncWebServerRequest *request) const {
if (request->url() == "/")
return true;
@@ -1859,12 +1878,6 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) {
#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS
if (request->method() == HTTP_OPTIONS && request->hasHeader(HEADER_CORS_REQ_PNA)) {
#ifdef USE_ARDUINO
// Header needs to be added to interesting header list for it to not be
// nuked by the time we handle the request later.
// Only required in Arduino framework.
request->addInterestingHeader(HEADER_CORS_REQ_PNA);
#endif
return true;
}
#endif
@@ -2145,7 +2158,7 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) {
#endif
}
bool WebServer::isRequestHandlerTrivial() { return false; }
bool WebServer::isRequestHandlerTrivial() const { return false; }
void WebServer::add_entity_config(EntityBase *entity, float weight, uint64_t group) {
this->sorting_entitys_[entity] = SortingComponents{weight, group};

View File

@@ -99,7 +99,7 @@ class DeferredUpdateEventSource : public AsyncEventSource {
protected:
// surface a couple methods from the base class
using AsyncEventSource::handleRequest;
using AsyncEventSource::try_send;
using AsyncEventSource::send;
ListEntitiesIterator entities_iterator_;
// vector is used very specifically for its zero memory overhead even though items are popped from the front (memory
@@ -468,11 +468,11 @@ class WebServer : public Controller, public Component, public AsyncWebHandler {
#endif
/// Override the web handler's canHandle method.
bool canHandle(AsyncWebServerRequest *request) override;
bool canHandle(AsyncWebServerRequest *request) const override;
/// Override the web handler's handleRequest method.
void handleRequest(AsyncWebServerRequest *request) override;
/// This web handle is not trivial.
bool isRequestHandlerTrivial() override; // NOLINT(readability-identifier-naming)
bool isRequestHandlerTrivial() const override; // NOLINT(readability-identifier-naming)
void add_entity_config(EntityBase *entity, float weight, uint64_t group);
void add_sorting_group(uint64_t group_id, const std::string &group_name, float weight);

View File

@@ -36,5 +36,7 @@ async def to_code(config):
cg.add_library("WiFi", None)
cg.add_library("FS", None)
cg.add_library("Update", None)
# https://github.com/esphome/ESPAsyncWebServer/blob/master/library.json
cg.add_library("esphome/ESPAsyncWebServer-esphome", "3.3.0")
if CORE.is_esp8266:
cg.add_library("ESP8266WiFi", None)
# https://github.com/ESP32Async/ESPAsyncWebServer/blob/main/library.json
cg.add_library("ESP32Async/ESPAsyncWebServer", "3.7.8")

View File

@@ -23,7 +23,7 @@ class MiddlewareHandler : public AsyncWebHandler {
public:
MiddlewareHandler(AsyncWebHandler *next) : next_(next) {}
bool canHandle(AsyncWebServerRequest *request) override { return next_->canHandle(request); }
bool canHandle(AsyncWebServerRequest *request) const override { return next_->canHandle(request); }
void handleRequest(AsyncWebServerRequest *request) override { next_->handleRequest(request); }
void handleUpload(AsyncWebServerRequest *request, const String &filename, size_t index, uint8_t *data, size_t len,
bool final) override {
@@ -32,7 +32,7 @@ class MiddlewareHandler : public AsyncWebHandler {
void handleBody(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) override {
next_->handleBody(request, data, len, index, total);
}
bool isRequestHandlerTrivial() override { return next_->isRequestHandlerTrivial(); }
bool isRequestHandlerTrivial() const override { return next_->isRequestHandlerTrivial(); }
protected:
AsyncWebHandler *next_;
@@ -131,12 +131,12 @@ class OTARequestHandler : public AsyncWebHandler {
void handleRequest(AsyncWebServerRequest *request) override;
void handleUpload(AsyncWebServerRequest *request, const String &filename, size_t index, uint8_t *data, size_t len,
bool final) override;
bool canHandle(AsyncWebServerRequest *request) override {
bool canHandle(AsyncWebServerRequest *request) const override {
return request->url() == "/update" && request->method() == HTTP_POST;
}
// NOLINTNEXTLINE(readability-identifier-naming)
bool isRequestHandlerTrivial() override { return false; }
bool isRequestHandlerTrivial() const override { return false; }
protected:
uint32_t last_ota_progress_{0};

View File

@@ -135,7 +135,7 @@ class AsyncWebServerRequest {
return res;
}
// NOLINTNEXTLINE(readability-identifier-naming)
AsyncWebServerResponse *beginResponse_P(int code, const char *content_type, const uint8_t *data,
AsyncWebServerResponse *beginResponse(int code, const char *content_type, const uint8_t *data,
const size_t data_size) {
auto *res = new AsyncWebServerResponseProgmem(this, data, data_size); // NOLINT(cppcoreguidelines-owning-memory)
this->init_response_(res, code, content_type);
@@ -211,7 +211,7 @@ class AsyncWebHandler {
public:
virtual ~AsyncWebHandler() {}
// NOLINTNEXTLINE(readability-identifier-naming)
virtual bool canHandle(AsyncWebServerRequest *request) { return false; }
virtual bool canHandle(AsyncWebServerRequest *request) const { return false; }
// NOLINTNEXTLINE(readability-identifier-naming)
virtual void handleRequest(AsyncWebServerRequest *request) {}
// NOLINTNEXTLINE(readability-identifier-naming)
@@ -220,7 +220,7 @@ class AsyncWebHandler {
// NOLINTNEXTLINE(readability-identifier-naming)
virtual void handleBody(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {}
// NOLINTNEXTLINE(readability-identifier-naming)
virtual bool isRequestHandlerTrivial() { return true; }
virtual bool isRequestHandlerTrivial() const { return true; }
};
#ifdef USE_WEBSERVER
@@ -290,7 +290,7 @@ class AsyncEventSource : public AsyncWebHandler {
~AsyncEventSource() override;
// NOLINTNEXTLINE(readability-identifier-naming)
bool canHandle(AsyncWebServerRequest *request) override {
bool canHandle(AsyncWebServerRequest *request) const override {
return request->method() == HTTP_GET && request->url() == this->url_;
}
// NOLINTNEXTLINE(readability-identifier-naming)

View File

@@ -91,6 +91,13 @@ bool parse_xiaomi_value(uint16_t value_type, const uint8_t *data, uint8_t value_
// MiaoMiaoce humidity, 1 byte, 8-bit unsigned integer, 1 %
else if ((value_type == 0x4C02) && (value_length == 1)) {
result.humidity = data[0];
}
// XMWSDJ04MMC humidity, 4 bytes, float, 0.1 °C
else if ((value_type == 0x4C08) && (value_length == 4)) {
const uint32_t int_number = encode_uint32(data[3], data[2], data[1], data[0]);
float humidity;
std::memcpy(&humidity, &int_number, sizeof(humidity));
result.humidity = humidity;
} else {
return false;
}
@@ -219,6 +226,11 @@ optional<XiaomiParseResult> parse_xiaomi_header(const esp32_ble_tracker::Service
} else if (device_uuid == 0x055b) { // small square body, segment LCD, encrypted
result.type = XiaomiParseResult::TYPE_LYWSD03MMC;
result.name = "LYWSD03MMC";
} else if (device_uuid == 0x1203) { // small square body, e-ink display, encrypted
result.type = XiaomiParseResult::TYPE_XMWSDJ04MMC;
result.name = "XMWSDJ04MMC";
if (raw.size() == 19)
result.raw_offset -= 6;
} else if (device_uuid == 0x07f6) { // Xiaomi-Yeelight BLE nightlight
result.type = XiaomiParseResult::TYPE_MJYD02YLA;
result.name = "MJYD02YLA";

View File

@@ -20,6 +20,7 @@ struct XiaomiParseResult {
TYPE_LYWSD02MMC,
TYPE_CGG1,
TYPE_LYWSD03MMC,
TYPE_XMWSDJ04MMC,
TYPE_CGD1,
TYPE_CGDK2,
TYPE_JQJCY01YM,

View File

@@ -0,0 +1,77 @@
import esphome.codegen as cg
from esphome.components import esp32_ble_tracker, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BATTERY_LEVEL,
CONF_BINDKEY,
CONF_HUMIDITY,
CONF_ID,
CONF_MAC_ADDRESS,
CONF_TEMPERATURE,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
ENTITY_CATEGORY_DIAGNOSTIC,
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
UNIT_PERCENT,
)
AUTO_LOAD = ["xiaomi_ble"]
CODEOWNERS = ["@medusalix"]
DEPENDENCIES = ["esp32_ble_tracker"]
xiaomi_xmwsdj04mmc_ns = cg.esphome_ns.namespace("xiaomi_xmwsdj04mmc")
XiaomiXMWSDJ04MMC = xiaomi_xmwsdj04mmc_ns.class_(
"XiaomiXMWSDJ04MMC", esp32_ble_tracker.ESPBTDeviceListener, cg.Component
)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(XiaomiXMWSDJ04MMC),
cv.Required(CONF_BINDKEY): cv.bind_key,
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_HUMIDITY): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=0,
device_class=DEVICE_CLASS_HUMIDITY,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=0,
device_class=DEVICE_CLASS_BATTERY,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await esp32_ble_tracker.register_ble_device(var, config)
cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
cg.add(var.set_bindkey(config[CONF_BINDKEY]))
if temperature_config := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature_config)
cg.add(var.set_temperature(sens))
if humidity_config := config.get(CONF_HUMIDITY):
sens = await sensor.new_sensor(humidity_config)
cg.add(var.set_humidity(sens))
if battery_level_config := config.get(CONF_BATTERY_LEVEL):
sens = await sensor.new_sensor(battery_level_config)
cg.add(var.set_battery_level(sens))

View File

@@ -0,0 +1,77 @@
#include "xiaomi_xmwsdj04mmc.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
namespace esphome {
namespace xiaomi_xmwsdj04mmc {
static const char *const TAG = "xiaomi_xmwsdj04mmc";
void XiaomiXMWSDJ04MMC::dump_config() {
ESP_LOGCONFIG(TAG, "Xiaomi XMWSDJ04MMC");
ESP_LOGCONFIG(TAG, " Bindkey: %s", format_hex_pretty(this->bindkey_, 16).c_str());
LOG_SENSOR(" ", "Temperature", this->temperature_);
LOG_SENSOR(" ", "Humidity", this->humidity_);
LOG_SENSOR(" ", "Battery Level", this->battery_level_);
}
bool XiaomiXMWSDJ04MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
if (device.address_uint64() != this->address_) {
ESP_LOGVV(TAG, "parse_device(): unknown MAC address.");
return false;
}
ESP_LOGVV(TAG, "parse_device(): MAC address %s found.", device.address_str().c_str());
bool success = false;
for (auto &service_data : device.get_service_datas()) {
auto res = xiaomi_ble::parse_xiaomi_header(service_data);
if (!res.has_value()) {
continue;
}
if (res->is_duplicate) {
continue;
}
if (res->has_encryption &&
(!(xiaomi_ble::decrypt_xiaomi_payload(const_cast<std::vector<uint8_t> &>(service_data.data), this->bindkey_,
this->address_)))) {
continue;
}
if (!(xiaomi_ble::parse_xiaomi_message(service_data.data, *res))) {
continue;
}
if (res->humidity.has_value() && this->humidity_ != nullptr) {
// see https://github.com/custom-components/sensor.mitemp_bt/issues/7#issuecomment-595948254
*res->humidity = trunc(*res->humidity);
}
if (!(xiaomi_ble::report_xiaomi_results(res, device.address_str()))) {
continue;
}
if (res->temperature.has_value() && this->temperature_ != nullptr)
this->temperature_->publish_state(*res->temperature);
if (res->humidity.has_value() && this->humidity_ != nullptr)
this->humidity_->publish_state(*res->humidity);
if (res->battery_level.has_value() && this->battery_level_ != nullptr)
this->battery_level_->publish_state(*res->battery_level);
success = true;
}
return success;
}
void XiaomiXMWSDJ04MMC::set_bindkey(const std::string &bindkey) {
memset(this->bindkey_, 0, 16);
if (bindkey.size() != 32) {
return;
}
char temp[3] = {0};
for (int i = 0; i < 16; i++) {
strncpy(temp, &(bindkey.c_str()[i * 2]), 2);
this->bindkey_[i] = std::strtoul(temp, nullptr, 16);
}
}
} // namespace xiaomi_xmwsdj04mmc
} // namespace esphome
#endif

View File

@@ -0,0 +1,37 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/components/xiaomi_ble/xiaomi_ble.h"
#ifdef USE_ESP32
namespace esphome {
namespace xiaomi_xmwsdj04mmc {
class XiaomiXMWSDJ04MMC : public Component, public esp32_ble_tracker::ESPBTDeviceListener {
public:
void set_address(uint64_t address) { this->address_ = address; }
void set_bindkey(const std::string &bindkey);
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::DATA; }
void set_temperature(sensor::Sensor *temperature) { this->temperature_ = temperature; }
void set_humidity(sensor::Sensor *humidity) { this->humidity_ = humidity; }
void set_battery_level(sensor::Sensor *battery_level) { this->battery_level_ = battery_level; }
protected:
uint64_t address_;
uint8_t bindkey_[16];
sensor::Sensor *temperature_{nullptr};
sensor::Sensor *humidity_{nullptr};
sensor::Sensor *battery_level_{nullptr};
};
} // namespace xiaomi_xmwsdj04mmc
} // namespace esphome
#endif

View File

@@ -97,7 +97,13 @@ void Application::loop() {
// Feed WDT with time
this->feed_wdt(last_op_end_time);
for (Component *component : this->looping_components_) {
// Mark that we're in the loop for safe reentrant modifications
this->in_loop_ = true;
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;
@@ -112,6 +118,8 @@ void Application::loop() {
this->app_state_ |= new_app_state;
this->feed_wdt(last_op_end_time);
}
this->in_loop_ = false;
this->app_state_ = new_app_state;
// Use the last component's end time instead of calling millis() again
@@ -235,10 +243,67 @@ void Application::teardown_components(uint32_t timeout_ms) {
}
void Application::calculate_looping_components_() {
// First add all active components
for (auto *obj : this->components_) {
if (obj->has_overridden_loop())
if (obj->has_overridden_loop() &&
(obj->get_component_state() & COMPONENT_STATE_MASK) != COMPONENT_STATE_LOOP_DONE) {
this->looping_components_.push_back(obj);
}
}
this->looping_components_active_end_ = this->looping_components_.size();
// Then add all inactive (LOOP_DONE) components
// This handles components that called disable_loop() during setup, before this method runs
for (auto *obj : this->components_) {
if (obj->has_overridden_loop() &&
(obj->get_component_state() & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE) {
this->looping_components_.push_back(obj);
}
}
}
void Application::disable_component_loop_(Component *component) {
// This method must be reentrant - components can disable themselves during their own loop() call
// Linear search to find component in active section
// Most configs have 10-30 looping components (30 is on the high end)
// O(n) is acceptable here as we optimize for memory, not complexity
for (uint16_t i = 0; i < this->looping_components_active_end_; i++) {
if (this->looping_components_[i] == component) {
// Move last active component to this position
this->looping_components_active_end_--;
if (i != this->looping_components_active_end_) {
std::swap(this->looping_components_[i], this->looping_components_[this->looping_components_active_end_]);
// If we're currently iterating and just swapped the current position
if (this->in_loop_ && i == this->current_loop_index_) {
// Decrement so we'll process the swapped component next
this->current_loop_index_--;
}
}
return;
}
}
}
void Application::enable_component_loop_(Component *component) {
// This method must be reentrant - components can re-enable themselves during their own loop() call
// Single pass through all components to find and move if needed
// With typical 10-30 components, O(n) is faster than maintaining a map
const uint16_t size = this->looping_components_.size();
for (uint16_t i = 0; i < size; i++) {
if (this->looping_components_[i] == component) {
if (i < this->looping_components_active_end_) {
return; // Already active
}
// Found in inactive section - move to active
if (i != this->looping_components_active_end_) {
std::swap(this->looping_components_[i], this->looping_components_[this->looping_components_active_end_]);
}
this->looping_components_active_end_++;
return;
}
}
}
#ifdef USE_SOCKET_SELECT_SUPPORT

View File

@@ -572,13 +572,41 @@ class Application {
void calculate_looping_components_();
// These methods are called by Component::disable_loop() and Component::enable_loop()
// Components should not call these directly - use this->disable_loop() or this->enable_loop()
// to ensure component state is properly updated along with the loop partition
void disable_component_loop_(Component *component);
void enable_component_loop_(Component *component);
void feed_wdt_arch_();
/// Perform a delay while also monitoring socket file descriptors for readiness
void yield_with_select_(uint32_t delay_ms);
std::vector<Component *> components_{};
// Partitioned vector design for looping components
// =================================================
// Components are partitioned into [active | inactive] sections:
//
// looping_components_: [A, B, C, D | E, F]
// ^
// looping_components_active_end_ (4)
//
// - Components A,B,C,D are active and will be called in loop()
// - Components E,F are inactive (disabled/failed) and won't be called
// - No flag checking needed during iteration - just loop 0 to active_end_
// - When a component is disabled, it's swapped with the last active component
// and active_end_ is decremented
// - When a component is enabled, it's swapped with the first inactive component
// and active_end_ is incremented
// - This eliminates branch mispredictions from flag checking in the hot loop
std::vector<Component *> looping_components_{};
uint16_t looping_components_active_end_{0};
// For safe reentrant modifications during iteration
uint16_t current_loop_index_{0};
bool in_loop_{false};
#ifdef USE_BINARY_SENSOR
std::vector<binary_sensor::BinarySensor *> binary_sensors_{};

View File

@@ -30,17 +30,18 @@ const float LATE = -100.0f;
} // namespace setup_priority
// Component state uses bits 0-1 (4 states)
const uint8_t COMPONENT_STATE_MASK = 0x03;
// Component state uses bits 0-2 (8 states, 5 used)
const uint8_t COMPONENT_STATE_MASK = 0x07;
const uint8_t COMPONENT_STATE_CONSTRUCTION = 0x00;
const uint8_t COMPONENT_STATE_SETUP = 0x01;
const uint8_t COMPONENT_STATE_LOOP = 0x02;
const uint8_t COMPONENT_STATE_FAILED = 0x03;
// Status LED uses bits 2-3
const uint8_t STATUS_LED_MASK = 0x0C;
const uint8_t COMPONENT_STATE_LOOP_DONE = 0x04;
// Status LED uses bits 3-4
const uint8_t STATUS_LED_MASK = 0x18;
const uint8_t STATUS_LED_OK = 0x00;
const uint8_t STATUS_LED_WARNING = 0x04; // Bit 2
const uint8_t STATUS_LED_ERROR = 0x08; // Bit 3
const uint8_t STATUS_LED_WARNING = 0x08; // Bit 3
const uint8_t STATUS_LED_ERROR = 0x10; // Bit 4
const uint16_t WARN_IF_BLOCKING_OVER_MS = 50U; ///< Initial blocking time allowed without warning
const uint16_t WARN_IF_BLOCKING_INCREMENT_MS = 10U; ///< How long the blocking time must be larger to warn again
@@ -113,6 +114,9 @@ void Component::call() {
case COMPONENT_STATE_FAILED: // NOLINT(bugprone-branch-clone)
// State failed: Do nothing
break;
case COMPONENT_STATE_LOOP_DONE: // NOLINT(bugprone-branch-clone)
// State loop done: Do nothing, component has finished its work
break;
default:
break;
}
@@ -136,14 +140,30 @@ bool Component::should_warn_of_blocking(uint32_t blocking_time) {
return false;
}
void Component::mark_failed() {
ESP_LOGE(TAG, "Component %s was marked as failed.", this->get_component_source());
ESP_LOGE(TAG, "Component %s was marked as failed", this->get_component_source());
this->component_state_ &= ~COMPONENT_STATE_MASK;
this->component_state_ |= COMPONENT_STATE_FAILED;
this->status_set_error();
// Also remove from loop since failed components shouldn't loop
App.disable_component_loop_(this);
}
void Component::disable_loop() {
ESP_LOGD(TAG, "%s loop disabled", this->get_component_source());
this->component_state_ &= ~COMPONENT_STATE_MASK;
this->component_state_ |= COMPONENT_STATE_LOOP_DONE;
App.disable_component_loop_(this);
}
void Component::enable_loop() {
if ((this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE) {
ESP_LOGD(TAG, "%s loop enabled", this->get_component_source());
this->component_state_ &= ~COMPONENT_STATE_MASK;
this->component_state_ |= COMPONENT_STATE_LOOP;
App.enable_component_loop_(this);
}
}
void Component::reset_to_construction_state() {
if ((this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_FAILED) {
ESP_LOGI(TAG, "Component %s is being reset to construction state.", this->get_component_source());
ESP_LOGI(TAG, "Component %s is being reset to construction state", this->get_component_source());
this->component_state_ &= ~COMPONENT_STATE_MASK;
this->component_state_ |= COMPONENT_STATE_CONSTRUCTION;
// Clear error status when resetting
@@ -276,8 +296,8 @@ uint32_t WarnIfComponentBlockingGuard::finish() {
}
if (should_warn) {
const char *src = component_ == nullptr ? "<null>" : component_->get_component_source();
ESP_LOGW(TAG, "Component %s took a long time for an operation (%" PRIu32 " ms).", src, blocking_time);
ESP_LOGW(TAG, "Components should block for at most 30 ms.");
ESP_LOGW(TAG, "Component %s took a long time for an operation (%" PRIu32 " ms)", src, blocking_time);
ESP_LOGW(TAG, "Components should block for at most 30 ms");
}
return curr_time;

View File

@@ -58,6 +58,7 @@ extern const uint8_t COMPONENT_STATE_CONSTRUCTION;
extern const uint8_t COMPONENT_STATE_SETUP;
extern const uint8_t COMPONENT_STATE_LOOP;
extern const uint8_t COMPONENT_STATE_FAILED;
extern const uint8_t COMPONENT_STATE_LOOP_DONE;
extern const uint8_t STATUS_LED_MASK;
extern const uint8_t STATUS_LED_OK;
extern const uint8_t STATUS_LED_WARNING;
@@ -150,6 +151,26 @@ class Component {
this->mark_failed();
}
/** Disable this component's loop. The loop() method will no longer be called.
*
* This is useful for components that only need to run for a certain period of time
* or when inactive, saving CPU cycles.
*
* @note Components should call this->disable_loop() on themselves, not on other components.
* This ensures the component's state is properly updated along with the loop partition.
*/
void disable_loop();
/** Enable this component's loop. The loop() method will be called normally.
*
* This is useful for components that transition between active and inactive states
* and need to re-enable their loop() method when becoming active again.
*
* @note Components should call this->enable_loop() on themselves, not on other components.
* This ensures the component's state is properly updated along with the loop partition.
*/
void enable_loop();
bool is_failed() const;
bool is_ready() const;

View File

@@ -29,7 +29,9 @@ Component = esphome_ns.class_("Component")
ComponentPtr = Component.operator("ptr")
PollingComponent = esphome_ns.class_("PollingComponent", Component)
Application = esphome_ns.class_("Application")
optional = esphome_ns.class_("optional")
# Create optional with explicit namespace to avoid ambiguity with std::optional
# The generated code will use esphome::optional instead of just optional
optional = global_ns.namespace("esphome").class_("optional")
arduino_json_ns = global_ns.namespace("ArduinoJson")
JsonObject = arduino_json_ns.class_("JsonObject")
JsonObjectConst = arduino_json_ns.class_("JsonObjectConst")

View File

@@ -65,14 +65,14 @@ lib_deps =
SPI ; spi (Arduino built-in)
Wire ; i2c (Arduino built-int)
heman/AsyncMqttClient-esphome@1.0.0 ; mqtt
esphome/ESPAsyncWebServer-esphome@3.3.0 ; web_server_base
ESP32Async/ESPAsyncWebServer@3.7.8 ; web_server_base
fastled/FastLED@3.9.16 ; fastled_base
mikalhart/TinyGPSPlus@1.1.0 ; gps
freekode/TM1651@1.0.1 ; tm1651
glmnet/Dsmr@0.7 ; dsmr
rweather/Crypto@0.4.0 ; dsmr
dudanov/MideaUART@1.1.9 ; midea
tonia/HeatpumpIR@1.0.32 ; heatpumpir
tonia/HeatpumpIR@1.0.35 ; heatpumpir
build_flags =
${common.build_flags}
-DUSE_ARDUINO
@@ -100,7 +100,7 @@ lib_deps =
${common:arduino.lib_deps}
ESP8266WiFi ; wifi (Arduino built-in)
Update ; ota (Arduino built-in)
esphome/ESPAsyncTCP-esphome@2.0.0 ; async_tcp
ESP32Async/ESPAsyncTCP@2.0.0 ; async_tcp
ESP8266HTTPClient ; http_request (Arduino built-in)
ESP8266mDNS ; mdns (Arduino built-in)
DNSServer ; captive_portal (Arduino built-in)
@@ -130,12 +130,12 @@ lib_deps =
WiFi ; wifi,web_server_base,ethernet (Arduino built-in)
Update ; ota,web_server_base (Arduino built-in)
${common:arduino.lib_deps}
esphome/AsyncTCP-esphome@2.1.4 ; async_tcp
ESP32Async/AsyncTCP@3.4.4 ; async_tcp
WiFiClientSecure ; http_request,nextion (Arduino built-in)
HTTPClient ; http_request,nextion (Arduino built-in)
ESPmDNS ; mdns (Arduino built-in)
DNSServer ; captive_portal (Arduino built-in)
esphome/ESP32-audioI2S@2.2.0 ; i2s_audio
esphome/ESP32-audioI2S@2.3.0 ; i2s_audio
droscy/esp_wireguard@0.4.2 ; wireguard
esphome/esp-audio-libs@1.1.4 ; audio
@@ -200,7 +200,7 @@ build_unflags =
; This are common settings for the LibreTiny (all variants) using Arduino.
[common:libretiny-arduino]
extends = common:arduino
platform = libretiny
platform = libretiny@1.9.1
framework = arduino
lib_deps =
droscy/esp_wireguard@0.4.2 ; wireguard

View File

@@ -120,10 +120,12 @@ select = [
ignore = [
"E501", # line too long
"PLC0415", # `import` should be at the top-level of a file
"PLR0911", # Too many return statements ({returns} > {max_returns})
"PLR0912", # Too many branches ({branches} > {max_branches})
"PLR0913", # Too many arguments to function call ({c_args} > {max_args})
"PLR0915", # Too many statements ({statements} > {max_statements})
"PLW1641", # Object does not implement `__hash__` method
"PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable
"PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target
"UP038", # https://github.com/astral-sh/ruff/issues/7871 https://github.com/astral-sh/ruff/pull/16681

View File

@@ -1,6 +1,6 @@
pylint==3.3.7
flake8==7.2.0 # also change in .pre-commit-config.yaml when updating
ruff==0.11.13 # also change in .pre-commit-config.yaml when updating
ruff==0.12.0 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating
pre-commit

View File

@@ -66,5 +66,5 @@ def test_text_config_lamda_is_set(generate_main):
main_cpp = generate_main("tests/component_tests/text/test_text.yaml")
# Then
assert "it_4->set_template([=]() -> optional<std::string> {" in main_cpp
assert "it_4->set_template([=]() -> esphome::optional<std::string> {" in main_cpp
assert 'return std::string{"Hello"};' in main_cpp

View File

@@ -1,3 +0,0 @@
sensor:
- platform: esp32_hall
name: ESP32 Hall Sensor

View File

@@ -0,0 +1,12 @@
esp32_ble_tracker:
sensor:
- platform: xiaomi_xmwsdj04mmc
mac_address: 84:B4:DB:5D:A3:8F
bindkey: d8ca2ed09bb5541dc8f045ca360b00ea
temperature:
name: Xiaomi XMWSDJ04MMC Temperature
humidity:
name: Xiaomi XMWSDJ04MMC Humidity
battery_level:
name: Xiaomi XMWSDJ04MMC Battery Level

View File

@@ -0,0 +1 @@
<<: !include common.yaml

View File

@@ -0,0 +1 @@
<<: !include common.yaml

View File

@@ -0,0 +1 @@
<<: !include common.yaml

View File

@@ -0,0 +1 @@
<<: !include common.yaml

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Generator
from collections.abc import AsyncGenerator, Callable, Generator
from contextlib import AbstractAsyncContextManager, asynccontextmanager
import logging
import os
@@ -46,6 +46,7 @@ if platform.system() == "Windows":
"Integration tests are not supported on Windows", allow_module_level=True
)
import pty # not available on Windows
@@ -362,7 +363,10 @@ async def api_client_connected(
async def _read_stream_lines(
stream: asyncio.StreamReader, lines: list[str], output_stream: TextIO
stream: asyncio.StreamReader,
lines: list[str],
output_stream: TextIO,
line_callback: Callable[[str], None] | None = None,
) -> None:
"""Read lines from a stream, append to list, and echo to output stream."""
log_parser = LogParser()
@@ -380,6 +384,9 @@ async def _read_stream_lines(
file=output_stream,
flush=True,
)
# Call the callback if provided
if line_callback:
line_callback(decoded_line.rstrip())
@asynccontextmanager
@@ -388,6 +395,7 @@ async def run_binary_and_wait_for_port(
host: str,
port: int,
timeout: float = PORT_WAIT_TIMEOUT,
line_callback: Callable[[str], None] | None = None,
) -> AsyncGenerator[None]:
"""Run a binary, wait for it to open a port, and clean up on exit."""
# Create a pseudo-terminal to make the binary think it's running interactively
@@ -435,7 +443,9 @@ async def run_binary_and_wait_for_port(
# Read from output stream
output_tasks = [
asyncio.create_task(
_read_stream_lines(output_reader, stdout_lines, sys.stdout)
_read_stream_lines(
output_reader, stdout_lines, sys.stdout, line_callback
)
)
]
@@ -515,6 +525,7 @@ async def run_compiled_context(
compile_esphome: CompileFunction,
port: int,
port_socket: socket.socket | None = None,
line_callback: Callable[[str], None] | None = None,
) -> AsyncGenerator[None]:
"""Context manager to write, compile and run an ESPHome configuration."""
# Write the YAML config
@@ -528,7 +539,9 @@ async def run_compiled_context(
port_socket.close()
# Run the binary and wait for the API server to start
async with run_binary_and_wait_for_port(binary_path, LOCALHOST, port):
async with run_binary_and_wait_for_port(
binary_path, LOCALHOST, port, line_callback=line_callback
):
yield
@@ -542,7 +555,9 @@ async def run_compiled(
port, port_socket = reserved_tcp_port
def _run_compiled(
yaml_content: str, filename: str | None = None
yaml_content: str,
filename: str | None = None,
line_callback: Callable[[str], None] | None = None,
) -> AbstractAsyncContextManager[asyncio.subprocess.Process]:
return run_compiled_context(
yaml_content,
@@ -551,6 +566,7 @@ async def run_compiled(
compile_esphome,
port,
port_socket,
line_callback=line_callback,
)
yield _run_compiled

View File

@@ -0,0 +1,78 @@
from esphome import automation
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_COMPONENTS, CONF_ID, CONF_NAME
CODEOWNERS = ["@esphome/tests"]
loop_test_component_ns = cg.esphome_ns.namespace("loop_test_component")
LoopTestComponent = loop_test_component_ns.class_("LoopTestComponent", cg.Component)
CONF_DISABLE_AFTER = "disable_after"
CONF_TEST_REDUNDANT_OPERATIONS = "test_redundant_operations"
COMPONENT_CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(LoopTestComponent),
cv.Required(CONF_NAME): cv.string,
cv.Optional(CONF_DISABLE_AFTER, default=0): cv.int_,
cv.Optional(CONF_TEST_REDUNDANT_OPERATIONS, default=False): cv.boolean,
}
)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(LoopTestComponent),
cv.Required(CONF_COMPONENTS): cv.ensure_list(COMPONENT_CONFIG_SCHEMA),
}
).extend(cv.COMPONENT_SCHEMA)
# Define actions
EnableAction = loop_test_component_ns.class_("EnableAction", automation.Action)
DisableAction = loop_test_component_ns.class_("DisableAction", automation.Action)
@automation.register_action(
"loop_test_component.enable",
EnableAction,
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(LoopTestComponent),
}
),
)
async def enable_to_code(config, action_id, template_arg, args):
parent = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, parent)
return var
@automation.register_action(
"loop_test_component.disable",
DisableAction,
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(LoopTestComponent),
}
),
)
async def disable_to_code(config, action_id, template_arg, args):
parent = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, parent)
return var
async def to_code(config):
# The parent config doesn't actually create a component
# We just create each sub-component
for comp_config in config[CONF_COMPONENTS]:
var = cg.new_Pvariable(comp_config[CONF_ID])
await cg.register_component(var, comp_config)
cg.add(var.set_name(comp_config[CONF_NAME]))
cg.add(var.set_disable_after(comp_config[CONF_DISABLE_AFTER]))
cg.add(
var.set_test_redundant_operations(
comp_config[CONF_TEST_REDUNDANT_OPERATIONS]
)
)

View File

@@ -0,0 +1,43 @@
#include "loop_test_component.h"
namespace esphome {
namespace loop_test_component {
void LoopTestComponent::setup() { ESP_LOGI(TAG, "[%s] Setup called", this->name_.c_str()); }
void LoopTestComponent::loop() {
this->loop_count_++;
ESP_LOGI(TAG, "[%s] Loop count: %d", this->name_.c_str(), this->loop_count_);
// Test self-disable after specified count
if (this->disable_after_ > 0 && this->loop_count_ == this->disable_after_) {
ESP_LOGI(TAG, "[%s] Disabling self after %d loops", this->name_.c_str(), this->disable_after_);
this->disable_loop();
}
// Test redundant operations
if (this->test_redundant_operations_ && this->loop_count_ == 5) {
if (this->name_ == "redundant_enable") {
ESP_LOGI(TAG, "[%s] Testing enable when already enabled", this->name_.c_str());
this->enable_loop();
} else if (this->name_ == "redundant_disable") {
ESP_LOGI(TAG, "[%s] Testing disable when will be disabled", this->name_.c_str());
// We'll disable at count 10, but try to disable again at 5
this->disable_loop();
ESP_LOGI(TAG, "[%s] First disable complete", this->name_.c_str());
}
}
}
void LoopTestComponent::service_enable() {
ESP_LOGI(TAG, "[%s] Service enable called", this->name_.c_str());
this->enable_loop();
}
void LoopTestComponent::service_disable() {
ESP_LOGI(TAG, "[%s] Service disable called", this->name_.c_str());
this->disable_loop();
}
} // namespace loop_test_component
} // namespace esphome

View File

@@ -0,0 +1,58 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/log.h"
#include "esphome/core/application.h"
#include "esphome/core/automation.h"
namespace esphome {
namespace loop_test_component {
static const char *const TAG = "loop_test_component";
class LoopTestComponent : public Component {
public:
void set_name(const std::string &name) { this->name_ = name; }
void set_disable_after(int count) { this->disable_after_ = count; }
void set_test_redundant_operations(bool test) { this->test_redundant_operations_ = test; }
void setup() override;
void loop() override;
// Service methods for external control
void service_enable();
void service_disable();
int get_loop_count() const { return this->loop_count_; }
float get_setup_priority() const override { return setup_priority::DATA; }
protected:
std::string name_;
int loop_count_{0};
int disable_after_{0};
bool test_redundant_operations_{false};
};
template<typename... Ts> class EnableAction : public Action<Ts...> {
public:
EnableAction(LoopTestComponent *parent) : parent_(parent) {}
void play(Ts... x) override { this->parent_->service_enable(); }
protected:
LoopTestComponent *parent_;
};
template<typename... Ts> class DisableAction : public Action<Ts...> {
public:
DisableAction(LoopTestComponent *parent) : parent_(parent) {}
void play(Ts... x) override { this->parent_->service_disable(); }
protected:
LoopTestComponent *parent_;
};
} // namespace loop_test_component
} // namespace esphome

View File

@@ -0,0 +1,48 @@
esphome:
name: loop-test
host:
api:
logger:
level: DEBUG
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
loop_test_component:
components:
# Component that disables itself after 10 loops
- id: self_disable_10
name: "self_disable_10"
disable_after: 10
# Component that never disables itself (for re-enable test)
- id: normal_component
name: "normal_component"
disable_after: 0
# Component that tests enable when already enabled
- id: redundant_enable
name: "redundant_enable"
test_redundant_operations: true
disable_after: 0
# Component that tests disable when already disabled
- id: redundant_disable
name: "redundant_disable"
test_redundant_operations: true
disable_after: 10
# Interval to re-enable the self_disable_10 component after some time
interval:
- interval: 0.5s
then:
- if:
condition:
lambda: 'return id(self_disable_10).get_loop_count() == 10;'
then:
- logger.log: "Re-enabling self_disable_10 via service"
- loop_test_component.enable:
id: self_disable_10

View File

@@ -0,0 +1,150 @@
"""Integration test for loop disable/enable functionality."""
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_loop_disable_enable(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that components can disable and enable their loop() method."""
# Get the absolute path to the external components directory
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
# Replace the placeholder in the YAML config with the actual path
yaml_config = yaml_config.replace(
"EXTERNAL_COMPONENT_PATH", external_components_path
)
# Track log messages and events
log_messages: list[str] = []
# Event fired when self_disable_10 component disables itself after 10 loops
self_disable_10_disabled = asyncio.Event()
# Event fired when normal_component reaches 10 loops
normal_component_10_loops = asyncio.Event()
# Event fired when redundant_enable component tests enabling when already enabled
redundant_enable_tested = asyncio.Event()
# Event fired when redundant_disable component tests disabling when already disabled
redundant_disable_tested = asyncio.Event()
# Event fired when self_disable_10 component is re-enabled and runs again (count > 10)
self_disable_10_re_enabled = asyncio.Event()
# Track loop counts for components
self_disable_10_counts: list[int] = []
normal_component_counts: list[int] = []
def on_log_line(line: str) -> None:
"""Process each log line from the process output."""
# Strip ANSI color codes
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
if "loop_test_component" not in clean_line:
return
log_messages.append(clean_line)
# Track specific events using the cleaned line
if "[self_disable_10]" in clean_line:
if "Loop count:" in clean_line:
# Extract loop count
try:
count = int(clean_line.split("Loop count: ")[1])
self_disable_10_counts.append(count)
# Check if component was re-enabled (count > 10)
if count > 10:
self_disable_10_re_enabled.set()
except (IndexError, ValueError):
pass
elif "Disabling self after 10 loops" in clean_line:
self_disable_10_disabled.set()
elif "[normal_component]" in clean_line and "Loop count:" in clean_line:
try:
count = int(clean_line.split("Loop count: ")[1])
normal_component_counts.append(count)
if count >= 10:
normal_component_10_loops.set()
except (IndexError, ValueError):
pass
elif (
"[redundant_enable]" in clean_line
and "Testing enable when already enabled" in clean_line
):
redundant_enable_tested.set()
elif (
"[redundant_disable]" in clean_line
and "Testing disable when will be disabled" in clean_line
):
redundant_disable_tested.set()
# Write, compile and run the ESPHome device with log callback
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
# Verify we can connect and get device info
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "loop-test"
# Wait for self_disable_10 to disable itself
try:
await asyncio.wait_for(self_disable_10_disabled.wait(), timeout=10.0)
except asyncio.TimeoutError:
pytest.fail("self_disable_10 did not disable itself within 10 seconds")
# Verify it ran at least 10 times before disabling
assert len([c for c in self_disable_10_counts if c <= 10]) == 10, (
f"Expected exactly 10 loops before disable, got {[c for c in self_disable_10_counts if c <= 10]}"
)
assert self_disable_10_counts[:10] == list(range(1, 11)), (
f"Expected first 10 counts to be 1-10, got {self_disable_10_counts[:10]}"
)
# Wait for normal_component to run at least 10 times
try:
await asyncio.wait_for(normal_component_10_loops.wait(), timeout=10.0)
except asyncio.TimeoutError:
pytest.fail(
f"normal_component did not reach 10 loops within timeout, got {len(normal_component_counts)}"
)
# Wait for redundant operation tests
try:
await asyncio.wait_for(redundant_enable_tested.wait(), timeout=10.0)
except asyncio.TimeoutError:
pytest.fail("redundant_enable did not test enabling when already enabled")
try:
await asyncio.wait_for(redundant_disable_tested.wait(), timeout=10.0)
except asyncio.TimeoutError:
pytest.fail(
"redundant_disable did not test disabling when will be disabled"
)
# Wait to see if self_disable_10 gets re-enabled
try:
await asyncio.wait_for(self_disable_10_re_enabled.wait(), timeout=5.0)
except asyncio.TimeoutError:
pytest.fail("self_disable_10 was not re-enabled within 5 seconds")
# Component was re-enabled - verify it ran more times
later_self_disable_counts = [c for c in self_disable_10_counts if c > 10]
assert later_self_disable_counts, (
"self_disable_10 was re-enabled but did not run additional times"
)

View File

@@ -13,7 +13,19 @@ from aioesphomeapi import APIClient
ConfigWriter = Callable[[str, str | None], Awaitable[Path]]
CompileFunction = Callable[[Path], Awaitable[Path]]
RunFunction = Callable[[Path], Awaitable[asyncio.subprocess.Process]]
RunCompiledFunction = Callable[[str, str | None], AbstractAsyncContextManager[None]]
class RunCompiledFunction(Protocol):
"""Protocol for run_compiled function with optional line callback."""
def __call__( # noqa: E704
self,
yaml_content: str,
filename: str | None = None,
line_callback: Callable[[str], None] | None = None,
) -> AbstractAsyncContextManager[None]: ...
WaitFunction = Callable[[APIClient, float], Awaitable[bool]]