1
0
mirror of https://github.com/esphome/esphome.git synced 2026-02-11 18:21:52 +00:00

Compare commits

...

47 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
Jonathan Swoboda
3c91d72403 Merge pull request #13632 from esphome/bump-2026.1.3
2026.1.3
2026-01-29 22:22:10 -05:00
Jonathan Swoboda
0a63fc6f05 Bump version to 2026.1.3 2026-01-29 21:11:09 -05:00
J. Nick Koston
50e739ee8e [http_request] Fix empty body for chunked transfer encoding responses (#13599) 2026-01-29 21:11:09 -05:00
J. Nick Koston
6c84f20491 [wifi] Fix ESP8266 yield panic when WiFi scan fails (#13603) 2026-01-29 21:11:09 -05:00
Cody Cutrer
a68506f924 [ld2450] preserve precision of angle (#13600) 2026-01-29 21:11:08 -05:00
esphomebot
a20d42ca0b Update webserver local assets to 20260127-190637 (#13573)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-01-29 21:11:08 -05:00
J. Nick Koston
4ec8846198 [web_server] Add name_id to SSE for entity ID format migration (#13535) 2026-01-29 21:11:08 -05:00
J. Nick Koston
40ea65b1c0 [socket] ESP8266: call delay(0) instead of esp_delay(0, cb) for zero timeout (#13530) 2026-01-29 21:11:08 -05:00
J. Nick Koston
f7937ef952 [ota] Improve error message when device closes connection without responding (#13562) 2026-01-29 21:11:08 -05:00
sebcaps
d6bf137026 [mhz19] Fix Uninitialized var warning message (#13526) 2026-01-29 21:11:08 -05:00
esphomebot
ed9a672f44 Update webserver local assets to 20260122-204614 (#13455)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-01-29 21:11:08 -05:00
Jonathan Swoboda
1141e83a7c Merge pull request #13529 from esphome/bump-2026.1.2
2026.1.2
2026-01-25 13:21:26 -05:00
Jonathan Swoboda
214ce95cf3 Bump version to 2026.1.2 2026-01-25 12:22:18 -05:00
J. Nick Koston
3a7b83ba93 [wifi] Fix scan flag race condition causing reconnect failure on ESP8266/LibreTiny (#13514) 2026-01-25 12:22:18 -05:00
Clyde Stubbs
cc2f3d85dc [wifi] Fix watchdog timeout on P4 WiFi scan (#13520) 2026-01-25 12:22:18 -05:00
Jonathan Swoboda
723f67d5e2 [i2c] Increase ESP-IDF I2C transaction timeout from 20ms to 100ms (#13483)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 12:22:18 -05:00
Jonathan Swoboda
70e45706d9 [modbus_controller] Fix YAML serialization error with custom_command (#13482)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 12:22:18 -05:00
Jas Strong
56a2a2269f [rd03d] Fix speed and resolution field order (#13495)
Co-authored-by: jas <jas@asspa.in>
2026-01-25 12:22:18 -05:00
Keith Burzinski
d6841ba33a [light] Fix cwww state restore (#13493) 2026-01-25 12:22:18 -05:00
Clyde Stubbs
10cbd0164a [lvgl] Fix setting empty text (#13494) 2026-01-25 12:22:18 -05:00
Big Mike
d285706b41 [sen5x] Fix store baseline functionality (#13469) 2026-01-25 12:22:18 -05:00
J. Nick Koston
ef469c20df [slow_pwm] Fix dump_summary deprecation warning (#13460) 2026-01-25 12:22:18 -05:00
Clyde Stubbs
6870d3dc50 [mipi_rgb] Add software reset command to st7701s init sequence (#13470) 2026-01-25 12:22:18 -05:00
Keith Burzinski
9cc39621a6 [ir_rf_proxy] Remove unnecessary headers, add tests (#13464) 2026-01-25 12:22:18 -05:00
J. Nick Koston
c4f7d09553 [rpi_dpi_rgb] Fix dump_summary deprecation warning (#13461) 2026-01-25 12:22:18 -05:00
J. Nick Koston
ab1661ef22 [mipi_rgb] Fix dump_summary deprecation warning (#13463) 2026-01-25 12:22:18 -05:00
J. Nick Koston
ccbf17d5ab [st7701s] Fix dump_summary deprecation warning (#13462) 2026-01-25 12:22:18 -05:00
80 changed files with 9777 additions and 9110 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.1 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

@@ -12,6 +12,7 @@ from esphome.const import (
KEY_FRAMEWORK_VERSION, KEY_FRAMEWORK_VERSION,
) )
from esphome.core import CORE from esphome.core import CORE
from esphome.cpp_generator import add_define
CODEOWNERS = ["@swoboda1337"] CODEOWNERS = ["@swoboda1337"]
@@ -42,6 +43,7 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config): async def to_code(config):
add_define("USE_ESP32_HOSTED")
if config[CONF_ACTIVE_HIGH]: if config[CONF_ACTIVE_HIGH]:
esp32.add_idf_sdkconfig_option( esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_HIGH", "CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_HIGH",

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

@@ -131,9 +131,27 @@ std::shared_ptr<HttpContainer> HttpRequestArduino::perform(const std::string &ur
} }
} }
// HTTPClient::getSize() returns -1 for chunked transfer encoding (no Content-Length).
// 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
// 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;
@@ -167,17 +185,23 @@ int HttpContainerArduino::read(uint8_t *buf, size_t max_len) {
} }
int available_data = stream_ptr->available(); int available_data = stream_ptr->available();
int bufsize = std::min(max_len, std::min(this->content_length - this->bytes_read_, (size_t) available_data)); // For chunked transfer encoding, HTTPClient::getSize() returns -1, which becomes SIZE_MAX when
// cast to size_t. SIZE_MAX - bytes_read_ is still huge, so it won't limit the read.
size_t remaining = (this->content_length > 0) ? (this->content_length - this->bytes_read_) : max_len;
int bufsize = std::min(max_len, std::min(remaining, (size_t) available_data));
if (bufsize == 0) { if (bufsize == 0) {
this->duration_ms += (millis() - start); this->duration_ms += (millis() - start);
// Check if we've read all expected content // Check if we've read all expected content (non-chunked only)
if (this->bytes_read_ >= this->content_length) { // For chunked encoding (content_length == SIZE_MAX), is_read_complete() returns false
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
// For chunked encoding, !connected() after reading means EOF (all chunks received)
// For known content_length with bytes_read_ < content_length, it means connection dropped
if (!stream_ptr->connected()) { if (!stream_ptr->connected()) {
return HTTP_ERROR_CONNECTION_CLOSED; // Connection closed prematurely return HTTP_ERROR_CONNECTION_CLOSED; // Connection closed or EOF for chunked
} }
return 0; // No data yet, caller should retry return 0; // No data yet, caller should retry
} }

View File

@@ -152,7 +152,10 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const std::string &url, c
} }
container->feed_wdt(); container->feed_wdt();
// 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.
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();
@@ -188,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();
@@ -220,14 +224,21 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const std::string &url, c
// //
// We normalize to HttpContainer::read() contract: // We normalize to HttpContainer::read() contract:
// > 0: bytes read // > 0: bytes read
// 0: no data yet / all content read (caller should check bytes_read vs content_length) // 0: all content read (only returned when content_length is known and fully read)
// < 0: error/connection closed // < 0: error/connection closed
//
// Note on chunked transfer encoding:
// esp_http_client_fetch_headers() returns 0 for chunked responses (no Content-Length header).
// We handle this by skipping the content_length check when content_length is 0,
// allowing esp_http_client_read() to handle chunked decoding internally and signal EOF
// by returning 0.
int HttpContainerIDF::read(uint8_t *buf, size_t max_len) { 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)
if (this->bytes_read_ >= this->content_length) { // For chunked responses (content_length == 0), esp_http_client_read() handles EOF
if (this->is_read_complete()) {
return 0; // All content read successfully return 0; // All content read successfully
} }
@@ -242,7 +253,13 @@ int HttpContainerIDF::read(uint8_t *buf, size_t max_len) {
return read_len_or_error; return read_len_or_error;
} }
// Connection closed by server before all content received // esp_http_client_read() returns 0 in two cases:
// 1. Known content_length: connection closed before all data received (error)
// 2. Chunked encoding (content_length == 0): end of stream reached (EOF)
// For case 1, returning HTTP_ERROR_CONNECTION_CLOSED is correct.
// For case 2, 0 indicates that all chunked data has already been delivered
// in previous successful read() calls, so treating this as a closed
// connection does not cause any loss of response data.
if (read_len_or_error == 0) { if (read_len_or_error == 0) {
return HTTP_ERROR_CONNECTION_CLOSED; return HTTP_ERROR_CONNECTION_CLOSED;
} }

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

@@ -185,7 +185,7 @@ ErrorCode IDFI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, s
} }
jobs[num_jobs++].command = I2C_MASTER_CMD_STOP; jobs[num_jobs++].command = I2C_MASTER_CMD_STOP;
ESP_LOGV(TAG, "Sending %zu jobs", num_jobs); ESP_LOGV(TAG, "Sending %zu jobs", num_jobs);
esp_err_t err = i2c_master_execute_defined_operations(this->dev_, jobs, num_jobs, 20); esp_err_t err = i2c_master_execute_defined_operations(this->dev_, jobs, num_jobs, 100);
if (err == ESP_ERR_INVALID_STATE) { if (err == ESP_ERR_INVALID_STATE) {
ESP_LOGV(TAG, "TX to %02X failed: not acked", address); ESP_LOGV(TAG, "TX to %02X failed: not acked", address);
return ERROR_NOT_ACKNOWLEDGED; return ERROR_NOT_ACKNOWLEDGED;

View File

@@ -5,8 +5,6 @@
// Once the API is considered stable, this warning will be removed. // Once the API is considered stable, this warning will be removed.
#include "esphome/components/infrared/infrared.h" #include "esphome/components/infrared/infrared.h"
#include "esphome/components/remote_transmitter/remote_transmitter.h"
#include "esphome/components/remote_receiver/remote_receiver.h"
namespace esphome::ir_rf_proxy { namespace esphome::ir_rf_proxy {

View File

@@ -451,7 +451,7 @@ void LD2450Component::handle_periodic_data_() {
int16_t ty = 0; int16_t ty = 0;
int16_t td = 0; int16_t td = 0;
int16_t ts = 0; int16_t ts = 0;
int16_t angle = 0; float angle = 0;
uint8_t index = 0; uint8_t index = 0;
Direction direction{DIRECTION_UNDEFINED}; Direction direction{DIRECTION_UNDEFINED};
bool is_moving = false; bool is_moving = false;

View File

@@ -143,6 +143,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend(
], ],
icon=ICON_FORMAT_TEXT_ROTATION_ANGLE_UP, icon=ICON_FORMAT_TEXT_ROTATION_ANGLE_UP,
unit_of_measurement=UNIT_DEGREES, unit_of_measurement=UNIT_DEGREES,
accuracy_decimals=1,
), ),
cv.Optional(CONF_DISTANCE): sensor.sensor_schema( cv.Optional(CONF_DISTANCE): sensor.sensor_schema(
device_class=DEVICE_CLASS_DISTANCE, device_class=DEVICE_CLASS_DISTANCE,

View File

@@ -391,7 +391,10 @@ void LightCall::transform_parameters_() {
min_mireds > 0.0f && max_mireds > 0.0f) { min_mireds > 0.0f && max_mireds > 0.0f) {
ESP_LOGD(TAG, "'%s': setting cold/warm white channels using white/color temperature values", ESP_LOGD(TAG, "'%s': setting cold/warm white channels using white/color temperature values",
this->parent_->get_name().c_str()); this->parent_->get_name().c_str());
if (this->has_color_temperature()) { // Only compute cold_white/warm_white from color_temperature if they're not already explicitly set.
// This is important for state restoration, where both color_temperature and cold_white/warm_white
// are restored from flash - we want to preserve the saved cold_white/warm_white values.
if (this->has_color_temperature() && !this->has_cold_white() && !this->has_warm_white()) {
const float color_temp = clamp(this->color_temperature_, min_mireds, max_mireds); const float color_temp = clamp(this->color_temperature_, min_mireds, max_mireds);
const float range = max_mireds - min_mireds; const float range = max_mireds - min_mireds;
const float ww_fraction = (color_temp - min_mireds) / range; const float ww_fraction = (color_temp - min_mireds) / range;

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

@@ -32,7 +32,7 @@ class LabelType(WidgetType):
async def to_code(self, w: Widget, config): async def to_code(self, w: Widget, config):
"""For a text object, create and set text""" """For a text object, create and set text"""
if value := config.get(CONF_TEXT): if (value := config.get(CONF_TEXT)) is not None:
await w.set_property(CONF_TEXT, await lv_text.process(value)) await w.set_property(CONF_TEXT, await lv_text.process(value))
await w.set_property(CONF_LONG_MODE, config) await w.set_property(CONF_LONG_MODE, config)
await w.set_property(CONF_RECOLOR, config) await w.set_property(CONF_RECOLOR, config)

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

@@ -155,6 +155,9 @@ void MHZ19Component::dump_config() {
case MHZ19_DETECTION_RANGE_0_10000PPM: case MHZ19_DETECTION_RANGE_0_10000PPM:
range_str = "0 to 10000ppm"; range_str = "0 to 10000ppm";
break; break;
default:
range_str = "default";
break;
} }
ESP_LOGCONFIG(TAG, " Detection range: %s", range_str); ESP_LOGCONFIG(TAG, " Detection range: %s", range_str);
} }

View File

@@ -1,9 +1,11 @@
#ifdef USE_ESP32_VARIANT_ESP32S3 #ifdef USE_ESP32_VARIANT_ESP32S3
#include "mipi_rgb.h" #include "mipi_rgb.h"
#include "esphome/core/gpio.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h" #include "esphome/core/helpers.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
#include "esphome/core/hal.h"
#include "esp_lcd_panel_rgb.h" #include "esp_lcd_panel_rgb.h"
#include <span>
namespace esphome { namespace esphome {
namespace mipi_rgb { namespace mipi_rgb {
@@ -343,19 +345,27 @@ int MipiRgb::get_height() {
} }
} }
static std::string get_pin_name(GPIOPin *pin) { static const char *get_pin_name(GPIOPin *pin, std::span<char, GPIO_SUMMARY_MAX_LEN> buffer) {
if (pin == nullptr) if (pin == nullptr)
return "None"; return "None";
return pin->dump_summary(); pin->dump_summary(buffer.data(), buffer.size());
return buffer.data();
} }
void MipiRgb::dump_pins_(uint8_t start, uint8_t end, const char *name, uint8_t offset) { void MipiRgb::dump_pins_(uint8_t start, uint8_t end, const char *name, uint8_t offset) {
char pin_summary[GPIO_SUMMARY_MAX_LEN];
for (uint8_t i = start; i != end; i++) { for (uint8_t i = start; i != end; i++) {
ESP_LOGCONFIG(TAG, " %s pin %d: %s", name, offset++, this->data_pins_[i]->dump_summary().c_str()); this->data_pins_[i]->dump_summary(pin_summary, sizeof(pin_summary));
ESP_LOGCONFIG(TAG, " %s pin %d: %s", name, offset++, pin_summary);
} }
} }
void MipiRgb::dump_config() { void MipiRgb::dump_config() {
char reset_buf[GPIO_SUMMARY_MAX_LEN];
char de_buf[GPIO_SUMMARY_MAX_LEN];
char pclk_buf[GPIO_SUMMARY_MAX_LEN];
char hsync_buf[GPIO_SUMMARY_MAX_LEN];
char vsync_buf[GPIO_SUMMARY_MAX_LEN];
ESP_LOGCONFIG(TAG, ESP_LOGCONFIG(TAG,
"MIPI_RGB LCD" "MIPI_RGB LCD"
"\n Model: %s" "\n Model: %s"
@@ -379,9 +389,9 @@ void MipiRgb::dump_config() {
this->model_, this->width_, this->height_, this->rotation_, YESNO(this->pclk_inverted_), this->model_, this->width_, this->height_, this->rotation_, YESNO(this->pclk_inverted_),
this->hsync_pulse_width_, this->hsync_back_porch_, this->hsync_front_porch_, this->vsync_pulse_width_, this->hsync_pulse_width_, this->hsync_back_porch_, this->hsync_front_porch_, this->vsync_pulse_width_,
this->vsync_back_porch_, this->vsync_front_porch_, YESNO(this->invert_colors_), this->vsync_back_porch_, this->vsync_front_porch_, YESNO(this->invert_colors_),
(unsigned) (this->pclk_frequency_ / 1000000), get_pin_name(this->reset_pin_).c_str(), (unsigned) (this->pclk_frequency_ / 1000000), get_pin_name(this->reset_pin_, reset_buf),
get_pin_name(this->de_pin_).c_str(), get_pin_name(this->pclk_pin_).c_str(), get_pin_name(this->de_pin_, de_buf), get_pin_name(this->pclk_pin_, pclk_buf),
get_pin_name(this->hsync_pin_).c_str(), get_pin_name(this->vsync_pin_).c_str()); get_pin_name(this->hsync_pin_, hsync_buf), get_pin_name(this->vsync_pin_, vsync_buf));
this->dump_pins_(8, 13, "Blue", 0); this->dump_pins_(8, 13, "Blue", 0);
this->dump_pins_(13, 16, "Green", 0); this->dump_pins_(13, 16, "Green", 0);

View File

@@ -55,6 +55,7 @@ st7701s = ST7701S(
pclk_frequency="16MHz", pclk_frequency="16MHz",
pclk_inverted=True, pclk_inverted=True,
initsequence=( initsequence=(
(0x01,), # Software Reset
(0xFF, 0x77, 0x01, 0x00, 0x00, 0x10), # Page 0 (0xFF, 0x77, 0x01, 0x00, 0x00, 0x10), # Page 0
(0xC0, 0x3B, 0x00), (0xC1, 0x0D, 0x02), (0xC2, 0x31, 0x05), (0xC0, 0x3B, 0x00), (0xC1, 0x0D, 0x02), (0xC2, 0x31, 0x05),
(0xB0, 0x00, 0x11, 0x18, 0x0E, 0x11, 0x06, 0x07, 0x08, 0x07, 0x22, 0x04, 0x12, 0x0F, 0xAA, 0x31, 0x18,), (0xB0, 0x00, 0x11, 0x18, 0x0E, 0x11, 0x06, 0x07, 0x08, 0x07, 0x22, 0x04, 0x12, 0x0F, 0xAA, 0x31, 0x18,),

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

@@ -279,7 +279,7 @@ def modbus_calc_properties(config):
if isinstance(value, str): if isinstance(value, str):
value = value.encode() value = value.encode()
config[CONF_ADDRESS] = binascii.crc_hqx(value, 0) config[CONF_ADDRESS] = binascii.crc_hqx(value, 0)
config[CONF_REGISTER_TYPE] = ModbusRegisterType.CUSTOM config[CONF_REGISTER_TYPE] = cv.enum(MODBUS_REGISTER_TYPE)("custom")
config[CONF_FORCE_NEW_RANGE] = True config[CONF_FORCE_NEW_RANGE] = True
return byte_offset, reg_count return byte_offset, reg_count

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,7 +132,7 @@ 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)
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];

View File

@@ -1,5 +1,6 @@
#ifdef USE_ESP32_VARIANT_ESP32S3 #ifdef USE_ESP32_VARIANT_ESP32S3
#include "rpi_dpi_rgb.h" #include "rpi_dpi_rgb.h"
#include "esphome/core/gpio.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
namespace esphome { namespace esphome {
@@ -134,8 +135,11 @@ void RpiDpiRgb::dump_config() {
LOG_PIN(" Enable Pin: ", this->enable_pin_); LOG_PIN(" Enable Pin: ", this->enable_pin_);
LOG_PIN(" Reset Pin: ", this->reset_pin_); LOG_PIN(" Reset Pin: ", this->reset_pin_);
size_t data_pin_count = sizeof(this->data_pins_) / sizeof(this->data_pins_[0]); size_t data_pin_count = sizeof(this->data_pins_) / sizeof(this->data_pins_[0]);
for (size_t i = 0; i != data_pin_count; i++) char pin_summary[GPIO_SUMMARY_MAX_LEN];
ESP_LOGCONFIG(TAG, " Data pin %d: %s", i, (this->data_pins_[i])->dump_summary().c_str()); for (size_t i = 0; i != data_pin_count; i++) {
this->data_pins_[i]->dump_summary(pin_summary, sizeof(pin_summary));
ESP_LOGCONFIG(TAG, " Data pin %d: %s", i, pin_summary);
}
} }
void RpiDpiRgb::reset_display_() const { void RpiDpiRgb::reset_display_() const {

View File

@@ -124,8 +124,8 @@ void SEN5XComponent::setup() {
sen5x_type = SEN55; sen5x_type = SEN55;
} }
} }
ESP_LOGD(TAG, "Product name: %s", this->product_name_.c_str());
} }
ESP_LOGD(TAG, "Product name: %s", this->product_name_.c_str());
if (this->humidity_sensor_ && sen5x_type == SEN50) { if (this->humidity_sensor_ && sen5x_type == SEN50) {
ESP_LOGE(TAG, "Relative humidity requires a SEN54 or SEN55"); ESP_LOGE(TAG, "Relative humidity requires a SEN54 or SEN55");
this->humidity_sensor_ = nullptr; // mark as not used this->humidity_sensor_ = nullptr; // mark as not used
@@ -159,28 +159,14 @@ void SEN5XComponent::setup() {
// This ensures the baseline storage is cleared after OTA // This ensures the baseline storage is cleared after OTA
// Serial numbers are unique to each sensor, so multiple sensors can be used without conflict // Serial numbers are unique to each sensor, so multiple sensors can be used without conflict
uint32_t hash = fnv1a_hash_extend(App.get_config_version_hash(), combined_serial); uint32_t hash = fnv1a_hash_extend(App.get_config_version_hash(), combined_serial);
this->pref_ = global_preferences->make_preference<Sen5xBaselines>(hash, true); this->pref_ = global_preferences->make_preference<uint16_t[4]>(hash, true);
this->voc_baseline_time_ = App.get_loop_component_start_time();
if (this->pref_.load(&this->voc_baselines_storage_)) { if (this->pref_.load(&this->voc_baseline_state_)) {
ESP_LOGI(TAG, "Loaded VOC baseline state0: 0x%04" PRIX32 ", state1: 0x%04" PRIX32, if (!this->write_command(SEN5X_CMD_VOC_ALGORITHM_STATE, this->voc_baseline_state_, 4)) {
this->voc_baselines_storage_.state0, this->voc_baselines_storage_.state1); ESP_LOGE(TAG, "VOC Baseline State write to sensor failed");
} } else {
ESP_LOGV(TAG, "VOC Baseline State loaded");
// Initialize storage timestamp delay(20);
this->seconds_since_last_store_ = 0;
if (this->voc_baselines_storage_.state0 > 0 && this->voc_baselines_storage_.state1 > 0) {
ESP_LOGI(TAG, "Setting VOC baseline from save state0: 0x%04" PRIX32 ", state1: 0x%04" PRIX32,
this->voc_baselines_storage_.state0, this->voc_baselines_storage_.state1);
uint16_t states[4];
states[0] = this->voc_baselines_storage_.state0 >> 16;
states[1] = this->voc_baselines_storage_.state0 & 0xFFFF;
states[2] = this->voc_baselines_storage_.state1 >> 16;
states[3] = this->voc_baselines_storage_.state1 & 0xFFFF;
if (!this->write_command(SEN5X_CMD_VOC_ALGORITHM_STATE, states, 4)) {
ESP_LOGE(TAG, "Failed to set VOC baseline from saved state");
} }
} }
} }
@@ -288,6 +274,14 @@ void SEN5XComponent::dump_config() {
ESP_LOGCONFIG(TAG, " RH/T acceleration mode: %s", ESP_LOGCONFIG(TAG, " RH/T acceleration mode: %s",
LOG_STR_ARG(rht_accel_mode_to_string(this->acceleration_mode_.value()))); LOG_STR_ARG(rht_accel_mode_to_string(this->acceleration_mode_.value())));
} }
if (this->voc_sensor_) {
char hex_buf[5 * 4];
format_hex_pretty_to(hex_buf, this->voc_baseline_state_, 4, 0);
ESP_LOGCONFIG(TAG,
" Store Baseline: %s\n"
" State: %s\n",
TRUEFALSE(this->store_baseline_), hex_buf);
}
LOG_UPDATE_INTERVAL(this); LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "PM 1.0", this->pm_1_0_sensor_); LOG_SENSOR(" ", "PM 1.0", this->pm_1_0_sensor_);
LOG_SENSOR(" ", "PM 2.5", this->pm_2_5_sensor_); LOG_SENSOR(" ", "PM 2.5", this->pm_2_5_sensor_);
@@ -304,36 +298,6 @@ void SEN5XComponent::update() {
return; return;
} }
// Store baselines after defined interval or if the difference between current and stored baseline becomes too
// much
if (this->store_baseline_ && this->seconds_since_last_store_ > SHORTEST_BASELINE_STORE_INTERVAL) {
if (this->write_command(SEN5X_CMD_VOC_ALGORITHM_STATE)) {
// run it a bit later to avoid adding a delay here
this->set_timeout(550, [this]() {
uint16_t states[4];
if (this->read_data(states, 4)) {
uint32_t state0 = states[0] << 16 | states[1];
uint32_t state1 = states[2] << 16 | states[3];
if ((uint32_t) std::abs(static_cast<int32_t>(this->voc_baselines_storage_.state0 - state0)) >
MAXIMUM_STORAGE_DIFF ||
(uint32_t) std::abs(static_cast<int32_t>(this->voc_baselines_storage_.state1 - state1)) >
MAXIMUM_STORAGE_DIFF) {
this->seconds_since_last_store_ = 0;
this->voc_baselines_storage_.state0 = state0;
this->voc_baselines_storage_.state1 = state1;
if (this->pref_.save(&this->voc_baselines_storage_)) {
ESP_LOGI(TAG, "Stored VOC baseline state0: 0x%04" PRIX32 ", state1: 0x%04" PRIX32,
this->voc_baselines_storage_.state0, this->voc_baselines_storage_.state1);
} else {
ESP_LOGW(TAG, "Could not store VOC baselines");
}
}
}
});
}
}
if (!this->write_command(SEN5X_CMD_READ_MEASUREMENT)) { if (!this->write_command(SEN5X_CMD_READ_MEASUREMENT)) {
this->status_set_warning(); this->status_set_warning();
ESP_LOGD(TAG, "Write error: read measurement (%d)", this->last_error_); ESP_LOGD(TAG, "Write error: read measurement (%d)", this->last_error_);
@@ -402,7 +366,29 @@ void SEN5XComponent::update() {
if (this->nox_sensor_ != nullptr) { if (this->nox_sensor_ != nullptr) {
this->nox_sensor_->publish_state(nox); this->nox_sensor_->publish_state(nox);
} }
this->status_clear_warning();
if (!this->voc_sensor_ || !this->store_baseline_ ||
(App.get_loop_component_start_time() - this->voc_baseline_time_) < SHORTEST_BASELINE_STORE_INTERVAL) {
this->status_clear_warning();
} else {
this->voc_baseline_time_ = App.get_loop_component_start_time();
if (!this->write_command(SEN5X_CMD_VOC_ALGORITHM_STATE)) {
this->status_set_warning();
ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL);
} else {
this->set_timeout(20, [this]() {
if (!this->read_data(this->voc_baseline_state_, 4)) {
this->status_set_warning();
ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL);
} else {
if (this->pref_.save(&this->voc_baseline_state_)) {
ESP_LOGD(TAG, "VOC Baseline State saved");
}
this->status_clear_warning();
}
});
}
}
}); });
} }

View File

@@ -24,11 +24,6 @@ enum RhtAccelerationMode : uint16_t {
HIGH_ACCELERATION = 2, HIGH_ACCELERATION = 2,
}; };
struct Sen5xBaselines {
int32_t state0;
int32_t state1;
} PACKED; // NOLINT
struct GasTuning { struct GasTuning {
uint16_t index_offset; uint16_t index_offset;
uint16_t learning_time_offset_hours; uint16_t learning_time_offset_hours;
@@ -44,11 +39,9 @@ struct TemperatureCompensation {
uint16_t time_constant; uint16_t time_constant;
}; };
// Shortest time interval of 3H for storing baseline values. // Shortest time interval of 2H (in milliseconds) for storing baseline values.
// Prevents wear of the flash because of too many write operations // Prevents wear of the flash because of too many write operations
static const uint32_t SHORTEST_BASELINE_STORE_INTERVAL = 10800; static const uint32_t SHORTEST_BASELINE_STORE_INTERVAL = 2 * 60 * 60 * 1000;
// Store anyway if the baseline difference exceeds the max storage diff value
static const uint32_t MAXIMUM_STORAGE_DIFF = 50;
class SEN5XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { class SEN5XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice {
public: public:
@@ -107,7 +100,8 @@ class SEN5XComponent : public PollingComponent, public sensirion_common::Sensiri
bool write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning); bool write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning);
bool write_temperature_compensation_(const TemperatureCompensation &compensation); bool write_temperature_compensation_(const TemperatureCompensation &compensation);
uint32_t seconds_since_last_store_; uint16_t voc_baseline_state_[4]{0};
uint32_t voc_baseline_time_;
uint16_t firmware_version_; uint16_t firmware_version_;
ERRORCODE error_code_; ERRORCODE error_code_;
uint8_t serial_number_[4]; uint8_t serial_number_[4];
@@ -132,7 +126,6 @@ class SEN5XComponent : public PollingComponent, public sensirion_common::Sensiri
optional<TemperatureCompensation> temperature_compensation_; optional<TemperatureCompensation> temperature_compensation_;
ESPPreferenceObject pref_; ESPPreferenceObject pref_;
std::string product_name_; std::string product_name_;
Sen5xBaselines voc_baselines_storage_;
}; };
} // namespace sen5x } // namespace sen5x

View File

@@ -210,6 +210,7 @@ SENSOR_MAP = {
SETTING_MAP = { SETTING_MAP = {
CONF_AUTO_CLEANING_INTERVAL: "set_auto_cleaning_interval", CONF_AUTO_CLEANING_INTERVAL: "set_auto_cleaning_interval",
CONF_ACCELERATION_MODE: "set_acceleration_mode", CONF_ACCELERATION_MODE: "set_acceleration_mode",
CONF_STORE_BASELINE: "set_store_baseline",
} }

View File

@@ -1,6 +1,7 @@
#include "slow_pwm_output.h" #include "slow_pwm_output.h"
#include "esphome/core/log.h"
#include "esphome/core/application.h" #include "esphome/core/application.h"
#include "esphome/core/gpio.h"
#include "esphome/core/log.h"
namespace esphome { namespace esphome {
namespace slow_pwm { namespace slow_pwm {
@@ -20,7 +21,9 @@ void SlowPWMOutput::set_output_state_(bool new_state) {
} }
if (new_state != current_state_) { if (new_state != current_state_) {
if (this->pin_) { if (this->pin_) {
ESP_LOGV(TAG, "Switching output pin %s to %s", this->pin_->dump_summary().c_str(), ONOFF(new_state)); char pin_summary[GPIO_SUMMARY_MAX_LEN];
this->pin_->dump_summary(pin_summary, sizeof(pin_summary));
ESP_LOGV(TAG, "Switching output pin %s to %s", pin_summary, ONOFF(new_state));
} else { } else {
ESP_LOGV(TAG, "Switching to %s", ONOFF(new_state)); ESP_LOGV(TAG, "Switching to %s", ONOFF(new_state));
} }

View File

@@ -29,6 +29,14 @@ void socket_delay(uint32_t ms) {
// Use esp_delay with a callback that checks if socket data arrived. // Use esp_delay with a callback that checks if socket data arrived.
// This allows the delay to exit early when socket_wake() is called by // This allows the delay to exit early when socket_wake() is called by
// lwip recv_fn/accept_fn callbacks, reducing socket latency. // lwip recv_fn/accept_fn callbacks, reducing socket latency.
//
// When ms is 0, we must use delay(0) because esp_delay(0, callback)
// exits immediately without yielding, which can cause watchdog timeouts
// when the main loop runs in high-frequency mode (e.g., during light effects).
if (ms == 0) {
delay(0);
return;
}
s_socket_woke = false; s_socket_woke = false;
esp_delay(ms, []() { return !s_socket_woke; }); esp_delay(ms, []() { return !s_socket_woke; });
} }

View File

@@ -1,5 +1,6 @@
#ifdef USE_ESP32_VARIANT_ESP32S3 #ifdef USE_ESP32_VARIANT_ESP32S3
#include "st7701s.h" #include "st7701s.h"
#include "esphome/core/gpio.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
namespace esphome { namespace esphome {
@@ -183,8 +184,11 @@ void ST7701S::dump_config() {
LOG_PIN(" DE Pin: ", this->de_pin_); LOG_PIN(" DE Pin: ", this->de_pin_);
LOG_PIN(" Reset Pin: ", this->reset_pin_); LOG_PIN(" Reset Pin: ", this->reset_pin_);
size_t data_pin_count = sizeof(this->data_pins_) / sizeof(this->data_pins_[0]); size_t data_pin_count = sizeof(this->data_pins_) / sizeof(this->data_pins_[0]);
for (size_t i = 0; i != data_pin_count; i++) char pin_summary[GPIO_SUMMARY_MAX_LEN];
ESP_LOGCONFIG(TAG, " Data pin %d: %s", i, (this->data_pins_[i])->dump_summary().c_str()); for (size_t i = 0; i != data_pin_count; i++) {
this->data_pins_[i]->dump_summary(pin_summary, sizeof(pin_summary));
ESP_LOGCONFIG(TAG, " Data pin %d: %s", i, pin_summary);
}
ESP_LOGCONFIG(TAG, " SPI Data rate: %dMHz", (unsigned) (this->data_rate_ / 1000000)); ESP_LOGCONFIG(TAG, " SPI Data rate: %dMHz", (unsigned) (this->data_rate_ / 1000000));
} }

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -527,7 +527,19 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J
memcpy(p, name.c_str(), name_len); memcpy(p, name.c_str(), name_len);
p[name_len] = '\0'; p[name_len] = '\0';
root[ESPHOME_F("id")] = id_buf; // name_id: new format {prefix}/{device?}/{name} - frontend should prefer this
// Remove in 2026.8.0 when id switches to new format permanently
root[ESPHOME_F("name_id")] = id_buf;
// id: old format {prefix}-{object_id} for backward compatibility
// Will switch to new format in 2026.8.0
char legacy_buf[ESPHOME_DOMAIN_MAX_LEN + 1 + OBJECT_ID_MAX_LEN];
char *lp = legacy_buf;
memcpy(lp, prefix, prefix_len);
lp += prefix_len;
*lp++ = '-';
obj->write_object_id_to(lp, sizeof(legacy_buf) - (lp - legacy_buf));
root[ESPHOME_F("id")] = legacy_buf;
if (start_config == DETAIL_ALL) { if (start_config == DETAIL_ALL) {
root[ESPHOME_F("domain")] = prefix; root[ESPHOME_F("domain")] = prefix;

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)
@@ -698,6 +702,10 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
if (!this->wifi_mode_(true, {})) if (!this->wifi_mode_(true, {}))
return false; return false;
// Reset scan_done_ before starting new scan to prevent stale flag from previous scan
// (e.g., roaming scan completed just before unexpected disconnect)
this->scan_done_ = false;
struct scan_config config {}; struct scan_config config {};
memset(&config, 0, sizeof(config)); memset(&config, 0, sizeof(config));
config.ssid = nullptr; config.ssid = nullptr;
@@ -752,7 +760,10 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) {
if (status != OK) { if (status != OK) {
ESP_LOGV(TAG, "Scan failed: %d", status); ESP_LOGV(TAG, "Scan failed: %d", status);
this->retry_connect(); // Don't call retry_connect() here - this callback runs in SDK system context
// where yield() cannot be called. Instead, just set scan_done_ and let
// check_scanning_finished() handle the empty scan_result_ from loop context.
this->scan_done_ = true;
return; return;
} }

View File

@@ -14,6 +14,7 @@
#include <algorithm> #include <algorithm>
#include <cinttypes> #include <cinttypes>
#include <memory>
#include <utility> #include <utility>
#ifdef USE_WIFI_WPA2_EAP #ifdef USE_WIFI_WPA2_EAP
#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1) #if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1)
@@ -709,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) {
@@ -742,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)
@@ -828,16 +832,29 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
uint16_t number = it.number; uint16_t number = it.number;
scan_result_.init(number); scan_result_.init(number);
#ifdef USE_ESP32_HOSTED
// Process one record at a time to avoid large buffer allocation // getting records one at a time fails on P4 with hosted esp32 WiFi coprocessor
wifi_ap_record_t record; // Presumably an upstream bug, work-around by getting all records at once
auto records = std::make_unique<wifi_ap_record_t[]>(number);
err = esp_wifi_scan_get_ap_records(&number, records.get());
if (err != ESP_OK) {
esp_wifi_clear_ap_list();
ESP_LOGW(TAG, "esp_wifi_scan_get_ap_records failed: %s", esp_err_to_name(err));
return;
}
for (uint16_t i = 0; i < number; i++) { for (uint16_t i = 0; i < number; i++) {
wifi_ap_record_t &record = records[i];
#else
// Process one record at a time to avoid large buffer allocation
for (uint16_t i = 0; i < number; i++) {
wifi_ap_record_t record;
err = esp_wifi_scan_get_ap_record(&record); err = esp_wifi_scan_get_ap_record(&record);
if (err != ESP_OK) { if (err != ESP_OK) {
ESP_LOGW(TAG, "esp_wifi_scan_get_ap_record failed: %s", esp_err_to_name(err)); ESP_LOGW(TAG, "esp_wifi_scan_get_ap_record failed: %s", esp_err_to_name(err));
esp_wifi_clear_ap_list(); // Free remaining records not yet retrieved esp_wifi_clear_ap_list(); // Free remaining records not yet retrieved
break; break;
} }
#endif // USE_ESP32_HOSTED
bssid_t bssid; bssid_t bssid;
std::copy(record.bssid, record.bssid + 6, bssid.begin()); std::copy(record.bssid, record.bssid + 6, bssid.begin());
std::string ssid(reinterpret_cast<const char *>(record.ssid)); std::string ssid(reinterpret_cast<const char *>(record.ssid));

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
@@ -649,6 +652,10 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
if (!this->wifi_mode_(true, {})) if (!this->wifi_mode_(true, {}))
return false; return false;
// Reset scan_done_ before starting new scan to prevent stale flag from previous scan
// (e.g., roaming scan completed just before unexpected disconnect)
this->scan_done_ = false;
// need to use WiFi because of WiFiScanClass allocations :( // need to use WiFi because of WiFiScanClass allocations :(
int16_t err = WiFi.scanNetworks(true, true, passive, 200); int16_t err = WiFi.scanNetworks(true, true, passive, 200);
if (err != WIFI_SCAN_RUNNING) { if (err != WIFI_SCAN_RUNNING) {

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.1" __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

@@ -42,6 +42,7 @@
#define USE_DEVICES #define USE_DEVICES
#define USE_DISPLAY #define USE_DISPLAY
#define USE_ENTITY_ICON #define USE_ENTITY_ICON
#define USE_ESP32_HOSTED
#define USE_ESP32_IMPROV_STATE_CALLBACK #define USE_ESP32_IMPROV_STATE_CALLBACK
#define USE_EVENT #define USE_EVENT
#define USE_FAN #define USE_FAN

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

@@ -154,6 +154,12 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None
""" """
if not expect: if not expect:
return return
if not data:
raise OTAError(
"Error: Device closed connection without responding. "
"This may indicate the device ran out of memory, "
"a network issue, or the connection was interrupted."
)
dat = data[0] dat = data[0]
if dat == RESPONSE_ERROR_MAGIC: if dat == RESPONSE_ERROR_MAGIC:
raise OTAError("Error: Invalid magic byte") raise OTAError("Error: Invalid magic byte")

View File

@@ -0,0 +1,18 @@
remote_receiver:
id: ir_receiver
pin: ${rx_pin}
# Test various hardware types with transmitter/receiver using infrared platform
infrared:
# Infrared receiver
- platform: ir_rf_proxy
id: ir_rx
name: "IR Receiver"
remote_receiver_id: ir_receiver
# RF 900MHz receiver
- platform: ir_rf_proxy
id: rf_900_rx
name: "RF 900 Receiver"
frequency: 900 MHz
remote_receiver_id: ir_receiver

View File

@@ -0,0 +1,19 @@
remote_transmitter:
id: ir_transmitter
pin: ${tx_pin}
carrier_duty_percent: 50%
# Test various hardware types with transmitter/receiver using infrared platform
infrared:
# Infrared transmitter
- platform: ir_rf_proxy
id: ir_tx
name: "IR Transmitter"
remote_transmitter_id: ir_transmitter
# RF 433MHz transmitter
- platform: ir_rf_proxy
id: rf_433_tx
name: "RF 433 Transmitter"
frequency: 433 MHz
remote_transmitter_id: ir_transmitter

View File

@@ -1,42 +1,7 @@
network:
wifi: wifi:
ssid: MySSID ssid: MySSID
password: password1 password: password1
api: api:
remote_transmitter:
id: ir_transmitter
pin: ${tx_pin}
carrier_duty_percent: 50%
remote_receiver:
id: ir_receiver
pin: ${rx_pin}
# Test various hardware types with transmitter/receiver using infrared platform
infrared:
# Infrared transmitter
- platform: ir_rf_proxy
id: ir_tx
name: "IR Transmitter"
remote_transmitter_id: ir_transmitter
# Infrared receiver
- platform: ir_rf_proxy
id: ir_rx
name: "IR Receiver"
remote_receiver_id: ir_receiver
# RF 433MHz transmitter
- platform: ir_rf_proxy
id: rf_433_tx
name: "RF 433 Transmitter"
frequency: 433 MHz
remote_transmitter_id: ir_transmitter
# RF 900MHz receiver
- platform: ir_rf_proxy
id: rf_900_rx
name: "RF 900 Receiver"
frequency: 900 MHz
remote_receiver_id: ir_receiver

View File

@@ -0,0 +1,7 @@
substitutions:
tx_pin: GPIO4
rx_pin: GPIO5
packages:
common: !include common.yaml
rx: !include common-rx.yaml

View File

@@ -0,0 +1,7 @@
substitutions:
tx_pin: GPIO4
rx_pin: GPIO5
packages:
common: !include common.yaml
rx: !include common-rx.yaml

View File

@@ -0,0 +1,7 @@
substitutions:
tx_pin: GPIO4
rx_pin: GPIO5
packages:
common: !include common.yaml
rx: !include common-rx.yaml

View File

@@ -0,0 +1,7 @@
substitutions:
tx_pin: GPIO4
rx_pin: GPIO5
packages:
common: !include common.yaml
tx: !include common-tx.yaml

View File

@@ -0,0 +1,7 @@
substitutions:
tx_pin: GPIO4
rx_pin: GPIO5
packages:
common: !include common.yaml
tx: !include common-tx.yaml

View File

@@ -0,0 +1,7 @@
substitutions:
tx_pin: GPIO4
rx_pin: GPIO5
packages:
common: !include common.yaml
tx: !include common-tx.yaml

View File

@@ -0,0 +1,8 @@
substitutions:
tx_pin: GPIO4
rx_pin: GPIO5
packages:
common: !include common.yaml
rx: !include common-rx.yaml
tx: !include common-tx.yaml

View File

@@ -2,4 +2,7 @@ substitutions:
tx_pin: GPIO4 tx_pin: GPIO4
rx_pin: GPIO5 rx_pin: GPIO5
<<: !include common.yaml packages:
common: !include common.yaml
rx: !include common-rx.yaml
tx: !include common-tx.yaml

View File

@@ -2,4 +2,7 @@ substitutions:
tx_pin: GPIO4 tx_pin: GPIO4
rx_pin: GPIO5 rx_pin: GPIO5
<<: !include common.yaml packages:
common: !include common.yaml
rx: !include common-rx.yaml
tx: !include common-tx.yaml

View File

@@ -2,4 +2,7 @@ substitutions:
tx_pin: GPIO4 tx_pin: GPIO4
rx_pin: GPIO5 rx_pin: GPIO5
<<: !include common.yaml packages:
common: !include common.yaml
rx: !include common-rx.yaml
tx: !include common-tx.yaml

View File

@@ -197,6 +197,9 @@ lvgl:
- lvgl.label.update: - lvgl.label.update:
id: msgbox_label id: msgbox_label
text: Unloaded text: Unloaded
- lvgl.label.update:
id: msgbox_label
text: "" # Empty text
on_all_events: on_all_events:
logger.log: logger.log:
format: "Event %s" format: "Event %s"

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

@@ -192,6 +192,20 @@ def test_check_error_unexpected_response() -> None:
espota2.check_error([0x7F], [espota2.RESPONSE_OK, espota2.RESPONSE_AUTH_OK]) espota2.check_error([0x7F], [espota2.RESPONSE_OK, espota2.RESPONSE_AUTH_OK])
def test_check_error_empty_data() -> None:
"""Test check_error raises error when device closes connection without responding."""
with pytest.raises(
espota2.OTAError, match="Device closed connection without responding"
):
espota2.check_error([], [espota2.RESPONSE_OK])
# Also test with empty bytes
with pytest.raises(
espota2.OTAError, match="Device closed connection without responding"
):
espota2.check_error(b"", [espota2.RESPONSE_OK])
def test_send_check_with_various_data_types(mock_socket: Mock) -> None: def test_send_check_with_various_data_types(mock_socket: Mock) -> None:
"""Test send_check handles different data types.""" """Test send_check handles different data types."""

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."""