1
0
mirror of https://github.com/esphome/esphome.git synced 2025-11-17 15:26:01 +00:00

Compare commits

..

37 Commits

Author SHA1 Message Date
J. Nick Koston
68bba2f13d [wifi] Skip redundant setter calls for default values 2025-11-16 16:06:19 -06:00
Clyde Stubbs
320120883c [lvgl] Migrate lv_font creation into Font class and optimise (#11915) 2025-11-17 08:47:54 +11:00
J. Nick Koston
4fc4da6ed2 [analyze-memory] Show all core symbols > 100 B instead of top 15 (#11909) 2025-11-16 07:35:31 -06:00
J. Nick Koston
6f4042f401 Add tests for sensor timeout filters (#11923) 2025-11-15 22:21:38 -06:00
J. Nick Koston
ea2b4c3e25 [binary_sensor] Modernize to C++17 nested namespaces and remove redundant qualifications (#11929) 2025-11-16 04:21:06 +00:00
J. Nick Koston
fc546ca3f6 [scheduler] Fix timing breakage after 49 days of uptime on ESP8266/RP2040 (#11924) 2025-11-15 22:20:57 -06:00
J. Nick Koston
6b158e760d [ld2410] Add timeout filter to prevent stuck targets (#11920) 2025-11-15 22:04:25 -06:00
J. Nick Koston
5710cab972 [ld2412] Fix stuck targets by adding timeout filter (#11919) 2025-11-15 22:03:43 -06:00
Clyde Stubbs
eb759efb3d [font] Store glyph data in flash only (#11926) 2025-11-16 12:48:02 +11:00
dependabot[bot]
1df996601d Bump ruff from 0.14.4 to 0.14.5 (#11910)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2025-11-14 19:14:07 +00:00
dependabot[bot]
c32891ec02 Bump github/codeql-action from 4.31.2 to 4.31.3 (#11911)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-14 13:09:59 -06:00
Jonathan Swoboda
2bf6d48fcf [uart] Improve error handling and validate buffer size (#11895)
Co-authored-by: J. Nick Koston <nick+github@koston.org>
2025-11-14 14:06:08 -05:00
Edward Firmo
e49a943cf7 [wifi] Allow use_psram with Arduino (#11902) 2025-11-14 09:13:48 -05:00
dependabot[bot]
67524e14ee Bump pylint from 4.0.2 to 4.0.3 (#11894)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-13 19:05:02 +00:00
Edward Firmo
2290eb0dd2 [light] Fix missing ColorMode::BRIGHTNESS case in logging (#11836) 2025-11-13 12:08:06 -06:00
Clyde Stubbs
0afcf67c32 [esp32] Add sdkconfig flag to make OTA work for 32MB flash (#11883)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2025-11-13 10:52:08 -05:00
Clyde Stubbs
952bdfaac2 [esp32] Make esp-idf default framework for P4 (#11884) 2025-11-13 09:55:48 -05:00
Jesse Hills
ed7e5cd325 Bump version to 2025.12.0-dev 2025-11-13 17:00:47 +13:00
Jonathan Swoboda
a15f46e741 Merge branch 'beta' into dev 2025-11-12 22:46:34 -05:00
tomaszduda23
d869108416 [nrf52] add settings for dcdc converter (#11841) 2025-11-12 20:06:20 -06:00
J. Nick Koston
2d6618da3c [wifi] Fix slow reconnection after connection loss for all network types (#11873)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-13 13:44:22 +13:00
J. Nick Koston
47fe84e922 [wifi][ethernet] Fix spurious warnings and unclear status after PR #9823 (#11871) 2025-11-13 13:43:51 +13:00
J. Nick Koston
735bf9930a [light] Fix dangling reference in compute_color_mode causing memory corruption (#11868) 2025-11-13 13:41:28 +13:00
J. Nick Koston
769137fc09 [mqtt] Fix crash with empty broker during upload/logs (#11866)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-13 13:40:26 +13:00
J. Nick Koston
3a5b3ad77d [thermostat] Replace std::map with FixedVector, reduce flash usage (#11875) 2025-11-12 17:55:06 -06:00
J. Nick Koston
859101ddc9 [api][event] Send events immediately to prevent loss during rapid triggers (#11777) 2025-11-13 12:42:50 +13:00
J. Nick Koston
29a50da635 [wifi] Use stack allocation for BSSID formatting in logging (#11859) 2025-11-12 14:27:06 -06:00
J. Nick Koston
5f0fa68d73 [esp32_ble] Use stack allocation for MAC formatting in dump_config (#11860) 2025-11-12 14:26:57 -06:00
J. Nick Koston
2f39b10baa [esp32_ble_tracker] Use initializer_list to eliminate compiler warning and reduce flash usage (#11861) 2025-11-12 14:26:46 -06:00
J. Nick Koston
5a550cc579 [api] Eliminate heap allocations when transmitting Event types (#11773) 2025-11-12 14:26:36 -06:00
J. Nick Koston
4b58cb4ce6 [wifi] Pass ManualIP by const reference to reduce stack usage (#11858) 2025-11-12 14:01:19 -06:00
J. Nick Koston
3872a2fd91 [captive_portal] Warn when enabled without WiFi AP configured (#11856) 2025-11-12 14:01:07 -06:00
dependabot[bot]
5d613ada83 Bump pytest from 9.0.0 to 9.0.1 (#11874)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-12 14:00:50 -06:00
J. Nick Koston
9de80b635a [core] Fix wait_until hanging when used in on_boot automations (#11869) 2025-11-12 17:56:19 +00:00
Jonathan Swoboda
748aee584a [esp32] Update the recommended platform to 55.03.31-2 (#11865) 2025-11-12 10:41:22 -05:00
Jonathan Swoboda
3cbfddcc83 Merge branch 'beta' into dev 2025-11-11 23:27:24 -05:00
J. Nick Koston
398dba4fc8 [ci] Reduce release time by removing 21 redundant ESP32-S3 IDF tests (#11850) 2025-11-12 16:44:19 +13:00
47 changed files with 743 additions and 910 deletions

View File

@@ -58,7 +58,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -86,6 +86,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
with:
category: "/language:${{matrix.language}}"

View File

@@ -11,7 +11,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.14.4
rev: v0.14.5
hooks:
# Run the linter.
- id: ruff

View File

@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2025.11.0b3
PROJECT_NUMBER = 2025.12.0-dev
# 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

View File

@@ -1,12 +1,11 @@
#include "automation.h"
#include "esphome/core/log.h"
namespace esphome {
namespace binary_sensor {
namespace esphome::binary_sensor {
static const char *const TAG = "binary_sensor.automation";
void binary_sensor::MultiClickTrigger::on_state_(bool state) {
void MultiClickTrigger::on_state_(bool state) {
// Handle duplicate events
if (state == this->last_state_) {
return;
@@ -67,7 +66,7 @@ void binary_sensor::MultiClickTrigger::on_state_(bool state) {
*this->at_index_ = *this->at_index_ + 1;
}
void binary_sensor::MultiClickTrigger::schedule_cooldown_() {
void MultiClickTrigger::schedule_cooldown_() {
ESP_LOGV(TAG, "Multi Click: Invalid length of press, starting cooldown of %" PRIu32 " ms", this->invalid_cooldown_);
this->is_in_cooldown_ = true;
this->set_timeout("cooldown", this->invalid_cooldown_, [this]() {
@@ -79,7 +78,7 @@ void binary_sensor::MultiClickTrigger::schedule_cooldown_() {
this->cancel_timeout("is_valid");
this->cancel_timeout("is_not_valid");
}
void binary_sensor::MultiClickTrigger::schedule_is_valid_(uint32_t min_length) {
void MultiClickTrigger::schedule_is_valid_(uint32_t min_length) {
if (min_length == 0) {
this->is_valid_ = true;
return;
@@ -90,19 +89,19 @@ void binary_sensor::MultiClickTrigger::schedule_is_valid_(uint32_t min_length) {
this->is_valid_ = true;
});
}
void binary_sensor::MultiClickTrigger::schedule_is_not_valid_(uint32_t max_length) {
void MultiClickTrigger::schedule_is_not_valid_(uint32_t max_length) {
this->set_timeout("is_not_valid", max_length, [this]() {
ESP_LOGV(TAG, "Multi Click: You waited too long to %s.", this->parent_->state ? "RELEASE" : "PRESS");
this->is_valid_ = false;
this->schedule_cooldown_();
});
}
void binary_sensor::MultiClickTrigger::cancel() {
void MultiClickTrigger::cancel() {
ESP_LOGV(TAG, "Multi Click: Sequence explicitly cancelled.");
this->is_valid_ = false;
this->schedule_cooldown_();
}
void binary_sensor::MultiClickTrigger::trigger_() {
void MultiClickTrigger::trigger_() {
ESP_LOGV(TAG, "Multi Click: Hooray, multi click is valid. Triggering!");
this->at_index_.reset();
this->cancel_timeout("trigger");
@@ -118,5 +117,4 @@ bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length) {
return length >= min_length && length <= max_length;
}
}
} // namespace binary_sensor
} // namespace esphome
} // namespace esphome::binary_sensor

View File

@@ -9,8 +9,7 @@
#include "esphome/core/helpers.h"
#include "esphome/components/binary_sensor/binary_sensor.h"
namespace esphome {
namespace binary_sensor {
namespace esphome::binary_sensor {
struct MultiClickTriggerEvent {
bool state;
@@ -172,5 +171,4 @@ template<typename... Ts> class BinarySensorInvalidateAction : public Action<Ts..
BinarySensor *sensor_;
};
} // namespace binary_sensor
} // namespace esphome
} // namespace esphome::binary_sensor

View File

@@ -3,9 +3,7 @@
#include "esphome/core/controller_registry.h"
#include "esphome/core/log.h"
namespace esphome {
namespace binary_sensor {
namespace esphome::binary_sensor {
static const char *const TAG = "binary_sensor";
@@ -63,6 +61,4 @@ void BinarySensor::add_filters(std::initializer_list<Filter *> filters) {
}
bool BinarySensor::is_status_binary_sensor() const { return false; }
} // namespace binary_sensor
} // namespace esphome
} // namespace esphome::binary_sensor

View File

@@ -6,9 +6,7 @@
#include <initializer_list>
namespace esphome {
namespace binary_sensor {
namespace esphome::binary_sensor {
class BinarySensor;
void log_binary_sensor(const char *tag, const char *prefix, const char *type, BinarySensor *obj);
@@ -70,5 +68,4 @@ class BinarySensorInitiallyOff : public BinarySensor {
bool has_state() const override { return true; }
};
} // namespace binary_sensor
} // namespace esphome
} // namespace esphome::binary_sensor

View File

@@ -2,9 +2,7 @@
#include "binary_sensor.h"
namespace esphome {
namespace binary_sensor {
namespace esphome::binary_sensor {
static const char *const TAG = "sensor.filter";
@@ -132,6 +130,4 @@ optional<bool> SettleFilter::new_value(bool value) {
float SettleFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
} // namespace binary_sensor
} // namespace esphome
} // namespace esphome::binary_sensor

View File

@@ -4,9 +4,7 @@
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
namespace esphome {
namespace binary_sensor {
namespace esphome::binary_sensor {
class BinarySensor;
@@ -139,6 +137,4 @@ class SettleFilter : public Filter, public Component {
bool steady_{true};
};
} // namespace binary_sensor
} // namespace esphome
} // namespace esphome::binary_sensor

View File

@@ -1,9 +1,12 @@
import logging
import esphome.codegen as cg
from esphome.components import web_server_base
from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import (
CONF_AP,
CONF_ID,
PLATFORM_BK72XX,
PLATFORM_ESP32,
@@ -14,6 +17,10 @@ from esphome.const import (
)
from esphome.core import CORE, coroutine_with_priority
from esphome.coroutine import CoroPriority
import esphome.final_validate as fv
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
def AUTO_LOAD() -> list[str]:
@@ -50,6 +57,27 @@ CONFIG_SCHEMA = cv.All(
)
def _final_validate(config: ConfigType) -> ConfigType:
full_config = fv.full_config.get()
wifi_conf = full_config.get("wifi")
if wifi_conf is None:
# This shouldn't happen due to DEPENDENCIES = ["wifi"], but check anyway
raise cv.Invalid("Captive portal requires the wifi component to be configured")
if CONF_AP not in wifi_conf:
_LOGGER.warning(
"Captive portal is enabled but no WiFi AP is configured. "
"The captive portal will not be accessible. "
"Add 'ap:' to your WiFi configuration to enable the captive portal."
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
@coroutine_with_priority(CoroPriority.CAPTIVE_PORTAL)
async def to_code(config):
paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID])

View File

@@ -634,11 +634,13 @@ void ESP32BLE::dump_config() {
io_capability_s = "invalid";
break;
}
char mac_s[18];
format_mac_addr_upper(mac_address, mac_s);
ESP_LOGCONFIG(TAG,
"BLE:\n"
" MAC address: %s\n"
" IO Capability: %s",
format_mac_address_pretty(mac_address).c_str(), io_capability_s);
mac_s, io_capability_s);
} else {
ESP_LOGCONFIG(TAG, "Bluetooth stack is not enabled");
}

View File

@@ -36,7 +36,6 @@ from esphome.const import (
CONF_WEIGHT,
)
from esphome.core import CORE, HexInt
from esphome.helpers import cpp_string_escape
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
@@ -50,7 +49,6 @@ font_ns = cg.esphome_ns.namespace("font")
Font = font_ns.class_("Font")
Glyph = font_ns.class_("Glyph")
GlyphData = font_ns.struct("GlyphData")
CONF_BPP = "bpp"
CONF_EXTRAS = "extras"
@@ -463,7 +461,7 @@ FONT_SCHEMA = cv.Schema(
)
),
cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
cv.GenerateID(CONF_RAW_GLYPH_ID): cv.declare_id(GlyphData),
cv.GenerateID(CONF_RAW_GLYPH_ID): cv.declare_id(Glyph),
},
)
@@ -488,6 +486,8 @@ class GlyphInfo:
def glyph_to_glyphinfo(glyph, font, size, bpp):
# Convert to 32 bit unicode codepoint
glyph = ord(glyph)
scale = 256 // (1 << bpp)
if not font.is_scalable:
sizes = [pt_to_px(x.size) for x in font.available_sizes]
@@ -583,22 +583,15 @@ async def to_code(config):
# Create the glyph table that points to data in the above array.
glyph_initializer = [
cg.StructInitializer(
GlyphData,
(
"a_char",
cg.RawExpression(f"(const uint8_t *){cpp_string_escape(x.glyph)}"),
),
(
"data",
cg.RawExpression(f"{str(prog_arr)} + {str(y - len(x.bitmap_data))}"),
),
("advance", x.advance),
("offset_x", x.offset_x),
("offset_y", x.offset_y),
("width", x.width),
("height", x.height),
)
[
x.glyph,
prog_arr + (y - len(x.bitmap_data)),
x.advance,
x.offset_x,
x.offset_y,
x.width,
x.height,
]
for (x, y) in zip(
glyph_args, list(accumulate([len(x.bitmap_data) for x in glyph_args]))
)

View File

@@ -6,133 +6,245 @@
namespace esphome {
namespace font {
static const char *const TAG = "font";
const uint8_t *Glyph::get_char() const { return this->glyph_data_->a_char; }
// Compare the char at the string position with this char.
// Return true if this char is less than or equal the other.
bool Glyph::compare_to(const uint8_t *str) const {
// 1 -> this->char_
// 2 -> str
for (uint32_t i = 0;; i++) {
if (this->glyph_data_->a_char[i] == '\0')
return true;
if (str[i] == '\0')
return false;
if (this->glyph_data_->a_char[i] > str[i])
return false;
if (this->glyph_data_->a_char[i] < str[i])
return true;
#ifdef USE_LVGL_FONT
const uint8_t *Font::get_glyph_bitmap(const lv_font_t *font, uint32_t unicode_letter) {
auto *fe = (Font *) font->dsc;
const auto *gd = fe->get_glyph_data_(unicode_letter);
if (gd == nullptr) {
return nullptr;
}
// this should not happen
return false;
}
int Glyph::match_length(const uint8_t *str) const {
for (uint32_t i = 0;; i++) {
if (this->glyph_data_->a_char[i] == '\0')
return i;
if (str[i] != this->glyph_data_->a_char[i])
return 0;
}
// this should not happen
return 0;
}
void Glyph::scan_area(int *x1, int *y1, int *width, int *height) const {
*x1 = this->glyph_data_->offset_x;
*y1 = this->glyph_data_->offset_y;
*width = this->glyph_data_->width;
*height = this->glyph_data_->height;
return gd->data;
}
Font::Font(const GlyphData *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight,
bool Font::get_glyph_dsc_cb(const lv_font_t *font, lv_font_glyph_dsc_t *dsc, uint32_t unicode_letter, uint32_t next) {
auto *fe = (Font *) font->dsc;
const auto *gd = fe->get_glyph_data_(unicode_letter);
if (gd == nullptr) {
return false;
}
dsc->adv_w = gd->advance;
dsc->ofs_x = gd->offset_x;
dsc->ofs_y = fe->height_ - gd->height - gd->offset_y - fe->lv_font_.base_line;
dsc->box_w = gd->width;
dsc->box_h = gd->height;
dsc->is_placeholder = 0;
dsc->bpp = fe->get_bpp();
return true;
}
const Glyph *Font::get_glyph_data_(uint32_t unicode_letter) {
if (unicode_letter == this->last_letter_ && this->last_letter_ != 0)
return this->last_data_;
auto *glyph = this->find_glyph(unicode_letter);
if (glyph == nullptr) {
return nullptr;
}
this->last_data_ = glyph;
this->last_letter_ = unicode_letter;
return glyph;
}
#endif
/**
* Attempt to extract a 32 bit Unicode codepoint from a UTF-8 string.
* If successful, return the codepoint and set the length to the number of bytes read.
* If the end of the string has been reached and a valid codepoint has not been found, return 0 and set the length to
* 0.
*
* @param utf8_str The input string
* @param length Pointer to length storage
* @return The extracted code point
*/
static uint32_t extract_unicode_codepoint(const char *utf8_str, size_t *length) {
// Safely cast to uint8_t* for correct bitwise operations on bytes
const uint8_t *current = reinterpret_cast<const uint8_t *>(utf8_str);
uint32_t code_point = 0;
uint8_t c1 = *current++;
// check for end of string
if (c1 == 0) {
*length = 0;
return 0;
}
// --- 1-Byte Sequence: 0xxxxxxx (ASCII) ---
if (c1 < 0x80) {
// Valid ASCII byte.
code_point = c1;
// Optimization: No need to check for continuation bytes.
}
// --- 2-Byte Sequence: 110xxxxx 10xxxxxx ---
else if ((c1 & 0xE0) == 0xC0) {
uint8_t c2 = *current++;
// Error Check 1: Check if c2 is a valid continuation byte (10xxxxxx)
if ((c2 & 0xC0) != 0x80) {
*length = 0;
return 0;
}
code_point = (c1 & 0x1F) << 6;
code_point |= (c2 & 0x3F);
// Error Check 2: Overlong check (2-byte must be > 0x7F)
if (code_point <= 0x7F) {
*length = 0;
return 0;
}
}
// --- 3-Byte Sequence: 1110xxxx 10xxxxxx 10xxxxxx ---
else if ((c1 & 0xF0) == 0xE0) {
uint8_t c2 = *current++;
uint8_t c3 = *current++;
// Error Check 1: Check continuation bytes
if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80)) {
*length = 0;
return 0;
}
code_point = (c1 & 0x0F) << 12;
code_point |= (c2 & 0x3F) << 6;
code_point |= (c3 & 0x3F);
// Error Check 2: Overlong check (3-byte must be > 0x7FF)
// Also check for surrogates (0xD800-0xDFFF)
if (code_point <= 0x7FF || (code_point >= 0xD800 && code_point <= 0xDFFF)) {
*length = 0;
return 0;
}
}
// --- 4-Byte Sequence: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx ---
else if ((c1 & 0xF8) == 0xF0) {
uint8_t c2 = *current++;
uint8_t c3 = *current++;
uint8_t c4 = *current++;
// Error Check 1: Check continuation bytes
if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80) || ((c4 & 0xC0) != 0x80)) {
*length = 0;
return 0;
}
code_point = (c1 & 0x07) << 18;
code_point |= (c2 & 0x3F) << 12;
code_point |= (c3 & 0x3F) << 6;
code_point |= (c4 & 0x3F);
// Error Check 2: Overlong check (4-byte must be > 0xFFFF)
// Also check for valid Unicode range (must be <= 0x10FFFF)
if (code_point <= 0xFFFF || code_point > 0x10FFFF) {
*length = 0;
return 0;
}
}
// --- Invalid leading byte (e.g., 10xxxxxx or 11111xxx) ---
else {
*length = 0;
return 0;
}
*length = current - reinterpret_cast<const uint8_t *>(utf8_str);
return code_point;
}
Font::Font(const Glyph *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight,
uint8_t bpp)
: baseline_(baseline),
: glyphs_(ConstVector(data, data_nr)),
baseline_(baseline),
height_(height),
descender_(descender),
linegap_(height - baseline - descender),
xheight_(xheight),
capheight_(capheight),
bpp_(bpp) {
glyphs_.reserve(data_nr);
for (int i = 0; i < data_nr; ++i)
glyphs_.emplace_back(&data[i]);
#ifdef USE_LVGL_FONT
this->lv_font_.dsc = this;
this->lv_font_.line_height = this->get_height();
this->lv_font_.base_line = this->lv_font_.line_height - this->get_baseline();
this->lv_font_.get_glyph_dsc = get_glyph_dsc_cb;
this->lv_font_.get_glyph_bitmap = get_glyph_bitmap;
this->lv_font_.subpx = LV_FONT_SUBPX_NONE;
this->lv_font_.underline_position = -1;
this->lv_font_.underline_thickness = 1;
#endif
}
int Font::match_next_glyph(const uint8_t *str, int *match_length) {
const Glyph *Font::find_glyph(uint32_t codepoint) const {
int lo = 0;
int hi = this->glyphs_.size() - 1;
while (lo != hi) {
int mid = (lo + hi + 1) / 2;
if (this->glyphs_[mid].compare_to(str)) {
if (this->glyphs_[mid].is_less_or_equal(codepoint)) {
lo = mid;
} else {
hi = mid - 1;
}
}
*match_length = this->glyphs_[lo].match_length(str);
if (*match_length <= 0)
return -1;
return lo;
auto *result = &this->glyphs_[lo];
if (result->code_point == codepoint)
return result;
return nullptr;
}
#ifdef USE_DISPLAY
void Font::measure(const char *str, int *width, int *x_offset, int *baseline, int *height) {
*baseline = this->baseline_;
*height = this->height_;
int i = 0;
int min_x = 0;
bool has_char = false;
int x = 0;
while (str[i] != '\0') {
int match_length;
int glyph_n = this->match_next_glyph((const uint8_t *) str + i, &match_length);
if (glyph_n < 0) {
for (;;) {
size_t length;
auto code_point = extract_unicode_codepoint(str, &length);
if (length == 0)
break;
str += length;
auto *glyph = this->find_glyph(code_point);
if (glyph == nullptr) {
// Unknown char, skip
if (!this->get_glyphs().empty())
x += this->get_glyphs()[0].glyph_data_->advance;
i++;
if (!this->glyphs_.empty())
x += this->glyphs_[0].advance;
continue;
}
const Glyph &glyph = this->glyphs_[glyph_n];
if (!has_char) {
min_x = glyph.glyph_data_->offset_x;
min_x = glyph->offset_x;
} else {
min_x = std::min(min_x, x + glyph.glyph_data_->offset_x);
min_x = std::min(min_x, x + glyph->offset_x);
}
x += glyph.glyph_data_->advance;
x += glyph->advance;
i += match_length;
has_char = true;
}
*x_offset = min_x;
*width = x - min_x;
}
void Font::print(int x_start, int y_start, display::Display *display, Color color, const char *text, Color background) {
int i = 0;
int x_at = x_start;
int scan_x1, scan_y1, scan_width, scan_height;
while (text[i] != '\0') {
int match_length;
int glyph_n = this->match_next_glyph((const uint8_t *) text + i, &match_length);
if (glyph_n < 0) {
for (;;) {
size_t length;
auto code_point = extract_unicode_codepoint(text, &length);
if (length == 0)
break;
text += length;
auto *glyph = this->find_glyph(code_point);
if (glyph == nullptr) {
// Unknown char, skip
ESP_LOGW(TAG, "Encountered character without representation in font: '%c'", text[i]);
if (!this->get_glyphs().empty()) {
uint8_t glyph_width = this->get_glyphs()[0].glyph_data_->advance;
display->filled_rectangle(x_at, y_start, glyph_width, this->height_, color);
ESP_LOGW(TAG, "Codepoint 0x%08" PRIx32 " not found in font", code_point);
if (!this->glyphs_.empty()) {
uint8_t glyph_width = this->glyphs_[0].advance;
display->rectangle(x_at, y_start, glyph_width, this->height_, color);
x_at += glyph_width;
}
i++;
continue;
}
const Glyph &glyph = this->get_glyphs()[glyph_n];
glyph.scan_area(&scan_x1, &scan_y1, &scan_width, &scan_height);
const uint8_t *data = glyph.glyph_data_->data;
const int max_x = x_at + scan_x1 + scan_width;
const int max_y = y_start + scan_y1 + scan_height;
const uint8_t *data = glyph->data;
const int max_x = x_at + glyph->offset_x + glyph->width;
const int max_y = y_start + glyph->offset_y + glyph->height;
uint8_t bitmask = 0;
uint8_t pixel_data = 0;
@@ -145,10 +257,10 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo
auto b_g = (float) background.g;
auto b_b = (float) background.b;
auto b_w = (float) background.w;
for (int glyph_y = y_start + scan_y1; glyph_y != max_y; glyph_y++) {
for (int glyph_x = x_at + scan_x1; glyph_x != max_x; glyph_x++) {
for (int glyph_y = y_start + glyph->offset_y; glyph_y != max_y; glyph_y++) {
for (int glyph_x = x_at + glyph->offset_x; glyph_x != max_x; glyph_x++) {
uint8_t pixel = 0;
for (int bit_num = 0; bit_num != this->bpp_; bit_num++) {
for (uint8_t bit_num = 0; bit_num != this->bpp_; bit_num++) {
if (bitmask == 0) {
pixel_data = progmem_read_byte(data++);
bitmask = 0x80;
@@ -168,12 +280,9 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo
}
}
}
x_at += glyph.glyph_data_->advance;
i += match_length;
x_at += glyph->advance;
}
}
#endif
} // namespace font
} // namespace esphome

View File

@@ -6,14 +6,30 @@
#ifdef USE_DISPLAY
#include "esphome/components/display/display.h"
#endif
#ifdef USE_LVGL_FONT
#include <lvgl.h>
#endif
namespace esphome {
namespace font {
class Font;
struct GlyphData {
const uint8_t *a_char;
class Glyph {
public:
constexpr Glyph(uint32_t code_point, const uint8_t *data, int advance, int offset_x, int offset_y, int width,
int height)
: code_point(code_point),
data(data),
advance(advance),
offset_x(offset_x),
offset_y(offset_y),
width(width),
height(height) {}
bool is_less_or_equal(uint32_t other) const { return this->code_point <= other; }
const uint32_t code_point;
const uint8_t *data;
int advance;
int offset_x;
@@ -22,26 +38,6 @@ struct GlyphData {
int height;
};
class Glyph {
public:
Glyph(const GlyphData *data) : glyph_data_(data) {}
const uint8_t *get_char() const;
bool compare_to(const uint8_t *str) const;
int match_length(const uint8_t *str) const;
void scan_area(int *x1, int *y1, int *width, int *height) const;
const GlyphData *get_glyph_data() const { return this->glyph_data_; }
protected:
friend Font;
const GlyphData *glyph_data_;
};
class Font
#ifdef USE_DISPLAY
: public display::BaseFont
@@ -50,8 +46,8 @@ class Font
public:
/** Construct the font with the given glyphs.
*
* @param data A vector of glyphs, must be sorted lexicographically.
* @param data_nr The number of glyphs in data.
* @param data A list of glyphs, must be sorted lexicographically.
* @param data_nr The number of glyphs
* @param baseline The y-offset from the top of the text to the baseline.
* @param height The y-offset from the top of the text to the bottom.
* @param descender The y-offset from the baseline to the lowest stroke in the font (e.g. from letters like g or p).
@@ -59,10 +55,10 @@ class Font
* @param capheight The height of capital letters, usually measured at the "X" glyph.
* @param bpp The bits per pixel used for this font. Used to read data out of the glyph bitmaps.
*/
Font(const GlyphData *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight,
Font(const Glyph *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight,
uint8_t bpp = 1);
int match_next_glyph(const uint8_t *str, int *match_length);
const Glyph *find_glyph(uint32_t codepoint) const;
#ifdef USE_DISPLAY
void print(int x_start, int y_start, display::Display *display, Color color, const char *text,
@@ -77,11 +73,14 @@ class Font
inline int get_xheight() { return this->xheight_; }
inline int get_capheight() { return this->capheight_; }
inline int get_bpp() { return this->bpp_; }
#ifdef USE_LVGL_FONT
const lv_font_t *get_lv_font() const { return &this->lv_font_; }
#endif
const std::vector<Glyph, RAMAllocator<Glyph>> &get_glyphs() const { return glyphs_; }
const ConstVector<Glyph> &get_glyphs() const { return glyphs_; }
protected:
std::vector<Glyph, RAMAllocator<Glyph>> glyphs_;
ConstVector<Glyph> glyphs_;
int baseline_;
int height_;
int descender_;
@@ -89,6 +88,14 @@ class Font
int xheight_;
int capheight_;
uint8_t bpp_; // bits per pixel
#ifdef USE_LVGL_FONT
lv_font_t lv_font_{};
static const uint8_t *get_glyph_bitmap(const lv_font_t *font, uint32_t unicode_letter);
static bool get_glyph_dsc_cb(const lv_font_t *font, lv_font_glyph_dsc_t *dsc, uint32_t unicode_letter, uint32_t next);
const Glyph *get_glyph_data_(uint32_t unicode_letter);
uint32_t last_letter_{};
const Glyph *last_data_{};
#endif
};
} // namespace font

View File

@@ -52,15 +52,7 @@ from .schemas import (
from .styles import add_top_layer, styles_to_code, theme_to_code
from .touchscreens import touchscreen_schema, touchscreens_to_code
from .trigger import add_on_boot_triggers, generate_triggers
from .types import (
FontEngine,
IdleTrigger,
PlainTrigger,
lv_font_t,
lv_group_t,
lv_style_t,
lvgl_ns,
)
from .types import IdleTrigger, PlainTrigger, lv_font_t, lv_group_t, lv_style_t, lvgl_ns
from .widgets import (
LvScrActType,
Widget,
@@ -244,7 +236,6 @@ async def to_code(configs):
cg.add_global(lvgl_ns.using)
for font in helpers.esphome_fonts_used:
await cg.get_variable(font)
cg.new_Pvariable(ID(f"{font}_engine", True, type=FontEngine), MockObj(font))
default_font = config_0[df.CONF_DEFAULT_FONT]
if not lvalid.is_lv_font(default_font):
add_define(
@@ -256,7 +247,8 @@ async def to_code(configs):
type=lv_font_t.operator("ptr").operator("const"),
)
cg.new_variable(
globfont_id, MockObj(await lvalid.lv_font.process(default_font))
globfont_id,
MockObj(await lvalid.lv_font.process(default_font), "->").get_lv_font(),
)
add_define("LV_FONT_DEFAULT", df.DEFAULT_ESPHOME_FONT)
else:

View File

@@ -1,76 +0,0 @@
#include "lvgl_esphome.h"
#ifdef USE_LVGL_FONT
namespace esphome {
namespace lvgl {
static const uint8_t *get_glyph_bitmap(const lv_font_t *font, uint32_t unicode_letter) {
auto *fe = (FontEngine *) font->dsc;
const auto *gd = fe->get_glyph_data(unicode_letter);
if (gd == nullptr)
return nullptr;
// esph_log_d(TAG, "Returning bitmap @ %X", (uint32_t)gd->data);
return gd->data;
}
static bool get_glyph_dsc_cb(const lv_font_t *font, lv_font_glyph_dsc_t *dsc, uint32_t unicode_letter, uint32_t next) {
auto *fe = (FontEngine *) font->dsc;
const auto *gd = fe->get_glyph_data(unicode_letter);
if (gd == nullptr)
return false;
dsc->adv_w = gd->advance;
dsc->ofs_x = gd->offset_x;
dsc->ofs_y = fe->height - gd->height - gd->offset_y - fe->baseline;
dsc->box_w = gd->width;
dsc->box_h = gd->height;
dsc->is_placeholder = 0;
dsc->bpp = fe->bpp;
return true;
}
FontEngine::FontEngine(font::Font *esp_font) : font_(esp_font) {
this->bpp = esp_font->get_bpp();
this->lv_font_.dsc = this;
this->lv_font_.line_height = this->height = esp_font->get_height();
this->lv_font_.base_line = this->baseline = this->lv_font_.line_height - esp_font->get_baseline();
this->lv_font_.get_glyph_dsc = get_glyph_dsc_cb;
this->lv_font_.get_glyph_bitmap = get_glyph_bitmap;
this->lv_font_.subpx = LV_FONT_SUBPX_NONE;
this->lv_font_.underline_position = -1;
this->lv_font_.underline_thickness = 1;
}
const lv_font_t *FontEngine::get_lv_font() { return &this->lv_font_; }
const font::GlyphData *FontEngine::get_glyph_data(uint32_t unicode_letter) {
if (unicode_letter == last_letter_)
return this->last_data_;
uint8_t unicode[5];
memset(unicode, 0, sizeof unicode);
if (unicode_letter > 0xFFFF) {
unicode[0] = 0xF0 + ((unicode_letter >> 18) & 0x7);
unicode[1] = 0x80 + ((unicode_letter >> 12) & 0x3F);
unicode[2] = 0x80 + ((unicode_letter >> 6) & 0x3F);
unicode[3] = 0x80 + (unicode_letter & 0x3F);
} else if (unicode_letter > 0x7FF) {
unicode[0] = 0xE0 + ((unicode_letter >> 12) & 0xF);
unicode[1] = 0x80 + ((unicode_letter >> 6) & 0x3F);
unicode[2] = 0x80 + (unicode_letter & 0x3F);
} else if (unicode_letter > 0x7F) {
unicode[0] = 0xC0 + ((unicode_letter >> 6) & 0x1F);
unicode[1] = 0x80 + (unicode_letter & 0x3F);
} else {
unicode[0] = unicode_letter;
}
int match_length;
int glyph_n = this->font_->match_next_glyph(unicode, &match_length);
if (glyph_n < 0)
return nullptr;
this->last_data_ = this->font_->get_glyphs()[glyph_n].get_glyph_data();
this->last_letter_ = unicode_letter;
return this->last_data_;
}
} // namespace lvgl
} // namespace esphome
#endif // USES_LVGL_FONT

View File

@@ -493,6 +493,7 @@ class LvFont(LValidator):
return LV_FONTS
if is_lv_font(value):
return lv_builtin_font(value)
add_lv_use("font")
fontval = cv.use_id(Font)(value)
esphome_fonts_used.add(fontval)
return requires_component("font")(fontval)
@@ -502,7 +503,9 @@ class LvFont(LValidator):
async def process(self, value, args=()):
if is_lv_font(value):
return literal(f"&lv_font_{value}")
return literal(f"{value}_engine->get_lv_font()")
if isinstance(value, str):
return literal(f"{value}")
return await super().process(value, args)
lv_font = LvFont()

View File

@@ -50,6 +50,14 @@ static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BIT
static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BITNESS_332;
#endif // LV_COLOR_DEPTH
#ifdef USE_LVGL_FONT
inline void lv_obj_set_style_text_font(lv_obj_t *obj, const font::Font *font, lv_style_selector_t part) {
lv_obj_set_style_text_font(obj, font->get_lv_font(), part);
}
inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) {
lv_style_set_text_font(style, font->get_lv_font());
}
#endif
#ifdef USE_LVGL_IMAGE
// Shortcut / overload, so that the source of an image can easily be updated
// from within a lambda.
@@ -134,24 +142,6 @@ template<typename... Ts> class ObjUpdateAction : public Action<Ts...> {
protected:
std::function<void(Ts...)> lamb_;
};
#ifdef USE_LVGL_FONT
class FontEngine {
public:
FontEngine(font::Font *esp_font);
const lv_font_t *get_lv_font();
const font::GlyphData *get_glyph_data(uint32_t unicode_letter);
uint16_t baseline{};
uint16_t height{};
uint8_t bpp{};
protected:
font::Font *font_{};
uint32_t last_letter_{};
const font::GlyphData *last_data_{};
lv_font_t lv_font_{};
};
#endif // USE_LVGL_FONT
#ifdef USE_LVGL_ANIMIMG
void lv_animimg_stop(lv_obj_t *obj);
#endif // USE_LVGL_ANIMIMG

View File

@@ -45,7 +45,6 @@ lv_coord_t = cg.global_ns.namespace("lv_coord_t")
lv_event_code_t = cg.global_ns.enum("lv_event_code_t")
lv_indev_type_t = cg.global_ns.enum("lv_indev_type_t")
lv_key_t = cg.global_ns.enum("lv_key_t")
FontEngine = lvgl_ns.class_("FontEngine")
PlainTrigger = esphome_ns.class_("Trigger<>", automation.Trigger.template())
DrawEndTrigger = esphome_ns.class_(
"Trigger<uint32_t, uint32_t>", automation.Trigger.template(cg.uint32, cg.uint32)

View File

@@ -118,10 +118,10 @@ struct IPAddress {
operator arduino_ns::IPAddress() const { return ip_addr_get_ip4_u32(&ip_addr_); }
#endif
bool is_set() { return !ip_addr_isany(&ip_addr_); } // NOLINT(readability-simplify-boolean-expr)
bool is_ip4() { return IP_IS_V4(&ip_addr_); }
bool is_ip6() { return IP_IS_V6(&ip_addr_); }
bool is_multicast() { return ip_addr_ismulticast(&ip_addr_); }
bool is_set() const { return !ip_addr_isany(&ip_addr_); } // NOLINT(readability-simplify-boolean-expr)
bool is_ip4() const { return IP_IS_V4(&ip_addr_); }
bool is_ip6() const { return IP_IS_V6(&ip_addr_); }
bool is_multicast() const { return ip_addr_ismulticast(&ip_addr_); }
std::string str() const { return str_lower_case(ipaddr_ntoa(&ip_addr_)); }
bool operator==(const IPAddress &other) const { return ip_addr_cmp(&ip_addr_, &other.ip_addr_); }
bool operator!=(const IPAddress &other) const { return !ip_addr_cmp(&ip_addr_, &other.ip_addr_); }

View File

@@ -103,6 +103,7 @@ nrf52_ns = cg.esphome_ns.namespace("nrf52")
DeviceFirmwareUpdate = nrf52_ns.class_("DeviceFirmwareUpdate", cg.Component)
CONF_DFU = "dfu"
CONF_DCDC = "dcdc"
CONF_REG0 = "reg0"
CONF_UICR_ERASE = "uicr_erase"
@@ -121,6 +122,7 @@ CONFIG_SCHEMA = cv.All(
cv.Required(CONF_RESET_PIN): pins.gpio_output_pin_schema,
}
),
cv.Optional(CONF_DCDC, default=True): cv.boolean,
cv.Optional(CONF_REG0): cv.Schema(
{
cv.Required(CONF_VOLTAGE): cv.All(
@@ -196,6 +198,7 @@ async def to_code(config: ConfigType) -> None:
if dfu_config := config.get(CONF_DFU):
CORE.add_job(_dfu_to_code, dfu_config)
zephyr_add_prj_conf("BOARD_ENABLE_DCDC", config[CONF_DCDC])
if reg0_config := config.get(CONF_REG0):
value = VOLTAGE_LEVELS.index(reg0_config[CONF_VOLTAGE])

View File

@@ -1,14 +1,9 @@
import logging
import esphome.codegen as cg
from esphome.components import time as time_
from esphome.config_helpers import merge_config
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
CONF_PLATFORM,
CONF_SERVERS,
CONF_TIME,
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
@@ -17,74 +12,13 @@ from esphome.const import (
PLATFORM_RTL87XX,
)
from esphome.core import CORE
import esphome.final_validate as fv
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
DEPENDENCIES = ["network"]
CONF_SNTP = "sntp"
sntp_ns = cg.esphome_ns.namespace("sntp")
SNTPComponent = sntp_ns.class_("SNTPComponent", time_.RealTimeClock)
DEFAULT_SERVERS = ["0.pool.ntp.org", "1.pool.ntp.org", "2.pool.ntp.org"]
def _sntp_final_validate(config: ConfigType) -> None:
"""Merge multiple SNTP instances into one, similar to OTA merging behavior."""
full_conf = fv.full_config.get()
time_confs = full_conf.get(CONF_TIME, [])
sntp_configs: list[ConfigType] = []
other_time_configs: list[ConfigType] = []
for time_conf in time_confs:
if time_conf.get(CONF_PLATFORM) == CONF_SNTP:
sntp_configs.append(time_conf)
else:
other_time_configs.append(time_conf)
if len(sntp_configs) <= 1:
return
# Merge all SNTP configs into the first one
merged = sntp_configs[0]
for sntp_conf in sntp_configs[1:]:
# Validate that IDs are consistent if manually specified
if merged[CONF_ID].is_manual and sntp_conf[CONF_ID].is_manual:
raise cv.Invalid(
f"Found multiple SNTP configurations but {CONF_ID} is inconsistent"
)
merged = merge_config(merged, sntp_conf)
# Deduplicate servers while preserving order
servers = merged[CONF_SERVERS]
unique_servers = list(dict.fromkeys(servers))
# Warn if we're dropping servers due to 3-server limit
if len(unique_servers) > 3:
dropped = unique_servers[3:]
unique_servers = unique_servers[:3]
_LOGGER.warning(
"SNTP supports maximum 3 servers. Dropped excess server(s): %s",
dropped,
)
merged[CONF_SERVERS] = unique_servers
_LOGGER.warning(
"Found and merged %d SNTP time configurations into one instance",
len(sntp_configs),
)
# Replace time configs with merged SNTP + other time platforms
other_time_configs.append(merged)
full_conf[CONF_TIME] = other_time_configs
fv.full_config.set(full_conf)
CONFIG_SCHEMA = cv.All(
time_.TIME_SCHEMA.extend(
{
@@ -106,8 +40,6 @@ CONFIG_SCHEMA = cv.All(
),
)
FINAL_VALIDATE_SCHEMA = _sntp_final_validate
async def to_code(config):
servers = config[CONF_SERVERS]

View File

@@ -56,19 +56,11 @@ uint32_t ESP8266UartComponent::get_config() {
}
void ESP8266UartComponent::setup() {
auto setup_pin_if_needed = [](InternalGPIOPin *pin) {
if (!pin) {
return;
}
const auto mask = gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN;
if ((pin->get_flags() & mask) != gpio::Flags::FLAG_NONE) {
pin->setup();
}
};
setup_pin_if_needed(this->rx_pin_);
if (this->rx_pin_ != this->tx_pin_) {
setup_pin_if_needed(this->tx_pin_);
if (this->rx_pin_) {
this->rx_pin_->setup();
}
if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) {
this->tx_pin_->setup();
}
// Use Arduino HardwareSerial UARTs if all used pins match the ones

View File

@@ -133,19 +133,11 @@ void IDFUARTComponent::load_settings(bool dump_config) {
return;
}
auto setup_pin_if_needed = [](InternalGPIOPin *pin) {
if (!pin) {
return;
}
const auto mask = gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN;
if ((pin->get_flags() & mask) != gpio::Flags::FLAG_NONE) {
pin->setup();
}
};
setup_pin_if_needed(this->rx_pin_);
if (this->rx_pin_ != this->tx_pin_) {
setup_pin_if_needed(this->tx_pin_);
if (this->rx_pin_) {
this->rx_pin_->setup();
}
if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) {
this->tx_pin_->setup();
}
int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1;

View File

@@ -53,7 +53,7 @@ void LibreTinyUARTComponent::setup() {
auto shouldFallbackToSoftwareSerial = [&]() -> bool {
auto hasFlags = [](InternalGPIOPin *pin, const gpio::Flags mask) -> bool {
return pin && (pin->get_flags() & mask) != gpio::Flags::FLAG_NONE;
return pin && pin->get_flags() & mask != gpio::Flags::FLAG_NONE;
};
if (hasFlags(this->tx_pin_, gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN) ||
hasFlags(this->rx_pin_, gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN)) {

View File

@@ -52,19 +52,11 @@ uint16_t RP2040UartComponent::get_config() {
}
void RP2040UartComponent::setup() {
auto setup_pin_if_needed = [](InternalGPIOPin *pin) {
if (!pin) {
return;
}
const auto mask = gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN;
if ((pin->get_flags() & mask) != gpio::Flags::FLAG_NONE) {
pin->setup();
}
};
setup_pin_if_needed(this->rx_pin_);
if (this->rx_pin_ != this->tx_pin_) {
setup_pin_if_needed(this->tx_pin_);
if (this->rx_pin_) {
this->rx_pin_->setup();
}
if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) {
this->tx_pin_->setup();
}
uint16_t config = get_config();

View File

@@ -1,17 +1,10 @@
import logging
import esphome.codegen as cg
from esphome.components.esp32 import add_idf_component
from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code
from esphome.config_helpers import merge_config
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_OTA, CONF_PLATFORM, CONF_WEB_SERVER
from esphome.const import CONF_ID
from esphome.core import CORE, coroutine_with_priority
from esphome.coroutine import CoroPriority
import esphome.final_validate as fv
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@esphome/core"]
DEPENDENCIES = ["network", "web_server_base"]
@@ -19,53 +12,6 @@ DEPENDENCIES = ["network", "web_server_base"]
web_server_ns = cg.esphome_ns.namespace("web_server")
WebServerOTAComponent = web_server_ns.class_("WebServerOTAComponent", OTAComponent)
def _web_server_ota_final_validate(config: ConfigType) -> None:
"""Merge multiple web_server OTA instances into one.
Multiple web_server OTA instances register duplicate HTTP handlers for /update,
causing undefined behavior. Merge them into a single instance.
"""
full_conf = fv.full_config.get()
ota_confs = full_conf.get(CONF_OTA, [])
web_server_ota_configs: list[ConfigType] = []
other_ota_configs: list[ConfigType] = []
for ota_conf in ota_confs:
if ota_conf.get(CONF_PLATFORM) == CONF_WEB_SERVER:
web_server_ota_configs.append(ota_conf)
else:
other_ota_configs.append(ota_conf)
if len(web_server_ota_configs) <= 1:
return
# Merge all web_server OTA configs into the first one
merged = web_server_ota_configs[0]
for ota_conf in web_server_ota_configs[1:]:
# Validate that IDs are consistent if manually specified
if (
merged[CONF_ID].is_manual
and ota_conf[CONF_ID].is_manual
and merged[CONF_ID] != ota_conf[CONF_ID]
):
raise cv.Invalid(
f"Found multiple web_server OTA configurations but {CONF_ID} is inconsistent"
)
merged = merge_config(merged, ota_conf)
_LOGGER.warning(
"Found and merged %d web_server OTA configurations into one instance",
len(web_server_ota_configs),
)
# Replace OTA configs with merged web_server + other OTA platforms
other_ota_configs.append(merged)
full_conf[CONF_OTA] = other_ota_configs
fv.full_config.set(full_conf)
CONFIG_SCHEMA = (
cv.Schema(
{
@@ -76,8 +22,6 @@ CONFIG_SCHEMA = (
.extend(cv.COMPONENT_SCHEMA)
)
FINAL_VALIDATE_SCHEMA = _web_server_ota_final_validate
@coroutine_with_priority(CoroPriority.WEB_SERVER_OTA)
async def to_code(config):

View File

@@ -489,18 +489,10 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest *
void AsyncEventSourceResponse::destroy(void *ptr) {
auto *rsp = static_cast<AsyncEventSourceResponse *>(ptr);
int fd = rsp->fd_.exchange(0); // Atomically get and clear fd
if (fd > 0) {
ESP_LOGD(TAG, "Event source connection closed (fd: %d)", fd);
// Immediately shut down the socket to prevent lwIP from delivering more data
// This prevents "recv_tcp: recv for wrong pcb!" assertions when the TCP stack
// tries to deliver queued data after the session is marked as dead
// See: https://github.com/esphome/esphome/issues/11936
shutdown(fd, SHUT_RDWR);
// Note: We don't close() the socket - httpd owns it and will close it
}
// Session will be cleaned up in the main loop to avoid race conditions
ESP_LOGD(TAG, "Event source connection closed (fd: %d)", rsp->fd_.load());
// Mark as dead by setting fd to 0 - will be cleaned up in the main loop
rsp->fd_.store(0);
// Note: We don't delete or remove from set here to avoid race conditions
}
// helper for allowing only unique entries in the queue

View File

@@ -479,11 +479,14 @@ async def to_code(config):
cg.add(var.set_min_auth_mode(config[CONF_MIN_AUTH_MODE]))
if config[CONF_FAST_CONNECT]:
cg.add_define("USE_WIFI_FAST_CONNECT")
cg.add(var.set_passive_scan(config[CONF_PASSIVE_SCAN]))
# passive_scan defaults to false in C++ - only set if true
if config[CONF_PASSIVE_SCAN]:
cg.add(var.set_passive_scan(True))
if CONF_OUTPUT_POWER in config:
cg.add(var.set_output_power(config[CONF_OUTPUT_POWER]))
cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT]))
# enable_on_boot defaults to true in C++ - only set if false
if not config[CONF_ENABLE_ON_BOOT]:
cg.add(var.set_enable_on_boot(False))
if CORE.is_esp8266:
cg.add_library("ESP8266WiFi", None)

View File

@@ -670,25 +670,25 @@ void WiFiComponent::save_wifi_sta(const std::string &ssid, const std::string &pa
void WiFiComponent::start_connecting(const WiFiAP &ap) {
// Log connection attempt at INFO level with priority
std::string bssid_formatted;
char bssid_s[18];
int8_t priority = 0;
if (ap.get_bssid().has_value()) {
bssid_formatted = format_mac_address_pretty(ap.get_bssid().value().data());
format_mac_addr_upper(ap.get_bssid().value().data(), bssid_s);
priority = this->get_sta_priority(ap.get_bssid().value());
}
ESP_LOGI(TAG,
"Connecting to " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") " (priority %d, attempt %u/%u in phase %s)...",
ap.get_ssid().c_str(), ap.get_bssid().has_value() ? bssid_formatted.c_str() : LOG_STR_LITERAL("any"),
priority, this->num_retried_ + 1, get_max_retries_for_phase(this->retry_phase_),
ap.get_ssid().c_str(), ap.get_bssid().has_value() ? bssid_s : LOG_STR_LITERAL("any"), priority,
this->num_retried_ + 1, get_max_retries_for_phase(this->retry_phase_),
LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_)));
#ifdef ESPHOME_LOG_HAS_VERBOSE
ESP_LOGV(TAG, "Connection Params:");
ESP_LOGV(TAG, " SSID: '%s'", ap.get_ssid().c_str());
if (ap.get_bssid().has_value()) {
ESP_LOGV(TAG, " BSSID: %s", format_mac_address_pretty(ap.get_bssid()->data()).c_str());
ESP_LOGV(TAG, " BSSID: %s", bssid_s);
} else {
ESP_LOGV(TAG, " BSSID: Not Set");
}
@@ -797,6 +797,8 @@ const LogString *get_signal_bars(int8_t rssi) {
void WiFiComponent::print_connect_params_() {
bssid_t bssid = wifi_bssid();
char bssid_s[18];
format_mac_addr_upper(bssid.data(), bssid_s);
ESP_LOGCONFIG(TAG, " Local MAC: %s", get_mac_address_pretty().c_str());
if (this->is_disabled()) {
@@ -819,9 +821,9 @@ void WiFiComponent::print_connect_params_() {
" Gateway: %s\n"
" DNS1: %s\n"
" DNS2: %s",
wifi_ssid().c_str(), format_mac_address_pretty(bssid.data()).c_str(), App.get_name().c_str(), rssi,
LOG_STR_ARG(get_signal_bars(rssi)), get_wifi_channel(), wifi_subnet_mask_().str().c_str(),
wifi_gateway_ip_().str().c_str(), wifi_dns_ip_(0).str().c_str(), wifi_dns_ip_(1).str().c_str());
wifi_ssid().c_str(), bssid_s, App.get_name().c_str(), rssi, LOG_STR_ARG(get_signal_bars(rssi)),
get_wifi_channel(), wifi_subnet_mask_().str().c_str(), wifi_gateway_ip_().str().c_str(),
wifi_dns_ip_(0).str().c_str(), wifi_dns_ip_(1).str().c_str());
#ifdef ESPHOME_LOG_HAS_VERBOSE
if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_bssid().has_value()) {
ESP_LOGV(TAG, " Priority: %d", this->get_sta_priority(*config->get_bssid()));
@@ -1412,8 +1414,10 @@ void WiFiComponent::log_and_adjust_priority_for_failed_connect_() {
(old_priority > std::numeric_limits<int8_t>::min()) ? (old_priority - 1) : std::numeric_limits<int8_t>::min();
this->set_sta_priority(failed_bssid.value(), new_priority);
}
ESP_LOGD(TAG, "Failed " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") ", priority %d → %d", ssid.c_str(),
format_mac_address_pretty(failed_bssid.value().data()).c_str(), old_priority, new_priority);
char bssid_s[18];
format_mac_addr_upper(failed_bssid.value().data(), bssid_s);
ESP_LOGD(TAG, "Failed " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") ", priority %d → %d", ssid.c_str(), bssid_s,
old_priority, new_priority);
// After adjusting priority, check if all priorities are now at minimum
// If so, clear the vector to save memory and reset for fresh start

View File

@@ -429,7 +429,7 @@ class WiFiComponent : public Component {
bool wifi_sta_pre_setup_();
bool wifi_apply_output_power_(float output_power);
bool wifi_apply_power_save_();
bool wifi_sta_ip_config_(optional<ManualIP> manual_ip);
bool wifi_sta_ip_config_(const optional<ManualIP> &manual_ip);
bool wifi_apply_hostname_();
bool wifi_sta_connect_(const WiFiAP &ap);
void wifi_pre_setup_();
@@ -437,7 +437,7 @@ class WiFiComponent : public Component {
bool wifi_scan_start_(bool passive);
#ifdef USE_WIFI_AP
bool wifi_ap_ip_config_(optional<ManualIP> manual_ip);
bool wifi_ap_ip_config_(const optional<ManualIP> &manual_ip);
bool wifi_start_ap_(const WiFiAP &ap);
#endif // USE_WIFI_AP
@@ -526,7 +526,7 @@ class WiFiComponent : public Component {
bool btm_{false};
bool rrm_{false};
#endif
bool enable_on_boot_;
bool enable_on_boot_{true};
bool got_ipv4_address_{false};
bool keep_scan_results_{false};

View File

@@ -117,7 +117,7 @@ void netif_set_addr(struct netif *netif, const ip4_addr_t *ip, const ip4_addr_t
};
#endif
bool WiFiComponent::wifi_sta_ip_config_(optional<ManualIP> manual_ip) {
bool WiFiComponent::wifi_sta_ip_config_(const optional<ManualIP> &manual_ip) {
// enable STA
if (!this->wifi_mode_(true, {}))
return false;
@@ -525,8 +525,10 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
ESP_LOGW(TAG, "Disconnected ssid='%s' reason='Probe Request Unsuccessful'", buf);
s_sta_connect_not_found = true;
} else {
ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf,
format_mac_address_pretty(it.bssid).c_str(), LOG_STR_ARG(get_disconnect_reason_str(it.reason)));
char bssid_s[18];
format_mac_addr_upper(it.bssid, bssid_s);
ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf, bssid_s,
LOG_STR_ARG(get_disconnect_reason_str(it.reason)));
s_sta_connect_error = true;
}
s_sta_connected = false;
@@ -730,7 +732,7 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) {
}
#ifdef USE_WIFI_AP
bool WiFiComponent::wifi_ap_ip_config_(optional<ManualIP> manual_ip) {
bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &manual_ip) {
// enable AP
if (!this->wifi_mode_({}, true))
return false;

View File

@@ -487,7 +487,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
return true;
}
bool WiFiComponent::wifi_sta_ip_config_(optional<ManualIP> manual_ip) {
bool WiFiComponent::wifi_sta_ip_config_(const optional<ManualIP> &manual_ip) {
// enable STA
if (!this->wifi_mode_(true, {}))
return false;
@@ -746,8 +746,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
ESP_LOGI(TAG, "Disconnected ssid='%s' reason='Station Roaming'", buf);
return;
} else {
ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf,
format_mac_address_pretty(it.bssid).c_str(), get_disconnect_reason_str(it.reason));
char bssid_s[18];
format_mac_addr_upper(it.bssid, bssid_s);
ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf, bssid_s,
get_disconnect_reason_str(it.reason));
s_sta_connect_error = true;
}
s_sta_connected = false;
@@ -884,7 +886,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
}
#ifdef USE_WIFI_AP
bool WiFiComponent::wifi_ap_ip_config_(optional<ManualIP> manual_ip) {
bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &manual_ip) {
esp_err_t err;
// enable AP

View File

@@ -68,7 +68,7 @@ bool WiFiComponent::wifi_sta_pre_setup_() {
return true;
}
bool WiFiComponent::wifi_apply_power_save_() { return WiFi.setSleep(this->power_save_ != WIFI_POWER_SAVE_NONE); }
bool WiFiComponent::wifi_sta_ip_config_(optional<ManualIP> manual_ip) {
bool WiFiComponent::wifi_sta_ip_config_(const optional<ManualIP> &manual_ip) {
// enable STA
if (!this->wifi_mode_(true, {}))
return false;
@@ -299,8 +299,10 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_
if (it.reason == WIFI_REASON_NO_AP_FOUND) {
ESP_LOGW(TAG, "Disconnected ssid='%s' reason='Probe Request Unsuccessful'", buf);
} else {
ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf,
format_mac_address_pretty(it.bssid).c_str(), get_disconnect_reason_str(it.reason));
char bssid_s[18];
format_mac_addr_upper(it.bssid, bssid_s);
ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf, bssid_s,
get_disconnect_reason_str(it.reason));
}
uint8_t reason = it.reason;
@@ -434,7 +436,7 @@ void WiFiComponent::wifi_scan_done_callback_() {
}
#ifdef USE_WIFI_AP
bool WiFiComponent::wifi_ap_ip_config_(optional<ManualIP> manual_ip) {
bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &manual_ip) {
// enable AP
if (!this->wifi_mode_({}, true))
return false;

View File

@@ -72,7 +72,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
bool WiFiComponent::wifi_sta_pre_setup_() { return this->wifi_mode_(true, {}); }
bool WiFiComponent::wifi_sta_ip_config_(optional<ManualIP> manual_ip) {
bool WiFiComponent::wifi_sta_ip_config_(const optional<ManualIP> &manual_ip) {
if (!manual_ip.has_value()) {
return true;
}
@@ -146,7 +146,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
}
#ifdef USE_WIFI_AP
bool WiFiComponent::wifi_ap_ip_config_(optional<ManualIP> manual_ip) {
bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &manual_ip) {
esphome::network::IPAddress ip_address, gateway, subnet, dns;
if (manual_ip.has_value()) {
ip_address = manual_ip->static_ip;

View File

@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2025.11.0b3"
__version__ = "2025.12.0-dev"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (

View File

@@ -111,6 +111,23 @@ template<> constexpr int64_t byteswap(int64_t n) { return __builtin_bswap64(n);
/// @name Container utilities
///@{
/// Lightweight read-only view over a const array stored in RODATA (will typically be in flash memory)
/// Avoids copying data from flash to RAM by keeping a pointer to the flash data.
/// Similar to std::span but with minimal overhead for embedded systems.
template<typename T> class ConstVector {
public:
constexpr ConstVector(const T *data, size_t size) : data_(data), size_(size) {}
const constexpr T &operator[](size_t i) const { return data_[i]; }
constexpr size_t size() const { return size_; }
constexpr bool empty() const { return size_ == 0; }
protected:
const T *data_;
size_t size_;
};
/// Minimal static vector - saves memory by avoiding std::vector overhead
template<typename T, size_t N> class StaticVector {
public:

View File

@@ -1,11 +1,11 @@
pylint==4.0.2
pylint==4.0.3
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
ruff==0.14.4 # also change in .pre-commit-config.yaml when updating
ruff==0.14.5 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.1 # also change in .pre-commit-config.yaml when updating
pre-commit
# Unit tests
pytest==9.0.0
pytest==9.0.1
pytest-cov==7.0.0
pytest-mock==3.15.1
pytest-asyncio==1.3.0

View File

@@ -1,18 +1,6 @@
"""Tests for the web_server OTA platform."""
from __future__ import annotations
from collections.abc import Callable
import logging
from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.web_server.ota import _web_server_ota_final_validate
from esphome.const import CONF_ID, CONF_OTA, CONF_PLATFORM, CONF_WEB_SERVER
from esphome.core import ID
import esphome.final_validate as fv
def test_web_server_ota_generated(generate_main: Callable[[str], str]) -> None:
@@ -112,144 +100,3 @@ def test_web_server_ota_esp8266(generate_main: Callable[[str], str]) -> None:
# Check web server OTA component is present
assert "WebServerOTAComponent" in main_cpp
assert "web_server::WebServerOTAComponent" in main_cpp
@pytest.mark.parametrize(
("ota_configs", "expected_count", "warning_expected"),
[
pytest.param(
[
{
CONF_PLATFORM: CONF_WEB_SERVER,
CONF_ID: ID("ota_web", is_manual=False),
}
],
1,
False,
id="single_instance_no_merge",
),
pytest.param(
[
{
CONF_PLATFORM: CONF_WEB_SERVER,
CONF_ID: ID("ota_web_1", is_manual=False),
},
{
CONF_PLATFORM: CONF_WEB_SERVER,
CONF_ID: ID("ota_web_2", is_manual=False),
},
],
1,
True,
id="two_instances_merged",
),
pytest.param(
[
{
CONF_PLATFORM: CONF_WEB_SERVER,
CONF_ID: ID("ota_web_1", is_manual=False),
},
{
CONF_PLATFORM: "esphome",
CONF_ID: ID("ota_esphome", is_manual=False),
},
{
CONF_PLATFORM: CONF_WEB_SERVER,
CONF_ID: ID("ota_web_2", is_manual=False),
},
],
2,
True,
id="mixed_platforms_web_server_merged",
),
],
)
def test_web_server_ota_instance_merging(
ota_configs: list[dict[str, Any]],
expected_count: int,
warning_expected: bool,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test web_server OTA instance merging behavior."""
full_conf = {CONF_OTA: ota_configs.copy()}
token = fv.full_config.set(full_conf)
try:
with caplog.at_level(logging.WARNING):
_web_server_ota_final_validate({})
updated_conf = fv.full_config.get()
# Verify total number of OTA platforms
assert len(updated_conf[CONF_OTA]) == expected_count
# Verify warning
if warning_expected:
assert any(
"Found and merged" in record.message
and "web_server OTA" in record.message
for record in caplog.records
), "Expected merge warning not found in log"
else:
assert len(caplog.records) == 0, "Unexpected warnings logged"
finally:
fv.full_config.reset(token)
def test_web_server_ota_consistent_manual_ids(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that consistent manual IDs can be merged successfully."""
ota_configs = [
{
CONF_PLATFORM: CONF_WEB_SERVER,
CONF_ID: ID("ota_web", is_manual=True),
},
{
CONF_PLATFORM: CONF_WEB_SERVER,
CONF_ID: ID("ota_web", is_manual=True),
},
]
full_conf = {CONF_OTA: ota_configs}
token = fv.full_config.set(full_conf)
try:
with caplog.at_level(logging.WARNING):
_web_server_ota_final_validate({})
updated_conf = fv.full_config.get()
assert len(updated_conf[CONF_OTA]) == 1
assert updated_conf[CONF_OTA][0][CONF_ID].id == "ota_web"
assert any(
"Found and merged" in record.message and "web_server OTA" in record.message
for record in caplog.records
)
finally:
fv.full_config.reset(token)
def test_web_server_ota_inconsistent_manual_ids() -> None:
"""Test that inconsistent manual IDs raise an error."""
ota_configs = [
{
CONF_PLATFORM: CONF_WEB_SERVER,
CONF_ID: ID("ota_web_1", is_manual=True),
},
{
CONF_PLATFORM: CONF_WEB_SERVER,
CONF_ID: ID("ota_web_2", is_manual=True),
},
]
full_conf = {CONF_OTA: ota_configs}
token = fv.full_config.set(full_conf)
try:
with pytest.raises(
cv.Invalid,
match="Found multiple web_server OTA configurations but id is inconsistent",
):
_web_server_ota_final_validate({})
finally:
fv.full_config.reset(token)

View File

@@ -1 +0,0 @@
"""Tests for SNTP component."""

View File

@@ -1,22 +0,0 @@
esphome:
name: sntp-test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "testssid"
password: "testpassword"
# Test multiple SNTP instances that should be merged
time:
- platform: sntp
servers:
- 192.168.1.1
- pool.ntp.org
- platform: sntp
servers:
- pool.ntp.org
- 192.168.1.2

View File

@@ -1,238 +0,0 @@
"""Tests for SNTP time configuration validation."""
from __future__ import annotations
import logging
from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.sntp.time import CONF_SNTP, _sntp_final_validate
from esphome.const import CONF_ID, CONF_PLATFORM, CONF_SERVERS, CONF_TIME
from esphome.core import ID
import esphome.final_validate as fv
@pytest.mark.parametrize(
("time_configs", "expected_count", "expected_servers", "warning_messages"),
[
pytest.param(
[
{
CONF_PLATFORM: CONF_SNTP,
CONF_ID: ID("sntp_time", is_manual=False),
CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"],
}
],
1,
["192.168.1.1", "pool.ntp.org"],
[],
id="single_instance_no_merge",
),
pytest.param(
[
{
CONF_PLATFORM: CONF_SNTP,
CONF_ID: ID("sntp_time_1", is_manual=False),
CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"],
},
{
CONF_PLATFORM: CONF_SNTP,
CONF_ID: ID("sntp_time_2", is_manual=False),
CONF_SERVERS: ["192.168.1.2"],
},
],
1,
["192.168.1.1", "pool.ntp.org", "192.168.1.2"],
["Found and merged 2 SNTP time configurations into one instance"],
id="two_instances_merged",
),
pytest.param(
[
{
CONF_PLATFORM: CONF_SNTP,
CONF_ID: ID("sntp_time_1", is_manual=False),
CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"],
},
{
CONF_PLATFORM: CONF_SNTP,
CONF_ID: ID("sntp_time_2", is_manual=False),
CONF_SERVERS: ["pool.ntp.org", "192.168.1.2"],
},
],
1,
["192.168.1.1", "pool.ntp.org", "192.168.1.2"],
["Found and merged 2 SNTP time configurations into one instance"],
id="deduplication_preserves_order",
),
pytest.param(
[
{
CONF_PLATFORM: CONF_SNTP,
CONF_ID: ID("sntp_time_1", is_manual=False),
CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"],
},
{
CONF_PLATFORM: CONF_SNTP,
CONF_ID: ID("sntp_time_2", is_manual=False),
CONF_SERVERS: ["192.168.1.2", "pool2.ntp.org"],
},
{
CONF_PLATFORM: CONF_SNTP,
CONF_ID: ID("sntp_time_3", is_manual=False),
CONF_SERVERS: ["pool3.ntp.org"],
},
],
1,
["192.168.1.1", "pool.ntp.org", "192.168.1.2"],
[
"SNTP supports maximum 3 servers. Dropped excess server(s): ['pool2.ntp.org', 'pool3.ntp.org']",
"Found and merged 3 SNTP time configurations into one instance",
],
id="three_instances_drops_excess_servers",
),
pytest.param(
[
{
CONF_PLATFORM: CONF_SNTP,
CONF_ID: ID("sntp_time_1", is_manual=False),
CONF_SERVERS: [
"192.168.1.1",
"pool.ntp.org",
"pool.ntp.org",
"192.168.1.1",
],
},
{
CONF_PLATFORM: CONF_SNTP,
CONF_ID: ID("sntp_time_2", is_manual=False),
CONF_SERVERS: ["pool.ntp.org", "192.168.1.2"],
},
],
1,
["192.168.1.1", "pool.ntp.org", "192.168.1.2"],
["Found and merged 2 SNTP time configurations into one instance"],
id="deduplication_multiple_duplicates",
),
],
)
def test_sntp_instance_merging(
time_configs: list[dict[str, Any]],
expected_count: int,
expected_servers: list[str],
warning_messages: list[str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test SNTP instance merging behavior."""
# Create a mock full config with time configs
full_conf = {CONF_TIME: time_configs.copy()}
# Set the context var
token = fv.full_config.set(full_conf)
try:
with caplog.at_level(logging.WARNING):
_sntp_final_validate({})
# Get the updated config
updated_conf = fv.full_config.get()
# Check if merging occurred
if len(time_configs) > 1:
# Verify only one SNTP instance remains
sntp_instances = [
tc
for tc in updated_conf[CONF_TIME]
if tc.get(CONF_PLATFORM) == CONF_SNTP
]
assert len(sntp_instances) == expected_count
# Verify server list
assert sntp_instances[0][CONF_SERVERS] == expected_servers
# Verify warnings
for expected_msg in warning_messages:
assert any(
expected_msg in record.message for record in caplog.records
), f"Expected warning message '{expected_msg}' not found in log"
else:
# Single instance should not trigger merging or warnings
assert len(caplog.records) == 0
# Config should be unchanged
assert updated_conf[CONF_TIME] == time_configs
finally:
fv.full_config.reset(token)
def test_sntp_inconsistent_manual_ids() -> None:
"""Test that inconsistent manual IDs raise an error."""
# Create configs with manual IDs that are inconsistent
time_configs = [
{
CONF_PLATFORM: CONF_SNTP,
CONF_ID: ID("sntp_time_1", is_manual=True),
CONF_SERVERS: ["192.168.1.1"],
},
{
CONF_PLATFORM: CONF_SNTP,
CONF_ID: ID("sntp_time_2", is_manual=True),
CONF_SERVERS: ["192.168.1.2"],
},
]
full_conf = {CONF_TIME: time_configs}
token = fv.full_config.set(full_conf)
try:
with pytest.raises(
cv.Invalid,
match="Found multiple SNTP configurations but id is inconsistent",
):
_sntp_final_validate({})
finally:
fv.full_config.reset(token)
def test_sntp_with_other_time_platforms(caplog: pytest.LogCaptureFixture) -> None:
"""Test that SNTP merging doesn't affect other time platforms."""
time_configs = [
{
CONF_PLATFORM: CONF_SNTP,
CONF_ID: ID("sntp_time_1", is_manual=False),
CONF_SERVERS: ["192.168.1.1"],
},
{
CONF_PLATFORM: "homeassistant",
CONF_ID: ID("homeassistant_time", is_manual=False),
},
{
CONF_PLATFORM: CONF_SNTP,
CONF_ID: ID("sntp_time_2", is_manual=False),
CONF_SERVERS: ["192.168.1.2"],
},
]
full_conf = {CONF_TIME: time_configs.copy()}
token = fv.full_config.set(full_conf)
try:
with caplog.at_level(logging.WARNING):
_sntp_final_validate({})
updated_conf = fv.full_config.get()
# Should have 2 time platforms: 1 merged SNTP + 1 homeassistant
assert len(updated_conf[CONF_TIME]) == 2
# Find the platforms
platforms = {tc[CONF_PLATFORM] for tc in updated_conf[CONF_TIME]}
assert platforms == {CONF_SNTP, "homeassistant"}
# Verify SNTP was merged
sntp_instances = [
tc for tc in updated_conf[CONF_TIME] if tc[CONF_PLATFORM] == CONF_SNTP
]
assert len(sntp_instances) == 1
assert sntp_instances[0][CONF_SERVERS] == ["192.168.1.1", "192.168.1.2"]
finally:
fv.full_config.reset(token)

View File

@@ -76,7 +76,7 @@ lvgl:
line_width: 8
line_rounded: true
- id: date_style
text_font: roboto10
text_font: !lambda return id(roboto10);
align: center
text_color: !lambda return color_id2;
bg_opa: cover
@@ -267,7 +267,7 @@ lvgl:
snprintf(buf, sizeof(buf), "Setup: %d", 42);
return std::string(buf);
align: top_mid
text_font: space16
text_font: !lambda return id(space16);
- label:
id: chip_info_label
# Test complex setup lambda (real-world pattern)

View File

@@ -18,6 +18,7 @@ touchscreen:
lvgl:
- id: lvgl_0
default_font: space16
displays: sdl0
- id: lvgl_1
displays: sdl1
@@ -39,3 +40,8 @@ lvgl:
text: Click ME
on_click:
logger.log: Clicked
font:
- file: "gfonts://Roboto"
id: space16
bpp: 4

View File

@@ -15,6 +15,7 @@ nrf52:
inverted: true
mode:
output: true
dcdc: False
reg0:
voltage: 2.1V
uicr_erase: true

View File

@@ -0,0 +1,150 @@
esphome:
name: test-timeout-filters
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: DEBUG
# Template sensors that we'll use to publish values
sensor:
- platform: template
name: "Source Timeout Last"
id: source_timeout_last
accuracy_decimals: 1
- platform: template
name: "Source Timeout Reset"
id: source_timeout_reset
accuracy_decimals: 1
- platform: template
name: "Source Timeout Static"
id: source_timeout_static
accuracy_decimals: 1
- platform: template
name: "Source Timeout Lambda"
id: source_timeout_lambda
accuracy_decimals: 1
# Test 1: TimeoutFilter - "last" mode (outputs last received value)
- platform: copy
source_id: source_timeout_last
name: "Timeout Last Sensor"
id: timeout_last_sensor
filters:
- timeout:
timeout: 100ms
value: last # Explicitly specify "last" mode to use TimeoutFilter class
# Test 2: TimeoutFilter - reset behavior (same filter, different source)
- platform: copy
source_id: source_timeout_reset
name: "Timeout Reset Sensor"
id: timeout_reset_sensor
filters:
- timeout:
timeout: 100ms
value: last # Explicitly specify "last" mode
# Test 3: TimeoutFilterConfigured - static value mode
- platform: copy
source_id: source_timeout_static
name: "Timeout Static Sensor"
id: timeout_static_sensor
filters:
- timeout:
timeout: 100ms
value: 99.9
# Test 4: TimeoutFilterConfigured - lambda mode
- platform: copy
source_id: source_timeout_lambda
name: "Timeout Lambda Sensor"
id: timeout_lambda_sensor
filters:
- timeout:
timeout: 100ms
value: !lambda "return -1.0;"
# Scripts to publish values with controlled timing
script:
# Test 1: Single value followed by timeout
- id: test_timeout_last_script
then:
# Publish initial value
- sensor.template.publish:
id: source_timeout_last
state: 42.0
# Wait for timeout to fire (100ms + margin)
- delay: 150ms
# Test 2: Multiple values before timeout (should reset timer)
- id: test_timeout_reset_script
then:
# Publish first value
- sensor.template.publish:
id: source_timeout_reset
state: 10.0
# Wait 50ms (halfway to timeout)
- delay: 50ms
# Publish second value (resets timeout)
- sensor.template.publish:
id: source_timeout_reset
state: 20.0
# Wait 50ms (halfway to timeout again)
- delay: 50ms
# Publish third value (resets timeout)
- sensor.template.publish:
id: source_timeout_reset
state: 30.0
# Wait for timeout to fire (100ms + margin)
- delay: 150ms
# Test 3: Static value timeout
- id: test_timeout_static_script
then:
# Publish initial value
- sensor.template.publish:
id: source_timeout_static
state: 55.5
# Wait for timeout to fire
- delay: 150ms
# Test 4: Lambda value timeout
- id: test_timeout_lambda_script
then:
# Publish initial value
- sensor.template.publish:
id: source_timeout_lambda
state: 77.7
# Wait for timeout to fire
- delay: 150ms
# Buttons to trigger each test scenario
button:
- platform: template
name: "Test Timeout Last Button"
id: test_timeout_last_button
on_press:
- script.execute: test_timeout_last_script
- platform: template
name: "Test Timeout Reset Button"
id: test_timeout_reset_button
on_press:
- script.execute: test_timeout_reset_script
- platform: template
name: "Test Timeout Static Button"
id: test_timeout_static_button
on_press:
- script.execute: test_timeout_static_script
- platform: template
name: "Test Timeout Lambda Button"
id: test_timeout_lambda_button
on_press:
- script.execute: test_timeout_lambda_script

View File

@@ -0,0 +1,185 @@
"""Test sensor timeout filter functionality."""
from __future__ import annotations
import asyncio
from aioesphomeapi import EntityState, SensorState
import pytest
from .state_utils import InitialStateHelper, build_key_to_entity_mapping
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_sensor_timeout_filter(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test TimeoutFilter and TimeoutFilterConfigured with all modes."""
loop = asyncio.get_running_loop()
# Track state changes for all sensors
timeout_last_states: list[float] = []
timeout_reset_states: list[float] = []
timeout_static_states: list[float] = []
timeout_lambda_states: list[float] = []
# Futures for each test scenario
test1_complete = loop.create_future() # TimeoutFilter - last mode
test2_complete = loop.create_future() # TimeoutFilter - reset behavior
test3_complete = loop.create_future() # TimeoutFilterConfigured - static value
test4_complete = loop.create_future() # TimeoutFilterConfigured - lambda
def on_state(state: EntityState) -> None:
"""Track sensor state updates."""
if not isinstance(state, SensorState):
return
if state.missing_state:
return
sensor_name = key_to_sensor.get(state.key)
# Test 1: TimeoutFilter - last mode
if sensor_name == "timeout_last_sensor":
timeout_last_states.append(state.state)
# Expect 2 values: initial 42.0 + timeout fires with 42.0
if len(timeout_last_states) >= 2 and not test1_complete.done():
test1_complete.set_result(True)
# Test 2: TimeoutFilter - reset behavior
elif sensor_name == "timeout_reset_sensor":
timeout_reset_states.append(state.state)
# Expect 4 values: 10.0, 20.0, 30.0, then timeout fires with 30.0
if len(timeout_reset_states) >= 4 and not test2_complete.done():
test2_complete.set_result(True)
# Test 3: TimeoutFilterConfigured - static value
elif sensor_name == "timeout_static_sensor":
timeout_static_states.append(state.state)
# Expect 2 values: initial 55.5 + timeout fires with 99.9
if len(timeout_static_states) >= 2 and not test3_complete.done():
test3_complete.set_result(True)
# Test 4: TimeoutFilterConfigured - lambda
elif sensor_name == "timeout_lambda_sensor":
timeout_lambda_states.append(state.state)
# Expect 2 values: initial 77.7 + timeout fires with -1.0
if len(timeout_lambda_states) >= 2 and not test4_complete.done():
test4_complete.set_result(True)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities, services = await client.list_entities_services()
key_to_sensor = build_key_to_entity_mapping(
entities,
[
"timeout_last_sensor",
"timeout_reset_sensor",
"timeout_static_sensor",
"timeout_lambda_sensor",
],
)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
# Helper to find buttons by object_id substring
def find_button(object_id_substring: str) -> int:
"""Find a button by object_id substring and return its key."""
button = next(
(e for e in entities if object_id_substring in e.object_id.lower()),
None,
)
assert button is not None, f"Button '{object_id_substring}' not found"
return button.key
# Find all test buttons
test1_button_key = find_button("test_timeout_last_button")
test2_button_key = find_button("test_timeout_reset_button")
test3_button_key = find_button("test_timeout_static_button")
test4_button_key = find_button("test_timeout_lambda_button")
# === Test 1: TimeoutFilter - last mode ===
client.button_command(test1_button_key)
try:
await asyncio.wait_for(test1_complete, timeout=2.0)
except TimeoutError:
pytest.fail(f"Test 1 timeout. Received states: {timeout_last_states}")
assert len(timeout_last_states) == 2, (
f"Test 1: Should have 2 states, got {len(timeout_last_states)}: {timeout_last_states}"
)
assert timeout_last_states[0] == pytest.approx(42.0), (
f"Test 1: First state should be 42.0, got {timeout_last_states[0]}"
)
assert timeout_last_states[1] == pytest.approx(42.0), (
f"Test 1: Timeout should output last value (42.0), got {timeout_last_states[1]}"
)
# === Test 2: TimeoutFilter - reset behavior ===
client.button_command(test2_button_key)
try:
await asyncio.wait_for(test2_complete, timeout=2.0)
except TimeoutError:
pytest.fail(f"Test 2 timeout. Received states: {timeout_reset_states}")
assert len(timeout_reset_states) == 4, (
f"Test 2: Should have 4 states, got {len(timeout_reset_states)}: {timeout_reset_states}"
)
assert timeout_reset_states[0] == pytest.approx(10.0), (
f"Test 2: First state should be 10.0, got {timeout_reset_states[0]}"
)
assert timeout_reset_states[1] == pytest.approx(20.0), (
f"Test 2: Second state should be 20.0, got {timeout_reset_states[1]}"
)
assert timeout_reset_states[2] == pytest.approx(30.0), (
f"Test 2: Third state should be 30.0, got {timeout_reset_states[2]}"
)
assert timeout_reset_states[3] == pytest.approx(30.0), (
f"Test 2: Timeout should output last value (30.0), got {timeout_reset_states[3]}"
)
# === Test 3: TimeoutFilterConfigured - static value ===
client.button_command(test3_button_key)
try:
await asyncio.wait_for(test3_complete, timeout=2.0)
except TimeoutError:
pytest.fail(f"Test 3 timeout. Received states: {timeout_static_states}")
assert len(timeout_static_states) == 2, (
f"Test 3: Should have 2 states, got {len(timeout_static_states)}: {timeout_static_states}"
)
assert timeout_static_states[0] == pytest.approx(55.5), (
f"Test 3: First state should be 55.5, got {timeout_static_states[0]}"
)
assert timeout_static_states[1] == pytest.approx(99.9), (
f"Test 3: Timeout should output configured value (99.9), got {timeout_static_states[1]}"
)
# === Test 4: TimeoutFilterConfigured - lambda ===
client.button_command(test4_button_key)
try:
await asyncio.wait_for(test4_complete, timeout=2.0)
except TimeoutError:
pytest.fail(f"Test 4 timeout. Received states: {timeout_lambda_states}")
assert len(timeout_lambda_states) == 2, (
f"Test 4: Should have 2 states, got {len(timeout_lambda_states)}: {timeout_lambda_states}"
)
assert timeout_lambda_states[0] == pytest.approx(77.7), (
f"Test 4: First state should be 77.7, got {timeout_lambda_states[0]}"
)
assert timeout_lambda_states[1] == pytest.approx(-1.0), (
f"Test 4: Timeout should evaluate lambda (-1.0), got {timeout_lambda_states[1]}"
)