diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index 5e1e8734e5..08439746b6 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -23,9 +23,11 @@ struct NVSData { size_t len; void set_data(const uint8_t *src, size_t size) { - this->data = std::make_unique(size); + if (!this->data || this->len != size) { + this->data = std::make_unique(size); + this->len = size; + } memcpy(this->data.get(), src, size); - this->len = size; } }; diff --git a/esphome/components/esp32_ble_tracker/automation.h b/esphome/components/esp32_ble_tracker/automation.h index bbf7992fa4..6d26040ccb 100644 --- a/esphome/components/esp32_ble_tracker/automation.h +++ b/esphome/components/esp32_ble_tracker/automation.h @@ -98,7 +98,13 @@ template class ESP32BLEStartScanAction : public Action { TEMPLATABLE_VALUE(bool, continuous) void play(const Ts &...x) override { this->parent_->set_scan_continuous(this->continuous_.value(x...)); - this->parent_->start_scan(); + // Only call start_scan() if scanner is IDLE + // For other states (STARTING, RUNNING, STOPPING, FAILED), the normal state + // machine flow will eventually transition back to IDLE, at which point + // loop() will see scan_continuous_ and restart scanning if it is true. + if (this->parent_->get_scanner_state() == ScannerState::IDLE) { + this->parent_->start_scan(); + } } protected: diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 7736319330..40202e52ca 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -15,6 +15,7 @@ from esphome.const import ( CONF_ID, CONF_LAMBDA, CONF_OE_PIN, + CONF_ROTATION, CONF_UPDATE_INTERVAL, ) from esphome.core import ID @@ -134,6 +135,14 @@ CLOCK_SPEEDS = { "20MHZ": Hub75ClockSpeed.HZ_20M, } +Hub75Rotation = cg.global_ns.enum("Hub75Rotation", is_class=True) +ROTATIONS = { + 0: Hub75Rotation.ROTATE_0, + 90: Hub75Rotation.ROTATE_90, + 180: Hub75Rotation.ROTATE_180, + 270: Hub75Rotation.ROTATE_270, +} + HUB75Display = hub75_ns.class_("HUB75Display", cg.PollingComponent, display.Display) Hub75Config = cg.global_ns.struct("Hub75Config") Hub75Pins = cg.global_ns.struct("Hub75Pins") @@ -361,6 +370,8 @@ CONFIG_SCHEMA = cv.All( display.FULL_DISPLAY_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(HUB75Display), + # Override rotation - store Hub75Rotation directly (driver handles rotation) + cv.Optional(CONF_ROTATION): cv.enum(ROTATIONS, int=True), # Board preset (optional - provides default pin mappings) cv.Optional(CONF_BOARD): cv.one_of(*BOARDS.keys(), lower=True), # Panel dimensions @@ -378,7 +389,7 @@ CONFIG_SCHEMA = cv.All( # Display configuration cv.Optional(CONF_DOUBLE_BUFFER): cv.boolean, cv.Optional(CONF_BRIGHTNESS): cv.int_range(min=0, max=255), - cv.Optional(CONF_BIT_DEPTH): cv.int_range(min=6, max=12), + cv.Optional(CONF_BIT_DEPTH): cv.int_range(min=4, max=12), cv.Optional(CONF_GAMMA_CORRECT): cv.enum( {"LINEAR": 0, "CIE1931": 1, "GAMMA_2_2": 2}, upper=True ), @@ -490,10 +501,11 @@ def _build_config_struct( Fields must be added in declaration order (see hub75_types.h) to satisfy C++ designated initializer requirements. The order is: 1. fields_before_pins (panel_width through layout) - 2. pins - 3. output_clock_speed - 4. min_refresh_rate - 5. fields_after_min_refresh (latch_blanking through brightness) + 2. rotation + 3. pins + 4. output_clock_speed + 5. min_refresh_rate + 6. fields_after_min_refresh (latch_blanking through brightness) """ fields_before_pins = [ (CONF_PANEL_WIDTH, "panel_width"), @@ -516,6 +528,10 @@ def _build_config_struct( _append_config_fields(config, fields_before_pins, config_fields) + # Rotation - config already contains Hub75Rotation enum from cv.enum + if CONF_ROTATION in config: + config_fields.append(("rotation", config[CONF_ROTATION])) + config_fields.append(("pins", pins_struct)) if CONF_CLOCK_SPEED in config: @@ -531,7 +547,7 @@ def _build_config_struct( async def to_code(config: ConfigType) -> None: add_idf_component( name="esphome/esp-hub75", - ref="0.1.7", + ref="0.2.2", ) # Set compile-time configuration via defines @@ -570,6 +586,11 @@ async def to_code(config: ConfigType) -> None: pins_struct = _build_pins_struct(pin_expressions, e_pin_num) hub75_config = _build_config_struct(config, pins_struct, min_refresh) + # Rotation is handled by the hub75 driver (config_.rotation already set above). + # Force rotation to 0 for ESPHome's Display base class to avoid double-rotation. + if CONF_ROTATION in config: + config[CONF_ROTATION] = 0 + # Create display and register var = cg.new_Pvariable(config[CONF_ID], hub75_config) await display.register_display(var, config) diff --git a/esphome/components/hub75/hub75.cpp b/esphome/components/hub75/hub75.cpp index e29f1a898c..cf8661b2b3 100644 --- a/esphome/components/hub75/hub75.cpp +++ b/esphome/components/hub75/hub75.cpp @@ -92,14 +92,25 @@ void HUB75Display::fill(Color color) { if (!this->enabled_) [[unlikely]] return; - // Special case: black (off) - use fast hardware clear - if (!color.is_on()) { + // Start with full display rect + display::Rect fill_rect(0, 0, this->get_width_internal(), this->get_height_internal()); + + // Apply clipping using Rect::shrink() to intersect + display::Rect clip = this->get_clipping(); + if (clip.is_set()) { + fill_rect.shrink(clip); + if (!fill_rect.is_set()) + return; // Completely clipped + } + + // Fast path: black filling entire display + if (!color.is_on() && fill_rect.x == 0 && fill_rect.y == 0 && fill_rect.w == this->get_width_internal() && + fill_rect.h == this->get_height_internal()) { driver_->clear(); return; } - // For non-black colors, fall back to base class (pixel-by-pixel) - Display::fill(color); + driver_->fill(fill_rect.x, fill_rect.y, fill_rect.w, fill_rect.h, color.r, color.g, color.b); } void HOT HUB75Display::draw_pixel_at(int x, int y, Color color) { diff --git a/esphome/components/hub75/hub75_component.h b/esphome/components/hub75/hub75_component.h index f0e7ea10d5..ab7e3fc5b1 100644 --- a/esphome/components/hub75/hub75_component.h +++ b/esphome/components/hub75/hub75_component.h @@ -39,8 +39,8 @@ class HUB75Display : public display::Display { protected: // Display internal methods - int get_width_internal() override { return config_.panel_width * config_.layout_cols; } - int get_height_internal() override { return config_.panel_height * config_.layout_rows; } + int get_width_internal() override { return this->driver_ != nullptr ? this->driver_->get_width() : 0; } + int get_height_internal() override { return this->driver_ != nullptr ? this->driver_->get_height() : 0; } // Member variables Hub75Driver *driver_{nullptr}; diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index e47e88c6f3..68bc279767 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -22,9 +22,11 @@ struct NVSData { size_t len; void set_data(const uint8_t *src, size_t size) { - this->data = std::make_unique(size); + if (!this->data || this->len != size) { + this->data = std::make_unique(size); + this->len = size; + } memcpy(this->data.get(), src, size); - this->len = size; } }; diff --git a/esphome/components/template/water_heater/template_water_heater.cpp b/esphome/components/template/water_heater/template_water_heater.cpp index 18ef8d3f06..5ae5c30f36 100644 --- a/esphome/components/template/water_heater/template_water_heater.cpp +++ b/esphome/components/template/water_heater/template_water_heater.cpp @@ -21,7 +21,7 @@ void TemplateWaterHeater::setup() { } water_heater::WaterHeaterTraits TemplateWaterHeater::traits() { - auto traits = water_heater::WaterHeater::get_traits(); + water_heater::WaterHeaterTraits traits; if (!this->supported_modes_.empty()) { traits.set_supported_modes(this->supported_modes_); diff --git a/esphome/components/ultrasonic/sensor.py b/esphome/components/ultrasonic/sensor.py index d341acb9d1..4b04ee7578 100644 --- a/esphome/components/ultrasonic/sensor.py +++ b/esphome/components/ultrasonic/sensor.py @@ -1,3 +1,5 @@ +import logging + from esphome import pins import esphome.codegen as cg from esphome.components import sensor @@ -11,6 +13,8 @@ from esphome.const import ( UNIT_METER, ) +_LOGGER = logging.getLogger(__name__) + CONF_PULSE_TIME = "pulse_time" ultrasonic_ns = cg.esphome_ns.namespace("ultrasonic") @@ -30,7 +34,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_TRIGGER_PIN): pins.internal_gpio_output_pin_schema, cv.Required(CONF_ECHO_PIN): pins.internal_gpio_input_pin_schema, - cv.Optional(CONF_TIMEOUT, default="2m"): cv.distance, + cv.Optional(CONF_TIMEOUT): cv.distance, cv.Optional( CONF_PULSE_TIME, default="10us" ): cv.positive_time_period_microseconds, @@ -49,5 +53,11 @@ async def to_code(config): echo = await cg.gpio_pin_expression(config[CONF_ECHO_PIN]) cg.add(var.set_echo_pin(echo)) - cg.add(var.set_timeout_us(config[CONF_TIMEOUT] / (0.000343 / 2))) + # Remove before 2026.8.0 + if CONF_TIMEOUT in config: + _LOGGER.warning( + "'timeout' option is deprecated and will be removed in 2026.8.0. " + "The option has no effect and can be safely removed." + ) + cg.add(var.set_pulse_time_us(config[CONF_PULSE_TIME])) diff --git a/esphome/components/ultrasonic/ultrasonic_sensor.cpp b/esphome/components/ultrasonic/ultrasonic_sensor.cpp index 184d93f189..369a10edbd 100644 --- a/esphome/components/ultrasonic/ultrasonic_sensor.cpp +++ b/esphome/components/ultrasonic/ultrasonic_sensor.cpp @@ -6,8 +6,8 @@ namespace esphome::ultrasonic { static const char *const TAG = "ultrasonic.sensor"; -static constexpr uint32_t DEBOUNCE_US = 50; // Ignore edges within 50us (noise filtering) -static constexpr uint32_t TIMEOUT_MARGIN_US = 1000; // Extra margin for sensor processing time +static constexpr uint32_t DEBOUNCE_US = 50; // Ignore edges within 50us (noise filtering) +static constexpr uint32_t MEASUREMENT_TIMEOUT_US = 80000; // Maximum time to wait for measurement completion void IRAM_ATTR UltrasonicSensorStore::gpio_intr(UltrasonicSensorStore *arg) { uint32_t now = micros(); @@ -64,12 +64,8 @@ void UltrasonicSensorComponent::loop() { } uint32_t elapsed = micros() - this->measurement_start_us_; - if (elapsed >= this->timeout_us_ + TIMEOUT_MARGIN_US) { - ESP_LOGD(TAG, - "'%s' - Timeout after %" PRIu32 "us (measurement_start=%" PRIu32 ", echo_start=%" PRIu32 - ", echo_end=%" PRIu32 ")", - this->name_.c_str(), elapsed, this->measurement_start_us_, this->store_.echo_start_us, - this->store_.echo_end_us); + if (elapsed >= MEASUREMENT_TIMEOUT_US) { + ESP_LOGD(TAG, "'%s' - Measurement timed out after %" PRIu32 "us", this->name_.c_str(), elapsed); this->publish_state(NAN); this->measurement_pending_ = false; } @@ -79,10 +75,7 @@ void UltrasonicSensorComponent::dump_config() { LOG_SENSOR("", "Ultrasonic Sensor", this); LOG_PIN(" Echo Pin: ", this->echo_pin_); LOG_PIN(" Trigger Pin: ", this->trigger_pin_); - ESP_LOGCONFIG(TAG, - " Pulse time: %" PRIu32 " us\n" - " Timeout: %" PRIu32 " us", - this->pulse_time_us_, this->timeout_us_); + ESP_LOGCONFIG(TAG, " Pulse time: %" PRIu32 " us", this->pulse_time_us_); LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/ultrasonic/ultrasonic_sensor.h b/esphome/components/ultrasonic/ultrasonic_sensor.h index e2266543ce..b0c00e51f0 100644 --- a/esphome/components/ultrasonic/ultrasonic_sensor.h +++ b/esphome/components/ultrasonic/ultrasonic_sensor.h @@ -22,9 +22,6 @@ class UltrasonicSensorComponent : public sensor::Sensor, public PollingComponent void set_trigger_pin(InternalGPIOPin *trigger_pin) { this->trigger_pin_ = trigger_pin; } void set_echo_pin(InternalGPIOPin *echo_pin) { this->echo_pin_ = echo_pin; } - /// Set the timeout for waiting for the echo in µs. - void set_timeout_us(uint32_t timeout_us) { this->timeout_us_ = timeout_us; } - void setup() override; void loop() override; void dump_config() override; @@ -44,7 +41,6 @@ class UltrasonicSensorComponent : public sensor::Sensor, public PollingComponent ISRInternalGPIOPin trigger_pin_isr_; InternalGPIOPin *echo_pin_; UltrasonicSensorStore store_; - uint32_t timeout_us_{}; uint32_t pulse_time_us_{}; uint32_t measurement_start_us_{0}; diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 5bf1f444e8..1906b672b8 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -245,6 +245,10 @@ enum WifiMinAuthMode : uint8_t { struct IDFWiFiEvent; #endif +#ifdef USE_LIBRETINY +struct LTWiFiEvent; +#endif + /** Listener interface for WiFi IP state changes. * * Components can implement this interface to receive IP address updates @@ -583,6 +587,7 @@ class WiFiComponent : public Component { #ifdef USE_LIBRETINY void wifi_event_callback_(arduino_event_id_t event, arduino_event_info_t info); + void wifi_process_event_(LTWiFiEvent *event); void wifi_scan_done_callback_(); #endif diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 9bbd319f33..e9ccb86871 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -3,12 +3,16 @@ #ifdef USE_WIFI #ifdef USE_LIBRETINY +#include #include #include #include "lwip/ip_addr.h" #include "lwip/err.h" #include "lwip/dns.h" +#include +#include + #include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" @@ -19,7 +23,68 @@ namespace esphome::wifi { static const char *const TAG = "wifi_lt"; -static bool s_sta_connecting = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +// Thread-safe event handling for LibreTiny WiFi +// +// LibreTiny's WiFi.onEvent() callback runs in the WiFi driver's thread context, +// not the main ESPHome loop. Without synchronization, modifying shared state +// (like connection status flags) from the callback causes race conditions: +// - The main loop may never see state changes (values cached in registers) +// - State changes may be visible in inconsistent order +// - LibreTiny targets (BK7231, RTL8720) lack atomic instructions (no LDREX/STREX) +// +// Solution: Queue events in the callback and process them in the main loop. +// This is the same approach used by ESP32 IDF's wifi_process_event_(). +// All state modifications happen in the main loop context, eliminating races. + +static constexpr size_t EVENT_QUEUE_SIZE = 16; // Max pending WiFi events before overflow +static QueueHandle_t s_event_queue = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static volatile uint32_t s_event_queue_overflow_count = + 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +// Event structure for queued WiFi events - contains a copy of event data +// to avoid lifetime issues with the original event data from the callback +struct LTWiFiEvent { + arduino_event_id_t event_id; + union { + struct { + uint8_t ssid[33]; + uint8_t ssid_len; + uint8_t bssid[6]; + uint8_t channel; + uint8_t authmode; + } sta_connected; + struct { + uint8_t ssid[33]; + uint8_t ssid_len; + uint8_t bssid[6]; + uint8_t reason; + } sta_disconnected; + struct { + uint8_t old_mode; + uint8_t new_mode; + } sta_authmode_change; + struct { + uint32_t status; + uint8_t number; + uint8_t scan_id; + } scan_done; + struct { + uint8_t mac[6]; + int rssi; + } ap_probe_req; + } data; +}; + +// Connection state machine - only modified from main loop after queue processing +enum class LTWiFiSTAState : uint8_t { + IDLE, // Not connecting + CONNECTING, // Connection in progress + CONNECTED, // Successfully connected with IP + ERROR_NOT_FOUND, // AP not found (probe failed) + ERROR_FAILED, // Connection failed (auth, timeout, etc.) +}; + +static LTWiFiSTAState s_sta_state = LTWiFiSTAState::IDLE; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) bool WiFiComponent::wifi_mode_(optional sta, optional ap) { uint8_t current_mode = WiFi.getMode(); @@ -136,7 +201,8 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { this->wifi_apply_hostname_(); - s_sta_connecting = true; + // Reset state machine before connecting + s_sta_state = LTWiFiSTAState::CONNECTING; WiFiStatus status = WiFi.begin(ap.get_ssid().c_str(), ap.get_password().empty() ? NULL : ap.get_password().c_str(), ap.get_channel(), // 0 = auto @@ -271,16 +337,101 @@ const char *get_disconnect_reason_str(uint8_t reason) { using esphome_wifi_event_id_t = arduino_event_id_t; using esphome_wifi_event_info_t = arduino_event_info_t; +// Event callback - runs in WiFi driver thread context +// Only queues events for processing in main loop, no logging or state changes here void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_wifi_event_info_t info) { + if (s_event_queue == nullptr) { + return; + } + + // Allocate on heap and fill directly to avoid extra memcpy + auto *to_send = new LTWiFiEvent{}; // NOLINT(cppcoreguidelines-owning-memory) + to_send->event_id = event; + + // Copy event-specific data switch (event) { + case ESPHOME_EVENT_ID_WIFI_STA_CONNECTED: { + auto &it = info.wifi_sta_connected; + to_send->data.sta_connected.ssid_len = it.ssid_len; + memcpy(to_send->data.sta_connected.ssid, it.ssid, + std::min(static_cast(it.ssid_len), sizeof(to_send->data.sta_connected.ssid) - 1)); + memcpy(to_send->data.sta_connected.bssid, it.bssid, 6); + to_send->data.sta_connected.channel = it.channel; + to_send->data.sta_connected.authmode = it.authmode; + break; + } + case ESPHOME_EVENT_ID_WIFI_STA_DISCONNECTED: { + auto &it = info.wifi_sta_disconnected; + to_send->data.sta_disconnected.ssid_len = it.ssid_len; + memcpy(to_send->data.sta_disconnected.ssid, it.ssid, + std::min(static_cast(it.ssid_len), sizeof(to_send->data.sta_disconnected.ssid) - 1)); + memcpy(to_send->data.sta_disconnected.bssid, it.bssid, 6); + to_send->data.sta_disconnected.reason = it.reason; + break; + } + case ESPHOME_EVENT_ID_WIFI_STA_AUTHMODE_CHANGE: { + auto &it = info.wifi_sta_authmode_change; + to_send->data.sta_authmode_change.old_mode = it.old_mode; + to_send->data.sta_authmode_change.new_mode = it.new_mode; + break; + } + case ESPHOME_EVENT_ID_WIFI_SCAN_DONE: { + auto &it = info.wifi_scan_done; + to_send->data.scan_done.status = it.status; + to_send->data.scan_done.number = it.number; + to_send->data.scan_done.scan_id = it.scan_id; + break; + } + case ESPHOME_EVENT_ID_WIFI_AP_PROBEREQRECVED: { + auto &it = info.wifi_ap_probereqrecved; + memcpy(to_send->data.ap_probe_req.mac, it.mac, 6); + to_send->data.ap_probe_req.rssi = it.rssi; + break; + } + case ESPHOME_EVENT_ID_WIFI_AP_STACONNECTED: { + auto &it = info.wifi_sta_connected; + memcpy(to_send->data.sta_connected.bssid, it.bssid, 6); + break; + } + case ESPHOME_EVENT_ID_WIFI_AP_STADISCONNECTED: { + auto &it = info.wifi_sta_disconnected; + memcpy(to_send->data.sta_disconnected.bssid, it.bssid, 6); + break; + } + case ESPHOME_EVENT_ID_WIFI_READY: + case ESPHOME_EVENT_ID_WIFI_STA_START: + case ESPHOME_EVENT_ID_WIFI_STA_STOP: + case ESPHOME_EVENT_ID_WIFI_STA_GOT_IP: + case ESPHOME_EVENT_ID_WIFI_STA_GOT_IP6: + case ESPHOME_EVENT_ID_WIFI_STA_LOST_IP: + case ESPHOME_EVENT_ID_WIFI_AP_START: + case ESPHOME_EVENT_ID_WIFI_AP_STOP: + case ESPHOME_EVENT_ID_WIFI_AP_STAIPASSIGNED: + // No additional data needed + break; + default: + // Unknown event, don't queue + delete to_send; // NOLINT(cppcoreguidelines-owning-memory) + return; + } + + // Queue event (don't block if queue is full) + if (xQueueSend(s_event_queue, &to_send, 0) != pdPASS) { + delete to_send; // NOLINT(cppcoreguidelines-owning-memory) + s_event_queue_overflow_count++; + } +} + +// Process a single event from the queue - runs in main loop context +void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { + switch (event->event_id) { case ESPHOME_EVENT_ID_WIFI_READY: { ESP_LOGV(TAG, "Ready"); break; } case ESPHOME_EVENT_ID_WIFI_SCAN_DONE: { - auto it = info.wifi_scan_done; - ESP_LOGV(TAG, "Scan done: status=%u number=%u scan_id=%u", it.status, it.number, it.scan_id); - + auto &it = event->data.scan_done; + ESP_LOGV(TAG, "Scan done: status=%" PRIu32 " number=%u scan_id=%u", it.status, it.number, it.scan_id); this->wifi_scan_done_callback_(); break; } @@ -291,14 +442,18 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } case ESPHOME_EVENT_ID_WIFI_STA_STOP: { ESP_LOGV(TAG, "STA stop"); - s_sta_connecting = false; + s_sta_state = LTWiFiSTAState::IDLE; break; } case ESPHOME_EVENT_ID_WIFI_STA_CONNECTED: { - auto it = info.wifi_sta_connected; + auto &it = event->data.sta_connected; + char bssid_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(it.bssid, bssid_buf); ESP_LOGV(TAG, "Connected ssid='%.*s' bssid=" LOG_SECRET("%s") " channel=%u, authmode=%s", it.ssid_len, - (const char *) it.ssid, format_mac_address_pretty(it.bssid).c_str(), it.channel, - get_auth_mode_str(it.authmode)); + (const char *) it.ssid, bssid_buf, it.channel, get_auth_mode_str(it.authmode)); + // Note: We don't set CONNECTED state here yet - wait for GOT_IP + // This matches ESP32 IDF behavior where s_sta_connected is set but + // wifi_sta_connect_status_() also checks got_ipv4_address_ #ifdef USE_WIFI_LISTENERS for (auto *listener : this->connect_state_listeners_) { listener->on_wifi_connect_state(StringRef(it.ssid, it.ssid_len), it.bssid); @@ -306,6 +461,7 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ // For static IP configurations, GOT_IP event may not fire, so notify IP listeners here #ifdef USE_WIFI_MANUAL_IP if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) { + s_sta_state = LTWiFiSTAState::CONNECTED; for (auto *listener : this->ip_state_listeners_) { listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); } @@ -315,19 +471,18 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ break; } case ESPHOME_EVENT_ID_WIFI_STA_DISCONNECTED: { - auto it = info.wifi_sta_disconnected; + auto &it = event->data.sta_disconnected; // LibreTiny can send spurious disconnect events with empty ssid/bssid during connection. // These are typically "Association Leave" events that don't indicate actual failures: // [W][wifi_lt]: Disconnected ssid='' bssid=00:00:00:00:00:00 reason='Association Leave' // [W][wifi_lt]: Disconnected ssid='' bssid=00:00:00:00:00:00 reason='Association Leave' // [V][wifi_lt]: Connected ssid='WIFI' bssid=... channel=3, authmode=WPA2 PSK - // Without this check, the spurious events set s_sta_connecting=false, causing - // wifi_sta_connect_status_() to return IDLE. The main loop then sees - // "Unknown connection status 0" (wifi_component.cpp check_connecting_finished) - // and calls retry_connect(), aborting a connection that may succeed moments later. - // Real connection failures will have ssid/bssid populated, or we'll hit the connection timeout. - if (it.ssid_len == 0 && s_sta_connecting) { + // Without this check, the spurious events would transition state to ERROR_FAILED, + // causing wifi_sta_connect_status_() to return an error. The main loop would then + // call retry_connect(), aborting a connection that may succeed moments later. + // Only ignore benign reasons - real failures like NO_AP_FOUND should still be processed. + if (it.ssid_len == 0 && s_sta_state == LTWiFiSTAState::CONNECTING && it.reason != WIFI_REASON_NO_AP_FOUND) { ESP_LOGV(TAG, "Ignoring disconnect event with empty ssid while connecting (reason=%s)", get_disconnect_reason_str(it.reason)); break; @@ -336,11 +491,13 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ if (it.reason == WIFI_REASON_NO_AP_FOUND) { ESP_LOGW(TAG, "Disconnected ssid='%.*s' reason='Probe Request Unsuccessful'", it.ssid_len, (const char *) it.ssid); + s_sta_state = LTWiFiSTAState::ERROR_NOT_FOUND; } else { - char bssid_s[18]; + char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(it.bssid, bssid_s); ESP_LOGW(TAG, "Disconnected ssid='%.*s' bssid=" LOG_SECRET("%s") " reason='%s'", it.ssid_len, (const char *) it.ssid, bssid_s, get_disconnect_reason_str(it.reason)); + s_sta_state = LTWiFiSTAState::ERROR_FAILED; } uint8_t reason = it.reason; @@ -351,7 +508,6 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ this->error_from_callback_ = true; } - s_sta_connecting = false; #ifdef USE_WIFI_LISTENERS static constexpr uint8_t EMPTY_BSSID[6] = {}; for (auto *listener : this->connect_state_listeners_) { @@ -361,24 +517,22 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ break; } case ESPHOME_EVENT_ID_WIFI_STA_AUTHMODE_CHANGE: { - auto it = info.wifi_sta_authmode_change; + auto &it = event->data.sta_authmode_change; ESP_LOGV(TAG, "Authmode Change old=%s new=%s", get_auth_mode_str(it.old_mode), get_auth_mode_str(it.new_mode)); // Mitigate CVE-2020-12638 // https://lbsfilm.at/blog/wpa2-authenticationmode-downgrade-in-espressif-microprocessors if (it.old_mode != WIFI_AUTH_OPEN && it.new_mode == WIFI_AUTH_OPEN) { ESP_LOGW(TAG, "Potential Authmode downgrade detected, disconnecting"); - // we can't call retry_connect() from this context, so disconnect immediately - // and notify main thread with error_from_callback_ WiFi.disconnect(); this->error_from_callback_ = true; + s_sta_state = LTWiFiSTAState::ERROR_FAILED; } break; } case ESPHOME_EVENT_ID_WIFI_STA_GOT_IP: { - // auto it = info.got_ip.ip_info; ESP_LOGV(TAG, "static_ip=%s gateway=%s", format_ip4_addr(WiFi.localIP()).c_str(), format_ip4_addr(WiFi.gatewayIP()).c_str()); - s_sta_connecting = false; + s_sta_state = LTWiFiSTAState::CONNECTED; #ifdef USE_WIFI_LISTENERS for (auto *listener : this->ip_state_listeners_) { listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); @@ -387,7 +541,6 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ break; } case ESPHOME_EVENT_ID_WIFI_STA_GOT_IP6: { - // auto it = info.got_ip.ip_info; ESP_LOGV(TAG, "Got IPv6"); #ifdef USE_WIFI_LISTENERS for (auto *listener : this->ip_state_listeners_) { @@ -398,6 +551,7 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } case ESPHOME_EVENT_ID_WIFI_STA_LOST_IP: { ESP_LOGV(TAG, "Lost IP"); + // Don't change state to IDLE - let the disconnect event handle that break; } case ESPHOME_EVENT_ID_WIFI_AP_START: { @@ -409,15 +563,21 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ break; } case ESPHOME_EVENT_ID_WIFI_AP_STACONNECTED: { - auto it = info.wifi_sta_connected; - auto &mac = it.bssid; - ESP_LOGV(TAG, "AP client connected MAC=%s", format_mac_address_pretty(mac).c_str()); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + auto &it = event->data.sta_connected; + char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(it.bssid, mac_buf); + ESP_LOGV(TAG, "AP client connected MAC=%s", mac_buf); +#endif break; } case ESPHOME_EVENT_ID_WIFI_AP_STADISCONNECTED: { - auto it = info.wifi_sta_disconnected; - auto &mac = it.bssid; - ESP_LOGV(TAG, "AP client disconnected MAC=%s", format_mac_address_pretty(mac).c_str()); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + auto &it = event->data.sta_disconnected; + char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(it.bssid, mac_buf); + ESP_LOGV(TAG, "AP client disconnected MAC=%s", mac_buf); +#endif break; } case ESPHOME_EVENT_ID_WIFI_AP_STAIPASSIGNED: { @@ -425,8 +585,12 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ break; } case ESPHOME_EVENT_ID_WIFI_AP_PROBEREQRECVED: { - auto it = info.wifi_ap_probereqrecved; - ESP_LOGVV(TAG, "AP receive Probe Request MAC=%s RSSI=%d", format_mac_address_pretty(it.mac).c_str(), it.rssi); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE + auto &it = event->data.ap_probe_req; + char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(it.mac, mac_buf); + ESP_LOGVV(TAG, "AP receive Probe Request MAC=%s RSSI=%d", mac_buf, it.rssi); +#endif break; } default: @@ -434,23 +598,35 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } } void WiFiComponent::wifi_pre_setup_() { + // Create event queue for thread-safe event handling + // Events are pushed from WiFi callback thread and processed in main loop + s_event_queue = xQueueCreate(EVENT_QUEUE_SIZE, sizeof(LTWiFiEvent *)); + if (s_event_queue == nullptr) { + ESP_LOGE(TAG, "Failed to create event queue"); + return; + } + auto f = std::bind(&WiFiComponent::wifi_event_callback_, this, std::placeholders::_1, std::placeholders::_2); WiFi.onEvent(f); // Make sure WiFi is in clean state before anything starts this->wifi_mode_(false, false); } WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() { - auto status = WiFi.status(); - if (status == WL_CONNECTED) { - return WiFiSTAConnectStatus::CONNECTED; - } else if (status == WL_CONNECT_FAILED || status == WL_CONNECTION_LOST) { - return WiFiSTAConnectStatus::ERROR_CONNECT_FAILED; - } else if (status == WL_NO_SSID_AVAIL) { - return WiFiSTAConnectStatus::ERROR_NETWORK_NOT_FOUND; - } else if (s_sta_connecting) { - return WiFiSTAConnectStatus::CONNECTING; + // Use state machine instead of querying WiFi.status() directly + // State is updated in main loop from queued events, ensuring thread safety + switch (s_sta_state) { + case LTWiFiSTAState::CONNECTED: + return WiFiSTAConnectStatus::CONNECTED; + case LTWiFiSTAState::ERROR_NOT_FOUND: + return WiFiSTAConnectStatus::ERROR_NETWORK_NOT_FOUND; + case LTWiFiSTAState::ERROR_FAILED: + return WiFiSTAConnectStatus::ERROR_CONNECT_FAILED; + case LTWiFiSTAState::CONNECTING: + return WiFiSTAConnectStatus::CONNECTING; + case LTWiFiSTAState::IDLE: + default: + return WiFiSTAConnectStatus::IDLE; } - return WiFiSTAConnectStatus::IDLE; } bool WiFiComponent::wifi_scan_start_(bool passive) { // enable STA @@ -534,9 +710,9 @@ network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {WiFi.softAPIP()}; #endif // USE_WIFI_AP bool WiFiComponent::wifi_disconnect_() { - // Clear connecting flag first so disconnect events aren't ignored + // Reset state first so disconnect events aren't ignored // and wifi_sta_connect_status_() returns IDLE instead of CONNECTING - s_sta_connecting = false; + s_sta_state = LTWiFiSTAState::IDLE; return WiFi.disconnect(); } @@ -563,7 +739,29 @@ int32_t WiFiComponent::get_wifi_channel() { return WiFi.channel(); } network::IPAddress WiFiComponent::wifi_subnet_mask_() { return {WiFi.subnetMask()}; } network::IPAddress WiFiComponent::wifi_gateway_ip_() { return {WiFi.gatewayIP()}; } network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return {WiFi.dnsIP(num)}; } -void WiFiComponent::wifi_loop_() {} +void WiFiComponent::wifi_loop_() { + // Process all pending events from the queue + if (s_event_queue == nullptr) { + return; + } + + // Check for dropped events due to queue overflow + if (s_event_queue_overflow_count > 0) { + ESP_LOGW(TAG, "Event queue overflow, %" PRIu32 " events dropped", s_event_queue_overflow_count); + s_event_queue_overflow_count = 0; + } + + while (true) { + LTWiFiEvent *event; + if (xQueueReceive(s_event_queue, &event, 0) != pdTRUE) { + // No more events + break; + } + + wifi_process_event_(event); + delete event; // NOLINT(cppcoreguidelines-owning-memory) + } +} } // namespace esphome::wifi #endif // USE_LIBRETINY diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 4573391bc1..36aa77c524 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -28,6 +28,6 @@ dependencies: rules: - if: "target in [esp32s2, esp32s3, esp32p4]" esphome/esp-hub75: - version: 0.1.7 + version: 0.2.2 rules: - if: "target in [esp32, esp32s2, esp32s3, esp32p4]" diff --git a/tests/components/hub75/test.esp32-s3-idf-rotate.yaml b/tests/components/hub75/test.esp32-s3-idf-rotate.yaml new file mode 100644 index 0000000000..9855fcb4e6 --- /dev/null +++ b/tests/components/hub75/test.esp32-s3-idf-rotate.yaml @@ -0,0 +1,39 @@ +display: + - platform: hub75 + id: my_hub75 + board: apollo-automation-rev6 + panel_width: 64 + panel_height: 64 + layout_rows: 1 + layout_cols: 2 + rotation: 90 + bit_depth: 4 + double_buffer: true + auto_clear_enabled: true + update_interval: 16ms + latch_blanking: 1 + clock_speed: 20MHz + lambda: |- + // Test clipping: 8 columns x 4 rows of 16x16 colored squares + Color colors[32] = { + Color(255, 0, 0), Color(0, 255, 0), Color(0, 0, 255), Color(255, 255, 0), + Color(255, 0, 255), Color(0, 255, 255), Color(255, 128, 0), Color(128, 0, 255), + Color(0, 128, 255), Color(255, 0, 128), Color(128, 255, 0), Color(0, 255, 128), + Color(255, 128, 128), Color(128, 255, 128), Color(128, 128, 255), Color(255, 255, 128), + Color(255, 128, 255), Color(128, 255, 255), Color(192, 64, 0), Color(64, 192, 0), + Color(0, 64, 192), Color(192, 0, 64), Color(64, 0, 192), Color(0, 192, 64), + Color(128, 64, 64), Color(64, 128, 64), Color(64, 64, 128), Color(128, 128, 64), + Color(128, 64, 128), Color(64, 128, 128), Color(255, 255, 255), Color(128, 128, 128) + }; + int idx = 0; + for (int row = 0; row < 4; row++) { + for (int col = 0; col < 8; col++) { + // Clipping mode: clip to square bounds, then fill "entire screen" + it.start_clipping(col * 16, row * 16, (col + 1) * 16, (row + 1) * 16); + it.fill(colors[idx]); + it.end_clipping(); + idx++; + } + } + +<<: !include common.yaml diff --git a/tests/components/water_heater/common.yaml b/tests/components/water_heater/common.yaml new file mode 100644 index 0000000000..8ec2b1b297 --- /dev/null +++ b/tests/components/water_heater/common.yaml @@ -0,0 +1,16 @@ +water_heater: + - platform: template + id: my_boiler + name: "Test Boiler" + min_temperature: 10 + max_temperature: 85 + target_temperature_step: 0.5 + current_temperature_step: 0.1 + optimistic: true + current_temperature: 45.0 + mode: !lambda "return water_heater::WATER_HEATER_MODE_ECO;" + visual: + min_temperature: 10 + max_temperature: 85 + target_temperature_step: 0.5 + current_temperature_step: 0.1 diff --git a/tests/components/web_server/common.yaml b/tests/components/web_server/common.yaml index eb768eeb91..82307c189c 100644 --- a/tests/components/web_server/common.yaml +++ b/tests/components/web_server/common.yaml @@ -36,3 +36,4 @@ datetime: optimistic: yes event: update: +water_heater: diff --git a/tests/integration/fixtures/water_heater_template.yaml b/tests/integration/fixtures/water_heater_template.yaml new file mode 100644 index 0000000000..b54ebed789 --- /dev/null +++ b/tests/integration/fixtures/water_heater_template.yaml @@ -0,0 +1,23 @@ +esphome: + name: water-heater-template-test +host: +api: +logger: + +water_heater: + - platform: template + id: test_boiler + name: Test Boiler + optimistic: true + current_temperature: !lambda "return 45.0f;" + # Note: No mode lambda - we want optimistic mode changes to stick + # A mode lambda would override mode changes in loop() + supported_modes: + - "off" + - eco + - gas + - performance + visual: + min_temperature: 30.0 + max_temperature: 85.0 + target_temperature_step: 0.5 diff --git a/tests/integration/test_water_heater_template.py b/tests/integration/test_water_heater_template.py new file mode 100644 index 0000000000..b5f1fb64c0 --- /dev/null +++ b/tests/integration/test_water_heater_template.py @@ -0,0 +1,109 @@ +"""Integration test for template water heater component.""" + +from __future__ import annotations + +import asyncio + +import aioesphomeapi +from aioesphomeapi import WaterHeaterInfo, WaterHeaterMode, WaterHeaterState +import pytest + +from .state_utils import InitialStateHelper +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_water_heater_template( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test template water heater basic state and mode changes.""" + loop = asyncio.get_running_loop() + async with run_compiled(yaml_config), api_client_connected() as client: + states: dict[int, aioesphomeapi.EntityState] = {} + gas_mode_future: asyncio.Future[WaterHeaterState] = loop.create_future() + eco_mode_future: asyncio.Future[WaterHeaterState] = loop.create_future() + + def on_state(state: aioesphomeapi.EntityState) -> None: + states[state.key] = state + if isinstance(state, WaterHeaterState): + # Wait for GAS mode + if state.mode == WaterHeaterMode.GAS and not gas_mode_future.done(): + gas_mode_future.set_result(state) + # Wait for ECO mode (we start at OFF, so test transitioning to ECO) + elif state.mode == WaterHeaterMode.ECO and not eco_mode_future.done(): + eco_mode_future.set_result(state) + + # Get entities and set up state synchronization + entities, services = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + water_heater_infos = [e for e in entities if isinstance(e, WaterHeaterInfo)] + assert len(water_heater_infos) == 1, ( + f"Expected exactly 1 water heater entity, got {len(water_heater_infos)}. Entity types: {[type(e).__name__ for e in entities]}" + ) + + test_water_heater = water_heater_infos[0] + + # Verify water heater entity info + assert test_water_heater.object_id == "test_boiler" + assert test_water_heater.name == "Test Boiler" + assert test_water_heater.min_temperature == 30.0 + assert test_water_heater.max_temperature == 85.0 + assert test_water_heater.target_temperature_step == 0.5 + + # Verify supported modes + supported_modes = test_water_heater.supported_modes + assert WaterHeaterMode.OFF in supported_modes, "Expected OFF in supported modes" + assert WaterHeaterMode.ECO in supported_modes, "Expected ECO in supported modes" + assert WaterHeaterMode.GAS in supported_modes, "Expected GAS in supported modes" + assert WaterHeaterMode.PERFORMANCE in supported_modes, ( + "Expected PERFORMANCE in supported modes" + ) + assert len(supported_modes) == 4, ( + f"Expected 4 supported modes, got {len(supported_modes)}: {supported_modes}" + ) + + # Subscribe with the wrapper that filters initial states + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + # Wait for all initial states to be broadcast + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + # Get initial state and verify + initial_state = initial_state_helper.initial_states.get(test_water_heater.key) + assert initial_state is not None, "Water heater initial state not found" + assert isinstance(initial_state, WaterHeaterState) + # Initial mode is OFF (default) since we don't have a mode lambda + # A mode lambda would override optimistic mode changes + assert initial_state.mode == WaterHeaterMode.OFF, ( + f"Expected initial mode OFF, got {initial_state.mode}" + ) + assert initial_state.current_temperature == 45.0, ( + f"Expected current temp 45.0, got {initial_state.current_temperature}" + ) + + # Test changing to GAS mode + client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.GAS) + + try: + gas_state = await asyncio.wait_for(gas_mode_future, timeout=5.0) + except TimeoutError: + pytest.fail("GAS mode change not received within 5 seconds") + + assert isinstance(gas_state, WaterHeaterState) + assert gas_state.mode == WaterHeaterMode.GAS + + # Test changing to ECO mode (from GAS) + client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.ECO) + + try: + eco_state = await asyncio.wait_for(eco_mode_future, timeout=5.0) + except TimeoutError: + pytest.fail("ECO mode change not received within 5 seconds") + + assert isinstance(eco_state, WaterHeaterState) + assert eco_state.mode == WaterHeaterMode.ECO