Compare commits

...

20 Commits

Author SHA1 Message Date
Jesse Hills
fb2f0ce62f Merge pull request #13915 from esphome/bump-2026.1.5
2026.1.5
2026-02-11 11:13:08 +13:00
Jesse Hills
a99f75ca71 Bump version to 2026.1.5 2026-02-11 08:45:06 +13:00
Sean Kelly
4168e8c30d [aqi] Fix AQI calculation for specific pm2.5 or pm10 readings (#13770) 2026-02-11 08:45:06 +13:00
Jonathan Swoboda
1f761902b6 [esp32] Set UV_CACHE_DIR inside data dir so Clean All clears it (#13888)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 07:48:20 +13:00
Clyde Stubbs
0b047c334d [lvgl] Fix crash with unconfigured top_layer (#13846) 2026-02-11 07:24:32 +13:00
tomaszduda23
a5dc4b0fce [nrf52,logger] fix printk (#13874) 2026-02-11 07:24:32 +13:00
J. Nick Koston
c1455ccc29 [dashboard] Close WebSocket after process exit to prevent zombie connections (#13834) 2026-02-11 07:24:32 +13:00
Jonathan Swoboda
438a0c4289 [ota] Fix CLI upload option shown when only http_request platform configured (#13784)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 07:24:32 +13:00
Jonathan Swoboda
9eee4c9924 [core] Add capacity check to register_component_ (#13778)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 07:24:32 +13:00
Jas Strong
eea7e9edff [rd03d] Revert incorrect field order swap (#13769)
Co-authored-by: jas <jas@asspa.in>
2026-02-11 07:24:32 +13:00
Jesse Hills
ab8ac72c4f Merge pull request #13757 from esphome/bump-2026.1.4
2026.1.4
2026-02-05 00:01:14 +13:00
Jesse Hills
1b3c9aa98e Bump version to 2026.1.4 2026-02-04 11:01:32 +01:00
Samuel Sieb
bafbd4235a [ultrasonic] adjust timeouts and bring the parameter back (#13738)
Co-authored-by: Samuel Sieb <samuel@sieb.net>
2026-02-04 11:01:31 +01:00
J. Nick Koston
900aab45f1 [wifi] Fix wifi.connected condition returning false in connect state listener automations (#13733) 2026-02-04 11:01:29 +01:00
J. Nick Koston
bc41d25657 [cse7766] Fix power reading stuck when load switches off (#13734) 2026-02-04 10:56:42 +01:00
J. Nick Koston
094d64f872 [http_request] Fix requests taking full timeout when response is already complete (#13649) 2026-02-04 10:56:42 +01:00
J. Nick Koston
b085585461 [core] Add missing uint32_t ID overloads for defer() and cancel_defer() (#13720) 2026-02-04 10:56:42 +01:00
rwrozelle
49ef4e00df [mqtt] resolve warnings related to use of ip.str() (#13719) 2026-02-04 10:56:42 +01:00
Jonathan Swoboda
8314ad9ca0 [max7219] Allocate buffer in constructor (#13660)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 10:56:42 +01:00
J0k3r2k1
5544f0d346 [mipi_spi] Fix log_pin() FlashStringHelper compatibility (#13624)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-02-04 10:56:34 +01:00
42 changed files with 528 additions and 167 deletions

View File

@@ -528,7 +528,7 @@ esphome/components/uart/packet_transport/* @clydebarrow
esphome/components/udp/* @clydebarrow esphome/components/udp/* @clydebarrow
esphome/components/ufire_ec/* @pvizeli esphome/components/ufire_ec/* @pvizeli
esphome/components/ufire_ise/* @pvizeli esphome/components/ufire_ise/* @pvizeli
esphome/components/ultrasonic/* @OttoWinter esphome/components/ultrasonic/* @ssieb @swoboda1337
esphome/components/update/* @jesserockz esphome/components/update/* @jesserockz
esphome/components/uponor_smatrix/* @kroimon esphome/components/uponor_smatrix/* @kroimon
esphome/components/usb_cdc_acm/* @kbx81 esphome/components/usb_cdc_acm/* @kbx81

View File

@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version # could be handy for archiving the generated documentation or if some version
# control system is used. # control system is used.
PROJECT_NUMBER = 2026.1.3 PROJECT_NUMBER = 2026.1.5
# Using the PROJECT_BRIEF tag one can provide an optional one line description # Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a # for a project that appears at the top of each page and should give viewer a

View File

@@ -287,8 +287,13 @@ def has_api() -> bool:
def has_ota() -> bool: def has_ota() -> bool:
"""Check if OTA is available.""" """Check if OTA upload is available (requires platform: esphome)."""
return CONF_OTA in CORE.config if CONF_OTA not in CORE.config:
return False
return any(
ota_item.get(CONF_PLATFORM) == CONF_ESPHOME
for ota_item in CORE.config[CONF_OTA]
)
def has_mqtt_ip_lookup() -> bool: def has_mqtt_ip_lookup() -> bool:

View File

@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <algorithm>
#include <cmath> #include <cmath>
#include <limits> #include <limits>
#include "abstract_aqi_calculator.h" #include "abstract_aqi_calculator.h"
@@ -14,7 +15,11 @@ class AQICalculator : public AbstractAQICalculator {
float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID); float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID);
float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID); float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID);
return static_cast<uint16_t>(std::round((pm2_5_index < pm10_0_index) ? pm10_0_index : pm2_5_index)); float aqi = std::max(pm2_5_index, pm10_0_index);
if (aqi < 0.0f) {
aqi = 0.0f;
}
return static_cast<uint16_t>(std::lround(aqi));
} }
protected: protected:
@@ -22,13 +27,27 @@ class AQICalculator : public AbstractAQICalculator {
static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 50}, {51, 100}, {101, 150}, {151, 200}, {201, 300}, {301, 500}}; static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 50}, {51, 100}, {101, 150}, {151, 200}, {201, 300}, {301, 500}};
static constexpr float PM2_5_GRID[NUM_LEVELS][2] = {{0.0f, 9.0f}, {9.1f, 35.4f}, static constexpr float PM2_5_GRID[NUM_LEVELS][2] = {
{35.5f, 55.4f}, {55.5f, 125.4f}, // clang-format off
{125.5f, 225.4f}, {225.5f, std::numeric_limits<float>::max()}}; {0.0f, 9.1f},
{9.1f, 35.5f},
{35.5f, 55.5f},
{55.5f, 125.5f},
{125.5f, 225.5f},
{225.5f, std::numeric_limits<float>::max()}
// clang-format on
};
static constexpr float PM10_0_GRID[NUM_LEVELS][2] = {{0.0f, 54.0f}, {55.0f, 154.0f}, static constexpr float PM10_0_GRID[NUM_LEVELS][2] = {
{155.0f, 254.0f}, {255.0f, 354.0f}, // clang-format off
{355.0f, 424.0f}, {425.0f, std::numeric_limits<float>::max()}}; {0.0f, 55.0f},
{55.0f, 155.0f},
{155.0f, 255.0f},
{255.0f, 355.0f},
{355.0f, 425.0f},
{425.0f, std::numeric_limits<float>::max()}
// clang-format on
};
static float calculate_index(float value, const float array[NUM_LEVELS][2]) { static float calculate_index(float value, const float array[NUM_LEVELS][2]) {
int grid_index = get_grid_index(value, array); int grid_index = get_grid_index(value, array);
@@ -45,7 +64,10 @@ class AQICalculator : public AbstractAQICalculator {
static int get_grid_index(float value, const float array[NUM_LEVELS][2]) { static int get_grid_index(float value, const float array[NUM_LEVELS][2]) {
for (int i = 0; i < NUM_LEVELS; i++) { for (int i = 0; i < NUM_LEVELS; i++) {
if (value >= array[i][0] && value <= array[i][1]) { const bool in_range =
(value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive
: (value < array[i][1])); // others exclusive on hi
if (in_range) {
return i; return i;
} }
} }

View File

@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <algorithm>
#include <cmath> #include <cmath>
#include <limits> #include <limits>
#include "abstract_aqi_calculator.h" #include "abstract_aqi_calculator.h"
@@ -12,7 +13,11 @@ class CAQICalculator : public AbstractAQICalculator {
float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID); float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID);
float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID); float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID);
return static_cast<uint16_t>(std::round((pm2_5_index < pm10_0_index) ? pm10_0_index : pm2_5_index)); float aqi = std::max(pm2_5_index, pm10_0_index);
if (aqi < 0.0f) {
aqi = 0.0f;
}
return static_cast<uint16_t>(std::lround(aqi));
} }
protected: protected:
@@ -21,10 +26,24 @@ class CAQICalculator : public AbstractAQICalculator {
static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}, {101, 400}}; static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}, {101, 400}};
static constexpr float PM2_5_GRID[NUM_LEVELS][2] = { static constexpr float PM2_5_GRID[NUM_LEVELS][2] = {
{0.0f, 15.0f}, {15.1f, 30.0f}, {30.1f, 55.0f}, {55.1f, 110.0f}, {110.1f, std::numeric_limits<float>::max()}}; // clang-format off
{0.0f, 15.1f},
{15.1f, 30.1f},
{30.1f, 55.1f},
{55.1f, 110.1f},
{110.1f, std::numeric_limits<float>::max()}
// clang-format on
};
static constexpr float PM10_0_GRID[NUM_LEVELS][2] = { static constexpr float PM10_0_GRID[NUM_LEVELS][2] = {
{0.0f, 25.0f}, {25.1f, 50.0f}, {50.1f, 90.0f}, {90.1f, 180.0f}, {180.1f, std::numeric_limits<float>::max()}}; // clang-format off
{0.0f, 25.1f},
{25.1f, 50.1f},
{50.1f, 90.1f},
{90.1f, 180.1f},
{180.1f, std::numeric_limits<float>::max()}
// clang-format on
};
static float calculate_index(float value, const float array[NUM_LEVELS][2]) { static float calculate_index(float value, const float array[NUM_LEVELS][2]) {
int grid_index = get_grid_index(value, array); int grid_index = get_grid_index(value, array);
@@ -42,7 +61,10 @@ class CAQICalculator : public AbstractAQICalculator {
static int get_grid_index(float value, const float array[NUM_LEVELS][2]) { static int get_grid_index(float value, const float array[NUM_LEVELS][2]) {
for (int i = 0; i < NUM_LEVELS; i++) { for (int i = 0; i < NUM_LEVELS; i++) {
if (value >= array[i][0] && value <= array[i][1]) { const bool in_range =
(value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive
: (value < array[i][1])); // others exclusive on hi
if (in_range) {
return i; return i;
} }
} }

View File

@@ -152,6 +152,10 @@ void CSE7766Component::parse_data_() {
if (this->power_sensor_ != nullptr) { if (this->power_sensor_ != nullptr) {
this->power_sensor_->publish_state(power); this->power_sensor_->publish_state(power);
} }
} else if (this->power_sensor_ != nullptr) {
// No valid power measurement from chip - publish 0W to avoid stale readings
// This typically happens when current is below the measurable threshold (~50mA)
this->power_sensor_->publish_state(0.0f);
} }
float current = 0.0f; float current = 0.0f;

View File

@@ -1026,6 +1026,10 @@ async def to_code(config):
Path(__file__).parent / "iram_fix.py.script", Path(__file__).parent / "iram_fix.py.script",
) )
# Set the uv cache inside the data dir so "Clean All" clears it.
# Avoids persistent corrupted cache from mid-stream download failures.
os.environ["UV_CACHE_DIR"] = str(CORE.relative_internal_path(".uv_cache"))
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
cg.add_platformio_option("framework", "espidf") cg.add_platformio_option("framework", "espidf")
cg.add_build_flag("-DUSE_ESP_IDF") cg.add_build_flag("-DUSE_ESP_IDF")

View File

@@ -193,11 +193,14 @@ bool Esp32HostedUpdate::fetch_manifest_() {
int read_or_error = container->read(buf, sizeof(buf)); int read_or_error = container->read(buf, sizeof(buf));
App.feed_wdt(); App.feed_wdt();
yield(); yield();
auto result = http_request::http_read_loop_result(read_or_error, last_data_time, read_timeout); auto result =
http_request::http_read_loop_result(read_or_error, last_data_time, read_timeout, container->is_read_complete());
if (result == http_request::HttpReadLoopResult::RETRY) if (result == http_request::HttpReadLoopResult::RETRY)
continue; continue;
// Note: COMPLETE is currently unreachable since the loop condition checks bytes_read < content_length,
// but this is defensive code in case chunked transfer encoding support is added in the future.
if (result != http_request::HttpReadLoopResult::DATA) if (result != http_request::HttpReadLoopResult::DATA)
break; // ERROR or TIMEOUT break; // COMPLETE, ERROR, or TIMEOUT
json_str.append(reinterpret_cast<char *>(buf), read_or_error); json_str.append(reinterpret_cast<char *>(buf), read_or_error);
} }
container->end(); container->end();
@@ -318,9 +321,14 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() {
App.feed_wdt(); App.feed_wdt();
yield(); yield();
auto result = http_request::http_read_loop_result(read_or_error, last_data_time, read_timeout); auto result =
http_request::http_read_loop_result(read_or_error, last_data_time, read_timeout, container->is_read_complete());
if (result == http_request::HttpReadLoopResult::RETRY) if (result == http_request::HttpReadLoopResult::RETRY)
continue; continue;
// Note: COMPLETE is currently unreachable since the loop condition checks bytes_read < content_length,
// but this is defensive code in case chunked transfer encoding support is added in the future.
if (result == http_request::HttpReadLoopResult::COMPLETE)
break;
if (result != http_request::HttpReadLoopResult::DATA) { if (result != http_request::HttpReadLoopResult::DATA) {
if (result == http_request::HttpReadLoopResult::TIMEOUT) { if (result == http_request::HttpReadLoopResult::TIMEOUT) {
ESP_LOGE(TAG, "Timeout reading firmware data"); ESP_LOGE(TAG, "Timeout reading firmware data");

View File

@@ -26,6 +26,7 @@ struct Header {
enum HttpStatus { enum HttpStatus {
HTTP_STATUS_OK = 200, HTTP_STATUS_OK = 200,
HTTP_STATUS_NO_CONTENT = 204, HTTP_STATUS_NO_CONTENT = 204,
HTTP_STATUS_RESET_CONTENT = 205,
HTTP_STATUS_PARTIAL_CONTENT = 206, HTTP_STATUS_PARTIAL_CONTENT = 206,
/* 3xx - Redirection */ /* 3xx - Redirection */
@@ -126,19 +127,21 @@ struct HttpReadResult {
/// Result of processing a non-blocking read with timeout (for manual loops) /// Result of processing a non-blocking read with timeout (for manual loops)
enum class HttpReadLoopResult : uint8_t { enum class HttpReadLoopResult : uint8_t {
DATA, ///< Data was read, process it DATA, ///< Data was read, process it
RETRY, ///< No data yet, already delayed, caller should continue loop COMPLETE, ///< All content has been read, caller should exit loop
ERROR, ///< Read error, caller should exit loop RETRY, ///< No data yet, already delayed, caller should continue loop
TIMEOUT, ///< Timeout waiting for data, caller should exit loop ERROR, ///< Read error, caller should exit loop
TIMEOUT, ///< Timeout waiting for data, caller should exit loop
}; };
/// Process a read result with timeout tracking and delay handling /// Process a read result with timeout tracking and delay handling
/// @param bytes_read_or_error Return value from read() - positive for bytes read, negative for error /// @param bytes_read_or_error Return value from read() - positive for bytes read, negative for error
/// @param last_data_time Time of last successful read, updated when data received /// @param last_data_time Time of last successful read, updated when data received
/// @param timeout_ms Maximum time to wait for data /// @param timeout_ms Maximum time to wait for data
/// @return DATA if data received, RETRY if should continue loop, ERROR/TIMEOUT if should exit /// @param is_read_complete Whether all expected content has been read (from HttpContainer::is_read_complete())
inline HttpReadLoopResult http_read_loop_result(int bytes_read_or_error, uint32_t &last_data_time, /// @return How the caller should proceed - see HttpReadLoopResult enum
uint32_t timeout_ms) { inline HttpReadLoopResult http_read_loop_result(int bytes_read_or_error, uint32_t &last_data_time, uint32_t timeout_ms,
bool is_read_complete) {
if (bytes_read_or_error > 0) { if (bytes_read_or_error > 0) {
last_data_time = millis(); last_data_time = millis();
return HttpReadLoopResult::DATA; return HttpReadLoopResult::DATA;
@@ -146,7 +149,10 @@ inline HttpReadLoopResult http_read_loop_result(int bytes_read_or_error, uint32_
if (bytes_read_or_error < 0) { if (bytes_read_or_error < 0) {
return HttpReadLoopResult::ERROR; return HttpReadLoopResult::ERROR;
} }
// bytes_read_or_error == 0: no data available yet // bytes_read_or_error == 0: either "no data yet" or "all content read"
if (is_read_complete) {
return HttpReadLoopResult::COMPLETE;
}
if (millis() - last_data_time >= timeout_ms) { if (millis() - last_data_time >= timeout_ms) {
return HttpReadLoopResult::TIMEOUT; return HttpReadLoopResult::TIMEOUT;
} }
@@ -159,9 +165,9 @@ class HttpRequestComponent;
class HttpContainer : public Parented<HttpRequestComponent> { class HttpContainer : public Parented<HttpRequestComponent> {
public: public:
virtual ~HttpContainer() = default; virtual ~HttpContainer() = default;
size_t content_length; size_t content_length{0};
int status_code; int status_code{-1}; ///< -1 indicates no response received yet
uint32_t duration_ms; uint32_t duration_ms{0};
/** /**
* @brief Read data from the HTTP response body. * @brief Read data from the HTTP response body.
@@ -194,9 +200,24 @@ class HttpContainer : public Parented<HttpRequestComponent> {
virtual void end() = 0; virtual void end() = 0;
void set_secure(bool secure) { this->secure_ = secure; } void set_secure(bool secure) { this->secure_ = secure; }
void set_chunked(bool chunked) { this->is_chunked_ = chunked; }
size_t get_bytes_read() const { return this->bytes_read_; } size_t get_bytes_read() const { return this->bytes_read_; }
/// Check if all expected content has been read
/// For chunked responses, returns false (completion detected via read() returning error/EOF)
bool is_read_complete() const {
// Per RFC 9112, these responses have no body:
// - 1xx (Informational), 204 No Content, 205 Reset Content, 304 Not Modified
if ((this->status_code >= 100 && this->status_code < 200) || this->status_code == HTTP_STATUS_NO_CONTENT ||
this->status_code == HTTP_STATUS_RESET_CONTENT || this->status_code == HTTP_STATUS_NOT_MODIFIED) {
return true;
}
// For non-chunked responses, complete when bytes_read >= content_length
// This handles both Content-Length: 0 and Content-Length: N cases
return !this->is_chunked_ && this->bytes_read_ >= this->content_length;
}
/** /**
* @brief Get response headers. * @brief Get response headers.
* *
@@ -209,6 +230,7 @@ class HttpContainer : public Parented<HttpRequestComponent> {
protected: protected:
size_t bytes_read_{0}; size_t bytes_read_{0};
bool secure_{false}; bool secure_{false};
bool is_chunked_{false}; ///< True if response uses chunked transfer encoding
std::map<std::string, std::list<std::string>> response_headers_{}; std::map<std::string, std::list<std::string>> response_headers_{};
}; };
@@ -219,7 +241,7 @@ class HttpContainer : public Parented<HttpRequestComponent> {
/// @param total_size Total bytes to read /// @param total_size Total bytes to read
/// @param chunk_size Maximum bytes per read call /// @param chunk_size Maximum bytes per read call
/// @param timeout_ms Read timeout in milliseconds /// @param timeout_ms Read timeout in milliseconds
/// @return HttpReadResult with status and error_code on failure /// @return HttpReadResult with status and error_code on failure; use container->get_bytes_read() for total bytes read
inline HttpReadResult http_read_fully(HttpContainer *container, uint8_t *buffer, size_t total_size, size_t chunk_size, inline HttpReadResult http_read_fully(HttpContainer *container, uint8_t *buffer, size_t total_size, size_t chunk_size,
uint32_t timeout_ms) { uint32_t timeout_ms) {
size_t read_index = 0; size_t read_index = 0;
@@ -231,9 +253,11 @@ inline HttpReadResult http_read_fully(HttpContainer *container, uint8_t *buffer,
App.feed_wdt(); App.feed_wdt();
yield(); yield();
auto result = http_read_loop_result(read_bytes_or_error, last_data_time, timeout_ms); auto result = http_read_loop_result(read_bytes_or_error, last_data_time, timeout_ms, container->is_read_complete());
if (result == HttpReadLoopResult::RETRY) if (result == HttpReadLoopResult::RETRY)
continue; continue;
if (result == HttpReadLoopResult::COMPLETE)
break; // Server sent less data than requested, but transfer is complete
if (result == HttpReadLoopResult::ERROR) if (result == HttpReadLoopResult::ERROR)
return {HttpReadStatus::ERROR, read_bytes_or_error}; return {HttpReadStatus::ERROR, read_bytes_or_error};
if (result == HttpReadLoopResult::TIMEOUT) if (result == HttpReadLoopResult::TIMEOUT)
@@ -393,11 +417,12 @@ template<typename... Ts> class HttpRequestSendAction : public Action<Ts...> {
int read_or_error = container->read(buf + read_index, std::min<size_t>(max_length - read_index, 512)); int read_or_error = container->read(buf + read_index, std::min<size_t>(max_length - read_index, 512));
App.feed_wdt(); App.feed_wdt();
yield(); yield();
auto result = http_read_loop_result(read_or_error, last_data_time, read_timeout); auto result =
http_read_loop_result(read_or_error, last_data_time, read_timeout, container->is_read_complete());
if (result == HttpReadLoopResult::RETRY) if (result == HttpReadLoopResult::RETRY)
continue; continue;
if (result != HttpReadLoopResult::DATA) if (result != HttpReadLoopResult::DATA)
break; // ERROR or TIMEOUT break; // COMPLETE, ERROR, or TIMEOUT
read_index += read_or_error; read_index += read_or_error;
} }
response_body.reserve(read_index); response_body.reserve(read_index);

View File

@@ -135,9 +135,23 @@ std::shared_ptr<HttpContainer> HttpRequestArduino::perform(const std::string &ur
// When cast to size_t, -1 becomes SIZE_MAX (4294967295 on 32-bit). // When cast to size_t, -1 becomes SIZE_MAX (4294967295 on 32-bit).
// The read() method handles this: bytes_read_ can never reach SIZE_MAX, so the // The read() method handles this: bytes_read_ can never reach SIZE_MAX, so the
// early return check (bytes_read_ >= content_length) will never trigger. // early return check (bytes_read_ >= content_length) will never trigger.
//
// TODO: Chunked transfer encoding is NOT properly supported on Arduino.
// The implementation in #7884 was incomplete - it only works correctly on ESP-IDF where
// esp_http_client_read() decodes chunks internally. On Arduino, using getStreamPtr()
// returns raw TCP data with chunk framing (e.g., "12a\r\n{json}\r\n0\r\n\r\n") instead
// of decoded content. This wasn't noticed because requests would complete and payloads
// were only examined on IDF. The long transfer times were also masked by the misleading
// "HTTP on Arduino version >= 3.1 is **very** slow" warning above. This causes two issues:
// 1. Response body is corrupted - contains chunk size headers mixed with data
// 2. Cannot detect end of transfer - connection stays open (keep-alive), causing timeout
// The proper fix would be to use getString() for chunked responses, which decodes chunks
// internally, but this buffers the entire response in memory.
int content_length = container->client_.getSize(); int content_length = container->client_.getSize();
ESP_LOGD(TAG, "Content-Length: %d", content_length); ESP_LOGD(TAG, "Content-Length: %d", content_length);
container->content_length = (size_t) content_length; container->content_length = (size_t) content_length;
// -1 (SIZE_MAX when cast to size_t) means chunked transfer encoding
container->set_chunked(content_length == -1);
container->duration_ms = millis() - start; container->duration_ms = millis() - start;
return container; return container;
@@ -178,9 +192,9 @@ int HttpContainerArduino::read(uint8_t *buf, size_t max_len) {
if (bufsize == 0) { if (bufsize == 0) {
this->duration_ms += (millis() - start); this->duration_ms += (millis() - start);
// Check if we've read all expected content (only valid when content_length is known and not SIZE_MAX) // Check if we've read all expected content (non-chunked only)
// For chunked encoding (content_length == SIZE_MAX), we can't use this check // For chunked encoding (content_length == SIZE_MAX), is_read_complete() returns false
if (this->content_length > 0 && this->bytes_read_ >= this->content_length) { if (this->is_read_complete()) {
return 0; // All content read successfully return 0; // All content read successfully
} }
// No data available - check if connection is still open // No data available - check if connection is still open

View File

@@ -155,6 +155,7 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const std::string &url, c
// esp_http_client_fetch_headers() returns 0 for chunked transfer encoding (no Content-Length header). // esp_http_client_fetch_headers() returns 0 for chunked transfer encoding (no Content-Length header).
// The read() method handles content_length == 0 specially to support chunked responses. // The read() method handles content_length == 0 specially to support chunked responses.
container->content_length = esp_http_client_fetch_headers(client); container->content_length = esp_http_client_fetch_headers(client);
container->set_chunked(esp_http_client_is_chunked_response(client));
container->feed_wdt(); container->feed_wdt();
container->status_code = esp_http_client_get_status_code(client); container->status_code = esp_http_client_get_status_code(client);
container->feed_wdt(); container->feed_wdt();
@@ -190,6 +191,7 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const std::string &url, c
container->feed_wdt(); container->feed_wdt();
container->content_length = esp_http_client_fetch_headers(client); container->content_length = esp_http_client_fetch_headers(client);
container->set_chunked(esp_http_client_is_chunked_response(client));
container->feed_wdt(); container->feed_wdt();
container->status_code = esp_http_client_get_status_code(client); container->status_code = esp_http_client_get_status_code(client);
container->feed_wdt(); container->feed_wdt();
@@ -234,10 +236,9 @@ int HttpContainerIDF::read(uint8_t *buf, size_t max_len) {
const uint32_t start = millis(); const uint32_t start = millis();
watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout()); watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout());
// Check if we've already read all expected content // Check if we've already read all expected content (non-chunked only)
// Skip this check when content_length is 0 (chunked transfer encoding or unknown length) // For chunked responses (content_length == 0), esp_http_client_read() handles EOF
// For chunked responses, esp_http_client_read() will return 0 when all data is received if (this->is_read_complete()) {
if (this->content_length > 0 && this->bytes_read_ >= this->content_length) {
return 0; // All content read successfully return 0; // All content read successfully
} }

View File

@@ -130,9 +130,13 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
App.feed_wdt(); App.feed_wdt();
yield(); yield();
auto result = http_read_loop_result(bufsize_or_error, last_data_time, read_timeout); auto result = http_read_loop_result(bufsize_or_error, last_data_time, read_timeout, container->is_read_complete());
if (result == HttpReadLoopResult::RETRY) if (result == HttpReadLoopResult::RETRY)
continue; continue;
// Note: COMPLETE is currently unreachable since the loop condition checks bytes_read < content_length,
// but this is defensive code in case chunked transfer encoding support is added for OTA in the future.
if (result == HttpReadLoopResult::COMPLETE)
break;
if (result != HttpReadLoopResult::DATA) { if (result != HttpReadLoopResult::DATA) {
if (result == HttpReadLoopResult::TIMEOUT) { if (result == HttpReadLoopResult::TIMEOUT) {
ESP_LOGE(TAG, "Timeout reading data"); ESP_LOGE(TAG, "Timeout reading data");

View File

@@ -68,7 +68,7 @@ void HOT Logger::write_msg_(const char *msg, size_t len) {
#ifdef CONFIG_PRINTK #ifdef CONFIG_PRINTK
// Requires the debug component and an active SWD connection. // Requires the debug component and an active SWD connection.
// It is used for pyocd rtt -t nrf52840 // It is used for pyocd rtt -t nrf52840
k_str_out(const_cast<char *>(msg), len); printk("%.*s", static_cast<int>(len), msg);
#endif #endif
if (this->uart_dev_ == nullptr) { if (this->uart_dev_ == nullptr) {
return; return;

View File

@@ -436,6 +436,7 @@ def container_schema(widget_type: WidgetType, extras=None):
schema = schema.extend(widget_type.schema) schema = schema.extend(widget_type.schema)
def validator(value): def validator(value):
value = value or {}
return append_layout_schema(schema, value)(value) return append_layout_schema(schema, value)(value)
return validator return validator

View File

@@ -28,11 +28,10 @@ CONFIG_SCHEMA = (
async def to_code(config): async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID]) var = cg.new_Pvariable(config[CONF_ID], config[CONF_NUM_CHIPS])
await spi.register_spi_device(var, config, write_only=True) await spi.register_spi_device(var, config, write_only=True)
await display.register_display(var, config) await display.register_display(var, config)
cg.add(var.set_num_chips(config[CONF_NUM_CHIPS]))
cg.add(var.set_intensity(config[CONF_INTENSITY])) cg.add(var.set_intensity(config[CONF_INTENSITY]))
cg.add(var.set_reverse(config[CONF_REVERSE_ENABLE])) cg.add(var.set_reverse(config[CONF_REVERSE_ENABLE]))

View File

@@ -3,8 +3,7 @@
#include "esphome/core/helpers.h" #include "esphome/core/helpers.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
namespace esphome { namespace esphome::max7219 {
namespace max7219 {
static const char *const TAG = "max7219"; static const char *const TAG = "max7219";
@@ -115,12 +114,14 @@ const uint8_t MAX7219_ASCII_TO_RAW[95] PROGMEM = {
}; };
float MAX7219Component::get_setup_priority() const { return setup_priority::PROCESSOR; } float MAX7219Component::get_setup_priority() const { return setup_priority::PROCESSOR; }
MAX7219Component::MAX7219Component(uint8_t num_chips) : num_chips_(num_chips) {
this->buffer_ = new uint8_t[this->num_chips_ * 8]; // NOLINT
memset(this->buffer_, 0, this->num_chips_ * 8);
}
void MAX7219Component::setup() { void MAX7219Component::setup() {
this->spi_setup(); this->spi_setup();
this->buffer_ = new uint8_t[this->num_chips_ * 8]; // NOLINT
for (uint8_t i = 0; i < this->num_chips_ * 8; i++)
this->buffer_[i] = 0;
// let's assume the user has all 8 digits connected, only important in daisy chained setups anyway // let's assume the user has all 8 digits connected, only important in daisy chained setups anyway
this->send_to_all_(MAX7219_REGISTER_SCAN_LIMIT, 7); this->send_to_all_(MAX7219_REGISTER_SCAN_LIMIT, 7);
// let's use our own ASCII -> led pattern encoding // let's use our own ASCII -> led pattern encoding
@@ -229,7 +230,6 @@ void MAX7219Component::set_intensity(uint8_t intensity) {
this->intensity_ = intensity; this->intensity_ = intensity;
} }
} }
void MAX7219Component::set_num_chips(uint8_t num_chips) { this->num_chips_ = num_chips; }
uint8_t MAX7219Component::strftime(uint8_t pos, const char *format, ESPTime time) { uint8_t MAX7219Component::strftime(uint8_t pos, const char *format, ESPTime time) {
char buffer[64]; char buffer[64];
@@ -240,5 +240,4 @@ uint8_t MAX7219Component::strftime(uint8_t pos, const char *format, ESPTime time
} }
uint8_t MAX7219Component::strftime(const char *format, ESPTime time) { return this->strftime(0, format, time); } uint8_t MAX7219Component::strftime(const char *format, ESPTime time) { return this->strftime(0, format, time); }
} // namespace max7219 } // namespace esphome::max7219
} // namespace esphome

View File

@@ -6,8 +6,7 @@
#include "esphome/components/spi/spi.h" #include "esphome/components/spi/spi.h"
#include "esphome/components/display/display.h" #include "esphome/components/display/display.h"
namespace esphome { namespace esphome::max7219 {
namespace max7219 {
class MAX7219Component; class MAX7219Component;
@@ -17,6 +16,8 @@ class MAX7219Component : public PollingComponent,
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW, public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW,
spi::CLOCK_PHASE_LEADING, spi::DATA_RATE_1MHZ> { spi::CLOCK_PHASE_LEADING, spi::DATA_RATE_1MHZ> {
public: public:
explicit MAX7219Component(uint8_t num_chips);
void set_writer(max7219_writer_t &&writer); void set_writer(max7219_writer_t &&writer);
void setup() override; void setup() override;
@@ -30,7 +31,6 @@ class MAX7219Component : public PollingComponent,
void display(); void display();
void set_intensity(uint8_t intensity); void set_intensity(uint8_t intensity);
void set_num_chips(uint8_t num_chips);
void set_reverse(bool reverse) { this->reverse_ = reverse; }; void set_reverse(bool reverse) { this->reverse_ = reverse; };
/// Evaluate the printf-format and print the result at the given position. /// Evaluate the printf-format and print the result at the given position.
@@ -56,10 +56,9 @@ class MAX7219Component : public PollingComponent,
uint8_t intensity_{15}; // Intensity of the display from 0 to 15 (most) uint8_t intensity_{15}; // Intensity of the display from 0 to 15 (most)
bool intensity_changed_{}; // True if we need to re-send the intensity bool intensity_changed_{}; // True if we need to re-send the intensity
uint8_t num_chips_{1}; uint8_t num_chips_{1};
uint8_t *buffer_; uint8_t *buffer_{nullptr};
bool reverse_{false}; bool reverse_{false};
max7219_writer_t writer_{}; max7219_writer_t writer_{};
}; };
} // namespace max7219 } // namespace esphome::max7219
} // namespace esphome

View File

@@ -1,6 +1,39 @@
#include "mipi_spi.h" #include "mipi_spi.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
namespace esphome { namespace esphome::mipi_spi {
namespace mipi_spi {} // namespace mipi_spi
} // namespace esphome void internal_dump_config(const char *model, int width, int height, int offset_width, int offset_height, uint8_t madctl,
bool invert_colors, int display_bits, bool is_big_endian, const optional<uint8_t> &brightness,
GPIOPin *cs, GPIOPin *reset, GPIOPin *dc, int spi_mode, uint32_t data_rate, int bus_width) {
ESP_LOGCONFIG(TAG,
"MIPI_SPI Display\n"
" Model: %s\n"
" Width: %d\n"
" Height: %d\n"
" Swap X/Y: %s\n"
" Mirror X: %s\n"
" Mirror Y: %s\n"
" Invert colors: %s\n"
" Color order: %s\n"
" Display pixels: %d bits\n"
" Endianness: %s\n"
" SPI Mode: %d\n"
" SPI Data rate: %uMHz\n"
" SPI Bus width: %d",
model, width, height, YESNO(madctl & MADCTL_MV), YESNO(madctl & (MADCTL_MX | MADCTL_XFLIP)),
YESNO(madctl & (MADCTL_MY | MADCTL_YFLIP)), YESNO(invert_colors), (madctl & MADCTL_BGR) ? "BGR" : "RGB",
display_bits, is_big_endian ? "Big" : "Little", spi_mode, static_cast<unsigned>(data_rate / 1000000),
bus_width);
LOG_PIN(" CS Pin: ", cs);
LOG_PIN(" Reset Pin: ", reset);
LOG_PIN(" DC Pin: ", dc);
if (offset_width != 0)
ESP_LOGCONFIG(TAG, " Offset width: %d", offset_width);
if (offset_height != 0)
ESP_LOGCONFIG(TAG, " Offset height: %d", offset_height);
if (brightness.has_value())
ESP_LOGCONFIG(TAG, " Brightness: %u", brightness.value());
}
} // namespace esphome::mipi_spi

View File

@@ -63,6 +63,11 @@ enum BusType {
BUS_TYPE_SINGLE_16 = 16, // Single bit bus, but 16 bits per transfer BUS_TYPE_SINGLE_16 = 16, // Single bit bus, but 16 bits per transfer
}; };
// Helper function for dump_config - defined in mipi_spi.cpp to allow use of LOG_PIN macro
void internal_dump_config(const char *model, int width, int height, int offset_width, int offset_height, uint8_t madctl,
bool invert_colors, int display_bits, bool is_big_endian, const optional<uint8_t> &brightness,
GPIOPin *cs, GPIOPin *reset, GPIOPin *dc, int spi_mode, uint32_t data_rate, int bus_width);
/** /**
* Base class for MIPI SPI displays. * Base class for MIPI SPI displays.
* All the methods are defined here in the header file, as it is not possible to define templated methods in a cpp file. * All the methods are defined here in the header file, as it is not possible to define templated methods in a cpp file.
@@ -201,40 +206,9 @@ class MipiSpi : public display::Display,
} }
void dump_config() override { void dump_config() override {
esph_log_config(TAG, internal_dump_config(this->model_, WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT, this->madctl_, this->invert_colors_,
"MIPI_SPI Display\n" DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, this->cs_, this->reset_pin_, this->dc_pin_,
" Model: %s\n" this->mode_, this->data_rate_, BUS_TYPE);
" Width: %u\n"
" Height: %u",
this->model_, WIDTH, HEIGHT);
if constexpr (OFFSET_WIDTH != 0)
esph_log_config(TAG, " Offset width: %u", OFFSET_WIDTH);
if constexpr (OFFSET_HEIGHT != 0)
esph_log_config(TAG, " Offset height: %u", OFFSET_HEIGHT);
esph_log_config(TAG,
" Swap X/Y: %s\n"
" Mirror X: %s\n"
" Mirror Y: %s\n"
" Invert colors: %s\n"
" Color order: %s\n"
" Display pixels: %d bits\n"
" Endianness: %s\n",
YESNO(this->madctl_ & MADCTL_MV), YESNO(this->madctl_ & (MADCTL_MX | MADCTL_XFLIP)),
YESNO(this->madctl_ & (MADCTL_MY | MADCTL_YFLIP)), YESNO(this->invert_colors_),
this->madctl_ & MADCTL_BGR ? "BGR" : "RGB", DISPLAYPIXEL * 8, IS_BIG_ENDIAN ? "Big" : "Little");
if (this->brightness_.has_value())
esph_log_config(TAG, " Brightness: %u", this->brightness_.value());
if (this->cs_ != nullptr)
esph_log_config(TAG, " CS Pin: %s", this->cs_->dump_summary().c_str());
if (this->reset_pin_ != nullptr)
esph_log_config(TAG, " Reset Pin: %s", this->reset_pin_->dump_summary().c_str());
if (this->dc_pin_ != nullptr)
esph_log_config(TAG, " DC Pin: %s", this->dc_pin_->dump_summary().c_str());
esph_log_config(TAG,
" SPI Mode: %d\n"
" SPI Data rate: %dMHz\n"
" SPI Bus width: %d",
this->mode_, static_cast<unsigned>(this->data_rate_ / 1000000), BUS_TYPE);
} }
protected: protected:

View File

@@ -139,7 +139,8 @@ class MQTTBackendESP32 final : public MQTTBackend {
this->lwt_retain_ = retain; this->lwt_retain_ = retain;
} }
void set_server(network::IPAddress ip, uint16_t port) final { void set_server(network::IPAddress ip, uint16_t port) final {
this->host_ = ip.str(); char ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
this->host_ = ip.str_to(ip_buf);
this->port_ = port; this->port_ = port;
} }
void set_server(const char *host, uint16_t port) final { void set_server(const char *host, uint16_t port) final {

View File

@@ -132,18 +132,15 @@ void RD03DComponent::process_frame_() {
// Header is 4 bytes, each target is 8 bytes // Header is 4 bytes, each target is 8 bytes
uint8_t offset = FRAME_HEADER_SIZE + (i * TARGET_DATA_SIZE); uint8_t offset = FRAME_HEADER_SIZE + (i * TARGET_DATA_SIZE);
// Extract raw bytes for this target // Extract raw bytes for this target (per datasheet Table 5-2: X, Y, Speed, Resolution)
// Note: Despite datasheet Table 5-2 showing order as X, Y, Speed, Resolution,
// actual radar output has Resolution before Speed (verified empirically -
// stationary targets were showing non-zero speed with original field order)
uint8_t x_low = this->buffer_[offset + 0]; uint8_t x_low = this->buffer_[offset + 0];
uint8_t x_high = this->buffer_[offset + 1]; uint8_t x_high = this->buffer_[offset + 1];
uint8_t y_low = this->buffer_[offset + 2]; uint8_t y_low = this->buffer_[offset + 2];
uint8_t y_high = this->buffer_[offset + 3]; uint8_t y_high = this->buffer_[offset + 3];
uint8_t res_low = this->buffer_[offset + 4]; uint8_t speed_low = this->buffer_[offset + 4];
uint8_t res_high = this->buffer_[offset + 5]; uint8_t speed_high = this->buffer_[offset + 5];
uint8_t speed_low = this->buffer_[offset + 6]; uint8_t res_low = this->buffer_[offset + 6];
uint8_t speed_high = this->buffer_[offset + 7]; uint8_t res_high = this->buffer_[offset + 7];
// Decode values per RD-03D format // Decode values per RD-03D format
int16_t x = decode_value(x_low, x_high); int16_t x = decode_value(x_low, x_high);

View File

@@ -1 +1 @@
CODEOWNERS = ["@OttoWinter"] CODEOWNERS = ["@swoboda1337", "@ssieb"]

View File

@@ -34,7 +34,7 @@ CONFIG_SCHEMA = (
{ {
cv.Required(CONF_TRIGGER_PIN): pins.internal_gpio_output_pin_schema, cv.Required(CONF_TRIGGER_PIN): pins.internal_gpio_output_pin_schema,
cv.Required(CONF_ECHO_PIN): pins.internal_gpio_input_pin_schema, cv.Required(CONF_ECHO_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_TIMEOUT): cv.distance, cv.Optional(CONF_TIMEOUT, default="2m"): cv.distance,
cv.Optional( cv.Optional(
CONF_PULSE_TIME, default="10us" CONF_PULSE_TIME, default="10us"
): cv.positive_time_period_microseconds, ): cv.positive_time_period_microseconds,
@@ -52,12 +52,5 @@ async def to_code(config):
cg.add(var.set_trigger_pin(trigger)) cg.add(var.set_trigger_pin(trigger))
echo = await cg.gpio_pin_expression(config[CONF_ECHO_PIN]) echo = await cg.gpio_pin_expression(config[CONF_ECHO_PIN])
cg.add(var.set_echo_pin(echo)) 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])) cg.add(var.set_pulse_time_us(config[CONF_PULSE_TIME]))

View File

@@ -6,12 +6,11 @@ namespace esphome::ultrasonic {
static const char *const TAG = "ultrasonic.sensor"; static const char *const TAG = "ultrasonic.sensor";
static constexpr uint32_t DEBOUNCE_US = 50; // Ignore edges within 50us (noise filtering) static constexpr uint32_t START_TIMEOUT_US = 40000; // Maximum time to wait for echo pulse to start
static constexpr uint32_t MEASUREMENT_TIMEOUT_US = 80000; // Maximum time to wait for measurement completion
void IRAM_ATTR UltrasonicSensorStore::gpio_intr(UltrasonicSensorStore *arg) { void IRAM_ATTR UltrasonicSensorStore::gpio_intr(UltrasonicSensorStore *arg) {
uint32_t now = micros(); uint32_t now = micros();
if (!arg->echo_start || (now - arg->echo_start_us) <= DEBOUNCE_US) { if (arg->echo_pin_isr.digital_read()) {
arg->echo_start_us = now; arg->echo_start_us = now;
arg->echo_start = true; arg->echo_start = true;
} else { } else {
@@ -38,6 +37,7 @@ void UltrasonicSensorComponent::setup() {
this->trigger_pin_->digital_write(false); this->trigger_pin_->digital_write(false);
this->trigger_pin_isr_ = this->trigger_pin_->to_isr(); this->trigger_pin_isr_ = this->trigger_pin_->to_isr();
this->echo_pin_->setup(); this->echo_pin_->setup();
this->store_.echo_pin_isr = this->echo_pin_->to_isr();
this->echo_pin_->attach_interrupt(UltrasonicSensorStore::gpio_intr, &this->store_, gpio::INTERRUPT_ANY_EDGE); this->echo_pin_->attach_interrupt(UltrasonicSensorStore::gpio_intr, &this->store_, gpio::INTERRUPT_ANY_EDGE);
} }
@@ -53,29 +53,55 @@ void UltrasonicSensorComponent::loop() {
return; return;
} }
if (!this->store_.echo_start) {
uint32_t elapsed = micros() - this->measurement_start_us_;
if (elapsed >= START_TIMEOUT_US) {
ESP_LOGW(TAG, "'%s' - Measurement start timed out", this->name_.c_str());
this->publish_state(NAN);
this->measurement_pending_ = false;
return;
}
} else {
uint32_t elapsed;
if (this->store_.echo_end) {
elapsed = this->store_.echo_end_us - this->store_.echo_start_us;
} else {
elapsed = micros() - this->store_.echo_start_us;
}
if (elapsed >= this->timeout_us_) {
ESP_LOGD(TAG, "'%s' - Measurement pulse timed out after %" PRIu32 "us", this->name_.c_str(), elapsed);
this->publish_state(NAN);
this->measurement_pending_ = false;
return;
}
}
if (this->store_.echo_end) { if (this->store_.echo_end) {
uint32_t pulse_duration = this->store_.echo_end_us - this->store_.echo_start_us; float result;
ESP_LOGV(TAG, "Echo took %" PRIu32 "us", pulse_duration); if (this->store_.echo_start) {
float result = UltrasonicSensorComponent::us_to_m(pulse_duration); uint32_t pulse_duration = this->store_.echo_end_us - this->store_.echo_start_us;
ESP_LOGD(TAG, "'%s' - Got distance: %.3f m", this->name_.c_str(), result); ESP_LOGV(TAG, "pulse start took %" PRIu32 "us, echo took %" PRIu32 "us",
this->store_.echo_start_us - this->measurement_start_us_, pulse_duration);
result = UltrasonicSensorComponent::us_to_m(pulse_duration);
ESP_LOGD(TAG, "'%s' - Got distance: %.3f m", this->name_.c_str(), result);
} else {
ESP_LOGW(TAG, "'%s' - pulse end before pulse start, does the echo pin need to be inverted?", this->name_.c_str());
result = NAN;
}
this->publish_state(result); this->publish_state(result);
this->measurement_pending_ = false; this->measurement_pending_ = false;
return; return;
} }
uint32_t elapsed = micros() - this->measurement_start_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;
}
} }
void UltrasonicSensorComponent::dump_config() { void UltrasonicSensorComponent::dump_config() {
LOG_SENSOR("", "Ultrasonic Sensor", this); LOG_SENSOR("", "Ultrasonic Sensor", this);
LOG_PIN(" Echo Pin: ", this->echo_pin_); LOG_PIN(" Echo Pin: ", this->echo_pin_);
LOG_PIN(" Trigger Pin: ", this->trigger_pin_); LOG_PIN(" Trigger Pin: ", this->trigger_pin_);
ESP_LOGCONFIG(TAG, " Pulse time: %" PRIu32 " us", this->pulse_time_us_); ESP_LOGCONFIG(TAG,
" Pulse time: %" PRIu32 " µs\n"
" Timeout: %" PRIu32 " µs",
this->pulse_time_us_, this->timeout_us_);
LOG_UPDATE_INTERVAL(this); LOG_UPDATE_INTERVAL(this);
} }

View File

@@ -11,6 +11,8 @@ namespace esphome::ultrasonic {
struct UltrasonicSensorStore { struct UltrasonicSensorStore {
static void gpio_intr(UltrasonicSensorStore *arg); static void gpio_intr(UltrasonicSensorStore *arg);
ISRInternalGPIOPin echo_pin_isr;
volatile uint32_t wait_start_us{0};
volatile uint32_t echo_start_us{0}; volatile uint32_t echo_start_us{0};
volatile uint32_t echo_end_us{0}; volatile uint32_t echo_end_us{0};
volatile bool echo_start{false}; volatile bool echo_start{false};
@@ -29,6 +31,8 @@ class UltrasonicSensorComponent : public sensor::Sensor, public PollingComponent
float get_setup_priority() const override { return setup_priority::DATA; } float get_setup_priority() const override { return setup_priority::DATA; }
/// Set the maximum time in µs to wait for the echo to return
void set_timeout_us(uint32_t timeout_us) { this->timeout_us_ = timeout_us; }
/// Set the time in µs the trigger pin should be enabled for in µs, defaults to 10µs (for HC-SR04) /// Set the time in µs the trigger pin should be enabled for in µs, defaults to 10µs (for HC-SR04)
void set_pulse_time_us(uint32_t pulse_time_us) { this->pulse_time_us_ = pulse_time_us; } void set_pulse_time_us(uint32_t pulse_time_us) { this->pulse_time_us_ = pulse_time_us; }
@@ -41,6 +45,7 @@ class UltrasonicSensorComponent : public sensor::Sensor, public PollingComponent
ISRInternalGPIOPin trigger_pin_isr_; ISRInternalGPIOPin trigger_pin_isr_;
InternalGPIOPin *echo_pin_; InternalGPIOPin *echo_pin_;
UltrasonicSensorStore store_; UltrasonicSensorStore store_;
uint32_t timeout_us_{};
uint32_t pulse_time_us_{}; uint32_t pulse_time_us_{};
uint32_t measurement_start_us_{0}; uint32_t measurement_start_us_{0};

View File

@@ -1383,6 +1383,12 @@ void WiFiComponent::check_connecting_finished(uint32_t now) {
this->release_scan_results_(); this->release_scan_results_();
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
// Notify listeners now that state machine has reached STA_CONNECTED
// This ensures wifi.connected condition returns true in listener automations
this->notify_connect_state_listeners_();
#endif
return; return;
} }
@@ -2090,6 +2096,21 @@ void WiFiComponent::release_scan_results_() {
} }
} }
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
void WiFiComponent::notify_connect_state_listeners_() {
if (!this->pending_.connect_state)
return;
this->pending_.connect_state = false;
// Get current SSID and BSSID from the WiFi driver
char ssid_buf[SSID_BUFFER_SIZE];
const char *ssid = this->wifi_ssid_to(ssid_buf);
bssid_t bssid = this->wifi_bssid();
for (auto *listener : this->connect_state_listeners_) {
listener->on_wifi_connect_state(StringRef(ssid, strlen(ssid)), bssid);
}
}
#endif // USE_WIFI_CONNECT_STATE_LISTENERS
void WiFiComponent::check_roaming_(uint32_t now) { void WiFiComponent::check_roaming_(uint32_t now) {
// Guard: not for hidden networks (may not appear in scan) // Guard: not for hidden networks (may not appear in scan)
const WiFiAP *selected = this->get_selected_sta_(); const WiFiAP *selected = this->get_selected_sta_();

View File

@@ -618,6 +618,11 @@ class WiFiComponent : public Component {
/// Free scan results memory unless a component needs them /// Free scan results memory unless a component needs them
void release_scan_results_(); void release_scan_results_();
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
/// Notify connect state listeners (called after state machine reaches STA_CONNECTED)
void notify_connect_state_listeners_();
#endif
#ifdef USE_ESP8266 #ifdef USE_ESP8266
static void wifi_event_callback(System_Event_t *event); static void wifi_event_callback(System_Event_t *event);
void wifi_scan_done_callback_(void *arg, STATUS status); void wifi_scan_done_callback_(void *arg, STATUS status);
@@ -721,6 +726,16 @@ class WiFiComponent : public Component {
SemaphoreHandle_t high_performance_semaphore_{nullptr}; SemaphoreHandle_t high_performance_semaphore_{nullptr};
#endif #endif
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
// Pending listener notifications deferred until state machine reaches appropriate state.
// Listeners are notified after state transitions complete so conditions like
// wifi.connected return correct values in automations.
// Uses bitfields to minimize memory; more flags may be added as needed.
struct {
bool connect_state : 1; // Notify connect state listeners after STA_CONNECTED
} pending_{};
#endif
// Pointers at the end (naturally aligned) // Pointers at the end (naturally aligned)
Trigger<> *connect_trigger_{new Trigger<>()}; Trigger<> *connect_trigger_{new Trigger<>()};
Trigger<> *disconnect_trigger_{new Trigger<>()}; Trigger<> *disconnect_trigger_{new Trigger<>()};

View File

@@ -500,6 +500,10 @@ const LogString *get_disconnect_reason_str(uint8_t reason) {
} }
} }
// TODO: This callback runs in ESP8266 system context with limited stack (~2KB).
// All listener notifications should be deferred to wifi_loop_() via pending_ flags
// to avoid stack overflow. Currently only connect_state is deferred; disconnect,
// IP, and scan listeners still run in this context and should be migrated.
void WiFiComponent::wifi_event_callback(System_Event_t *event) { void WiFiComponent::wifi_event_callback(System_Event_t *event) {
switch (event->event) { switch (event->event) {
case EVENT_STAMODE_CONNECTED: { case EVENT_STAMODE_CONNECTED: {
@@ -512,9 +516,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
#endif #endif
s_sta_connected = true; s_sta_connected = true;
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS #ifdef USE_WIFI_CONNECT_STATE_LISTENERS
for (auto *listener : global_wifi_component->connect_state_listeners_) { // Defer listener notification until state machine reaches STA_CONNECTED
listener->on_wifi_connect_state(StringRef(it.ssid, it.ssid_len), it.bssid); // This ensures wifi.connected condition returns true in listener automations
} global_wifi_component->pending_.connect_state = true;
#endif #endif
// For static IP configurations, GOT_IP event may not fire, so notify IP listeners here // For static IP configurations, GOT_IP event may not fire, so notify IP listeners here
#if defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP) #if defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP)

View File

@@ -710,6 +710,9 @@ void WiFiComponent::wifi_loop_() {
delete data; // NOLINT(cppcoreguidelines-owning-memory) delete data; // NOLINT(cppcoreguidelines-owning-memory)
} }
} }
// Events are processed from queue in main loop context, but listener notifications
// must be deferred until after the state machine transitions (in check_connecting_finished)
// so that conditions like wifi.connected return correct values in automations.
void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
esp_err_t err; esp_err_t err;
if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_START) { if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_START) {
@@ -743,9 +746,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
#endif #endif
s_sta_connected = true; s_sta_connected = true;
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS #ifdef USE_WIFI_CONNECT_STATE_LISTENERS
for (auto *listener : this->connect_state_listeners_) { // Defer listener notification until state machine reaches STA_CONNECTED
listener->on_wifi_connect_state(StringRef(it.ssid, it.ssid_len), it.bssid); // This ensures wifi.connected condition returns true in listener automations
} this->pending_.connect_state = true;
#endif #endif
// For static IP configurations, GOT_IP event may not fire, so notify IP listeners here // For static IP configurations, GOT_IP event may not fire, so notify IP listeners here
#if defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP) #if defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP)

View File

@@ -423,7 +423,10 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_
} }
} }
// Process a single event from the queue - runs in main loop context // Process a single event from the queue - runs in main loop context.
// Listener notifications must be deferred until after the state machine transitions
// (in check_connecting_finished) so that conditions like wifi.connected return
// correct values in automations.
void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) {
switch (event->event_id) { switch (event->event_id) {
case ESPHOME_EVENT_ID_WIFI_READY: { case ESPHOME_EVENT_ID_WIFI_READY: {
@@ -456,9 +459,9 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) {
// This matches ESP32 IDF behavior where s_sta_connected is set but // This matches ESP32 IDF behavior where s_sta_connected is set but
// wifi_sta_connect_status_() also checks got_ipv4_address_ // wifi_sta_connect_status_() also checks got_ipv4_address_
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS #ifdef USE_WIFI_CONNECT_STATE_LISTENERS
for (auto *listener : this->connect_state_listeners_) { // Defer listener notification until state machine reaches STA_CONNECTED
listener->on_wifi_connect_state(StringRef(it.ssid, it.ssid_len), it.bssid); // This ensures wifi.connected condition returns true in listener automations
} this->pending_.connect_state = true;
#endif #endif
// For static IP configurations, GOT_IP event may not fire, so set connected state here // For static IP configurations, GOT_IP event may not fire, so set connected state here
#ifdef USE_WIFI_MANUAL_IP #ifdef USE_WIFI_MANUAL_IP

View File

@@ -240,6 +240,10 @@ network::IPAddress WiFiComponent::wifi_dns_ip_(int num) {
return network::IPAddress(dns_ip); return network::IPAddress(dns_ip);
} }
// Pico W uses polling for connection state detection.
// Connect state listener notifications are deferred until after the state machine
// transitions (in check_connecting_finished) so that conditions like wifi.connected
// return correct values in automations.
void WiFiComponent::wifi_loop_() { void WiFiComponent::wifi_loop_() {
// Handle scan completion // Handle scan completion
if (this->state_ == WIFI_COMPONENT_STATE_STA_SCANNING && !cyw43_wifi_scan_active(&cyw43_state)) { if (this->state_ == WIFI_COMPONENT_STATE_STA_SCANNING && !cyw43_wifi_scan_active(&cyw43_state)) {
@@ -264,11 +268,9 @@ void WiFiComponent::wifi_loop_() {
s_sta_was_connected = true; s_sta_was_connected = true;
ESP_LOGV(TAG, "Connected"); ESP_LOGV(TAG, "Connected");
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS #ifdef USE_WIFI_CONNECT_STATE_LISTENERS
String ssid = WiFi.SSID(); // Defer listener notification until state machine reaches STA_CONNECTED
bssid_t bssid = this->wifi_bssid(); // This ensures wifi.connected condition returns true in listener automations
for (auto *listener : this->connect_state_listeners_) { this->pending_.connect_state = true;
listener->on_wifi_connect_state(StringRef(ssid.c_str(), ssid.length()), bssid);
}
#endif #endif
// For static IP configurations, notify IP listeners immediately as the IP is already configured // For static IP configurations, notify IP listeners immediately as the IP is already configured
#if defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP) #if defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP)

View File

@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum from esphome.enum import StrEnum
__version__ = "2026.1.3" __version__ = "2026.1.5"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = ( VALID_SUBSTITUTIONS_CHARACTERS = (

View File

@@ -81,6 +81,10 @@ void Application::register_component_(Component *comp) {
return; return;
} }
} }
if (this->components_.size() >= ESPHOME_COMPONENT_COUNT) {
ESP_LOGE(TAG, "Cannot register component %s - at capacity!", LOG_STR_ARG(comp->get_component_log_str()));
return;
}
this->components_.push_back(comp); this->components_.push_back(comp);
} }
void Application::setup() { void Application::setup() {

View File

@@ -356,6 +356,10 @@ void Component::defer(const std::string &name, std::function<void()> &&f) { //
void Component::defer(const char *name, std::function<void()> &&f) { // NOLINT void Component::defer(const char *name, std::function<void()> &&f) { // NOLINT
App.scheduler.set_timeout(this, name, 0, std::move(f)); App.scheduler.set_timeout(this, name, 0, std::move(f));
} }
void Component::defer(uint32_t id, std::function<void()> &&f) { // NOLINT
App.scheduler.set_timeout(this, id, 0, std::move(f));
}
bool Component::cancel_defer(uint32_t id) { return App.scheduler.cancel_timeout(this, id); }
void Component::set_timeout(uint32_t timeout, std::function<void()> &&f) { // NOLINT void Component::set_timeout(uint32_t timeout, std::function<void()> &&f) { // NOLINT
App.scheduler.set_timeout(this, static_cast<const char *>(nullptr), timeout, std::move(f)); App.scheduler.set_timeout(this, static_cast<const char *>(nullptr), timeout, std::move(f));
} }

View File

@@ -494,11 +494,15 @@ class Component {
/// Defer a callback to the next loop() call. /// Defer a callback to the next loop() call.
void defer(std::function<void()> &&f); // NOLINT void defer(std::function<void()> &&f); // NOLINT
/// Defer a callback with a numeric ID (zero heap allocation)
void defer(uint32_t id, std::function<void()> &&f); // NOLINT
/// Cancel a defer callback using the specified name, name must not be empty. /// Cancel a defer callback using the specified name, name must not be empty.
// Remove before 2026.7.0 // Remove before 2026.7.0
ESPDEPRECATED("Use const char* overload instead. Removed in 2026.7.0", "2026.1.0") ESPDEPRECATED("Use const char* overload instead. Removed in 2026.7.0", "2026.1.0")
bool cancel_defer(const std::string &name); // NOLINT bool cancel_defer(const std::string &name); // NOLINT
bool cancel_defer(const char *name); // NOLINT bool cancel_defer(const char *name); // NOLINT
bool cancel_defer(uint32_t id); // NOLINT
// Ordered for optimal packing on 32-bit systems // Ordered for optimal packing on 32-bit systems
const LogString *component_source_{nullptr}; const LogString *component_source_{nullptr};

View File

@@ -317,6 +317,7 @@ class EsphomeCommandWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandl
# Check if the proc was not forcibly closed # Check if the proc was not forcibly closed
_LOGGER.info("Process exited with return code %s", returncode) _LOGGER.info("Process exited with return code %s", returncode)
self.write_message({"event": "exit", "code": returncode}) self.write_message({"event": "exit", "code": returncode})
self.close()
def on_close(self) -> None: def on_close(self) -> None:
# Check if proc exists (if 'start' has been run) # Check if proc exists (if 'start' has been run)

View File

@@ -20,6 +20,8 @@ lvgl:
- id: lvgl_0 - id: lvgl_0
default_font: space16 default_font: space16
displays: sdl0 displays: sdl0
top_layer:
- id: lvgl_1 - id: lvgl_1
displays: sdl1 displays: sdl1
on_idle: on_idle:

View File

@@ -0,0 +1,10 @@
substitutions:
dc_pin: GPIO15
cs_pin: GPIO5
enable_pin: GPIO4
reset_pin: GPIO16
packages:
spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml
<<: !include common.yaml

View File

@@ -29,7 +29,7 @@ from esphome.dashboard.entries import (
bool_to_entry_state, bool_to_entry_state,
) )
from esphome.dashboard.models import build_importable_device_dict from esphome.dashboard.models import build_importable_device_dict
from esphome.dashboard.web_server import DashboardSubscriber from esphome.dashboard.web_server import DashboardSubscriber, EsphomeCommandWebSocket
from esphome.zeroconf import DiscoveredImport from esphome.zeroconf import DiscoveredImport
from .common import get_fixture_path from .common import get_fixture_path
@@ -1654,3 +1654,25 @@ async def test_websocket_check_origin_multiple_trusted_domains(
assert data["event"] == "initial_state" assert data["event"] == "initial_state"
finally: finally:
ws.close() ws.close()
def test_proc_on_exit_calls_close() -> None:
"""Test _proc_on_exit sends exit event and closes the WebSocket."""
handler = Mock(spec=EsphomeCommandWebSocket)
handler._is_closed = False
EsphomeCommandWebSocket._proc_on_exit(handler, 0)
handler.write_message.assert_called_once_with({"event": "exit", "code": 0})
handler.close.assert_called_once()
def test_proc_on_exit_skips_when_already_closed() -> None:
"""Test _proc_on_exit does nothing when WebSocket is already closed."""
handler = Mock(spec=EsphomeCommandWebSocket)
handler._is_closed = True
EsphomeCommandWebSocket._proc_on_exit(handler, 0)
handler.write_message.assert_not_called()
handler.close.assert_not_called()

View File

@@ -20,6 +20,9 @@ globals:
- id: retry_counter - id: retry_counter
type: int type: int
initial_value: '0' initial_value: '0'
- id: defer_counter
type: int
initial_value: '0'
- id: tests_done - id: tests_done
type: bool type: bool
initial_value: 'false' initial_value: 'false'
@@ -136,11 +139,49 @@ script:
App.scheduler.cancel_retry(component1, 6002U); App.scheduler.cancel_retry(component1, 6002U);
ESP_LOGI("test", "Cancelled numeric retry 6002"); ESP_LOGI("test", "Cancelled numeric retry 6002");
// Test 12: defer with numeric ID (Component method)
class TestDeferComponent : public Component {
public:
void test_defer_methods() {
// Test defer with uint32_t ID - should execute on next loop
this->defer(7001U, []() {
ESP_LOGI("test", "Component numeric defer 7001 fired");
id(defer_counter) += 1;
});
// Test another defer with numeric ID
this->defer(7002U, []() {
ESP_LOGI("test", "Component numeric defer 7002 fired");
id(defer_counter) += 1;
});
}
};
static TestDeferComponent test_defer_component;
test_defer_component.test_defer_methods();
// Test 13: cancel_defer with numeric ID (Component method)
class TestCancelDeferComponent : public Component {
public:
void test_cancel_defer() {
// Set a defer that should be cancelled
this->defer(8001U, []() {
ESP_LOGE("test", "ERROR: Numeric defer 8001 should have been cancelled");
});
// Cancel it immediately
bool cancelled = this->cancel_defer(8001U);
ESP_LOGI("test", "Cancelled numeric defer 8001: %s", cancelled ? "true" : "false");
}
};
static TestCancelDeferComponent test_cancel_defer_component;
test_cancel_defer_component.test_cancel_defer();
- id: report_results - id: report_results
then: then:
- lambda: |- - lambda: |-
ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d, Retries: %d", ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d, Retries: %d, Defers: %d",
id(timeout_counter), id(interval_counter), id(retry_counter)); id(timeout_counter), id(interval_counter), id(retry_counter), id(defer_counter));
sensor: sensor:
- platform: template - platform: template

View File

@@ -19,6 +19,7 @@ async def test_scheduler_numeric_id_test(
timeout_count = 0 timeout_count = 0
interval_count = 0 interval_count = 0
retry_count = 0 retry_count = 0
defer_count = 0
# Events for each test completion # Events for each test completion
numeric_timeout_1001_fired = asyncio.Event() numeric_timeout_1001_fired = asyncio.Event()
@@ -33,6 +34,9 @@ async def test_scheduler_numeric_id_test(
max_id_timeout_fired = asyncio.Event() max_id_timeout_fired = asyncio.Event()
numeric_retry_done = asyncio.Event() numeric_retry_done = asyncio.Event()
numeric_retry_cancelled = asyncio.Event() numeric_retry_cancelled = asyncio.Event()
numeric_defer_7001_fired = asyncio.Event()
numeric_defer_7002_fired = asyncio.Event()
numeric_defer_cancelled = asyncio.Event()
final_results_logged = asyncio.Event() final_results_logged = asyncio.Event()
# Track interval counts # Track interval counts
@@ -40,7 +44,7 @@ async def test_scheduler_numeric_id_test(
numeric_retry_count = 0 numeric_retry_count = 0
def on_log_line(line: str) -> None: def on_log_line(line: str) -> None:
nonlocal timeout_count, interval_count, retry_count nonlocal timeout_count, interval_count, retry_count, defer_count
nonlocal numeric_interval_count, numeric_retry_count nonlocal numeric_interval_count, numeric_retry_count
# Strip ANSI color codes # Strip ANSI color codes
@@ -105,15 +109,27 @@ async def test_scheduler_numeric_id_test(
elif "Cancelled numeric retry 6002" in clean_line: elif "Cancelled numeric retry 6002" in clean_line:
numeric_retry_cancelled.set() numeric_retry_cancelled.set()
# Check for numeric defer tests
elif "Component numeric defer 7001 fired" in clean_line:
numeric_defer_7001_fired.set()
elif "Component numeric defer 7002 fired" in clean_line:
numeric_defer_7002_fired.set()
elif "Cancelled numeric defer 8001: true" in clean_line:
numeric_defer_cancelled.set()
# Check for final results # Check for final results
elif "Final results" in clean_line: elif "Final results" in clean_line:
match = re.search( match = re.search(
r"Timeouts: (\d+), Intervals: (\d+), Retries: (\d+)", clean_line r"Timeouts: (\d+), Intervals: (\d+), Retries: (\d+), Defers: (\d+)",
clean_line,
) )
if match: if match:
timeout_count = int(match.group(1)) timeout_count = int(match.group(1))
interval_count = int(match.group(2)) interval_count = int(match.group(2))
retry_count = int(match.group(3)) retry_count = int(match.group(3))
defer_count = int(match.group(4))
final_results_logged.set() final_results_logged.set()
async with ( async with (
@@ -201,6 +217,23 @@ async def test_scheduler_numeric_id_test(
"Numeric retry 6002 should have been cancelled" "Numeric retry 6002 should have been cancelled"
) )
# Wait for numeric defer tests
try:
await asyncio.wait_for(numeric_defer_7001_fired.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Numeric defer 7001 did not fire within 0.5 seconds")
try:
await asyncio.wait_for(numeric_defer_7002_fired.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Numeric defer 7002 did not fire within 0.5 seconds")
# Verify numeric defer was cancelled
try:
await asyncio.wait_for(numeric_defer_cancelled.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Numeric defer 8001 cancel confirmation not received")
# Wait for final results # Wait for final results
try: try:
await asyncio.wait_for(final_results_logged.wait(), timeout=3.0) await asyncio.wait_for(final_results_logged.wait(), timeout=3.0)
@@ -215,3 +248,4 @@ async def test_scheduler_numeric_id_test(
assert retry_count >= 2, ( assert retry_count >= 2, (
f"Expected at least 2 retry attempts, got {retry_count}" f"Expected at least 2 retry attempts, got {retry_count}"
) )
assert defer_count >= 2, f"Expected at least 2 defer fires, got {defer_count}"

View File

@@ -32,6 +32,7 @@ from esphome.__main__ import (
has_mqtt_ip_lookup, has_mqtt_ip_lookup,
has_mqtt_logging, has_mqtt_logging,
has_non_ip_address, has_non_ip_address,
has_ota,
has_resolvable_address, has_resolvable_address,
mqtt_get_ip, mqtt_get_ip,
run_esphome, run_esphome,
@@ -332,7 +333,9 @@ def test_choose_upload_log_host_with_mixed_hostnames_and_ips() -> None:
def test_choose_upload_log_host_with_ota_list() -> None: def test_choose_upload_log_host_with_ota_list() -> None:
"""Test with OTA as the only item in the list.""" """Test with OTA as the only item in the list."""
setup_core(config={CONF_OTA: {}}, address="192.168.1.100") setup_core(
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100"
)
result = choose_upload_log_host( result = choose_upload_log_host(
default=["OTA"], default=["OTA"],
@@ -345,7 +348,7 @@ def test_choose_upload_log_host_with_ota_list() -> None:
@pytest.mark.usefixtures("mock_has_mqtt_logging") @pytest.mark.usefixtures("mock_has_mqtt_logging")
def test_choose_upload_log_host_with_ota_list_mqtt_fallback() -> None: def test_choose_upload_log_host_with_ota_list_mqtt_fallback() -> None:
"""Test with OTA list falling back to MQTT when no address.""" """Test with OTA list falling back to MQTT when no address."""
setup_core(config={CONF_OTA: {}, "mqtt": {}}) setup_core(config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], "mqtt": {}})
result = choose_upload_log_host( result = choose_upload_log_host(
default=["OTA"], default=["OTA"],
@@ -408,7 +411,9 @@ def test_choose_upload_log_host_with_serial_device_with_ports(
def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None: def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None:
"""Test OTA device when OTA is configured.""" """Test OTA device when OTA is configured."""
setup_core(config={CONF_OTA: {}}, address="192.168.1.100") setup_core(
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100"
)
result = choose_upload_log_host( result = choose_upload_log_host(
default="OTA", default="OTA",
@@ -475,7 +480,9 @@ def test_choose_upload_log_host_with_ota_device_no_fallback() -> None:
@pytest.mark.usefixtures("mock_choose_prompt") @pytest.mark.usefixtures("mock_choose_prompt")
def test_choose_upload_log_host_multiple_devices() -> None: def test_choose_upload_log_host_multiple_devices() -> None:
"""Test with multiple devices including special identifiers.""" """Test with multiple devices including special identifiers."""
setup_core(config={CONF_OTA: {}}, address="192.168.1.100") setup_core(
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100"
)
mock_ports = [MockSerialPort("/dev/ttyUSB0", "USB Serial")] mock_ports = [MockSerialPort("/dev/ttyUSB0", "USB Serial")]
@@ -514,7 +521,9 @@ def test_choose_upload_log_host_no_defaults_with_serial_ports(
@pytest.mark.usefixtures("mock_no_serial_ports") @pytest.mark.usefixtures("mock_no_serial_ports")
def test_choose_upload_log_host_no_defaults_with_ota() -> None: def test_choose_upload_log_host_no_defaults_with_ota() -> None:
"""Test interactive mode with OTA option.""" """Test interactive mode with OTA option."""
setup_core(config={CONF_OTA: {}}, address="192.168.1.100") setup_core(
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100"
)
with patch( with patch(
"esphome.__main__.choose_prompt", return_value="192.168.1.100" "esphome.__main__.choose_prompt", return_value="192.168.1.100"
@@ -575,7 +584,11 @@ def test_choose_upload_log_host_no_defaults_with_all_options(
) -> None: ) -> None:
"""Test interactive mode with all options available.""" """Test interactive mode with all options available."""
setup_core( setup_core(
config={CONF_OTA: {}, CONF_API: {}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}}, config={
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
CONF_API: {},
CONF_MQTT: {CONF_BROKER: "mqtt.local"},
},
address="192.168.1.100", address="192.168.1.100",
) )
@@ -604,7 +617,11 @@ def test_choose_upload_log_host_no_defaults_with_all_options_logging(
) -> None: ) -> None:
"""Test interactive mode with all options available.""" """Test interactive mode with all options available."""
setup_core( setup_core(
config={CONF_OTA: {}, CONF_API: {}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}}, config={
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
CONF_API: {},
CONF_MQTT: {CONF_BROKER: "mqtt.local"},
},
address="192.168.1.100", address="192.168.1.100",
) )
@@ -632,7 +649,9 @@ def test_choose_upload_log_host_no_defaults_with_all_options_logging(
@pytest.mark.usefixtures("mock_no_serial_ports") @pytest.mark.usefixtures("mock_no_serial_ports")
def test_choose_upload_log_host_check_default_matches() -> None: def test_choose_upload_log_host_check_default_matches() -> None:
"""Test when check_default matches an available option.""" """Test when check_default matches an available option."""
setup_core(config={CONF_OTA: {}}, address="192.168.1.100") setup_core(
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100"
)
result = choose_upload_log_host( result = choose_upload_log_host(
default=None, default=None,
@@ -704,7 +723,10 @@ def test_choose_upload_log_host_mixed_resolved_unresolved() -> None:
def test_choose_upload_log_host_ota_both_conditions() -> None: def test_choose_upload_log_host_ota_both_conditions() -> None:
"""Test OTA device when both OTA and API are configured and enabled.""" """Test OTA device when both OTA and API are configured and enabled."""
setup_core(config={CONF_OTA: {}, CONF_API: {}}, address="192.168.1.100") setup_core(
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], CONF_API: {}},
address="192.168.1.100",
)
result = choose_upload_log_host( result = choose_upload_log_host(
default="OTA", default="OTA",
@@ -719,7 +741,7 @@ def test_choose_upload_log_host_ota_ip_all_options() -> None:
"""Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not.""" """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not."""
setup_core( setup_core(
config={ config={
CONF_OTA: {}, CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
CONF_API: {}, CONF_API: {},
CONF_MQTT: { CONF_MQTT: {
CONF_BROKER: "mqtt.local", CONF_BROKER: "mqtt.local",
@@ -744,7 +766,7 @@ def test_choose_upload_log_host_ota_local_all_options() -> None:
"""Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not.""" """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not."""
setup_core( setup_core(
config={ config={
CONF_OTA: {}, CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
CONF_API: {}, CONF_API: {},
CONF_MQTT: { CONF_MQTT: {
CONF_BROKER: "mqtt.local", CONF_BROKER: "mqtt.local",
@@ -769,7 +791,7 @@ def test_choose_upload_log_host_ota_ip_all_options_logging() -> None:
"""Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not.""" """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not."""
setup_core( setup_core(
config={ config={
CONF_OTA: {}, CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
CONF_API: {}, CONF_API: {},
CONF_MQTT: { CONF_MQTT: {
CONF_BROKER: "mqtt.local", CONF_BROKER: "mqtt.local",
@@ -794,7 +816,7 @@ def test_choose_upload_log_host_ota_local_all_options_logging() -> None:
"""Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not.""" """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not."""
setup_core( setup_core(
config={ config={
CONF_OTA: {}, CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
CONF_API: {}, CONF_API: {},
CONF_MQTT: { CONF_MQTT: {
CONF_BROKER: "mqtt.local", CONF_BROKER: "mqtt.local",
@@ -817,7 +839,7 @@ def test_choose_upload_log_host_ota_local_all_options_logging() -> None:
@pytest.mark.usefixtures("mock_no_mqtt_logging") @pytest.mark.usefixtures("mock_no_mqtt_logging")
def test_choose_upload_log_host_no_address_with_ota_config() -> None: def test_choose_upload_log_host_no_address_with_ota_config() -> None:
"""Test OTA device when OTA is configured but no address is set.""" """Test OTA device when OTA is configured but no address is set."""
setup_core(config={CONF_OTA: {}}) setup_core(config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]})
with pytest.raises( with pytest.raises(
EsphomeError, match="All specified devices .* could not be resolved" EsphomeError, match="All specified devices .* could not be resolved"
@@ -1532,10 +1554,43 @@ def test_has_mqtt() -> None:
assert has_mqtt() is False assert has_mqtt() is False
# Test with other components but no MQTT # Test with other components but no MQTT
setup_core(config={CONF_API: {}, CONF_OTA: {}}) setup_core(config={CONF_API: {}, CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]})
assert has_mqtt() is False assert has_mqtt() is False
def test_has_ota() -> None:
"""Test has_ota function.
The has_ota function should only return True when OTA is configured
with platform: esphome, not when only platform: http_request is configured.
This is because CLI OTA upload only works with the esphome platform.
"""
# Test with OTA esphome platform configured
setup_core(config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]})
assert has_ota() is True
# Test with OTA http_request platform only (should return False)
# This is the bug scenario from issue #13783
setup_core(config={CONF_OTA: [{CONF_PLATFORM: "http_request"}]})
assert has_ota() is False
# Test without OTA configured
setup_core(config={})
assert has_ota() is False
# Test with multiple OTA platforms including esphome
setup_core(
config={
CONF_OTA: [{CONF_PLATFORM: "http_request"}, {CONF_PLATFORM: CONF_ESPHOME}]
}
)
assert has_ota() is True
# Test with empty OTA list
setup_core(config={CONF_OTA: []})
assert has_ota() is False
def test_get_port_type() -> None: def test_get_port_type() -> None:
"""Test get_port_type function.""" """Test get_port_type function."""