mirror of
https://github.com/esphome/esphome.git
synced 2026-07-11 01:15:33 +00:00
Merge remote-tracking branch 'upstream/inline-micros-esp32' into integration
# Conflicts: # esphome/components/api/proto.h # esphome/external_files.py # script/api_protobuf/api_protobuf.py # tests/unit_tests/test_external_files.py
This commit is contained in:
@@ -844,11 +844,12 @@ class ProtoSize {
|
||||
return field_id_size + varint(value);
|
||||
}
|
||||
/// 48-bit MAC address variant: matches encode_varint_raw_48bit's fast path.
|
||||
/// When value uses any of bits [42..47] the encoded varint is 7 bytes;
|
||||
/// When any of the top 6 of 48 bits is set the encoded varint is 7 bytes;
|
||||
/// otherwise fall back to the general size calculation.
|
||||
/// Caller must guarantee value fits in 48 bits (encoder asserts in debug).
|
||||
static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_uint64_48bit_force(uint32_t field_id_size,
|
||||
uint64_t value) {
|
||||
return field_id_size + (value >= (1ULL << 42) ? 7 : varint(value));
|
||||
return field_id_size + (value >= (1ULL << (MAC_ADDRESS_SIZE * 8 - 6)) ? 7 : varint(value));
|
||||
}
|
||||
static constexpr uint32_t calc_length(uint32_t field_id_size, size_t len) {
|
||||
return len ? field_id_size + varint(static_cast<uint32_t>(len)) + static_cast<uint32_t>(len) : 0;
|
||||
|
||||
@@ -210,8 +210,9 @@ void Smartair2Climate::process_phase(std::chrono::steady_clock::time_point now)
|
||||
#ifdef USE_WIFI
|
||||
else if (this->send_wifi_signal_ &&
|
||||
(std::chrono::duration_cast<std::chrono::milliseconds>(now - this->last_signal_request_).count() >
|
||||
SIGNAL_LEVEL_UPDATE_INTERVAL_MS))
|
||||
SIGNAL_LEVEL_UPDATE_INTERVAL_MS)) {
|
||||
this->set_phase(ProtocolPhases::SENDING_UPDATE_SIGNAL_REQUEST);
|
||||
}
|
||||
#endif
|
||||
} break;
|
||||
default:
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
#include "i2c_bus.h"
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
struct device;
|
||||
struct device; // NOLINT(readability-identifier-naming) - forward decl of Zephyr's device type
|
||||
|
||||
namespace esphome::i2c {
|
||||
|
||||
|
||||
@@ -41,12 +41,12 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic
|
||||
ESP_LOGVV(TAG, "parse_device(): service_data is expected to be empty");
|
||||
return false;
|
||||
}
|
||||
auto mnf_datas = device.get_manufacturer_datas();
|
||||
const auto &mnf_datas = device.get_manufacturer_datas();
|
||||
if (mnf_datas.size() != 1) {
|
||||
ESP_LOGVV(TAG, "parse_device(): manufacturer_datas is expected to have a single element");
|
||||
return false;
|
||||
}
|
||||
auto mnf_data = mnf_datas[0];
|
||||
const auto &mnf_data = mnf_datas[0];
|
||||
if (mnf_data.uuid.get_uuid().len != ESP_UUID_LEN_16) {
|
||||
ESP_LOGVV(TAG, "parse_device(): manufacturer data element is expected to have uuid of length 16");
|
||||
return false;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import math
|
||||
import re
|
||||
import textwrap
|
||||
|
||||
@@ -85,6 +86,22 @@ def grid_free_space(value):
|
||||
|
||||
grid_spec = cv.Any(size, LvConstant("LV_GRID_", "CONTENT").one_of, grid_free_space)
|
||||
|
||||
|
||||
def grid_dimension(value):
|
||||
"""
|
||||
Validator for a grid `rows` or `columns` value.
|
||||
Accepts either a positive integer (interpreted as that many cells of equal
|
||||
`LV_GRID_FR(1)` size) or a non-empty list of grid specs.
|
||||
"""
|
||||
if isinstance(value, int):
|
||||
value = cv.int_range(min=1)(value)
|
||||
return ["LV_GRID_FR(1)"] * value
|
||||
result = cv.Schema([grid_spec])(value)
|
||||
if not result:
|
||||
raise cv.Invalid("Grid dimension list must contain at least one entry")
|
||||
return result
|
||||
|
||||
|
||||
GRID_CELL_SCHEMA = {
|
||||
cv.Optional(CONF_GRID_CELL_ROW_POS): cv.positive_int,
|
||||
cv.Optional(CONF_GRID_CELL_COLUMN_POS): cv.positive_int,
|
||||
@@ -184,7 +201,16 @@ class DirectionalLayout(FlexLayout):
|
||||
|
||||
|
||||
class GridLayout(Layout):
|
||||
_GRID_LAYOUT_REGEX = re.compile(r"^\s*(\d+)\s*x\s*(\d+)\s*$")
|
||||
# Match shorthand grid layout strings: "NxM", "Nx" or "xM".
|
||||
# At least one of the two numbers must be present; this is enforced after matching.
|
||||
_GRID_LAYOUT_REGEX = re.compile(r"^\s*(\d+)?\s*x\s*(\d+)?\s*$")
|
||||
|
||||
@staticmethod
|
||||
def _match_shorthand(layout):
|
||||
match = GridLayout._GRID_LAYOUT_REGEX.match(layout)
|
||||
if match is None or (match.group(1) is None and match.group(2) is None):
|
||||
return None
|
||||
return match
|
||||
|
||||
def get_type(self):
|
||||
return TYPE_GRID
|
||||
@@ -192,7 +218,7 @@ class GridLayout(Layout):
|
||||
def get_layout_schemas(self, config: dict) -> tuple:
|
||||
layout = config.get(CONF_LAYOUT)
|
||||
if isinstance(layout, str):
|
||||
if GridLayout._GRID_LAYOUT_REGEX.match(layout):
|
||||
if GridLayout._match_shorthand(layout):
|
||||
return (
|
||||
cv.string,
|
||||
{
|
||||
@@ -213,59 +239,107 @@ class GridLayout(Layout):
|
||||
|
||||
if not isinstance(layout, dict) or layout.get(CONF_TYPE).lower() != TYPE_GRID:
|
||||
return None, {}
|
||||
x_default = (
|
||||
"center" if isinstance(layout.get(CONF_GRID_ROWS), int) else cv.UNDEFINED
|
||||
)
|
||||
y_default = (
|
||||
"center" if isinstance(layout.get(CONF_GRID_COLUMNS), int) else cv.UNDEFINED
|
||||
)
|
||||
x_align = layout.get(CONF_GRID_CELL_X_ALIGN, x_default)
|
||||
y_align = layout.get(CONF_GRID_CELL_Y_ALIGN, y_default)
|
||||
return (
|
||||
{
|
||||
cv.Required(CONF_TYPE): cv.one_of(TYPE_GRID, lower=True),
|
||||
cv.Required(CONF_GRID_ROWS): [grid_spec],
|
||||
cv.Required(CONF_GRID_COLUMNS): [grid_spec],
|
||||
cv.Optional(CONF_GRID_ROWS): grid_dimension,
|
||||
cv.Optional(CONF_GRID_COLUMNS): grid_dimension,
|
||||
cv.Optional(CONF_GRID_COLUMN_ALIGN): grid_alignments,
|
||||
cv.Optional(CONF_GRID_ROW_ALIGN): grid_alignments,
|
||||
cv.Optional(CONF_PAD_ROW): padding,
|
||||
cv.Optional(CONF_PAD_COLUMN): padding,
|
||||
cv.Optional(CONF_MULTIPLE_WIDGETS_PER_CELL, default=False): cv.boolean,
|
||||
cv.Optional(CONF_GRID_CELL_X_ALIGN): grid_alignments,
|
||||
cv.Optional(CONF_GRID_CELL_Y_ALIGN): grid_alignments,
|
||||
},
|
||||
{
|
||||
cv.Optional(CONF_GRID_CELL_ROW_POS): cv.positive_int,
|
||||
cv.Optional(CONF_GRID_CELL_COLUMN_POS): cv.positive_int,
|
||||
cv.Optional(CONF_GRID_CELL_ROW_SPAN): cv.int_range(min=1),
|
||||
cv.Optional(CONF_GRID_CELL_COLUMN_SPAN): cv.int_range(min=1),
|
||||
cv.Optional(CONF_GRID_CELL_X_ALIGN): grid_alignments,
|
||||
cv.Optional(CONF_GRID_CELL_Y_ALIGN): grid_alignments,
|
||||
cv.Optional(CONF_GRID_CELL_X_ALIGN, default=x_align): grid_alignments,
|
||||
cv.Optional(CONF_GRID_CELL_Y_ALIGN, default=y_align): grid_alignments,
|
||||
},
|
||||
)
|
||||
|
||||
def validate(self, config: dict):
|
||||
"""
|
||||
Validate the grid layout.
|
||||
The `layout:` key may be a dictionary with `rows` and `columns` keys, or a string in the format "rows x columns".
|
||||
The `layout:` key may be a dictionary with `rows` and/or `columns` keys, or a
|
||||
shorthand string in the format "<rows>x<columns>", "<rows>x" or "x<columns>".
|
||||
Either dimension may be omitted, in which case it will be calculated from the
|
||||
other dimension and the number of configured widgets.
|
||||
Either all cells must have a row and column,
|
||||
or none, in which case the grid layout is auto-generated.
|
||||
:param config:
|
||||
:return: The config updated with auto-generated values
|
||||
"""
|
||||
layout = config.get(CONF_LAYOUT)
|
||||
widgets = config.get(CONF_WIDGETS, [])
|
||||
num_widgets = len(widgets)
|
||||
if isinstance(layout, str):
|
||||
# If the layout is a string, assume it is in the format "rows x columns", implying
|
||||
# a grid layout with the specified number of rows and columns each with CONTENT sizing.
|
||||
# Shorthand string: "<rows>x<columns>", "<rows>x" or "x<columns>".
|
||||
# Each dimension defaults to LV_GRID_FR(1). A missing dimension is
|
||||
# calculated from the other dimension and the number of widgets.
|
||||
layout = layout.strip()
|
||||
match = GridLayout._GRID_LAYOUT_REGEX.match(layout)
|
||||
if match:
|
||||
rows = int(match.group(1))
|
||||
cols = int(match.group(2))
|
||||
layout = {
|
||||
CONF_TYPE: TYPE_GRID,
|
||||
CONF_GRID_ROWS: ["LV_GRID_FR(1)"] * rows,
|
||||
CONF_GRID_COLUMNS: ["LV_GRID_FR(1)"] * cols,
|
||||
}
|
||||
config[CONF_LAYOUT] = layout
|
||||
else:
|
||||
match = GridLayout._match_shorthand(layout)
|
||||
if not match:
|
||||
raise cv.Invalid(
|
||||
f"Invalid grid layout format: {config}, expected 'rows x columns'",
|
||||
f"Invalid grid layout format: {layout!r}, expected "
|
||||
"'<rows>x<columns>', '<rows>x' or 'x<columns>'",
|
||||
[CONF_LAYOUT],
|
||||
)
|
||||
rows_int = int(match.group(1)) if match.group(1) is not None else None
|
||||
cols_int = int(match.group(2)) if match.group(2) is not None else None
|
||||
for label, val in (("row", rows_int), ("column", cols_int)):
|
||||
if val is not None and val < 1:
|
||||
raise cv.Invalid(
|
||||
f"Invalid grid layout {layout!r}: {label} count must be "
|
||||
"at least 1",
|
||||
[CONF_LAYOUT],
|
||||
)
|
||||
if rows_int is not None and cols_int is not None:
|
||||
rows = rows_int
|
||||
cols = cols_int
|
||||
elif rows_int is not None:
|
||||
rows = rows_int
|
||||
cols = max(1, math.ceil(num_widgets / rows)) if num_widgets else 1
|
||||
else:
|
||||
cols = cols_int
|
||||
rows = max(1, math.ceil(num_widgets / cols)) if num_widgets else 1
|
||||
layout = {
|
||||
CONF_TYPE: TYPE_GRID,
|
||||
CONF_GRID_ROWS: ["LV_GRID_FR(1)"] * rows,
|
||||
CONF_GRID_COLUMNS: ["LV_GRID_FR(1)"] * cols,
|
||||
}
|
||||
config[CONF_LAYOUT] = layout
|
||||
# should be guaranteed to be a dict at this point
|
||||
assert isinstance(layout, dict)
|
||||
assert layout.get(CONF_TYPE).lower() == TYPE_GRID
|
||||
rows_list = layout.get(CONF_GRID_ROWS)
|
||||
cols_list = layout.get(CONF_GRID_COLUMNS)
|
||||
if rows_list is None and cols_list is None:
|
||||
raise cv.Invalid(
|
||||
"Grid layout requires at least one of 'rows' or 'columns' to be "
|
||||
"specified",
|
||||
[CONF_LAYOUT],
|
||||
)
|
||||
if rows_list is None:
|
||||
cols = len(cols_list)
|
||||
rows = max(1, math.ceil(num_widgets / cols)) if num_widgets else 1
|
||||
layout[CONF_GRID_ROWS] = ["LV_GRID_FR(1)"] * rows
|
||||
elif cols_list is None:
|
||||
rows = len(rows_list)
|
||||
cols = max(1, math.ceil(num_widgets / rows)) if num_widgets else 1
|
||||
layout[CONF_GRID_COLUMNS] = ["LV_GRID_FR(1)"] * cols
|
||||
allow_multiple = layout.get(CONF_MULTIPLE_WIDGETS_PER_CELL, False)
|
||||
rows = len(layout[CONF_GRID_ROWS])
|
||||
columns = len(layout[CONF_GRID_COLUMNS])
|
||||
@@ -379,7 +453,8 @@ def append_layout_schema(schema, config: dict):
|
||||
textwrap.dedent(
|
||||
"""
|
||||
Invalid 'layout' value
|
||||
layout choices are 'horizontal', 'vertical', '<rows>x<cols>',
|
||||
layout choices are 'horizontal', 'vertical',
|
||||
'<rows>x<cols>', '<rows>x', 'x<cols>',
|
||||
or a dictionary with a 'type' key
|
||||
"""
|
||||
),
|
||||
|
||||
@@ -28,7 +28,7 @@ bool OnlineImage::validate_url_(const std::string &url) {
|
||||
ESP_LOGE(TAG, "URL is too long");
|
||||
return false;
|
||||
}
|
||||
if (url.compare(0, 7, "http://") != 0 && url.compare(0, 8, "https://") != 0) {
|
||||
if (!url.starts_with("http://") && !url.starts_with("https://")) {
|
||||
ESP_LOGE(TAG, "URL must start with http:// or https://");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -317,6 +317,7 @@ enum PN532ReadReady PN532::read_ready_(bool block) {
|
||||
if (!this->rd_start_time_.has_value()) {
|
||||
this->rd_start_time_ = millis();
|
||||
}
|
||||
const uint32_t rd_start_time = *this->rd_start_time_;
|
||||
|
||||
while (true) {
|
||||
if (this->is_read_ready()) {
|
||||
@@ -324,7 +325,7 @@ enum PN532ReadReady PN532::read_ready_(bool block) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (millis() - *this->rd_start_time_ > 100) {
|
||||
if (millis() - rd_start_time > 100) {
|
||||
ESP_LOGV(TAG, "Timed out waiting for readiness from PN532!");
|
||||
this->rd_ready_ = TIMEOUT;
|
||||
break;
|
||||
|
||||
@@ -110,7 +110,7 @@ void Sim800LComponent::parse_cmd_(std::string message) {
|
||||
case STATE_INIT: {
|
||||
// While we were waiting for update to check for messages, this notifies a message
|
||||
// is available.
|
||||
bool message_available = message.compare(0, 6, "+CMTI:") == 0;
|
||||
bool message_available = message.starts_with("+CMTI:");
|
||||
if (!message_available) {
|
||||
if (message == "RING") {
|
||||
// Incoming call...
|
||||
@@ -120,7 +120,7 @@ void Sim800LComponent::parse_cmd_(std::string message) {
|
||||
this->call_state_ = 6;
|
||||
this->call_disconnected_callback_.call();
|
||||
}
|
||||
} else if (message.compare(0, 6, "+CUSD:") == 0) {
|
||||
} else if (message.starts_with("+CUSD:")) {
|
||||
// Incoming USSD MESSAGE
|
||||
this->state_ = STATE_CHECK_USSD;
|
||||
}
|
||||
@@ -175,7 +175,7 @@ void Sim800LComponent::parse_cmd_(std::string message) {
|
||||
break;
|
||||
case STATE_CHECK_USSD:
|
||||
ESP_LOGD(TAG, "Check ussd code: '%s'", message.c_str());
|
||||
if (message.compare(0, 6, "+CUSD:") == 0) {
|
||||
if (message.starts_with("+CUSD:")) {
|
||||
this->state_ = STATE_RECEIVED_USSD;
|
||||
this->ussd_ = "";
|
||||
size_t start = 10;
|
||||
@@ -196,8 +196,7 @@ void Sim800LComponent::parse_cmd_(std::string message) {
|
||||
case STATE_CREG_WAIT: {
|
||||
// Response: "+CREG: 0,1" -- the one there means registered ok
|
||||
// "+CREG: -,-" means not registered ok
|
||||
bool registered =
|
||||
message.size() > 9 && message.compare(0, 6, "+CREG:") == 0 && (message[9] == '1' || message[9] == '5');
|
||||
bool registered = message.size() > 9 && message.starts_with("+CREG:") && (message[9] == '1' || message[9] == '5');
|
||||
if (registered) {
|
||||
if (!this->registered_) {
|
||||
ESP_LOGD(TAG, "Registered OK");
|
||||
@@ -223,7 +222,7 @@ void Sim800LComponent::parse_cmd_(std::string message) {
|
||||
this->state_ = STATE_CSQ_RESPONSE;
|
||||
break;
|
||||
case STATE_CSQ_RESPONSE:
|
||||
if (message.compare(0, 5, "+CSQ:") == 0) {
|
||||
if (message.starts_with("+CSQ:")) {
|
||||
size_t comma = message.find(',', 6);
|
||||
if (comma != 6) {
|
||||
int rssi = parse_number<int>(message.substr(6, comma - 6)).value_or(0);
|
||||
@@ -243,7 +242,7 @@ void Sim800LComponent::parse_cmd_(std::string message) {
|
||||
this->state_ = STATE_CHECK_SMS;
|
||||
break;
|
||||
case STATE_PARSE_SMS_RESPONSE:
|
||||
if (message.compare(0, 6, "+CMGL:") == 0 && this->parse_index_ == 0) {
|
||||
if (message.starts_with("+CMGL:") && this->parse_index_ == 0) {
|
||||
size_t start = 7;
|
||||
size_t end = message.find(',', start);
|
||||
uint8_t item = 0;
|
||||
@@ -278,7 +277,7 @@ void Sim800LComponent::parse_cmd_(std::string message) {
|
||||
}
|
||||
break;
|
||||
case STATE_CHECK_CALL:
|
||||
if (message.compare(0, 6, "+CLCC:") == 0 && this->parse_index_ == 0) {
|
||||
if (message.starts_with("+CLCC:") && this->parse_index_ == 0) {
|
||||
this->expect_ack_ = true;
|
||||
size_t start = 7;
|
||||
size_t end = message.find(',', start);
|
||||
@@ -324,7 +323,7 @@ void Sim800LComponent::parse_cmd_(std::string message) {
|
||||
/* Our recipient is set and the message body is in message
|
||||
kick ESPHome callback now
|
||||
*/
|
||||
if (ok || message.compare(0, 6, "+CMGL:") == 0) {
|
||||
if (ok || message.starts_with("+CMGL:")) {
|
||||
ESP_LOGD(TAG,
|
||||
"Received SMS from: %s\n"
|
||||
" %s",
|
||||
@@ -360,7 +359,7 @@ void Sim800LComponent::parse_cmd_(std::string message) {
|
||||
}
|
||||
break;
|
||||
case STATE_SENDING_SMS_3:
|
||||
if (message.compare(0, 6, "+CMGS:") == 0) {
|
||||
if (message.starts_with("+CMGS:")) {
|
||||
ESP_LOGD(TAG, "SMS Sent OK: %s", message.c_str());
|
||||
this->send_pending_ = false;
|
||||
this->state_ = STATE_CHECK_SMS;
|
||||
@@ -383,7 +382,7 @@ void Sim800LComponent::parse_cmd_(std::string message) {
|
||||
this->state_ = STATE_INIT;
|
||||
break;
|
||||
case STATE_PARSE_CLIP:
|
||||
if (message.compare(0, 6, "+CLIP:") == 0) {
|
||||
if (message.starts_with("+CLIP:")) {
|
||||
std::string caller_id;
|
||||
size_t start = 7;
|
||||
size_t end = message.find(',', start);
|
||||
|
||||
@@ -502,7 +502,7 @@ void SpeakerMediaPlayer::control(const media_player::MediaPlayerCall &call) {
|
||||
media_command.announce = false;
|
||||
}
|
||||
|
||||
auto media_url = call.get_media_url();
|
||||
const auto &media_url = call.get_media_url();
|
||||
if (media_url.has_value()) {
|
||||
media_command.url =
|
||||
new std::string(*media_url); // Must be manually deleted after receiving media_command from a queue
|
||||
|
||||
@@ -698,7 +698,7 @@ void SpeakerSourceMediaPlayer::control(const media_player::MediaPlayerCall &call
|
||||
}
|
||||
}
|
||||
|
||||
auto media_url = call.get_media_url();
|
||||
const auto &media_url = call.get_media_url();
|
||||
if (media_url.has_value()) {
|
||||
auto command = call.get_command();
|
||||
bool enqueue = command.has_value() && command.value() == media_player::MEDIA_PLAYER_COMMAND_ENQUEUE;
|
||||
|
||||
@@ -897,11 +897,12 @@ void Sprinkler::resume() {
|
||||
}
|
||||
|
||||
if (this->paused_valve_.has_value() && (this->resume_duration_.has_value())) {
|
||||
const size_t paused_valve = *this->paused_valve_;
|
||||
const uint32_t resume_duration = *this->resume_duration_;
|
||||
// Resume only if valve has not been completed yet
|
||||
if (!this->valve_cycle_complete_(this->paused_valve_.value())) {
|
||||
ESP_LOGD(TAG, "Resuming valve %zu with %" PRIu32 " seconds remaining", this->paused_valve_.value_or(0),
|
||||
this->resume_duration_.value_or(0));
|
||||
this->fsm_request_(this->paused_valve_.value(), this->resume_duration_.value());
|
||||
if (!this->valve_cycle_complete_(paused_valve)) {
|
||||
ESP_LOGD(TAG, "Resuming valve %zu with %" PRIu32 " seconds remaining", paused_valve, resume_duration);
|
||||
this->fsm_request_(paused_valve, resume_duration);
|
||||
}
|
||||
this->reset_resume();
|
||||
} else {
|
||||
|
||||
@@ -1633,7 +1633,7 @@ template<typename... Ts> struct Callback<void(Ts...)> {
|
||||
void *ctx_{nullptr};
|
||||
|
||||
/// Invoke the callback. Only valid on Callbacks created via create(), never on default-constructed instances.
|
||||
void call(Ts... args) const { this->fn_(this->ctx_, args...); }
|
||||
void call(Ts... args) const { this->fn_(this->ctx_, std::forward<Ts>(args)...); }
|
||||
|
||||
/// Create from any callable. Small trivially-copyable callables (like [this] lambdas)
|
||||
/// are stored inline in the ctx pointer without heap allocation.
|
||||
@@ -1709,7 +1709,7 @@ template<typename... Ts> class CallbackManager<void(Ts...)> {
|
||||
template<typename F> void add(F &&callback) { this->add_(CbType::create(std::forward<F>(callback))); }
|
||||
|
||||
/// Call all callbacks in this manager.
|
||||
inline void ESPHOME_ALWAYS_INLINE call(Ts... args) {
|
||||
inline void ESPHOME_ALWAYS_INLINE call(const Ts &...args) {
|
||||
if (this->size_ != 0) {
|
||||
for (auto *it = this->data_, *end = it + this->size_; it != end; ++it) {
|
||||
it->call(args...);
|
||||
@@ -1719,7 +1719,7 @@ template<typename... Ts> class CallbackManager<void(Ts...)> {
|
||||
uint16_t size() const { return this->size_; }
|
||||
|
||||
/// Call all callbacks in this manager.
|
||||
void operator()(Ts... args) { this->call(args...); }
|
||||
void operator()(const Ts &...args) { this->call(args...); }
|
||||
|
||||
protected:
|
||||
template<typename...> friend class LazyCallbackManager;
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Unit tests for the LVGL grid layout shorthand and rows/columns auto-sizing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from voluptuous import Invalid
|
||||
|
||||
from esphome.components.lvgl.defines import (
|
||||
CONF_GRID_COLUMNS,
|
||||
CONF_GRID_ROWS,
|
||||
CONF_LAYOUT,
|
||||
CONF_WIDGETS,
|
||||
TYPE_GRID,
|
||||
)
|
||||
from esphome.components.lvgl.layout import GridLayout, grid_dimension
|
||||
from esphome.const import CONF_TYPE
|
||||
|
||||
FR1 = "LV_GRID_FR(1)"
|
||||
|
||||
|
||||
def _widgets(n: int) -> list[dict]:
|
||||
"""Build a list of `n` placeholder widgets for the validate() input."""
|
||||
return [{"label": {}} for _ in range(n)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# grid_dimension validator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_grid_dimension_int_expands_to_fr1_list() -> None:
|
||||
"""A positive integer should expand to a list of LV_GRID_FR(1) entries."""
|
||||
assert grid_dimension(1) == [FR1]
|
||||
assert grid_dimension(3) == [FR1, FR1, FR1]
|
||||
|
||||
|
||||
def test_grid_dimension_zero_or_negative_rejected() -> None:
|
||||
"""Non-positive integers must be rejected."""
|
||||
with pytest.raises(Invalid):
|
||||
grid_dimension(0)
|
||||
with pytest.raises(Invalid):
|
||||
grid_dimension(-2)
|
||||
|
||||
|
||||
def test_grid_dimension_list_passes_through() -> None:
|
||||
"""A list should be validated through the existing grid_spec list schema."""
|
||||
result = grid_dimension(["100px", "content", "fr(2)"])
|
||||
# `grid_spec` normalises each entry: pixel sizes become ints, the
|
||||
# CONTENT keyword is uppercased and prefixed, and FR(n) is normalised.
|
||||
assert result == [100, "LV_GRID_CONTENT", "LV_GRID_FR(2)"]
|
||||
|
||||
|
||||
def test_grid_dimension_invalid_string_rejected() -> None:
|
||||
"""A string is not a valid grid dimension and should be rejected."""
|
||||
with pytest.raises(Invalid):
|
||||
grid_dimension("not a list")
|
||||
|
||||
|
||||
def test_grid_dimension_empty_list_rejected() -> None:
|
||||
"""An empty list of grid specs must be rejected."""
|
||||
with pytest.raises(Invalid, match="at least one entry"):
|
||||
grid_dimension([])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shorthand string layouts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_shorthand_full_form_unchanged() -> None:
|
||||
"""`<rows>x<cols>` continues to work and yields the exact dimensions."""
|
||||
config = {CONF_LAYOUT: "2x3", CONF_WIDGETS: _widgets(0)}
|
||||
result = GridLayout().validate(config)
|
||||
layout = result[CONF_LAYOUT]
|
||||
assert layout[CONF_TYPE] == TYPE_GRID
|
||||
assert layout[CONF_GRID_ROWS] == [FR1, FR1]
|
||||
assert layout[CONF_GRID_COLUMNS] == [FR1, FR1, FR1]
|
||||
|
||||
|
||||
def test_shorthand_rows_only_calculates_columns_from_widgets() -> None:
|
||||
"""`<rows>x` derives the column count from the number of widgets."""
|
||||
config = {CONF_LAYOUT: "3x", CONF_WIDGETS: _widgets(7)}
|
||||
result = GridLayout().validate(config)
|
||||
layout = result[CONF_LAYOUT]
|
||||
# 7 widgets / 3 rows -> ceil = 3 columns.
|
||||
assert len(layout[CONF_GRID_ROWS]) == 3
|
||||
assert len(layout[CONF_GRID_COLUMNS]) == 3
|
||||
|
||||
|
||||
def test_shorthand_columns_only_calculates_rows_from_widgets() -> None:
|
||||
"""`x<cols>` derives the row count from the number of widgets."""
|
||||
config = {CONF_LAYOUT: "x4", CONF_WIDGETS: _widgets(5)}
|
||||
result = GridLayout().validate(config)
|
||||
layout = result[CONF_LAYOUT]
|
||||
# 5 widgets / 4 cols -> ceil = 2 rows.
|
||||
assert len(layout[CONF_GRID_ROWS]) == 2
|
||||
assert len(layout[CONF_GRID_COLUMNS]) == 4
|
||||
|
||||
|
||||
def test_shorthand_rows_only_no_widgets_defaults_columns_to_one() -> None:
|
||||
"""With no widgets and only rows specified, the column count defaults to 1."""
|
||||
config = {CONF_LAYOUT: "3x", CONF_WIDGETS: []}
|
||||
result = GridLayout().validate(config)
|
||||
layout = result[CONF_LAYOUT]
|
||||
assert len(layout[CONF_GRID_ROWS]) == 3
|
||||
assert len(layout[CONF_GRID_COLUMNS]) == 1
|
||||
|
||||
|
||||
def test_shorthand_columns_only_no_widgets_defaults_rows_to_one() -> None:
|
||||
"""With no widgets and only columns specified, the row count defaults to 1."""
|
||||
config = {CONF_LAYOUT: "x4", CONF_WIDGETS: []}
|
||||
result = GridLayout().validate(config)
|
||||
layout = result[CONF_LAYOUT]
|
||||
assert len(layout[CONF_GRID_ROWS]) == 1
|
||||
assert len(layout[CONF_GRID_COLUMNS]) == 4
|
||||
|
||||
|
||||
def test_shorthand_with_whitespace_accepted() -> None:
|
||||
"""The shorthand parser should tolerate whitespace around the components."""
|
||||
config = {CONF_LAYOUT: " 3 x ", CONF_WIDGETS: _widgets(6)}
|
||||
result = GridLayout().validate(config)
|
||||
layout = result[CONF_LAYOUT]
|
||||
# 6 widgets / 3 rows -> 2 columns.
|
||||
assert len(layout[CONF_GRID_ROWS]) == 3
|
||||
assert len(layout[CONF_GRID_COLUMNS]) == 2
|
||||
|
||||
|
||||
def test_shorthand_bare_x_rejected() -> None:
|
||||
"""Pure `x` (no digits at all) is not a valid shorthand."""
|
||||
config = {CONF_LAYOUT: "x", CONF_WIDGETS: _widgets(2)}
|
||||
with pytest.raises(Invalid):
|
||||
GridLayout().validate(config)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"layout,bad_label",
|
||||
[
|
||||
("0x3", "row"),
|
||||
("3x0", "column"),
|
||||
("0x", "row"),
|
||||
("x0", "column"),
|
||||
("0x0", "row"),
|
||||
],
|
||||
)
|
||||
def test_shorthand_zero_dimension_rejected(layout: str, bad_label: str) -> None:
|
||||
"""Shorthand row/column counts must be >= 1."""
|
||||
config = {CONF_LAYOUT: layout, CONF_WIDGETS: _widgets(2)}
|
||||
with pytest.raises(Invalid, match=f"{bad_label} count must be at least 1"):
|
||||
GridLayout().validate(config)
|
||||
|
||||
|
||||
def test_shorthand_get_layout_schemas_recognizes_partial_forms() -> None:
|
||||
"""`<rows>x` and `x<cols>` should be picked up by GridLayout.get_layout_schemas."""
|
||||
grid = GridLayout()
|
||||
for layout in ("3x", "x4", "2x3"):
|
||||
layout_schema, _ = grid.get_layout_schemas({CONF_LAYOUT: layout})
|
||||
assert layout_schema is not None, f"{layout!r} should be recognised"
|
||||
# Pure `x` and unrelated strings should not be picked up as a grid layout.
|
||||
for layout in ("x", "horizontal"):
|
||||
layout_schema, _ = grid.get_layout_schemas({CONF_LAYOUT: layout})
|
||||
assert layout_schema is None, f"{layout!r} should not be recognised"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dict-form layouts with rows/columns auto-sizing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dict_rows_only_calculates_columns_from_widgets() -> None:
|
||||
"""A dict layout with only rows fills in the column count from widget count."""
|
||||
config = {
|
||||
CONF_LAYOUT: {
|
||||
CONF_TYPE: TYPE_GRID,
|
||||
CONF_GRID_ROWS: [FR1, FR1],
|
||||
},
|
||||
CONF_WIDGETS: _widgets(5),
|
||||
}
|
||||
result = GridLayout().validate(config)
|
||||
layout = result[CONF_LAYOUT]
|
||||
# 5 widgets / 2 rows -> ceil = 3 columns.
|
||||
assert len(layout[CONF_GRID_ROWS]) == 2
|
||||
assert layout[CONF_GRID_COLUMNS] == [FR1, FR1, FR1]
|
||||
|
||||
|
||||
def test_dict_columns_only_calculates_rows_from_widgets() -> None:
|
||||
"""A dict layout with only columns fills in the row count from widget count."""
|
||||
config = {
|
||||
CONF_LAYOUT: {
|
||||
CONF_TYPE: TYPE_GRID,
|
||||
CONF_GRID_COLUMNS: [FR1, FR1, FR1],
|
||||
},
|
||||
CONF_WIDGETS: _widgets(7),
|
||||
}
|
||||
result = GridLayout().validate(config)
|
||||
layout = result[CONF_LAYOUT]
|
||||
# 7 widgets / 3 cols -> ceil = 3 rows.
|
||||
assert layout[CONF_GRID_ROWS] == [FR1, FR1, FR1]
|
||||
assert len(layout[CONF_GRID_COLUMNS]) == 3
|
||||
|
||||
|
||||
def test_dict_rows_only_no_widgets_defaults_columns_to_one() -> None:
|
||||
"""A dict layout with rows but no widgets defaults columns to 1."""
|
||||
config = {
|
||||
CONF_LAYOUT: {
|
||||
CONF_TYPE: TYPE_GRID,
|
||||
CONF_GRID_ROWS: [FR1, FR1, FR1],
|
||||
},
|
||||
CONF_WIDGETS: [],
|
||||
}
|
||||
result = GridLayout().validate(config)
|
||||
layout = result[CONF_LAYOUT]
|
||||
assert len(layout[CONF_GRID_ROWS]) == 3
|
||||
assert layout[CONF_GRID_COLUMNS] == [FR1]
|
||||
|
||||
|
||||
def test_dict_neither_rows_nor_columns_rejected() -> None:
|
||||
"""A grid layout dict without rows AND without columns must be rejected."""
|
||||
config = {
|
||||
CONF_LAYOUT: {CONF_TYPE: TYPE_GRID},
|
||||
CONF_WIDGETS: _widgets(3),
|
||||
}
|
||||
with pytest.raises(Invalid):
|
||||
GridLayout().validate(config)
|
||||
|
||||
|
||||
def test_dict_both_rows_and_columns_unchanged() -> None:
|
||||
"""When both dimensions are present they are preserved as-is."""
|
||||
config = {
|
||||
CONF_LAYOUT: {
|
||||
CONF_TYPE: TYPE_GRID,
|
||||
CONF_GRID_ROWS: [FR1, FR1],
|
||||
CONF_GRID_COLUMNS: [FR1, FR1, FR1],
|
||||
},
|
||||
CONF_WIDGETS: _widgets(0),
|
||||
}
|
||||
result = GridLayout().validate(config)
|
||||
layout = result[CONF_LAYOUT]
|
||||
assert layout[CONF_GRID_ROWS] == [FR1, FR1]
|
||||
assert layout[CONF_GRID_COLUMNS] == [FR1, FR1, FR1]
|
||||
@@ -1113,6 +1113,8 @@ lvgl:
|
||||
pad_row: 6px
|
||||
pad_column: 0
|
||||
multiple_widgets_per_cell: true
|
||||
grid_cell_x_align: center
|
||||
grid_cell_y_align: center
|
||||
widgets:
|
||||
- image:
|
||||
grid_cell_row_pos: 0
|
||||
@@ -1305,6 +1307,87 @@ lvgl:
|
||||
hidden: true
|
||||
mode: text_lower
|
||||
|
||||
# Grid shorthand "<rows>x": 3 rows specified, columns derived
|
||||
# from widget count (4 widgets / 3 rows -> 2 columns)
|
||||
- obj:
|
||||
id: grid_rows_only_shorthand
|
||||
layout: 3x
|
||||
widgets:
|
||||
- label:
|
||||
text: "r1"
|
||||
- label:
|
||||
text: "r2"
|
||||
- label:
|
||||
text: "r3"
|
||||
- label:
|
||||
text: "r4"
|
||||
|
||||
# Grid shorthand "x<cols>": 4 columns specified, rows derived
|
||||
# from widget count (5 widgets / 4 cols -> 2 rows)
|
||||
- obj:
|
||||
id: grid_cols_only_shorthand
|
||||
layout: x4
|
||||
widgets:
|
||||
- label:
|
||||
text: "a"
|
||||
- label:
|
||||
text: "b"
|
||||
- label:
|
||||
text: "c"
|
||||
- label:
|
||||
text: "d"
|
||||
- label:
|
||||
text: "e"
|
||||
|
||||
# Grid dict form with grid_rows as a plain integer; columns derived
|
||||
- obj:
|
||||
id: grid_rows_int
|
||||
layout:
|
||||
type: grid
|
||||
grid_rows: 2
|
||||
widgets:
|
||||
- label:
|
||||
text: "1"
|
||||
- label:
|
||||
text: "2"
|
||||
- label:
|
||||
text: "3"
|
||||
|
||||
# Grid dict form with grid_columns as a plain integer; rows derived
|
||||
- obj:
|
||||
id: grid_cols_int
|
||||
layout:
|
||||
type: grid
|
||||
grid_columns: 3
|
||||
widgets:
|
||||
- label:
|
||||
text: "x"
|
||||
- label:
|
||||
text: "y"
|
||||
- label:
|
||||
text: "z"
|
||||
- label:
|
||||
text: "w"
|
||||
- label:
|
||||
text: "v"
|
||||
|
||||
# Grid dict form with both grid_rows and grid_columns as plain integers
|
||||
- obj:
|
||||
id: grid_both_int
|
||||
layout:
|
||||
type: grid
|
||||
grid_rows: 2
|
||||
grid_columns: 2
|
||||
widgets:
|
||||
- label:
|
||||
text: "1,1"
|
||||
- label:
|
||||
text: "1,2"
|
||||
- label:
|
||||
text: "2,1"
|
||||
- label:
|
||||
text: "2,2"
|
||||
|
||||
font:
|
||||
- file: "gfonts://Roboto"
|
||||
id: space16
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: 18
|
||||
mosi_pin: 19
|
||||
miso_pin: 16
|
||||
cs_pin: 17
|
||||
interrupt_pin: 21
|
||||
reset_pin: 20
|
||||
manual_ip:
|
||||
static_ip: 192.168.178.56
|
||||
gateway: 192.168.178.1
|
||||
subnet: 255.255.255.0
|
||||
domain: .local
|
||||
mac_address: "02:AA:BB:CC:DD:01"
|
||||
|
||||
mdns:
|
||||
disabled: false
|
||||
services:
|
||||
- service: _test_service
|
||||
protocol: _tcp
|
||||
port: 8888
|
||||
txt:
|
||||
static_string: Anything
|
||||
@@ -0,0 +1 @@
|
||||
<<: !include common-enabled-ethernet.yaml
|
||||
Reference in New Issue
Block a user