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:
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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_);
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]"
|
||||
|
||||
39
tests/components/hub75/test.esp32-s3-idf-rotate.yaml
Normal file
39
tests/components/hub75/test.esp32-s3-idf-rotate.yaml
Normal 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
|
||||
16
tests/components/water_heater/common.yaml
Normal file
16
tests/components/water_heater/common.yaml
Normal 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
|
||||
@@ -36,3 +36,4 @@ datetime:
|
||||
optimistic: yes
|
||||
event:
|
||||
update:
|
||||
water_heater:
|
||||
|
||||
23
tests/integration/fixtures/water_heater_template.yaml
Normal file
23
tests/integration/fixtures/water_heater_template.yaml
Normal 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
|
||||
109
tests/integration/test_water_heater_template.py
Normal file
109
tests/integration/test_water_heater_template.py
Normal 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
|
||||
Reference in New Issue
Block a user