mirror of
https://github.com/esphome/esphome.git
synced 2025-11-20 00:35:44 +00:00
Merge branch 'integration' into memory_api
This commit is contained in:
@@ -36,7 +36,6 @@ from esphome.const import (
|
|||||||
CONF_WEIGHT,
|
CONF_WEIGHT,
|
||||||
)
|
)
|
||||||
from esphome.core import CORE, HexInt
|
from esphome.core import CORE, HexInt
|
||||||
from esphome.helpers import cpp_string_escape
|
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -50,7 +49,6 @@ font_ns = cg.esphome_ns.namespace("font")
|
|||||||
|
|
||||||
Font = font_ns.class_("Font")
|
Font = font_ns.class_("Font")
|
||||||
Glyph = font_ns.class_("Glyph")
|
Glyph = font_ns.class_("Glyph")
|
||||||
GlyphData = font_ns.struct("GlyphData")
|
|
||||||
|
|
||||||
CONF_BPP = "bpp"
|
CONF_BPP = "bpp"
|
||||||
CONF_EXTRAS = "extras"
|
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_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),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -583,22 +581,15 @@ async def to_code(config):
|
|||||||
|
|
||||||
# Create the glyph table that points to data in the above array.
|
# Create the glyph table that points to data in the above array.
|
||||||
glyph_initializer = [
|
glyph_initializer = [
|
||||||
cg.StructInitializer(
|
[
|
||||||
GlyphData,
|
x.glyph,
|
||||||
(
|
prog_arr + (y - len(x.bitmap_data)),
|
||||||
"a_char",
|
x.advance,
|
||||||
cg.RawExpression(f"(const uint8_t *){cpp_string_escape(x.glyph)}"),
|
x.offset_x,
|
||||||
),
|
x.offset_y,
|
||||||
(
|
x.width,
|
||||||
"data",
|
x.height,
|
||||||
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),
|
|
||||||
)
|
|
||||||
for (x, y) in zip(
|
for (x, y) in zip(
|
||||||
glyph_args, list(accumulate([len(x.bitmap_data) for x in glyph_args]))
|
glyph_args, list(accumulate([len(x.bitmap_data) for x in glyph_args]))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,20 +9,19 @@ namespace font {
|
|||||||
|
|
||||||
static const char *const TAG = "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.
|
// Compare the char at the string position with this char.
|
||||||
// Return true if this char is less than or equal the other.
|
// Return true if this char is less than or equal the other.
|
||||||
bool Glyph::compare_to(const uint8_t *str) const {
|
bool Glyph::compare_to(const uint8_t *str) const {
|
||||||
// 1 -> this->char_
|
// 1 -> this->char_
|
||||||
// 2 -> str
|
// 2 -> str
|
||||||
for (uint32_t i = 0;; i++) {
|
for (uint32_t i = 0;; i++) {
|
||||||
if (this->glyph_data_->a_char[i] == '\0')
|
if (this->a_char[i] == '\0')
|
||||||
return true;
|
return true;
|
||||||
if (str[i] == '\0')
|
if (str[i] == '\0')
|
||||||
return false;
|
return false;
|
||||||
if (this->glyph_data_->a_char[i] > str[i])
|
if (this->a_char[i] > str[i])
|
||||||
return false;
|
return false;
|
||||||
if (this->glyph_data_->a_char[i] < str[i])
|
if (this->a_char[i] < str[i])
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// this should not happen
|
// this should not happen
|
||||||
@@ -30,35 +29,32 @@ bool Glyph::compare_to(const uint8_t *str) const {
|
|||||||
}
|
}
|
||||||
int Glyph::match_length(const uint8_t *str) const {
|
int Glyph::match_length(const uint8_t *str) const {
|
||||||
for (uint32_t i = 0;; i++) {
|
for (uint32_t i = 0;; i++) {
|
||||||
if (this->glyph_data_->a_char[i] == '\0')
|
if (this->a_char[i] == '\0')
|
||||||
return i;
|
return i;
|
||||||
if (str[i] != this->glyph_data_->a_char[i])
|
if (str[i] != this->a_char[i])
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
// this should not happen
|
// this should not happen
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
void Glyph::scan_area(int *x1, int *y1, int *width, int *height) const {
|
void Glyph::scan_area(int *x1, int *y1, int *width, int *height) const {
|
||||||
*x1 = this->glyph_data_->offset_x;
|
*x1 = this->offset_x;
|
||||||
*y1 = this->glyph_data_->offset_y;
|
*y1 = this->offset_y;
|
||||||
*width = this->glyph_data_->width;
|
*width = this->width;
|
||||||
*height = this->glyph_data_->height;
|
*height = this->height;
|
||||||
}
|
}
|
||||||
|
|
||||||
Font::Font(const GlyphData *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight,
|
Font::Font(const Glyph *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight,
|
||||||
uint8_t bpp)
|
uint8_t bpp)
|
||||||
: baseline_(baseline),
|
: glyphs_(ConstVector(data, data_nr)),
|
||||||
|
baseline_(baseline),
|
||||||
height_(height),
|
height_(height),
|
||||||
descender_(descender),
|
descender_(descender),
|
||||||
linegap_(height - baseline - descender),
|
linegap_(height - baseline - descender),
|
||||||
xheight_(xheight),
|
xheight_(xheight),
|
||||||
capheight_(capheight),
|
capheight_(capheight),
|
||||||
bpp_(bpp) {
|
bpp_(bpp) {}
|
||||||
glyphs_.reserve(data_nr);
|
int Font::match_next_glyph(const uint8_t *str, int *match_length) const {
|
||||||
for (int i = 0; i < data_nr; ++i)
|
|
||||||
glyphs_.emplace_back(&data[i]);
|
|
||||||
}
|
|
||||||
int Font::match_next_glyph(const uint8_t *str, int *match_length) {
|
|
||||||
int lo = 0;
|
int lo = 0;
|
||||||
int hi = this->glyphs_.size() - 1;
|
int hi = this->glyphs_.size() - 1;
|
||||||
while (lo != hi) {
|
while (lo != hi) {
|
||||||
@@ -88,18 +84,18 @@ void Font::measure(const char *str, int *width, int *x_offset, int *baseline, in
|
|||||||
if (glyph_n < 0) {
|
if (glyph_n < 0) {
|
||||||
// Unknown char, skip
|
// Unknown char, skip
|
||||||
if (!this->get_glyphs().empty())
|
if (!this->get_glyphs().empty())
|
||||||
x += this->get_glyphs()[0].glyph_data_->advance;
|
x += this->get_glyphs()[0].advance;
|
||||||
i++;
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Glyph &glyph = this->glyphs_[glyph_n];
|
const Glyph &glyph = this->glyphs_[glyph_n];
|
||||||
if (!has_char) {
|
if (!has_char) {
|
||||||
min_x = glyph.glyph_data_->offset_x;
|
min_x = glyph.offset_x;
|
||||||
} else {
|
} 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;
|
i += match_length;
|
||||||
has_char = true;
|
has_char = true;
|
||||||
@@ -118,7 +114,7 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo
|
|||||||
// Unknown char, skip
|
// Unknown char, skip
|
||||||
ESP_LOGW(TAG, "Encountered character without representation in font: '%c'", text[i]);
|
ESP_LOGW(TAG, "Encountered character without representation in font: '%c'", text[i]);
|
||||||
if (!this->get_glyphs().empty()) {
|
if (!this->get_glyphs().empty()) {
|
||||||
uint8_t glyph_width = this->get_glyphs()[0].glyph_data_->advance;
|
uint8_t glyph_width = this->get_glyphs()[0].advance;
|
||||||
display->filled_rectangle(x_at, y_start, glyph_width, this->height_, color);
|
display->filled_rectangle(x_at, y_start, glyph_width, this->height_, color);
|
||||||
x_at += glyph_width;
|
x_at += glyph_width;
|
||||||
}
|
}
|
||||||
@@ -130,7 +126,7 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo
|
|||||||
const Glyph &glyph = this->get_glyphs()[glyph_n];
|
const Glyph &glyph = this->get_glyphs()[glyph_n];
|
||||||
glyph.scan_area(&scan_x1, &scan_y1, &scan_width, &scan_height);
|
glyph.scan_area(&scan_x1, &scan_y1, &scan_width, &scan_height);
|
||||||
|
|
||||||
const uint8_t *data = glyph.glyph_data_->data;
|
const uint8_t *data = glyph.data;
|
||||||
const int max_x = x_at + scan_x1 + scan_width;
|
const int max_x = x_at + scan_x1 + scan_width;
|
||||||
const int max_y = y_start + scan_y1 + scan_height;
|
const int max_y = y_start + scan_y1 + scan_height;
|
||||||
|
|
||||||
@@ -168,7 +164,7 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
x_at += glyph.glyph_data_->advance;
|
x_at += glyph.advance;
|
||||||
|
|
||||||
i += match_length;
|
i += match_length;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,21 +12,19 @@ namespace font {
|
|||||||
|
|
||||||
class Font;
|
class Font;
|
||||||
|
|
||||||
struct GlyphData {
|
|
||||||
const uint8_t *a_char;
|
|
||||||
const uint8_t *data;
|
|
||||||
int advance;
|
|
||||||
int offset_x;
|
|
||||||
int offset_y;
|
|
||||||
int width;
|
|
||||||
int height;
|
|
||||||
};
|
|
||||||
|
|
||||||
class Glyph {
|
class Glyph {
|
||||||
public:
|
public:
|
||||||
Glyph(const GlyphData *data) : glyph_data_(data) {}
|
constexpr Glyph(const char *a_char, const uint8_t *data, int advance, int offset_x, int offset_y, int width,
|
||||||
|
int height)
|
||||||
|
: a_char(a_char),
|
||||||
|
data(data),
|
||||||
|
advance(advance),
|
||||||
|
offset_x(offset_x),
|
||||||
|
offset_y(offset_y),
|
||||||
|
width(width),
|
||||||
|
height(height) {}
|
||||||
|
|
||||||
const uint8_t *get_char() const;
|
const uint8_t *get_char() const { return reinterpret_cast<const uint8_t *>(this->a_char); }
|
||||||
|
|
||||||
bool compare_to(const uint8_t *str) const;
|
bool compare_to(const uint8_t *str) const;
|
||||||
|
|
||||||
@@ -34,12 +32,16 @@ class Glyph {
|
|||||||
|
|
||||||
void scan_area(int *x1, int *y1, int *width, int *height) const;
|
void scan_area(int *x1, int *y1, int *width, int *height) const;
|
||||||
|
|
||||||
const GlyphData *get_glyph_data() const { return this->glyph_data_; }
|
const char *a_char;
|
||||||
|
const uint8_t *data;
|
||||||
|
int advance;
|
||||||
|
int offset_x;
|
||||||
|
int offset_y;
|
||||||
|
int width;
|
||||||
|
int height;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
friend Font;
|
friend Font;
|
||||||
|
|
||||||
const GlyphData *glyph_data_;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class Font
|
class Font
|
||||||
@@ -50,8 +52,8 @@ class Font
|
|||||||
public:
|
public:
|
||||||
/** Construct the font with the given glyphs.
|
/** Construct the font with the given glyphs.
|
||||||
*
|
*
|
||||||
* @param data A vector of glyphs, must be sorted lexicographically.
|
* @param data A list of glyphs, must be sorted lexicographically.
|
||||||
* @param data_nr The number of glyphs in data.
|
* @param data_nr The number of glyphs
|
||||||
* @param baseline The y-offset from the top of the text to the baseline.
|
* @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 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).
|
* @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 +61,10 @@ class Font
|
|||||||
* @param capheight The height of capital letters, usually measured at the "X" glyph.
|
* @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.
|
* @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);
|
uint8_t bpp = 1);
|
||||||
|
|
||||||
int match_next_glyph(const uint8_t *str, int *match_length);
|
int match_next_glyph(const uint8_t *str, int *match_length) const;
|
||||||
|
|
||||||
#ifdef USE_DISPLAY
|
#ifdef USE_DISPLAY
|
||||||
void print(int x_start, int y_start, display::Display *display, Color color, const char *text,
|
void print(int x_start, int y_start, display::Display *display, Color color, const char *text,
|
||||||
@@ -78,10 +80,10 @@ class Font
|
|||||||
inline int get_capheight() { return this->capheight_; }
|
inline int get_capheight() { return this->capheight_; }
|
||||||
inline int get_bpp() { return this->bpp_; }
|
inline int get_bpp() { return this->bpp_; }
|
||||||
|
|
||||||
const std::vector<Glyph, RAMAllocator<Glyph>> &get_glyphs() const { return glyphs_; }
|
const ConstVector<Glyph> &get_glyphs() const { return glyphs_; }
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
std::vector<Glyph, RAMAllocator<Glyph>> glyphs_;
|
ConstVector<Glyph> glyphs_;
|
||||||
int baseline_;
|
int baseline_;
|
||||||
int height_;
|
int height_;
|
||||||
int descender_;
|
int descender_;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ FontEngine::FontEngine(font::Font *esp_font) : font_(esp_font) {
|
|||||||
|
|
||||||
const lv_font_t *FontEngine::get_lv_font() { return &this->lv_font_; }
|
const lv_font_t *FontEngine::get_lv_font() { return &this->lv_font_; }
|
||||||
|
|
||||||
const font::GlyphData *FontEngine::get_glyph_data(uint32_t unicode_letter) {
|
const font::Glyph *FontEngine::get_glyph_data(uint32_t unicode_letter) {
|
||||||
if (unicode_letter == last_letter_)
|
if (unicode_letter == last_letter_)
|
||||||
return this->last_data_;
|
return this->last_data_;
|
||||||
uint8_t unicode[5];
|
uint8_t unicode[5];
|
||||||
@@ -67,7 +67,7 @@ const font::GlyphData *FontEngine::get_glyph_data(uint32_t unicode_letter) {
|
|||||||
int glyph_n = this->font_->match_next_glyph(unicode, &match_length);
|
int glyph_n = this->font_->match_next_glyph(unicode, &match_length);
|
||||||
if (glyph_n < 0)
|
if (glyph_n < 0)
|
||||||
return nullptr;
|
return nullptr;
|
||||||
this->last_data_ = this->font_->get_glyphs()[glyph_n].get_glyph_data();
|
this->last_data_ = &this->font_->get_glyphs()[glyph_n];
|
||||||
this->last_letter_ = unicode_letter;
|
this->last_letter_ = unicode_letter;
|
||||||
return this->last_data_;
|
return this->last_data_;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ class FontEngine {
|
|||||||
FontEngine(font::Font *esp_font);
|
FontEngine(font::Font *esp_font);
|
||||||
const lv_font_t *get_lv_font();
|
const lv_font_t *get_lv_font();
|
||||||
|
|
||||||
const font::GlyphData *get_glyph_data(uint32_t unicode_letter);
|
const font::Glyph *get_glyph_data(uint32_t unicode_letter);
|
||||||
uint16_t baseline{};
|
uint16_t baseline{};
|
||||||
uint16_t height{};
|
uint16_t height{};
|
||||||
uint8_t bpp{};
|
uint8_t bpp{};
|
||||||
@@ -148,7 +148,7 @@ class FontEngine {
|
|||||||
protected:
|
protected:
|
||||||
font::Font *font_{};
|
font::Font *font_{};
|
||||||
uint32_t last_letter_{};
|
uint32_t last_letter_{};
|
||||||
const font::GlyphData *last_data_{};
|
const font::Glyph *last_data_{};
|
||||||
lv_font_t lv_font_{};
|
lv_font_t lv_font_{};
|
||||||
};
|
};
|
||||||
#endif // USE_LVGL_FONT
|
#endif // USE_LVGL_FONT
|
||||||
|
|||||||
@@ -490,10 +490,18 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest *
|
|||||||
|
|
||||||
void AsyncEventSourceResponse::destroy(void *ptr) {
|
void AsyncEventSourceResponse::destroy(void *ptr) {
|
||||||
auto *rsp = static_cast<AsyncEventSourceResponse *>(ptr);
|
auto *rsp = static_cast<AsyncEventSourceResponse *>(ptr);
|
||||||
ESP_LOGD(TAG, "Event source connection closed (fd: %d)", rsp->fd_.load());
|
int fd = rsp->fd_.exchange(0); // Atomically get and clear fd
|
||||||
// Mark as dead by setting fd to 0 - will be cleaned up in the main loop
|
|
||||||
rsp->fd_.store(0);
|
if (fd > 0) {
|
||||||
// Note: We don't delete or remove from set here to avoid race conditions
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// helper for allowing only unique entries in the queue
|
// helper for allowing only unique entries in the queue
|
||||||
|
|||||||
@@ -111,6 +111,23 @@ template<> constexpr int64_t byteswap(int64_t n) { return __builtin_bswap64(n);
|
|||||||
/// @name Container utilities
|
/// @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
|
/// Minimal static vector - saves memory by avoiding std::vector overhead
|
||||||
template<typename T, size_t N> class StaticVector {
|
template<typename T, size_t N> class StaticVector {
|
||||||
public:
|
public:
|
||||||
|
|||||||
150
tests/integration/fixtures/sensor_timeout_filter.yaml
Normal file
150
tests/integration/fixtures/sensor_timeout_filter.yaml
Normal 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
|
||||||
185
tests/integration/test_sensor_timeout_filter.py
Normal file
185
tests/integration/test_sensor_timeout_filter.py
Normal 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]}"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user