1
0
mirror of https://github.com/esphome/esphome.git synced 2026-02-08 00:31:58 +00:00

Merge branch 'dev' into combine_logs

This commit is contained in:
J. Nick Koston
2026-01-04 11:57:00 -10:00
committed by GitHub
18 changed files with 514 additions and 82 deletions

View File

@@ -23,9 +23,11 @@ struct NVSData {
size_t len;
void set_data(const uint8_t *src, size_t size) {
this->data = std::make_unique<uint8_t[]>(size);
if (!this->data || this->len != size) {
this->data = std::make_unique<uint8_t[]>(size);
this->len = size;
}
memcpy(this->data.get(), src, size);
this->len = size;
}
};

View File

@@ -98,7 +98,13 @@ template<typename... Ts> class ESP32BLEStartScanAction : public Action<Ts...> {
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:

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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};

View File

@@ -22,9 +22,11 @@ struct NVSData {
size_t len;
void set_data(const uint8_t *src, size_t size) {
this->data = std::make_unique<uint8_t[]>(size);
if (!this->data || this->len != size) {
this->data = std::make_unique<uint8_t[]>(size);
this->len = size;
}
memcpy(this->data.get(), src, size);
this->len = size;
}
};

View File

@@ -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_);

View File

@@ -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]))

View File

@@ -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);
}

View File

@@ -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};

View File

@@ -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

View File

@@ -3,12 +3,16 @@
#ifdef USE_WIFI
#ifdef USE_LIBRETINY
#include <cinttypes>
#include <utility>
#include <algorithm>
#include "lwip/ip_addr.h"
#include "lwip/err.h"
#include "lwip/dns.h"
#include <FreeRTOS.h>
#include <queue.h>
#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<bool> sta, optional<bool> 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<size_t>(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<size_t>(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

View File

@@ -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]"

View File

@@ -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

View File

@@ -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

View File

@@ -36,3 +36,4 @@ datetime:
optimistic: yes
event:
update:
water_heater:

View File

@@ -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

View File

@@ -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