mirror of
https://github.com/esphome/esphome.git
synced 2026-07-03 13:53:16 +00:00
Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 572fb83015 | |||
| 0d3db2b670 | |||
| bab9cd3e7a | |||
| 36812591eb | |||
| 1862c6115f | |||
| ef780886c3 | |||
| 602305b20d | |||
| 78701debec | |||
| 08ac61ae94 | |||
| 6d5340f253 | |||
| e2dfef5ddc | |||
| 1d88027618 | |||
| 9841deec31 | |||
| ed5852c2d6 | |||
| b26601a3dc | |||
| f5806818cd | |||
| c3e739eba9 | |||
| b167b64f06 | |||
| 722cfae04c | |||
| 9cb2b562b9 | |||
| 81fb6712fe | |||
| 227dfa3730 | |||
| aa80bdbbc6 | |||
| 914ed10bcc | |||
| 92c99a7d41 | |||
| af1aaba547 | |||
| 5a2b7546f6 | |||
| 4047d5af5f | |||
| 6857e1ceb4 | |||
| 4479212008 | |||
| cb90ac45c3 | |||
| 82c0cb8929 | |||
| 2bdd9f6217 | |||
| 767a8c49b0 | |||
| 4c43f7e9d0 | |||
| 3ef140e25d | |||
| 0a568a3e1e | |||
| ef44491c69 | |||
| 089a2c99e2 | |||
| 311812c8cc | |||
| a77ab59436 | |||
| 89fbfc6f71 | |||
| 28f3bcdba3 | |||
| 445715b9fd | |||
| 8843c36ec6 | |||
| bd63f63b36 | |||
| 033e144e06 | |||
| 20d49f9a7c | |||
| 3b2caa1f5b | |||
| c3769e4fce | |||
| 6d894dd6ee | |||
| 0c06d78a4f | |||
| a408b5a4fe | |||
| e264c97454 | |||
| 8790dec137 | |||
| 6480868e6e | |||
| 0578e43352 | |||
| 2a89d4835f | |||
| 5084c61016 | |||
| b45f94d511 | |||
| 66a4752e13 | |||
| 4d4f78de81 | |||
| 0faa641c8a | |||
| 0f16d27a72 | |||
| 835ee456a5 | |||
| 17f3b7dbd5 | |||
| 171a429526 | |||
| e4ee2b7c04 | |||
| c85a062e23 | |||
| 873378fa1f | |||
| 4f00ad409e | |||
| 20b516ff11 |
+1
-1
@@ -1 +1 @@
|
||||
f31f13994768b5b07e29624406c9b053bf4bb26e1623ac2bc1e9d4a9477502d6
|
||||
10c432ae818f9ed7fd4a0176a04467b1f2634363f5ec985045a6d72747f60b90
|
||||
|
||||
@@ -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 = 2026.4.0b1
|
||||
PROJECT_NUMBER = 2026.4.1
|
||||
|
||||
# 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
|
||||
|
||||
+8
-1
@@ -750,8 +750,15 @@ def upload_using_esptool(
|
||||
platformio_api.FlashImage(
|
||||
path=idedata.firmware_bin_path, offset=firmware_offset
|
||||
),
|
||||
*idedata.extra_flash_images,
|
||||
]
|
||||
for image in idedata.extra_flash_images:
|
||||
if not image.path.is_file():
|
||||
_LOGGER.warning(
|
||||
"Skipping missing flash image declared by platform: %s",
|
||||
image.path,
|
||||
)
|
||||
continue
|
||||
flash_images.append(image)
|
||||
|
||||
mcu = "esp8266"
|
||||
if CORE.is_esp32:
|
||||
|
||||
+72
-6
@@ -151,8 +151,8 @@ class ConfigBundleCreator:
|
||||
|
||||
def __init__(self, config: dict[str, Any]) -> None:
|
||||
self._config = config
|
||||
self._config_dir = CORE.config_dir
|
||||
self._config_path = CORE.config_path
|
||||
self._config_dir = Path(CORE.config_dir).resolve()
|
||||
self._config_path = Path(CORE.config_path).resolve()
|
||||
self._files: list[BundleFile] = []
|
||||
self._seen_paths: set[Path] = set()
|
||||
self._secrets_paths: set[Path] = set()
|
||||
@@ -258,21 +258,36 @@ class ConfigBundleCreator:
|
||||
def _discover_yaml_includes(self) -> None:
|
||||
"""Discover YAML files loaded during config parsing.
|
||||
|
||||
We track files by wrapping _load_yaml_internal. The config has already
|
||||
been loaded at this point (bundle is a POST_CONFIG_ACTION), so we
|
||||
re-load just to discover the file list.
|
||||
Deliberately uses a fresh re-parse and force-loads every deferred
|
||||
``IncludeFile`` to include *all* potentially-reachable includes,
|
||||
even branches not selected by the local substitutions. Bundles are
|
||||
meant to be compiled on another system where command-line
|
||||
substitution overrides may choose a different branch — e.g.
|
||||
``!include network/${eth_model}/config.yaml`` must ship every
|
||||
candidate so the remote build can pick any one.
|
||||
|
||||
Entries with unresolved substitution variables in the filename
|
||||
path are skipped with a warning (they cannot be resolved without
|
||||
the substitution pass).
|
||||
|
||||
Secrets files are tracked separately so we can filter them to
|
||||
only include the keys this config actually references.
|
||||
"""
|
||||
# Must be a fresh parse: IncludeFile.load() caches its result in
|
||||
# _content, and we discover files by listening for loader calls. On
|
||||
# an already-parsed tree the cache is populated, .load() returns
|
||||
# without calling the loader, the listener never fires, and the
|
||||
# referenced files would be silently dropped from the bundle.
|
||||
with yaml_util.track_yaml_loads() as loaded_files:
|
||||
try:
|
||||
yaml_util.load_yaml(self._config_path)
|
||||
data = yaml_util.load_yaml(self._config_path)
|
||||
except EsphomeError:
|
||||
_LOGGER.debug(
|
||||
"Bundle: re-loading YAML for include discovery failed, "
|
||||
"proceeding with partial file list"
|
||||
)
|
||||
else:
|
||||
_force_load_include_files(data)
|
||||
|
||||
for fpath in loaded_files:
|
||||
if fpath == self._config_path.resolve():
|
||||
@@ -608,6 +623,57 @@ def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None:
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
|
||||
|
||||
def _force_load_include_files(obj: Any, _seen: set[int] | None = None) -> None:
|
||||
"""Recursively resolve any ``IncludeFile`` instances in a YAML tree.
|
||||
|
||||
Nested ``!include`` returns a deferred ``IncludeFile`` that is only
|
||||
resolved during the substitution pass. During bundle discovery we need
|
||||
the referenced files to actually load so the ``track_yaml_loads``
|
||||
listener fires for them.
|
||||
|
||||
``IncludeFile`` instances with unresolved substitution variables in the
|
||||
filename cannot be loaded — we skip and warn about those.
|
||||
"""
|
||||
if _seen is None:
|
||||
_seen = set()
|
||||
|
||||
if isinstance(obj, yaml_util.IncludeFile):
|
||||
if id(obj) in _seen:
|
||||
return
|
||||
_seen.add(id(obj))
|
||||
if obj.has_unresolved_expressions():
|
||||
_LOGGER.warning(
|
||||
"Bundle: cannot resolve !include %s (referenced from %s) "
|
||||
"with substitutions in path",
|
||||
obj.file,
|
||||
obj.parent_file,
|
||||
)
|
||||
return
|
||||
try:
|
||||
loaded = obj.load()
|
||||
except EsphomeError as err:
|
||||
_LOGGER.warning(
|
||||
"Bundle: failed to load !include %s (referenced from %s): %s",
|
||||
obj.file,
|
||||
obj.parent_file,
|
||||
err,
|
||||
)
|
||||
return
|
||||
_force_load_include_files(loaded, _seen)
|
||||
elif isinstance(obj, dict):
|
||||
if id(obj) in _seen:
|
||||
return
|
||||
_seen.add(id(obj))
|
||||
for value in obj.values():
|
||||
_force_load_include_files(value, _seen)
|
||||
elif isinstance(obj, (list, tuple)):
|
||||
if id(obj) in _seen:
|
||||
return
|
||||
_seen.add(id(obj))
|
||||
for item in obj:
|
||||
_force_load_include_files(item, _seen)
|
||||
|
||||
|
||||
def _resolve_include_path(include_path: Any) -> Path | None:
|
||||
"""Resolve an include path to absolute, skipping system includes."""
|
||||
if isinstance(include_path, str) and include_path.startswith("<"):
|
||||
|
||||
@@ -2,7 +2,11 @@ import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor, voltage_sampler
|
||||
from esphome.components.esp32 import get_esp32_variant, include_builtin_idf_component
|
||||
from esphome.components.esp32 import (
|
||||
get_esp32_variant,
|
||||
include_builtin_idf_component,
|
||||
require_adc_oneshot_iram,
|
||||
)
|
||||
from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC
|
||||
from esphome.components.zephyr import (
|
||||
zephyr_add_overlay,
|
||||
@@ -24,6 +28,7 @@ from esphome.const import (
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import (
|
||||
ATTENUATION_MODES,
|
||||
@@ -65,6 +70,13 @@ def validate_config(config):
|
||||
return config
|
||||
|
||||
|
||||
def _require_adc_iram(config: ConfigType) -> ConfigType:
|
||||
"""Register ADC oneshot IRAM requirement during config validation."""
|
||||
if CORE.is_esp32:
|
||||
require_adc_oneshot_iram()
|
||||
return config
|
||||
|
||||
|
||||
ADCSensor = adc_ns.class_(
|
||||
"ADCSensor", sensor.Sensor, cg.PollingComponent, voltage_sampler.VoltageSampler
|
||||
)
|
||||
@@ -95,6 +107,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
.extend(cv.polling_component_schema("60s")),
|
||||
validate_config,
|
||||
_require_adc_iram,
|
||||
)
|
||||
|
||||
CONF_ADC_CHANNEL_ID = "adc_channel_id"
|
||||
|
||||
@@ -671,6 +671,7 @@ message SensorStateResponse {
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_SENSOR";
|
||||
option (no_delay) = true;
|
||||
option (speed_optimized) = true;
|
||||
|
||||
fixed32 key = 1 [(force) = true];
|
||||
float state = 2;
|
||||
@@ -777,9 +778,10 @@ message SubscribeLogsResponse {
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (log) = false;
|
||||
option (no_delay) = false;
|
||||
option (speed_optimized) = true;
|
||||
|
||||
LogLevel level = 1;
|
||||
bytes message = 3;
|
||||
LogLevel level = 1 [(force) = true];
|
||||
bytes message = 3 [(force) = true];
|
||||
}
|
||||
|
||||
// ==================== NOISE ENCRYPTION ====================
|
||||
@@ -1625,6 +1627,7 @@ message BluetoothLEAdvertisementResponse {
|
||||
}
|
||||
|
||||
message BluetoothLERawAdvertisement {
|
||||
option (inline_encode) = true;
|
||||
uint64 address = 1 [(force) = true];
|
||||
sint32 rssi = 2 [(force) = true];
|
||||
uint32 address_type = 3 [(max_value) = 4];
|
||||
@@ -1637,6 +1640,7 @@ message BluetoothLERawAdvertisementsResponse {
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (no_delay) = true;
|
||||
option (speed_optimized) = true;
|
||||
|
||||
repeated BluetoothLERawAdvertisement advertisements = 1 [(fixed_array_with_length_define) = "BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE"];
|
||||
}
|
||||
|
||||
@@ -52,11 +52,11 @@
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
// Read a maximum of 5 messages per loop iteration to prevent starving other components.
|
||||
// Maximum messages to read per loop iteration to prevent starving other components.
|
||||
// This is a balance between API responsiveness and allowing other components to run.
|
||||
// Since each message could contain multiple protobuf messages when using packet batching,
|
||||
// this limits the number of messages processed, not the number of TCP packets.
|
||||
static constexpr uint8_t MAX_MESSAGES_PER_LOOP = 5;
|
||||
static constexpr uint8_t MAX_MESSAGES_PER_LOOP = 10;
|
||||
static constexpr uint8_t MAX_PING_RETRIES = 60;
|
||||
static constexpr uint16_t PING_RETRY_INTERVAL = 1000;
|
||||
static constexpr uint32_t KEEPALIVE_DISCONNECT_TIMEOUT = (KEEPALIVE_TIMEOUT_MS * 5) / 2;
|
||||
@@ -220,10 +220,17 @@ void APIConnection::loop() {
|
||||
}
|
||||
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
// Check if socket has data ready before attempting to read
|
||||
if (this->helper_->is_socket_ready()) {
|
||||
// Check if socket has data ready before attempting to read.
|
||||
// Also try reading if we hit the message limit last time — LWIP's rcvevent
|
||||
// (used by is_socket_ready) tracks pbuf dequeues, not bytes. When multiple
|
||||
// messages share a TCP segment, the last message's data stays in LWIP's
|
||||
// lastdata cache after rcvevent hits 0, making is_socket_ready() return false
|
||||
// even though data remains.
|
||||
if (this->helper_->is_socket_ready() || this->flags_.may_have_remaining_data) {
|
||||
this->flags_.may_have_remaining_data = false;
|
||||
// Read up to MAX_MESSAGES_PER_LOOP messages per loop to improve throughput
|
||||
for (uint8_t message_count = 0; message_count < MAX_MESSAGES_PER_LOOP; message_count++) {
|
||||
uint8_t message_count = 0;
|
||||
for (; message_count < MAX_MESSAGES_PER_LOOP; message_count++) {
|
||||
ReadPacketBuffer buffer;
|
||||
err = this->helper_->read_packet(&buffer);
|
||||
if (err == APIError::WOULD_BLOCK) {
|
||||
@@ -245,6 +252,11 @@ void APIConnection::loop() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// If we hit the limit, there may be more data remaining in LWIP's
|
||||
// lastdata cache that rcvevent doesn't account for.
|
||||
if (message_count == MAX_MESSAGES_PER_LOOP) {
|
||||
this->flags_.may_have_remaining_data = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Process deferred batch if scheduled and timer has expired
|
||||
@@ -2086,6 +2098,13 @@ void APIConnection::process_batch_() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure TCP_NODELAY is on before draining overflow and writing batch data.
|
||||
// Log messages enable Nagle (NODELAY off) to coalesce small packets.
|
||||
// If Nagle is still on when we try to drain, LWIP holds data in the
|
||||
// Nagle buffer, the TCP send buffer stays full, and the overflow
|
||||
// buffer can never drain — blocking the batch write indefinitely.
|
||||
this->helper_->set_nodelay_for_message(false);
|
||||
|
||||
// Try to clear buffer first
|
||||
if (!this->try_to_clear_buffer(true)) {
|
||||
// Can't write now, we'll try again later
|
||||
@@ -2193,13 +2212,6 @@ void APIConnection::process_batch_multi_(APIBuffer &shared_buf, size_t num_items
|
||||
shared_buf.resize(shared_buf.size() + footer_size);
|
||||
}
|
||||
|
||||
// Ensure TCP_NODELAY is on before writing batch data.
|
||||
// Log messages enable Nagle (NODELAY off) to coalesce small packets.
|
||||
// Without this, batch data written to the socket sits in LWIP's Nagle
|
||||
// buffer — the remote won't ACK until it sends its own data (e.g. a
|
||||
// ping), which can take 20+ seconds.
|
||||
this->helper_->set_nodelay_for_message(false);
|
||||
|
||||
// Send all collected messages
|
||||
APIError err = this->helper_->write_protobuf_messages(ProtoWriteBuffer{&shared_buf},
|
||||
std::span<const MessageInfo>(message_info, items_processed));
|
||||
|
||||
@@ -771,6 +771,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
uint8_t batch_scheduled : 1;
|
||||
uint8_t batch_first_message : 1; // For batch buffer allocation
|
||||
uint8_t should_try_send_immediately : 1; // True after initial states are sent
|
||||
uint8_t may_have_remaining_data : 1; // Read loop hit limit, retry without ready check
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
uint8_t log_only_mode : 1;
|
||||
#endif
|
||||
|
||||
@@ -22,6 +22,8 @@ extend google.protobuf.MessageOptions {
|
||||
optional bool log = 1039 [default=true];
|
||||
optional bool no_delay = 1040 [default=false];
|
||||
optional string base_class = 1041;
|
||||
optional bool inline_encode = 1042 [default=false];
|
||||
optional bool speed_optimized = 1043 [default=false];
|
||||
}
|
||||
|
||||
extend google.protobuf.FieldOptions {
|
||||
|
||||
@@ -745,7 +745,9 @@ uint32_t ListEntitiesSensorResponse::calculate_size() const {
|
||||
#endif
|
||||
return size;
|
||||
}
|
||||
uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
|
||||
uint8_t *
|
||||
SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key);
|
||||
ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state);
|
||||
@@ -755,7 +757,9 @@ uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG
|
||||
#endif
|
||||
return pos;
|
||||
}
|
||||
uint32_t SensorStateResponse::calculate_size() const {
|
||||
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
|
||||
uint32_t
|
||||
SensorStateResponse::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
size += 5;
|
||||
size += ProtoSize::calc_float(1, this->state);
|
||||
@@ -912,16 +916,22 @@ bool SubscribeLogsRequest::decode_varint(uint32_t field_id, proto_varint_value_t
|
||||
}
|
||||
return true;
|
||||
}
|
||||
uint8_t *SubscribeLogsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
|
||||
uint8_t *
|
||||
SubscribeLogsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->level));
|
||||
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->message_ptr_, this->message_len_);
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->level), true);
|
||||
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 26);
|
||||
ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_len_);
|
||||
ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_ptr_, this->message_len_);
|
||||
return pos;
|
||||
}
|
||||
uint32_t SubscribeLogsResponse::calculate_size() const {
|
||||
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
|
||||
uint32_t
|
||||
SubscribeLogsResponse::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
size += this->level ? 2 : 0;
|
||||
size += ProtoSize::calc_length(1, this->message_len_);
|
||||
size += 2;
|
||||
size += ProtoSize::calc_length_force(1, this->message_len_);
|
||||
return size;
|
||||
}
|
||||
#ifdef USE_API_NOISE
|
||||
@@ -2328,40 +2338,41 @@ bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id,
|
||||
}
|
||||
return true;
|
||||
}
|
||||
uint8_t *BluetoothLERawAdvertisement::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8);
|
||||
ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, this->address);
|
||||
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16);
|
||||
ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(this->rssi));
|
||||
if (this->address_type) {
|
||||
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24);
|
||||
ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, this->address_type);
|
||||
}
|
||||
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34);
|
||||
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast<uint8_t>(this->data_len));
|
||||
ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, this->data, this->data_len);
|
||||
return pos;
|
||||
}
|
||||
uint32_t BluetoothLERawAdvertisement::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
size += ProtoSize::calc_uint64_force(1, this->address);
|
||||
size += ProtoSize::calc_sint32_force(1, this->rssi);
|
||||
size += this->address_type ? 2 : 0;
|
||||
size += 2 + this->data_len;
|
||||
return size;
|
||||
}
|
||||
uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
|
||||
uint8_t *
|
||||
BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
for (uint16_t i = 0; i < this->advertisements_len; i++) {
|
||||
ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, this->advertisements[i]);
|
||||
auto &sub_msg = this->advertisements[i];
|
||||
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10);
|
||||
uint8_t *len_pos = pos;
|
||||
ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG);
|
||||
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8);
|
||||
ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address);
|
||||
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16);
|
||||
ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi));
|
||||
if (sub_msg.address_type) {
|
||||
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24);
|
||||
ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address_type);
|
||||
}
|
||||
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34);
|
||||
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast<uint8_t>(sub_msg.data_len));
|
||||
ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.data, sub_msg.data_len);
|
||||
*len_pos = static_cast<uint8_t>(pos - len_pos - 1);
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const {
|
||||
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
|
||||
uint32_t
|
||||
BluetoothLERawAdvertisementsResponse::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
for (uint16_t i = 0; i < this->advertisements_len; i++) {
|
||||
size += ProtoSize::calc_message_force(1, this->advertisements[i].calculate_size());
|
||||
auto &sub_msg = this->advertisements[i];
|
||||
size += 2;
|
||||
size += ProtoSize::calc_uint64_force(1, sub_msg.address);
|
||||
size += ProtoSize::calc_sint32_force(1, sub_msg.rssi);
|
||||
size += sub_msg.address_type ? 2 : 0;
|
||||
size += 2 + sub_msg.data_len;
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
@@ -1888,8 +1888,6 @@ class BluetoothLERawAdvertisement final : public ProtoMessage {
|
||||
uint32_t address_type{0};
|
||||
uint8_t data[62]{};
|
||||
uint8_t data_len{0};
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
@@ -352,6 +352,12 @@ class ProtoEncode {
|
||||
PROTO_ENCODE_CHECK_BOUNDS(pos, 1);
|
||||
*pos++ = b;
|
||||
}
|
||||
/// Reserve one byte for later backpatch (e.g., sub-message length).
|
||||
/// Advances pos past the reserved byte without writing a value.
|
||||
static inline void ESPHOME_ALWAYS_INLINE reserve_byte(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM) {
|
||||
PROTO_ENCODE_CHECK_BOUNDS(pos, 1);
|
||||
pos++;
|
||||
}
|
||||
/// Write raw bytes to the buffer (no tag, no length prefix).
|
||||
static inline void ESPHOME_ALWAYS_INLINE encode_raw(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
const void *data, size_t len) {
|
||||
|
||||
@@ -111,14 +111,14 @@ class ATM90E32Component : public PollingComponent,
|
||||
#endif
|
||||
float get_reference_voltage(uint8_t phase) {
|
||||
#ifdef USE_NUMBER
|
||||
return (phase >= 0 && phase < 3 && ref_voltages_[phase]) ? ref_voltages_[phase]->state : 120.0; // Default voltage
|
||||
return (phase < 3 && ref_voltages_[phase]) ? ref_voltages_[phase]->state : 120.0; // Default voltage
|
||||
#else
|
||||
return 120.0; // Default voltage
|
||||
#endif
|
||||
}
|
||||
float get_reference_current(uint8_t phase) {
|
||||
#ifdef USE_NUMBER
|
||||
return (phase >= 0 && phase < 3 && ref_currents_[phase]) ? ref_currents_[phase]->state : 5.0f; // Default current
|
||||
return (phase < 3 && ref_currents_[phase]) ? ref_currents_[phase]->state : 5.0f; // Default current
|
||||
#else
|
||||
return 5.0f; // Default current
|
||||
#endif
|
||||
|
||||
@@ -63,7 +63,7 @@ void BM8563::read_time() {
|
||||
rtc_time.day_of_week, rtc_time.hour, rtc_time.minute, rtc_time.second);
|
||||
|
||||
rtc_time.recalc_timestamp_utc(false);
|
||||
if (!rtc_time.is_valid()) {
|
||||
if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) {
|
||||
ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -162,7 +162,6 @@ async def canbus_action_to_code(config, action_id, template_arg, args):
|
||||
await cg.register_parented(var, config[CONF_CANBUS_ID])
|
||||
|
||||
if (can_id := config.get(CONF_CAN_ID)) is not None:
|
||||
can_id = await cg.templatable(can_id, args, cg.uint32)
|
||||
cg.add(var.set_can_id(can_id))
|
||||
cg.add(var.set_use_extended_id(config[CONF_USE_EXTENDED_ID]))
|
||||
|
||||
|
||||
@@ -102,6 +102,8 @@ CC1101Component::CC1101Component() {
|
||||
memset(this->pa_table_, 0, sizeof(this->pa_table_));
|
||||
}
|
||||
|
||||
void IRAM_ATTR CC1101Component::gpio_intr(CC1101Component *arg) { arg->enable_loop_soon_any_context(); }
|
||||
|
||||
void CC1101Component::setup() {
|
||||
this->spi_setup();
|
||||
this->cs_->digital_write(true);
|
||||
@@ -148,7 +150,12 @@ void CC1101Component::setup() {
|
||||
// Defer pin mode setup until after all components have completed setup()
|
||||
// This handles the case where remote_transmitter runs after CC1101 and changes pin mode
|
||||
if (this->gdo0_pin_ != nullptr) {
|
||||
this->defer([this]() { this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT); });
|
||||
this->defer([this]() {
|
||||
this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT);
|
||||
if (this->state_.PKT_FORMAT == static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO)) {
|
||||
this->gdo0_pin_->attach_interrupt(&CC1101Component::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +167,7 @@ void CC1101Component::call_listeners_(const std::vector<uint8_t> &packet, float
|
||||
}
|
||||
|
||||
void CC1101Component::loop() {
|
||||
this->disable_loop();
|
||||
if (this->state_.PKT_FORMAT != static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO) || this->gdo0_pin_ == nullptr ||
|
||||
!this->gdo0_pin_->digital_read()) {
|
||||
return;
|
||||
@@ -240,6 +248,7 @@ void CC1101Component::begin_tx() {
|
||||
this->write_(Register::PKTCTRL0, 0x32);
|
||||
ESP_LOGV(TAG, "Beginning TX sequence");
|
||||
if (this->gdo0_pin_ != nullptr) {
|
||||
this->gdo0_pin_->detach_interrupt();
|
||||
this->gdo0_pin_->pin_mode(gpio::FLAG_OUTPUT);
|
||||
}
|
||||
// Transition through IDLE to bypass CCA (Clear Channel Assessment) which can
|
||||
@@ -669,6 +678,13 @@ void CC1101Component::set_packet_mode(bool value) {
|
||||
this->state_.GDO0_CFG = 0x0D;
|
||||
}
|
||||
if (this->initialized_) {
|
||||
if (this->gdo0_pin_ != nullptr) {
|
||||
if (value) {
|
||||
this->gdo0_pin_->attach_interrupt(&CC1101Component::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE);
|
||||
} else {
|
||||
this->gdo0_pin_->detach_interrupt();
|
||||
}
|
||||
}
|
||||
this->write_(Register::PKTCTRL0);
|
||||
this->write_(Register::PKTCTRL1);
|
||||
this->write_(Register::IOCFG0);
|
||||
|
||||
@@ -93,6 +93,7 @@ class CC1101Component : public Component,
|
||||
|
||||
// GDO pin for packet reception
|
||||
InternalGPIOPin *gdo0_pin_{nullptr};
|
||||
static void IRAM_ATTR gpio_intr(CC1101Component *arg);
|
||||
|
||||
// Packet handling
|
||||
void call_listeners_(const std::vector<uint8_t> &packet, float freq_offset, float rssi, uint8_t lqi);
|
||||
|
||||
@@ -44,7 +44,7 @@ void DS1307Component::read_time() {
|
||||
.year = uint16_t(ds1307_.reg.year + 10u * ds1307_.reg.year_10 + 2000),
|
||||
};
|
||||
rtc_time.recalc_timestamp_utc(false);
|
||||
if (!rtc_time.is_valid()) {
|
||||
if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) {
|
||||
ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -671,11 +671,12 @@ def _is_framework_url(source: str) -> bool:
|
||||
# The default/recommended arduino framework version
|
||||
# - https://github.com/espressif/arduino-esp32/releases
|
||||
ARDUINO_FRAMEWORK_VERSION_LOOKUP = {
|
||||
"recommended": cv.Version(3, 3, 7),
|
||||
"latest": cv.Version(3, 3, 7),
|
||||
"dev": cv.Version(3, 3, 7),
|
||||
"recommended": cv.Version(3, 3, 8),
|
||||
"latest": cv.Version(3, 3, 8),
|
||||
"dev": cv.Version(3, 3, 8),
|
||||
}
|
||||
ARDUINO_PLATFORM_VERSION_LOOKUP = {
|
||||
cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"),
|
||||
cv.Version(3, 3, 7): cv.Version(55, 3, 37),
|
||||
cv.Version(3, 3, 6): cv.Version(55, 3, 36),
|
||||
cv.Version(3, 3, 5): cv.Version(55, 3, 35),
|
||||
@@ -695,6 +696,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = {
|
||||
# These versions correspond to pioarduino/esp-idf releases
|
||||
# See: https://github.com/pioarduino/esp-idf/releases
|
||||
ARDUINO_IDF_VERSION_LOOKUP = {
|
||||
cv.Version(3, 3, 8): cv.Version(5, 5, 4),
|
||||
cv.Version(3, 3, 7): cv.Version(5, 5, 3, "1"),
|
||||
cv.Version(3, 3, 6): cv.Version(5, 5, 2),
|
||||
cv.Version(3, 3, 5): cv.Version(5, 5, 2),
|
||||
@@ -714,17 +716,15 @@ ARDUINO_IDF_VERSION_LOOKUP = {
|
||||
# The default/recommended esp-idf framework version
|
||||
# - https://github.com/espressif/esp-idf/releases
|
||||
ESP_IDF_FRAMEWORK_VERSION_LOOKUP = {
|
||||
"recommended": cv.Version(5, 5, 3, "1"),
|
||||
"latest": cv.Version(5, 5, 3, "1"),
|
||||
"recommended": cv.Version(5, 5, 4),
|
||||
"latest": cv.Version(5, 5, 4),
|
||||
"dev": cv.Version(5, 5, 4),
|
||||
}
|
||||
ESP_IDF_PLATFORM_VERSION_LOOKUP = {
|
||||
cv.Version(
|
||||
6, 0, 0
|
||||
): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6",
|
||||
cv.Version(
|
||||
5, 5, 4
|
||||
): "https://github.com/pioarduino/platform-espressif32.git#develop",
|
||||
cv.Version(5, 5, 4): cv.Version(55, 3, 38, "1"),
|
||||
cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37),
|
||||
cv.Version(5, 5, 3): cv.Version(55, 3, 37),
|
||||
cv.Version(5, 5, 2): cv.Version(55, 3, 37),
|
||||
@@ -744,8 +744,8 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = {
|
||||
# The platform-espressif32 version
|
||||
# - https://github.com/pioarduino/platform-espressif32/releases
|
||||
PLATFORM_VERSION_LOOKUP = {
|
||||
"recommended": cv.Version(55, 3, 37),
|
||||
"latest": cv.Version(55, 3, 37),
|
||||
"recommended": cv.Version(55, 3, 38, "1"),
|
||||
"latest": cv.Version(55, 3, 38, "1"),
|
||||
"dev": "https://github.com/pioarduino/platform-espressif32.git#develop",
|
||||
}
|
||||
|
||||
@@ -1058,6 +1058,7 @@ CONF_DISABLE_MBEDTLS_PEER_CERT = "disable_mbedtls_peer_cert"
|
||||
CONF_DISABLE_MBEDTLS_PKCS7 = "disable_mbedtls_pkcs7"
|
||||
CONF_DISABLE_REGI2C_IN_IRAM = "disable_regi2c_in_iram"
|
||||
CONF_DISABLE_FATFS = "disable_fatfs"
|
||||
CONF_ADC_ONESHOT_IN_IRAM = "adc_oneshot_in_iram"
|
||||
|
||||
# VFS requirement tracking
|
||||
# Components that need VFS features can call require_vfs_*() functions
|
||||
@@ -1071,6 +1072,7 @@ KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required"
|
||||
KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required"
|
||||
KEY_FATFS_REQUIRED = "fatfs_required"
|
||||
KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required"
|
||||
KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required"
|
||||
|
||||
|
||||
def require_vfs_select() -> None:
|
||||
@@ -1168,6 +1170,17 @@ def require_fatfs() -> None:
|
||||
CORE.data[KEY_ESP32][KEY_FATFS_REQUIRED] = True
|
||||
|
||||
|
||||
def require_adc_oneshot_iram() -> None:
|
||||
"""Mark that ADC oneshot IRAM safety is required by a component.
|
||||
|
||||
Call this from components that use the ADC oneshot driver. When flash cache is
|
||||
disabled (e.g., during NVS writes by WiFi, BLE, Zigbee, or power management),
|
||||
the ADC oneshot read function must be in IRAM to avoid crashes.
|
||||
This sets CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM.
|
||||
"""
|
||||
CORE.data[KEY_ESP32][KEY_ADC_ONESHOT_IRAM_REQUIRED] = True
|
||||
|
||||
|
||||
def _parse_idf_component(value: str) -> ConfigType:
|
||||
"""Parse IDF component shorthand syntax like 'owner/component^version'"""
|
||||
# Match operator followed by version-like string (digit or *)
|
||||
@@ -1209,7 +1222,7 @@ FRAMEWORK_SCHEMA = cv.Schema(
|
||||
cv.Optional(CONF_IGNORE_EFUSE_CUSTOM_MAC, default=False): cv.boolean,
|
||||
cv.Optional(CONF_IGNORE_EFUSE_MAC_CRC, default=False): cv.boolean,
|
||||
cv.Optional(CONF_MINIMUM_CHIP_REVISION): cv.one_of(
|
||||
*ESP32_CHIP_REVISIONS
|
||||
*ESP32_CHIP_REVISIONS, string=True
|
||||
),
|
||||
cv.Optional(CONF_SRAM1_AS_IRAM, default=False): cv.boolean,
|
||||
# DHCP server is needed for WiFi AP mode. When WiFi component is used,
|
||||
@@ -1268,6 +1281,7 @@ FRAMEWORK_SCHEMA = cv.Schema(
|
||||
cv.Optional(CONF_DISABLE_MBEDTLS_PEER_CERT, default=True): cv.boolean,
|
||||
cv.Optional(CONF_DISABLE_MBEDTLS_PKCS7, default=True): cv.boolean,
|
||||
cv.Optional(CONF_DISABLE_REGI2C_IN_IRAM, default=True): cv.boolean,
|
||||
cv.Optional(CONF_ADC_ONESHOT_IN_IRAM, default=False): cv.boolean,
|
||||
cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean,
|
||||
}
|
||||
),
|
||||
@@ -2068,6 +2082,16 @@ async def to_code(config):
|
||||
if advanced[CONF_DISABLE_REGI2C_IN_IRAM]:
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_REGI2C_CTRL_FUNC_IN_IRAM", False)
|
||||
|
||||
# Place ADC oneshot control functions in IRAM for cache safety
|
||||
# When flash cache is disabled (during NVS writes by WiFi, BLE, Zigbee, Thread,
|
||||
# power management, etc.), ADC reads will crash if these functions are in flash.
|
||||
# Components using ADC call require_adc_oneshot_iram() to force this.
|
||||
if (
|
||||
CORE.data[KEY_ESP32].get(KEY_ADC_ONESHOT_IRAM_REQUIRED, False)
|
||||
or advanced[CONF_ADC_ONESHOT_IN_IRAM]
|
||||
):
|
||||
add_idf_sdkconfig_option("CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM", True)
|
||||
|
||||
# Disable FATFS support
|
||||
# Components that need FATFS (SD card, etc.) can call require_fatfs()
|
||||
if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False):
|
||||
|
||||
@@ -1960,6 +1960,10 @@ BOARDS = {
|
||||
"name": "Hornbill ESP32 Minima",
|
||||
"variant": VARIANT_ESP32,
|
||||
},
|
||||
"huidu_hd_wf1": {
|
||||
"name": "Huidu HD-WF1",
|
||||
"variant": VARIANT_ESP32S2,
|
||||
},
|
||||
"huidu_hd_wf2": {
|
||||
"name": "Huidu HD-WF2",
|
||||
"variant": VARIANT_ESP32S3,
|
||||
@@ -2028,6 +2032,10 @@ BOARDS = {
|
||||
"name": "LilyGo T-Display-S3",
|
||||
"variant": VARIANT_ESP32S3,
|
||||
},
|
||||
"lilygo-t-energy-s3": {
|
||||
"name": "LilyGo T-Energy-S3",
|
||||
"variant": VARIANT_ESP32S3,
|
||||
},
|
||||
"lilygo-t3-s3": {
|
||||
"name": "LilyGo T3-S3",
|
||||
"variant": VARIANT_ESP32S3,
|
||||
@@ -2289,10 +2297,18 @@ BOARDS = {
|
||||
"name": "S.ODI Ultra v1",
|
||||
"variant": VARIANT_ESP32,
|
||||
},
|
||||
"seeed_xiao_esp32_s3_plus": {
|
||||
"name": "Seeed Studio XIAO ESP32S3 Plus",
|
||||
"variant": VARIANT_ESP32S3,
|
||||
},
|
||||
"seeed_xiao_esp32c3": {
|
||||
"name": "Seeed Studio XIAO ESP32C3",
|
||||
"variant": VARIANT_ESP32C3,
|
||||
},
|
||||
"seeed_xiao_esp32c5": {
|
||||
"name": "Seeed Studio XIAO ESP32C5",
|
||||
"variant": VARIANT_ESP32C5,
|
||||
},
|
||||
"seeed_xiao_esp32c6": {
|
||||
"name": "Seeed Studio XIAO ESP32C6",
|
||||
"variant": VARIANT_ESP32C6,
|
||||
|
||||
@@ -172,10 +172,16 @@ def validate_gpio_pin(pin):
|
||||
exc,
|
||||
)
|
||||
else:
|
||||
# Throw an exception if used for a pin that would not have resulted
|
||||
# in a validation error anyway!
|
||||
# `ignore_pin_validation_error` only suppresses an error raised by the
|
||||
# variant's pin_validation above (e.g. SPI flash/PSRAM pins, invalid pin
|
||||
# numbers). If that didn't raise, the option is a no-op -- warn so the
|
||||
# user can clean it up, but don't block the build.
|
||||
if ignore_pin_validation_warning:
|
||||
raise cv.Invalid(f"GPIO{pin[CONF_NUMBER]} is not a reserved pin")
|
||||
_LOGGER.warning(
|
||||
"GPIO%d has no validation errors to ignore; "
|
||||
"remove `ignore_pin_validation_error: true` from this pin.",
|
||||
pin[CONF_NUMBER],
|
||||
)
|
||||
|
||||
return pin
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@ class EthernetComponent final : public Component {
|
||||
int reset_pin_{-1};
|
||||
int phy_addr_spi_{-1};
|
||||
int clock_speed_;
|
||||
spi_host_device_t interface_{SPI3_HOST};
|
||||
spi_host_device_t interface_{SPI2_HOST};
|
||||
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
|
||||
uint32_t polling_interval_{0};
|
||||
#endif
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace gdk101 {
|
||||
|
||||
static const char *const TAG = "gdk101";
|
||||
static constexpr uint8_t NUMBER_OF_READ_RETRIES = 5;
|
||||
static constexpr uint8_t NUMBER_OF_RESET_RETRIES = 10;
|
||||
static constexpr uint8_t NUMBER_OF_RESET_RETRIES = 30;
|
||||
static constexpr uint32_t RESET_INTERVAL_ID = 0;
|
||||
static constexpr uint32_t RESET_INTERVAL_MS = 1000;
|
||||
|
||||
|
||||
@@ -108,8 +108,13 @@ async def globals_set_to_code(config, action_id, template_arg, args):
|
||||
full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID])
|
||||
template_arg = cg.TemplateArguments(full_id.type, *template_arg)
|
||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||
# Use the global's value_type alias as the lambda return type so
|
||||
# TemplatableFn stores a direct function pointer instead of going through
|
||||
# the deprecated converting trampoline when the value expression deduces
|
||||
# to a different type (e.g. int literal assigned to a float global).
|
||||
value_type = cg.RawExpression(f"{full_id.type}::value_type")
|
||||
templ = await cg.templatable(
|
||||
config[CONF_VALUE], args, None, to_exp=cg.RawExpression, wrap_constant=True
|
||||
config[CONF_VALUE], args, value_type, to_exp=cg.RawExpression
|
||||
)
|
||||
cg.add(var.set_value(templ))
|
||||
return var
|
||||
|
||||
@@ -8,7 +8,7 @@ from .. import hbridge_ns
|
||||
CODEOWNERS = ["@DotNetDann"]
|
||||
|
||||
HBridgeLightOutput = hbridge_ns.class_(
|
||||
"HBridgeLightOutput", cg.PollingComponent, light.LightOutput
|
||||
"HBridgeLightOutput", cg.Component, light.LightOutput
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend(
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/output/float_output.h"
|
||||
#include "esphome/components/light/light_output.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/components/output/float_output.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace hbridge {
|
||||
|
||||
// Using PollingComponent as the updates are more consistent and reduces flickering
|
||||
class HBridgeLightOutput : public PollingComponent, public light::LightOutput {
|
||||
class HBridgeLightOutput : public Component, public light::LightOutput {
|
||||
public:
|
||||
HBridgeLightOutput() : PollingComponent(1) {}
|
||||
|
||||
void set_pina_pin(output::FloatOutput *pina_pin) { pina_pin_ = pina_pin; }
|
||||
void set_pinb_pin(output::FloatOutput *pinb_pin) { pinb_pin_ = pinb_pin; }
|
||||
void set_pina_pin(output::FloatOutput *pina_pin) { this->pina_pin_ = pina_pin; }
|
||||
void set_pinb_pin(output::FloatOutput *pinb_pin) { this->pinb_pin_ = pinb_pin; }
|
||||
|
||||
light::LightTraits get_traits() override {
|
||||
auto traits = light::LightTraits();
|
||||
@@ -24,16 +21,16 @@ class HBridgeLightOutput : public PollingComponent, public light::LightOutput {
|
||||
return traits;
|
||||
}
|
||||
|
||||
void setup() override { this->forward_direction_ = false; }
|
||||
void setup() override { this->disable_loop(); }
|
||||
|
||||
void update() override {
|
||||
// This method runs around 60 times per second
|
||||
// We cannot do the PWM ourselves so we are reliant on the hardware PWM
|
||||
if (!this->forward_direction_) { // First LED Direction
|
||||
void loop() override {
|
||||
// Only called when both channels are active — alternate H-bridge direction
|
||||
// each iteration to multiplex cold and warm white.
|
||||
if (!this->forward_direction_) {
|
||||
this->pina_pin_->set_level(this->pina_duty_);
|
||||
this->pinb_pin_->set_level(0);
|
||||
this->forward_direction_ = true;
|
||||
} else { // Second LED Direction
|
||||
} else {
|
||||
this->pina_pin_->set_level(0);
|
||||
this->pinb_pin_->set_level(this->pinb_duty_);
|
||||
this->forward_direction_ = false;
|
||||
@@ -43,15 +40,32 @@ class HBridgeLightOutput : public PollingComponent, public light::LightOutput {
|
||||
float get_setup_priority() const override { return setup_priority::HARDWARE; }
|
||||
|
||||
void write_state(light::LightState *state) override {
|
||||
state->current_values_as_cwww(&this->pina_duty_, &this->pinb_duty_, false);
|
||||
float new_pina, new_pinb;
|
||||
state->current_values_as_cwww(&new_pina, &new_pinb, false);
|
||||
|
||||
this->pina_duty_ = new_pina;
|
||||
this->pinb_duty_ = new_pinb;
|
||||
|
||||
if (new_pina != 0.0f && new_pinb != 0.0f) {
|
||||
// Both channels active — need loop to alternate H-bridge direction
|
||||
this->high_freq_.start();
|
||||
this->enable_loop();
|
||||
} else {
|
||||
// Zero or one channel active — drive pins directly, no multiplexing needed
|
||||
this->high_freq_.stop();
|
||||
this->disable_loop();
|
||||
this->pina_pin_->set_level(new_pina);
|
||||
this->pinb_pin_->set_level(new_pinb);
|
||||
}
|
||||
}
|
||||
|
||||
protected:
|
||||
output::FloatOutput *pina_pin_;
|
||||
output::FloatOutput *pinb_pin_;
|
||||
float pina_duty_ = 0;
|
||||
float pinb_duty_ = 0;
|
||||
bool forward_direction_ = false;
|
||||
float pina_duty_{0};
|
||||
float pinb_duty_{0};
|
||||
bool forward_direction_{false};
|
||||
HighFrequencyLoopRequester high_freq_;
|
||||
};
|
||||
|
||||
} // namespace hbridge
|
||||
|
||||
@@ -36,7 +36,7 @@ I2SAudioMicrophone = i2s_audio_ns.class_(
|
||||
)
|
||||
|
||||
INTERNAL_ADC_VARIANTS = [esp32.VARIANT_ESP32]
|
||||
PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3]
|
||||
PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3, esp32.VARIANT_ESP32P4]
|
||||
|
||||
|
||||
def _validate_esp32_variant(config):
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome {
|
||||
namespace ili9xxx {
|
||||
|
||||
|
||||
@@ -229,6 +229,10 @@ void ILI9XXXDisplay::update() {
|
||||
}
|
||||
|
||||
void ILI9XXXDisplay::display_() {
|
||||
// buffer may be null if allocation failed
|
||||
if (this->buffer_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
// check if something was displayed
|
||||
if ((this->x_high_ < this->x_low_) || (this->y_high_ < this->y_low_)) {
|
||||
return;
|
||||
|
||||
@@ -28,7 +28,6 @@ from esphome.const import (
|
||||
CONF_URL,
|
||||
)
|
||||
from esphome.core import CORE, HexInt
|
||||
from esphome.final_validate import full_config
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -676,12 +675,16 @@ def _final_validate(config):
|
||||
:param config:
|
||||
:return:
|
||||
"""
|
||||
fv = full_config.get()
|
||||
if "lvgl" in fv and not all(CONF_BYTE_ORDER in x for x in config):
|
||||
config = config.copy()
|
||||
for c in config:
|
||||
if not c.get(CONF_BYTE_ORDER):
|
||||
c[CONF_BYTE_ORDER] = "LITTLE_ENDIAN"
|
||||
config = config.copy()
|
||||
for c in config:
|
||||
if byte_order := c.get(CONF_BYTE_ORDER):
|
||||
if byte_order == "BIG_ENDIAN":
|
||||
_LOGGER.warning(
|
||||
"The image '%s' is configured with big-endian byte order, little-endian is expected",
|
||||
c.get(CONF_FILE),
|
||||
)
|
||||
else:
|
||||
c[CONF_BYTE_ORDER] = "LITTLE_ENDIAN"
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -189,7 +189,7 @@ Color Image::get_rgb_pixel_(int x, int y) const {
|
||||
}
|
||||
Color Image::get_rgb565_pixel_(int x, int y) const {
|
||||
const uint8_t *pos = this->data_start_ + (x + y * this->width_) * this->bpp_ / 8;
|
||||
uint16_t rgb565 = encode_uint16(progmem_read_byte(pos), progmem_read_byte(pos + 1));
|
||||
uint16_t rgb565 = encode_uint16(progmem_read_byte(pos + 1), progmem_read_byte(pos));
|
||||
auto r = (rgb565 & 0xF800) >> 11;
|
||||
auto g = (rgb565 & 0x07E0) >> 5;
|
||||
auto b = rgb565 & 0x001F;
|
||||
|
||||
@@ -58,6 +58,12 @@ void AddressableLightTransformer::start() {
|
||||
// our transition will handle brightness, disable brightness in correction.
|
||||
this->light_.correction_.set_local_brightness(255);
|
||||
this->target_color_ *= to_uint8_scale(end_values.get_brightness() * end_values.get_state());
|
||||
|
||||
// Uniformity scan is deferred to the first apply() call. start() can run before the underlying
|
||||
// LED output's setup() has allocated its frame buffer (e.g. on_boot at priority > HARDWARE
|
||||
// triggering a transition), and reading through ESPColorView would deref a null buffer.
|
||||
this->uniform_start_scanned_ = false;
|
||||
this->uniform_start_is_uniform_ = false;
|
||||
}
|
||||
|
||||
inline constexpr uint8_t subtract_scaled_difference(uint8_t a, uint8_t b, int32_t scale) {
|
||||
@@ -97,12 +103,57 @@ optional<LightColorValues> AddressableLightTransformer::apply() {
|
||||
// non-linear when applying small deltas.
|
||||
|
||||
if (smoothed_progress > this->last_transition_progress_ && this->last_transition_progress_ < 1.f) {
|
||||
int32_t scale = int32_t(256.f * std::max((1.f - smoothed_progress) / (1.f - this->last_transition_progress_), 0.f));
|
||||
for (auto led : this->light_) {
|
||||
led.set_rgbw(subtract_scaled_difference(this->target_color_.red, led.get_red(), scale),
|
||||
subtract_scaled_difference(this->target_color_.green, led.get_green(), scale),
|
||||
subtract_scaled_difference(this->target_color_.blue, led.get_blue(), scale),
|
||||
subtract_scaled_difference(this->target_color_.white, led.get_white(), scale));
|
||||
// Lazy uniformity scan: deferred from start() so the LED output's setup() has run and the
|
||||
// frame buffer is valid. When every LED already has the same color (the common case: plain
|
||||
// turn_on/turn_off on a uniform strip), interpolate math-only against a single start color.
|
||||
// Avoiding the per-step read-back through the 8-bit stored byte prevents gamma round-trip
|
||||
// quantization from stalling the fade at low values (e.g. gamma 2.8 pre-gamma values <27
|
||||
// round to stored 0, freezing progress).
|
||||
if (!this->uniform_start_scanned_) {
|
||||
this->uniform_start_scanned_ = true;
|
||||
if (this->light_.size() > 0) {
|
||||
Color first = this->light_[0].get();
|
||||
bool uniform = true;
|
||||
for (int32_t i = 1; i < this->light_.size(); i++) {
|
||||
if (this->light_[i].get() != first) {
|
||||
uniform = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (uniform) {
|
||||
this->uniform_start_color_ = first;
|
||||
this->uniform_start_is_uniform_ = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this->uniform_start_is_uniform_) {
|
||||
// All LEDs started at the same color: compute the interpolated value once and write it to
|
||||
// every LED. No read-back, so each LED's stored byte advances through every gamma threshold
|
||||
// as smoothed_progress crosses it, instead of stalling at 0 for low pre-gamma values.
|
||||
//
|
||||
// Trade-off: any mid-transition writes to individual LEDs (e.g. from a user lambda) will be
|
||||
// overwritten on the next apply() here. The fallback path below would have respected them
|
||||
// via its read-back. Concurrent per-LED mutation during a transition isn't a pattern we
|
||||
// support, so this is acceptable.
|
||||
// lerp(start, target, progress) via existing helper: target - (target-start)*(1-progress).
|
||||
const Color &start = this->uniform_start_color_;
|
||||
int32_t remaining = int32_t(256.f * (1.f - smoothed_progress));
|
||||
uint8_t r = subtract_scaled_difference(this->target_color_.red, start.red, remaining);
|
||||
uint8_t g = subtract_scaled_difference(this->target_color_.green, start.green, remaining);
|
||||
uint8_t b = subtract_scaled_difference(this->target_color_.blue, start.blue, remaining);
|
||||
uint8_t w = subtract_scaled_difference(this->target_color_.white, start.white, remaining);
|
||||
for (auto led : this->light_) {
|
||||
led.set_rgbw(r, g, b, w);
|
||||
}
|
||||
} else {
|
||||
int32_t scale =
|
||||
int32_t(256.f * std::max((1.f - smoothed_progress) / (1.f - this->last_transition_progress_), 0.f));
|
||||
for (auto led : this->light_) {
|
||||
led.set_rgbw(subtract_scaled_difference(this->target_color_.red, led.get_red(), scale),
|
||||
subtract_scaled_difference(this->target_color_.green, led.get_green(), scale),
|
||||
subtract_scaled_difference(this->target_color_.blue, led.get_blue(), scale),
|
||||
subtract_scaled_difference(this->target_color_.white, led.get_white(), scale));
|
||||
}
|
||||
}
|
||||
this->last_transition_progress_ = smoothed_progress;
|
||||
this->light_.schedule_show();
|
||||
|
||||
@@ -115,6 +115,9 @@ class AddressableLightTransformer : public LightTransformer {
|
||||
AddressableLight &light_;
|
||||
float last_transition_progress_{0.0f};
|
||||
Color target_color_{};
|
||||
Color uniform_start_color_{};
|
||||
bool uniform_start_scanned_{false};
|
||||
bool uniform_start_is_uniform_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::light
|
||||
|
||||
@@ -44,6 +44,7 @@ from esphome.core import CORE, ID, Lambda
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.final_validate import full_config
|
||||
from esphome.helpers import write_file_if_changed
|
||||
from esphome.writer import clean_build
|
||||
from esphome.yaml_util import load_yaml
|
||||
|
||||
from . import defines as df, helpers, lv_validation as lvalid, widgets
|
||||
@@ -341,7 +342,7 @@ async def to_code(configs):
|
||||
df.LOGGER.info("LVGL will use hardware rotation via display driver")
|
||||
else:
|
||||
rotation_type = RotationType.ROTATION_SOFTWARE
|
||||
if get_esp32_variant() == VARIANT_ESP32P4:
|
||||
if CORE.is_esp32 and get_esp32_variant() == VARIANT_ESP32P4:
|
||||
df.LOGGER.info("LVGL will use software rotation (PPA accelerated)")
|
||||
else:
|
||||
df.LOGGER.info("LVGL will use software rotation")
|
||||
@@ -451,7 +452,8 @@ async def to_code(configs):
|
||||
df.add_define(f"LV_DRAW_SW_SUPPORT_{fmt}", "1")
|
||||
|
||||
lv_conf_h_file = CORE.relative_src_path(LV_CONF_FILENAME)
|
||||
write_file_if_changed(lv_conf_h_file, generate_lv_conf_h())
|
||||
if write_file_if_changed(lv_conf_h_file, generate_lv_conf_h()):
|
||||
clean_build(clear_pio_cache=False)
|
||||
cg.add_build_flag("-DLV_CONF_H=1")
|
||||
# handle windows paths in a way that doesn't break the generated C++
|
||||
lv_conf_h_path = Path(lv_conf_h_file).as_posix()
|
||||
|
||||
@@ -642,26 +642,28 @@ void LvglComponent::write_random_() {
|
||||
int iterations = 6 - lv_display_get_inactive_time(this->disp_) / 60000;
|
||||
if (iterations <= 0)
|
||||
iterations = 1;
|
||||
int16_t width = lv_display_get_horizontal_resolution(this->disp_);
|
||||
int16_t height = lv_display_get_vertical_resolution(this->disp_);
|
||||
while (iterations-- != 0) {
|
||||
int32_t col = random_uint32() % this->width_;
|
||||
int32_t col = random_uint32() % width;
|
||||
col = col / this->draw_rounding * this->draw_rounding;
|
||||
int32_t row = random_uint32() % this->height_;
|
||||
int32_t row = random_uint32() % height;
|
||||
row = row / this->draw_rounding * this->draw_rounding;
|
||||
// size will be between 8 and 32, and a multiple of draw_rounding
|
||||
int32_t size = (random_uint32() % 25 + 8) / this->draw_rounding * this->draw_rounding;
|
||||
lv_area_t area{col, row, col + size - 1, row + size - 1};
|
||||
lv_area_t area{.x1 = col, .y1 = row, .x2 = col + size - 1, .y2 = row + size - 1};
|
||||
// clip to display bounds just in case
|
||||
if (area.x2 >= this->width_)
|
||||
area.x2 = this->width_ - 1;
|
||||
if (area.y2 >= this->height_)
|
||||
area.y2 = this->height_ - 1;
|
||||
if (area.x2 >= width)
|
||||
area.x2 = width - 1;
|
||||
if (area.y2 >= height)
|
||||
area.y2 = height - 1;
|
||||
|
||||
// line_len can't exceed 1024, and minimum buffer size is 2048, so this won't overflow the buffer
|
||||
size_t line_len = lv_area_get_width(&area) * lv_area_get_height(&area) / 2;
|
||||
for (size_t i = 0; i != line_len; i++) {
|
||||
((uint32_t *) (this->draw_buf_))[i] = random_uint32();
|
||||
reinterpret_cast<uint32_t *>(this->draw_buf_)[i] = random_uint32();
|
||||
}
|
||||
this->draw_buffer_(&area, (lv_color_data *) this->draw_buf_);
|
||||
this->draw_buffer_(&area, reinterpret_cast<lv_color_data *>(this->draw_buf_));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,16 +76,17 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) {
|
||||
}
|
||||
#endif
|
||||
#if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE)
|
||||
// Shortcut / overload, so that the source of an image can easily be updated
|
||||
// from within a lambda.
|
||||
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { lv_image_set_src(obj, image->get_lv_image_dsc()); }
|
||||
#if LV_USE_IMAGE
|
||||
// Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda.
|
||||
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); }
|
||||
#endif // LV_USE_IMAGE
|
||||
|
||||
inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) {
|
||||
lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector);
|
||||
::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector);
|
||||
}
|
||||
|
||||
inline void lv_obj_set_style_bg_image_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) {
|
||||
lv_obj_set_style_bg_image_src(obj, image->get_lv_image_dsc(), selector);
|
||||
::lv_obj_set_style_bg_image_src(obj, image->get_lv_image_dsc(), selector);
|
||||
}
|
||||
#endif // USE_LVGL_IMAGE
|
||||
#ifdef USE_LVGL_ANIMIMG
|
||||
|
||||
@@ -77,8 +77,11 @@ class ArcType(NumberType):
|
||||
# start_angle and end_angle are mapped to bg_start_angle and bg_end_angle
|
||||
prop = str(prop)
|
||||
if prop.endswith("_angle"):
|
||||
prop = "bg_" + prop
|
||||
await w.set_property(prop, config, processor=validator)
|
||||
await w.set_property(
|
||||
"bg_" + prop, await validator.process(config.get(prop))
|
||||
)
|
||||
else:
|
||||
await w.set_property(prop, config, processor=validator)
|
||||
if CONF_ADJUSTABLE in config:
|
||||
if not config[CONF_ADJUSTABLE]:
|
||||
lv_obj.remove_style(w.obj, nullptr, LV_PART.KNOB)
|
||||
|
||||
@@ -5,6 +5,7 @@ import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_INPUT,
|
||||
CONF_INTERRUPT_PIN,
|
||||
CONF_INVERTED,
|
||||
CONF_MODE,
|
||||
CONF_NUMBER,
|
||||
@@ -24,6 +25,7 @@ CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(MCP23016),
|
||||
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
@@ -35,6 +37,8 @@ async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
if interrupt_pin := config.get(CONF_INTERRUPT_PIN):
|
||||
cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin)))
|
||||
|
||||
|
||||
def validate_mode(value):
|
||||
|
||||
@@ -24,11 +24,22 @@ void MCP23016::setup() {
|
||||
|
||||
// all pins input
|
||||
this->write_reg_(MCP23016_IODIR1, 0xFFFF);
|
||||
|
||||
if (this->interrupt_pin_ != nullptr) {
|
||||
this->interrupt_pin_->setup();
|
||||
this->interrupt_pin_->attach_interrupt(&MCP23016::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE);
|
||||
this->set_invalidate_on_read_(false);
|
||||
}
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
void IRAM_ATTR MCP23016::gpio_intr(MCP23016 *arg) { arg->enable_loop_soon_any_context(); }
|
||||
void MCP23016::loop() {
|
||||
// Invalidate cache at the start of each loop
|
||||
this->reset_pin_cache_();
|
||||
if (this->interrupt_pin_ != nullptr) {
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
bool MCP23016::digital_read_hw(uint8_t pin) { return this->read_reg_(MCP23016_GP1, &this->input_mask_); }
|
||||
|
||||
@@ -37,6 +48,9 @@ void MCP23016::digital_write_hw(uint8_t pin, bool value) { this->update_reg_(pin
|
||||
void MCP23016::pin_mode(uint8_t pin, gpio::Flags flags) {
|
||||
if (flags == gpio::FLAG_INPUT) {
|
||||
this->update_reg_(pin, true, MCP23016_IODIR1);
|
||||
if (this->interrupt_pin_ == nullptr) {
|
||||
this->enable_loop();
|
||||
}
|
||||
} else if (flags == gpio::FLAG_OUTPUT) {
|
||||
this->update_reg_(pin, false, MCP23016_IODIR1);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,10 @@ class MCP23016 : public Component, public i2c::I2CDevice, public gpio_expander::
|
||||
|
||||
float get_setup_priority() const override;
|
||||
|
||||
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
|
||||
|
||||
protected:
|
||||
static void IRAM_ATTR gpio_intr(MCP23016 *arg);
|
||||
// Virtual methods from CachedGpioExpander
|
||||
bool digital_read_hw(uint8_t pin) override;
|
||||
bool digital_read_cache(uint8_t pin) override;
|
||||
@@ -51,6 +54,7 @@ class MCP23016 : public Component, public i2c::I2CDevice, public gpio_expander::
|
||||
uint16_t olat_{0x0000};
|
||||
// Cache for input values (16-bit combined for both banks)
|
||||
uint16_t input_mask_{0x0000};
|
||||
InternalGPIOPin *interrupt_pin_{nullptr};
|
||||
};
|
||||
|
||||
class MCP23016GPIOPin : public GPIOPin {
|
||||
|
||||
@@ -170,7 +170,7 @@ async def to_code(config):
|
||||
cg.add_library("LEAmDNS", None)
|
||||
|
||||
if CORE.is_esp32:
|
||||
add_idf_component(name="espressif/mdns", ref="1.10.0")
|
||||
add_idf_component(name="espressif/mdns", ref="1.11.0")
|
||||
|
||||
cg.add_define("USE_MDNS")
|
||||
|
||||
|
||||
@@ -451,6 +451,8 @@ async def to_code(config):
|
||||
ota.request_ota_state_listeners()
|
||||
|
||||
esp32.add_idf_component(name="espressif/esp-tflite-micro", ref="1.3.3~1")
|
||||
# Pin esp-nn for stable future builds (esp-tflite-micro depends on esp-nn)
|
||||
esp32.add_idf_component(name="espressif/esp-nn", ref="1.1.2")
|
||||
|
||||
cg.add_build_flag("-DTF_LITE_STATIC_MEMORY")
|
||||
cg.add_build_flag("-DTF_LITE_DISABLE_X86_NEON")
|
||||
|
||||
@@ -29,14 +29,6 @@ void VADModel::log_model_config() {
|
||||
bool StreamingModel::load_model_() {
|
||||
RAMAllocator<uint8_t> arena_allocator;
|
||||
|
||||
if (this->tensor_arena_ == nullptr) {
|
||||
this->tensor_arena_ = arena_allocator.allocate(this->tensor_arena_size_);
|
||||
if (this->tensor_arena_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Could not allocate the streaming model's tensor arena.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (this->var_arena_ == nullptr) {
|
||||
this->var_arena_ = arena_allocator.allocate(STREAMING_MODEL_VARIABLE_ARENA_SIZE);
|
||||
if (this->var_arena_ == nullptr) {
|
||||
@@ -53,6 +45,26 @@ bool StreamingModel::load_model_() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Probe for the actual required tensor arena size if not yet determined
|
||||
if (!this->tensor_arena_size_probed_) {
|
||||
size_t probed_size = this->probe_arena_size_();
|
||||
if (probed_size > 0) {
|
||||
ESP_LOGD(TAG, "Probed tensor arena size: %zu bytes", probed_size);
|
||||
this->tensor_arena_size_ = probed_size;
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Arena size probe failed, using manifest size: %zu bytes", this->tensor_arena_size_);
|
||||
}
|
||||
this->tensor_arena_size_probed_ = true;
|
||||
}
|
||||
|
||||
if (this->tensor_arena_ == nullptr) {
|
||||
this->tensor_arena_ = arena_allocator.allocate(this->tensor_arena_size_);
|
||||
if (this->tensor_arena_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Could not allocate the streaming model's tensor arena.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (this->interpreter_ == nullptr) {
|
||||
this->interpreter_ =
|
||||
make_unique<tflite::MicroInterpreter>(tflite::GetModel(this->model_start_), this->streaming_op_resolver_,
|
||||
@@ -94,6 +106,70 @@ bool StreamingModel::load_model_() {
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t StreamingModel::probe_arena_size_() {
|
||||
RAMAllocator<uint8_t> arena_allocator;
|
||||
|
||||
// Try with the manifest size first, then escalates to 1.5, then 2x if it fails. Different platforms and different
|
||||
// versions of the esp-nn library require different amounts of memory, so the manifest size may not always be correct,
|
||||
// and probing allows us to find the actual required size for the current build and platform. Aligns test sizes to 16
|
||||
// bytes.
|
||||
size_t attempt_sizes[] = {(this->tensor_arena_size_ + 15) & ~15, (this->tensor_arena_size_ * 3 / 2 + 15) & ~15,
|
||||
(this->tensor_arena_size_ * 2 + 15) & ~15};
|
||||
|
||||
for (size_t attempt_size : attempt_sizes) {
|
||||
uint8_t *probe_arena = arena_allocator.allocate(attempt_size);
|
||||
if (probe_arena == nullptr) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verify the model works at all with this arena size
|
||||
auto probe_interpreter = make_unique<tflite::MicroInterpreter>(
|
||||
tflite::GetModel(this->model_start_), this->streaming_op_resolver_, probe_arena, attempt_size, this->mrv_);
|
||||
|
||||
if (probe_interpreter->AllocateTensors() != kTfLiteOk) {
|
||||
probe_interpreter.reset();
|
||||
arena_allocator.deallocate(probe_arena, attempt_size);
|
||||
this->ma_ = tflite::MicroAllocator::Create(this->var_arena_, STREAMING_MODEL_VARIABLE_ARENA_SIZE);
|
||||
this->mrv_ = tflite::MicroResourceVariables::Create(this->ma_, 20);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to shrink the arena. Start with arena_used_bytes() + 16 (rounded to 16-byte alignment).
|
||||
// If that works, use it. Otherwise, try midpoints between that and the full size until one succeeds.
|
||||
size_t lower = (probe_interpreter->arena_used_bytes() + 16 + 15) & ~15;
|
||||
probe_interpreter.reset();
|
||||
this->ma_ = tflite::MicroAllocator::Create(this->var_arena_, STREAMING_MODEL_VARIABLE_ARENA_SIZE);
|
||||
this->mrv_ = tflite::MicroResourceVariables::Create(this->ma_, 20);
|
||||
|
||||
size_t upper = attempt_size;
|
||||
|
||||
while (lower < upper) {
|
||||
auto test_interpreter = make_unique<tflite::MicroInterpreter>(
|
||||
tflite::GetModel(this->model_start_), this->streaming_op_resolver_, probe_arena, lower, this->mrv_);
|
||||
|
||||
bool ok = test_interpreter->AllocateTensors() == kTfLiteOk;
|
||||
|
||||
test_interpreter.reset();
|
||||
this->ma_ = tflite::MicroAllocator::Create(this->var_arena_, STREAMING_MODEL_VARIABLE_ARENA_SIZE);
|
||||
this->mrv_ = tflite::MicroResourceVariables::Create(this->ma_, 20);
|
||||
|
||||
if (ok) {
|
||||
// Found a working size smaller than the full arena
|
||||
upper = lower + 16; // Pad by 16 bytes to be safe for future allocations
|
||||
break;
|
||||
}
|
||||
|
||||
// Try the midpoint between current attempt and full size
|
||||
lower = ((lower + upper) / 2 + 15) & ~15;
|
||||
}
|
||||
|
||||
arena_allocator.deallocate(probe_arena, attempt_size);
|
||||
return upper;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
void StreamingModel::unload_model() {
|
||||
this->interpreter_.reset();
|
||||
|
||||
|
||||
@@ -63,6 +63,10 @@ class StreamingModel {
|
||||
/// @brief Allocates tensor and variable arenas and sets up the model interpreter
|
||||
/// @return True if successful, false otherwise
|
||||
bool load_model_();
|
||||
/// @brief Probes the actual required tensor arena size by trial allocation.
|
||||
/// Tries the manifest size first, then 2x if that fails.
|
||||
/// @return The required arena size rounded up to 16-byte alignment, or 0 on failure.
|
||||
size_t probe_arena_size_();
|
||||
/// @brief Returns true if successfully registered the streaming model's TensorFlow operations
|
||||
bool register_streaming_ops_(tflite::MicroMutableOpResolver<20> &op_resolver);
|
||||
|
||||
@@ -70,6 +74,7 @@ class StreamingModel {
|
||||
|
||||
bool loaded_{false};
|
||||
bool enabled_{true};
|
||||
bool tensor_arena_size_probed_{false};
|
||||
bool unprocessed_probability_status_{false};
|
||||
uint8_t current_stride_step_{0};
|
||||
int16_t ignore_windows_{-MIN_SLICES_BEFORE_DETECTION};
|
||||
|
||||
@@ -28,7 +28,8 @@ void AirConditioner::on_status_change() {
|
||||
if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK &&
|
||||
this->base_.getCapabilities().supportFrostProtectionPreset() && !this->frost_protection_set_) {
|
||||
// Read existing presets (set by codegen), append frost protection, write back
|
||||
const auto &existing = this->get_traits().get_supported_custom_presets();
|
||||
auto traits = this->get_traits();
|
||||
const auto &existing = traits.get_supported_custom_presets();
|
||||
bool found = false;
|
||||
for (const char *p : existing) {
|
||||
if (strcmp(p, Constants::FREEZE_PROTECTION) == 0) {
|
||||
|
||||
@@ -195,7 +195,7 @@ def model_schema(config):
|
||||
"big_endian", "little_endian", lower=True
|
||||
),
|
||||
model.option(CONF_COLOR_DEPTH, 16): cv.one_of(*color_depth, lower=True),
|
||||
model.option(CONF_DRAW_ROUNDING, 2): power_of_two,
|
||||
model.option(CONF_DRAW_ROUNDING, 1): power_of_two,
|
||||
model.option(CONF_PIXEL_MODE, DISPLAY_16BIT): cv.one_of(
|
||||
*pixel_modes, lower=True
|
||||
),
|
||||
@@ -297,9 +297,9 @@ def _final_validate(config):
|
||||
|
||||
buffer_size = color_depth // 8 * width * height // frac
|
||||
# Target a buffer size of 20kB, except for large displays, which shouldn't end up here
|
||||
fraction = min(20000.0, buffer_size // 16) / buffer_size
|
||||
fraction = min(20000.0, buffer_size // 4) / buffer_size
|
||||
config[CONF_BUFFER_SIZE] = 1.0 / next(
|
||||
x for x in range(2, 17) if fraction >= 1 / x
|
||||
(x for x in range(2, 8) if fraction >= 1 / x), 8
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -234,9 +234,9 @@ class MipiSpi : public display::Display,
|
||||
}
|
||||
|
||||
void dump_config() override {
|
||||
internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT, MADCTL,
|
||||
this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, this->cs_,
|
||||
this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE,
|
||||
internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT,
|
||||
(uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_,
|
||||
this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE,
|
||||
HAS_HARDWARE_ROTATION);
|
||||
}
|
||||
|
||||
@@ -305,7 +305,7 @@ class MipiSpi : public display::Display,
|
||||
this->write_command_(BRIGHTNESS, this->brightness_.value());
|
||||
|
||||
// calculate new madctl value from base value adjusted for rotation
|
||||
uint8_t madctl = MADCTL; // lower 8 bits only
|
||||
uint8_t madctl = (uint8_t) MADCTL; // lower 8 bits only
|
||||
constexpr bool use_flips = (MADCTL & MADCTL_FLIP_FLAG) != 0;
|
||||
constexpr uint8_t x_mask = use_flips ? MADCTL_XFLIP : MADCTL_MX;
|
||||
constexpr uint8_t y_mask = use_flips ? MADCTL_YFLIP : MADCTL_MY;
|
||||
@@ -546,13 +546,12 @@ class MipiSpiBuffer : public MipiSpi<BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DIS
|
||||
}
|
||||
// for updates with a small buffer, we repeatedly call the writer_ function, clipping the height to a fraction of
|
||||
// the display height,
|
||||
for (this->start_line_ = 0; this->start_line_ < this->get_height_internal();
|
||||
this->start_line_ += this->get_height_internal() / FRACTION) {
|
||||
auto increment = (this->get_height_internal() / FRACTION / ROUNDING) * ROUNDING;
|
||||
for (this->start_line_ = 0; this->start_line_ < this->get_height_internal(); this->start_line_ = this->end_line_) {
|
||||
#if ESPHOME_LOG_LEVEL == ESPHOME_LOG_LEVEL_VERBOSE
|
||||
auto lap = millis();
|
||||
#endif
|
||||
this->end_line_ =
|
||||
clamp_at_most(this->start_line_ + this->get_height_internal() / FRACTION, this->get_height_internal());
|
||||
this->end_line_ = clamp_at_most(this->start_line_ + increment, this->get_height_internal());
|
||||
if (this->auto_clear_enabled_) {
|
||||
this->clear();
|
||||
}
|
||||
@@ -574,12 +573,13 @@ class MipiSpiBuffer : public MipiSpi<BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DIS
|
||||
// Some chips require that the drawing window be aligned on certain boundaries
|
||||
this->x_low_ = this->x_low_ / ROUNDING * ROUNDING;
|
||||
this->y_low_ = this->y_low_ / ROUNDING * ROUNDING;
|
||||
this->x_high_ = (this->x_high_ + ROUNDING) / ROUNDING * ROUNDING - 1;
|
||||
this->y_high_ = (this->y_high_ + ROUNDING) / ROUNDING * ROUNDING - 1;
|
||||
this->x_high_ = round_buffer(this->x_high_ + 1) - 1;
|
||||
this->y_high_ = clamp_at_most(round_buffer(this->y_high_ + 1) - 1, this->end_line_ - 1);
|
||||
int w = this->x_high_ - this->x_low_ + 1;
|
||||
int h = this->y_high_ - this->y_low_ + 1;
|
||||
this->write_to_display_(this->x_low_, this->y_low_, w, h, this->buffer_, this->x_low_,
|
||||
this->y_low_ - this->start_line_, round_buffer(this->get_width_internal()) - w);
|
||||
this->y_low_ - this->start_line_,
|
||||
round_buffer(this->get_width_internal()) - w - this->x_low_);
|
||||
// invalidate watermarks
|
||||
this->x_low_ = this->get_width_internal();
|
||||
this->y_low_ = this->get_height_internal();
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace esphome::mitsubishi_cn105 {
|
||||
static const char *const TAG = "mitsubishi_cn105.climate";
|
||||
|
||||
static constexpr std::array MODE_MAP{
|
||||
std::pair{MitsubishiCN105::Mode::AUTO, climate::CLIMATE_MODE_AUTO},
|
||||
std::pair{MitsubishiCN105::Mode::AUTO, climate::CLIMATE_MODE_HEAT_COOL},
|
||||
std::pair{MitsubishiCN105::Mode::HEAT, climate::CLIMATE_MODE_HEAT},
|
||||
std::pair{MitsubishiCN105::Mode::DRY, climate::CLIMATE_MODE_DRY},
|
||||
std::pair{MitsubishiCN105::Mode::COOL, climate::CLIMATE_MODE_COOL},
|
||||
@@ -76,23 +76,13 @@ void MitsubishiCN105Climate::loop() {
|
||||
climate::ClimateTraits MitsubishiCN105Climate::traits() {
|
||||
climate::ClimateTraits traits;
|
||||
|
||||
traits.set_supported_modes({
|
||||
climate::CLIMATE_MODE_OFF,
|
||||
climate::CLIMATE_MODE_COOL,
|
||||
climate::CLIMATE_MODE_HEAT,
|
||||
climate::CLIMATE_MODE_DRY,
|
||||
climate::CLIMATE_MODE_FAN_ONLY,
|
||||
climate::CLIMATE_MODE_AUTO,
|
||||
});
|
||||
for (const auto &p : MODE_MAP) {
|
||||
traits.add_supported_mode(p.second);
|
||||
}
|
||||
|
||||
traits.set_supported_fan_modes({
|
||||
climate::CLIMATE_FAN_AUTO,
|
||||
climate::CLIMATE_FAN_QUIET,
|
||||
climate::CLIMATE_FAN_LOW,
|
||||
climate::CLIMATE_FAN_MEDIUM,
|
||||
climate::CLIMATE_FAN_MIDDLE,
|
||||
climate::CLIMATE_FAN_HIGH,
|
||||
});
|
||||
for (const auto &p : FAN_MODE_MAP) {
|
||||
traits.add_supported_fan_mode(p.second);
|
||||
}
|
||||
|
||||
traits.set_visual_min_temperature(16.0f);
|
||||
traits.set_visual_max_temperature(31.0f);
|
||||
|
||||
@@ -36,8 +36,9 @@ bool Nextion::send_command_(const std::string &command) {
|
||||
}
|
||||
|
||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||
if (!this->connection_state_.ignore_is_setup_ && !this->command_pacer_.can_send()) {
|
||||
ESP_LOGN(TAG, "Command spacing: delaying command '%s'", command.c_str());
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
if (!this->connection_state_.ignore_is_setup_ && !this->command_pacer_.can_send(now)) {
|
||||
ESP_LOGN(TAG, "Command spacing: delaying '%s'", command.c_str());
|
||||
return false;
|
||||
}
|
||||
#endif // USE_NEXTION_COMMAND_SPACING
|
||||
@@ -48,6 +49,16 @@ bool Nextion::send_command_(const std::string &command) {
|
||||
const uint8_t to_send[3] = {0xFF, 0xFF, 0xFF};
|
||||
this->write_array(to_send, sizeof(to_send));
|
||||
|
||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||
// Mark sent immediately after writing to UART. The pacer enforces inter-command
|
||||
// spacing from the transmit side. Marking on ACK (0x01) would leave last_command_time_
|
||||
// at zero indefinitely, making can_send() always return true and spacing a no-op.
|
||||
// ignore_is_setup_ commands (setup/init sequence) bypass spacing intentionally.
|
||||
if (!this->connection_state_.ignore_is_setup_) {
|
||||
this->command_pacer_.mark_sent(now);
|
||||
}
|
||||
#endif // USE_NEXTION_COMMAND_SPACING
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -253,11 +264,8 @@ bool Nextion::send_command(const char *command) {
|
||||
if ((!this->is_setup() && !this->connection_state_.ignore_is_setup_) || this->is_sleeping())
|
||||
return false;
|
||||
|
||||
if (this->send_command_(command)) {
|
||||
this->add_no_result_to_queue_("command");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
this->add_no_result_to_queue_with_command_("command", command);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Nextion::send_command_printf(const char *format, ...) {
|
||||
@@ -274,11 +282,8 @@ bool Nextion::send_command_printf(const char *format, ...) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this->send_command_(buffer)) {
|
||||
this->add_no_result_to_queue_("command_printf");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
this->add_no_result_to_queue_with_command_("command_printf", buffer);
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifdef NEXTION_PROTOCOL_LOG
|
||||
@@ -349,25 +354,43 @@ void Nextion::loop() {
|
||||
}
|
||||
|
||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||
// Try to send any pending commands if spacing allows
|
||||
this->process_pending_in_queue_();
|
||||
#ifdef USE_NEXTION_WAVEFORM
|
||||
if (!this->waveform_queue_.empty()) {
|
||||
this->check_pending_waveform_();
|
||||
}
|
||||
#endif // USE_NEXTION_WAVEFORM
|
||||
#endif // USE_NEXTION_COMMAND_SPACING
|
||||
}
|
||||
|
||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||
void Nextion::process_pending_in_queue_() {
|
||||
if (this->nextion_queue_.empty() || !this->command_pacer_.can_send()) {
|
||||
return;
|
||||
}
|
||||
#ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP
|
||||
size_t commands_sent = 0;
|
||||
#endif // USE_NEXTION_MAX_COMMANDS_PER_LOOP
|
||||
|
||||
// Check if first item in queue has a pending command
|
||||
auto *front_item = this->nextion_queue_.front();
|
||||
if (front_item && !front_item->pending_command.empty()) {
|
||||
if (this->send_command_(front_item->pending_command)) {
|
||||
// Command sent successfully, clear the pending command
|
||||
front_item->pending_command.clear();
|
||||
ESP_LOGVV(TAG, "Pending command sent: %s", front_item->component->get_variable_name().c_str());
|
||||
for (auto *item : this->nextion_queue_) {
|
||||
if (item == nullptr || item->pending_command.empty()) {
|
||||
continue; // Already sent, waiting for ACK — skip, don't stop
|
||||
}
|
||||
|
||||
#ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP
|
||||
if (++commands_sent > this->max_commands_per_loop_) {
|
||||
ESP_LOGV(TAG, "Pending cmds: loop limit reached, deferring");
|
||||
break;
|
||||
}
|
||||
#endif // USE_NEXTION_MAX_COMMANDS_PER_LOOP
|
||||
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
if (!this->command_pacer_.can_send(now)) {
|
||||
break; // Spacing not elapsed, stop for this loop iteration
|
||||
}
|
||||
|
||||
if (!this->send_command_(item->pending_command)) {
|
||||
break; // Unexpected send failure, stop
|
||||
}
|
||||
item->pending_command.clear();
|
||||
ESP_LOGVV(TAG, "Pending cmd sent: %s", item->component->get_variable_name().c_str());
|
||||
}
|
||||
}
|
||||
#endif // USE_NEXTION_COMMAND_SPACING
|
||||
@@ -470,10 +493,6 @@ void Nextion::process_nextion_commands_() {
|
||||
this->setup_callback_.call();
|
||||
}
|
||||
}
|
||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||
this->command_pacer_.mark_sent(); // Here is where we should mark the command as sent
|
||||
ESP_LOGN(TAG, "Command spacing: marked command sent");
|
||||
#endif
|
||||
break;
|
||||
case 0x02: // invalid Component ID or name was used
|
||||
ESP_LOGW(TAG, "Invalid component ID/name");
|
||||
@@ -1079,10 +1098,18 @@ void Nextion::add_no_result_to_queue_(const std::string &variable_name) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief
|
||||
* @brief Send a command and enqueue it for response tracking.
|
||||
*
|
||||
* @param variable_name Variable name for the queue
|
||||
* @param command
|
||||
* Callers are responsible for checking is_sleeping() before calling this
|
||||
* method. The sleep guard is deliberately absent here because some callers
|
||||
* (e.g. add_no_result_to_queue_with_ignore_sleep_printf_()) are explicitly
|
||||
* sleep-safe and must bypass it.
|
||||
*
|
||||
* If USE_NEXTION_COMMAND_SPACING is enabled and the pacer is not ready,
|
||||
* the command is saved in the queue entry for retry rather than dropped.
|
||||
*
|
||||
* @param variable_name Name of the variable or component associated with the command.
|
||||
* @param command The raw command string to send.
|
||||
*/
|
||||
void Nextion::add_no_result_to_queue_with_command_(const std::string &variable_name, const std::string &command) {
|
||||
if ((!this->is_setup() && !this->connection_state_.ignore_is_setup_) || command.empty())
|
||||
@@ -1263,9 +1290,22 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) {
|
||||
|
||||
std::string command = "get " + component->get_variable_name_to_send();
|
||||
|
||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||
// Always enqueue first so the response handler is present when the command
|
||||
// is eventually sent. Store the command for retry if spacing blocked it;
|
||||
// process_pending_in_queue_() will transmit it when the pacer allows.
|
||||
nextion_queue->pending_command = command;
|
||||
this->nextion_queue_.push_back(nextion_queue);
|
||||
if (this->send_command_(command)) {
|
||||
nextion_queue->pending_command.clear();
|
||||
}
|
||||
#else // USE_NEXTION_COMMAND_SPACING
|
||||
if (this->send_command_(command)) {
|
||||
this->nextion_queue_.push_back(nextion_queue);
|
||||
} else {
|
||||
delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory)
|
||||
}
|
||||
#endif // USE_NEXTION_COMMAND_SPACING
|
||||
}
|
||||
|
||||
#ifdef USE_NEXTION_WAVEFORM
|
||||
@@ -1309,10 +1349,10 @@ void Nextion::check_pending_waveform_() {
|
||||
char command[24]; // "addt " + uint8 + "," + uint8 + "," + uint8 + null = max 17 chars
|
||||
buf_append_printf(command, sizeof(command), 0, "addt %u,%u,%zu", component->get_component_id(),
|
||||
component->get_wave_channel_id(), buffer_to_send);
|
||||
if (!this->send_command_(command)) {
|
||||
delete nb; // NOLINT(cppcoreguidelines-owning-memory)
|
||||
this->waveform_queue_.pop();
|
||||
}
|
||||
// If spacing or setup state blocks the send, leave the entry at the front
|
||||
// of waveform_queue_ for retry on the next loop iteration via
|
||||
// check_pending_waveform_(). Only pop on a successful send.
|
||||
this->send_command_(command);
|
||||
}
|
||||
#endif // USE_NEXTION_WAVEFORM
|
||||
|
||||
|
||||
@@ -55,15 +55,20 @@ class NextionCommandPacer {
|
||||
uint8_t get_spacing() const { return spacing_ms_; }
|
||||
|
||||
/**
|
||||
* @brief Check if enough time has passed to send next command
|
||||
* @return true if enough time has passed since last command
|
||||
* @brief Check if enough time has passed to send the next command.
|
||||
* @param now Current timestamp in milliseconds (use App.get_loop_component_start_time()
|
||||
* for consistency with the rest of the queue timing).
|
||||
* @return true if the spacing interval has elapsed since the last command was sent.
|
||||
*/
|
||||
bool can_send() const { return (millis() - last_command_time_) >= spacing_ms_; }
|
||||
bool can_send(uint32_t now) const { return (now - last_command_time_) >= spacing_ms_; }
|
||||
|
||||
/**
|
||||
* @brief Mark a command as sent, updating the timing
|
||||
* @brief Record the transmit timestamp for the most recently sent command.
|
||||
* @param now Current timestamp in milliseconds, as returned by
|
||||
* App.get_loop_component_start_time(). Must use the same clock
|
||||
* source as can_send() to avoid unsigned underflow.
|
||||
*/
|
||||
void mark_sent() { last_command_time_ = millis(); }
|
||||
void mark_sent(uint32_t now) { last_command_time_ = now; }
|
||||
|
||||
private:
|
||||
uint8_t spacing_ms_;
|
||||
|
||||
@@ -8,8 +8,11 @@ from typing import Any
|
||||
from esphome import git, yaml_util
|
||||
from esphome.components.substitutions import (
|
||||
ContextVars,
|
||||
ErrList,
|
||||
push_context,
|
||||
raise_first_undefined,
|
||||
resolve_include,
|
||||
resolve_substitutions_block,
|
||||
substitute,
|
||||
)
|
||||
from esphome.components.substitutions.jinja import has_jinja
|
||||
@@ -45,6 +48,18 @@ def is_remote_package(package_config: dict) -> bool:
|
||||
return CONF_URL in package_config
|
||||
|
||||
|
||||
def is_package_definition(value: object) -> bool:
|
||||
"""Returns True if the value looks like a package definition rather than a config fragment.
|
||||
|
||||
Package definitions are IncludeFile objects, git URL shorthand strings, or
|
||||
remote package dicts (containing a ``url:`` key). Config fragments are
|
||||
plain dicts that represent component configuration.
|
||||
"""
|
||||
return isinstance(value, (yaml_util.IncludeFile, str)) or (
|
||||
isinstance(value, dict) and is_remote_package(value)
|
||||
)
|
||||
|
||||
|
||||
def valid_package_contents(package_config: dict) -> dict:
|
||||
"""Validate that a package looks like a plausible ESPHome config fragment.
|
||||
|
||||
@@ -309,20 +324,23 @@ def _walk_packages(
|
||||
return config
|
||||
packages = config[CONF_PACKAGES]
|
||||
|
||||
if not isinstance(packages, (dict, list)):
|
||||
raise cv.Invalid(
|
||||
f"Packages must be a key to value mapping or list, got {type(packages)} instead"
|
||||
)
|
||||
|
||||
with cv.prepend_path(CONF_PACKAGES):
|
||||
if isinstance(packages, yaml_util.IncludeFile):
|
||||
# If the packages key is an IncludeFile, resolve it first before processing.
|
||||
packages, _ = resolve_include(packages, [], context, strict_undefined=False)
|
||||
if not isinstance(packages, (dict, list)):
|
||||
raise cv.Invalid(
|
||||
f"Packages must be a key to value mapping or list, got {type(packages)} instead"
|
||||
)
|
||||
|
||||
if not isinstance(packages, dict):
|
||||
_walk_package_list(packages, callback, context)
|
||||
elif (result := _walk_package_dict(packages, callback, context)) is not None:
|
||||
if not validate_deprecated:
|
||||
if not validate_deprecated or any(
|
||||
is_package_definition(v) for v in packages.values()
|
||||
):
|
||||
raise result
|
||||
# Fallback: treat the dict as a single deprecated package.
|
||||
# Note: this catches *any* cv.Invalid from the callback, which may
|
||||
# mask real validation errors in named package dicts.
|
||||
# This block can be removed once the single-package
|
||||
# deprecation period (2026.7.0) is over.
|
||||
config[CONF_PACKAGES] = [packages]
|
||||
@@ -344,12 +362,19 @@ def _substitute_package_definition(
|
||||
if isinstance(package_config, str) or (
|
||||
isinstance(package_config, dict) and is_remote_package(package_config)
|
||||
):
|
||||
# Collect undefined-variable errors (rather than raising strict) so the
|
||||
# path walked through a remote-package dict is preserved and the user
|
||||
# sees which field (url / path / ref / ...) referenced the undefined
|
||||
# variable.
|
||||
errors: ErrList = []
|
||||
package_config = substitute(
|
||||
item=package_config,
|
||||
path=[],
|
||||
parent_context=context_vars or ContextVars(),
|
||||
strict_undefined=False,
|
||||
errors=errors,
|
||||
)
|
||||
raise_first_undefined(errors, package_config, "package definition")
|
||||
return package_config
|
||||
|
||||
|
||||
@@ -461,6 +486,9 @@ class _PackageProcessor:
|
||||
self, package_config: dict | str, context_vars: ContextVars | None
|
||||
) -> dict:
|
||||
"""Resolve a single package and recurse into any nested packages."""
|
||||
from_remote = isinstance(package_config, dict) and is_remote_package(
|
||||
package_config
|
||||
)
|
||||
package_config = self.resolve_package(package_config, context_vars)
|
||||
self.collect_substitutions(package_config)
|
||||
|
||||
@@ -470,7 +498,18 @@ class _PackageProcessor:
|
||||
# Push context from !include vars on the package root and on the packages key
|
||||
context_vars = push_context(package_config, context_vars)
|
||||
context_vars = push_context(package_config[CONF_PACKAGES], context_vars)
|
||||
return _walk_packages(package_config, self.process_package, context_vars)
|
||||
# Disable the deprecated single-package fallback for remote
|
||||
# packages. _process_remote_package returns dicts with
|
||||
# already-resolved values that is_package_definition cannot
|
||||
# distinguish from config fragments, so the fallback would
|
||||
# always fire and mask real errors with wrong paths
|
||||
# (packages->0 instead of packages-><name>).
|
||||
return _walk_packages(
|
||||
package_config,
|
||||
self.process_package,
|
||||
context_vars,
|
||||
validate_deprecated=not from_remote,
|
||||
)
|
||||
|
||||
|
||||
def do_packages_pass(
|
||||
@@ -487,7 +526,12 @@ def do_packages_pass(
|
||||
if CONF_PACKAGES not in config:
|
||||
return config
|
||||
|
||||
substitutions = UserDict(config.pop(CONF_SUBSTITUTIONS, {}))
|
||||
with cv.prepend_path(CONF_SUBSTITUTIONS):
|
||||
substitutions = UserDict(
|
||||
resolve_substitutions_block(
|
||||
config.pop(CONF_SUBSTITUTIONS, {}), command_line_substitutions
|
||||
)
|
||||
)
|
||||
processor = _PackageProcessor(
|
||||
substitutions, command_line_substitutions, skip_update
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_INPUT,
|
||||
CONF_INTERRUPT_PIN,
|
||||
CONF_INVERTED,
|
||||
CONF_MODE,
|
||||
CONF_NUMBER,
|
||||
@@ -25,7 +26,12 @@ PCA6416AGPIOPin = pca6416a_ns.class_(
|
||||
|
||||
CONF_PCA6416A = "pca6416a"
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema({cv.Required(CONF_ID): cv.declare_id(PCA6416AComponent)})
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(PCA6416AComponent),
|
||||
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(i2c.i2c_device_schema(0x21))
|
||||
)
|
||||
@@ -35,6 +41,8 @@ async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
if interrupt_pin := config.get(CONF_INTERRUPT_PIN):
|
||||
cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin)))
|
||||
|
||||
|
||||
def validate_mode(value):
|
||||
|
||||
@@ -49,11 +49,22 @@ void PCA6416AComponent::setup() {
|
||||
|
||||
ESP_LOGD(TAG, "Initialization complete. Warning: %d, Error: %d", this->status_has_warning(),
|
||||
this->status_has_error());
|
||||
|
||||
if (this->interrupt_pin_ != nullptr) {
|
||||
this->interrupt_pin_->setup();
|
||||
this->interrupt_pin_->attach_interrupt(&PCA6416AComponent::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE);
|
||||
this->set_invalidate_on_read_(false);
|
||||
}
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
void IRAM_ATTR PCA6416AComponent::gpio_intr(PCA6416AComponent *arg) { arg->enable_loop_soon_any_context(); }
|
||||
void PCA6416AComponent::loop() {
|
||||
// Invalidate cache at the start of each loop
|
||||
this->reset_pin_cache_();
|
||||
if (this->interrupt_pin_ != nullptr) {
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
|
||||
void PCA6416AComponent::dump_config() {
|
||||
@@ -62,6 +73,7 @@ void PCA6416AComponent::dump_config() {
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, "PCA6416A:");
|
||||
}
|
||||
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
|
||||
LOG_I2C_DEVICE(this)
|
||||
if (this->is_failed()) {
|
||||
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
|
||||
@@ -101,6 +113,9 @@ void PCA6416AComponent::pin_mode(uint8_t pin, gpio::Flags flags) {
|
||||
this->update_register_(pin, true, pull_dir);
|
||||
this->update_register_(pin, false, pull_en);
|
||||
}
|
||||
if (this->interrupt_pin_ == nullptr) {
|
||||
this->enable_loop();
|
||||
}
|
||||
} else if (flags == (gpio::FLAG_INPUT | gpio::FLAG_PULLUP)) {
|
||||
this->update_register_(pin, true, io_dir);
|
||||
if (has_pullup_) {
|
||||
@@ -109,6 +124,9 @@ void PCA6416AComponent::pin_mode(uint8_t pin, gpio::Flags flags) {
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Your PCA6416A does not support pull-up resistors");
|
||||
}
|
||||
if (this->interrupt_pin_ == nullptr) {
|
||||
this->enable_loop();
|
||||
}
|
||||
} else if (flags == gpio::FLAG_OUTPUT) {
|
||||
this->update_register_(pin, false, io_dir);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ class PCA6416AComponent : public Component,
|
||||
|
||||
void dump_config() override;
|
||||
|
||||
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
|
||||
|
||||
protected:
|
||||
static void IRAM_ATTR gpio_intr(PCA6416AComponent *arg);
|
||||
// Virtual methods from CachedGpioExpander
|
||||
bool digital_read_hw(uint8_t pin) override;
|
||||
bool digital_read_cache(uint8_t pin) override;
|
||||
@@ -43,6 +46,7 @@ class PCA6416AComponent : public Component,
|
||||
esphome::i2c::ErrorCode last_error_;
|
||||
/// Only the PCAL6416A has pull-up resistors
|
||||
bool has_pullup_{false};
|
||||
InternalGPIOPin *interrupt_pin_{nullptr};
|
||||
};
|
||||
|
||||
/// Helper class to expose a PCA6416A pin as an internal input GPIO pin.
|
||||
|
||||
@@ -44,7 +44,7 @@ void PCF85063Component::read_time() {
|
||||
.year = uint16_t(pcf85063_.reg.year + 10u * pcf85063_.reg.year_10 + 2000),
|
||||
};
|
||||
rtc_time.recalc_timestamp_utc(false);
|
||||
if (!rtc_time.is_valid()) {
|
||||
if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) {
|
||||
ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ void PCF8563Component::read_time() {
|
||||
.year = uint16_t(pcf8563_.reg.year + 10u * pcf8563_.reg.year_10 + 2000),
|
||||
};
|
||||
rtc_time.recalc_timestamp_utc(false);
|
||||
if (!rtc_time.is_valid()) {
|
||||
if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) {
|
||||
ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ void QMC5883LComponent::update() {
|
||||
// ROL_PNT in setup and reading 7 bytes starting at the status register.
|
||||
// If status and all three axes are desired, using ROL_PNT saves you 3 bytes.
|
||||
// But simply not reading status saves you 4 bytes always and is much simpler.
|
||||
if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG) {
|
||||
if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE) {
|
||||
err = this->read_register(QMC5883L_REGISTER_STATUS, &status, 1);
|
||||
if (err != i2c::ERROR_OK) {
|
||||
char buf[32];
|
||||
@@ -165,7 +165,7 @@ void QMC5883LComponent::update() {
|
||||
temp = int16_t(raw_temp) * 0.01f;
|
||||
}
|
||||
|
||||
ESP_LOGD(TAG, "Got x=%0.02fµT y=%0.02fµT z=%0.02fµT heading=%0.01f° temperature=%0.01f°C status=%u", x, y, z, heading,
|
||||
ESP_LOGV(TAG, "Got x=%0.02fµT y=%0.02fµT z=%0.02fµT heading=%0.01f° temperature=%0.01f°C status=%u", x, y, z, heading,
|
||||
temp, status);
|
||||
|
||||
if (this->x_sensor_ != nullptr)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
#include <WiFi.h>
|
||||
#include <pico/cyw43_arch.h> // For cyw43_arch_lwip_begin/end (LwIPLock)
|
||||
#elif defined(USE_ETHERNET)
|
||||
#include <LwipEthernet.h> // For ethernet_arch_lwip_begin/end (LwIPLock)
|
||||
#include <lwip_wrap.h> // For LWIPMutex — LwIPLock mirrors its semantics (see below)
|
||||
#include "esphome/components/ethernet/ethernet_component.h"
|
||||
#endif
|
||||
#include <hardware/structs/rosc.h>
|
||||
@@ -43,9 +43,18 @@ IRAM_ATTR InterruptLock::~InterruptLock() { restore_interrupts(state_); }
|
||||
// main loop, corrupting the shared rx_buf_ pbuf chain (use-after-free, pbuf_cat
|
||||
// assertion failures). See esphome#10681.
|
||||
//
|
||||
// WiFi uses cyw43_arch_lwip_begin/end; Ethernet uses ethernet_arch_lwip_begin/end.
|
||||
// Both acquire the async_context recursive mutex to prevent IRQ callbacks from
|
||||
// firing during critical sections.
|
||||
// WiFi uses cyw43_arch_lwip_begin/end.
|
||||
//
|
||||
// For wired Ethernet, taking only the async_context lock is NOT enough. The
|
||||
// W5500 GPIO IRQ path (LwipIntfDev::_irq) checks arduino-pico's `__inLWIP`
|
||||
// counter to decide whether to defer packet processing. If we hold the
|
||||
// async_context lock without bumping `__inLWIP`, an interrupt-driven packet
|
||||
// arrival re-enters lwIP from IRQ context and corrupts pbufs (the `pbuf_cat`
|
||||
// assertion crash on wiznet-w5500-evb-pico). We mirror arduino-pico's
|
||||
// LWIPMutex (cores/rp2040/lwip_wrap.h) exactly: bump `__inLWIP`, take the
|
||||
// lock, and on release re-unmask any GPIO IRQs that were deferred while we
|
||||
// held it. We can't `using LwIPLock = LWIPMutex;` in helpers.h because
|
||||
// pulling lwip_wrap.h there poisons many TUs with lwIP types.
|
||||
//
|
||||
// When neither WiFi nor Ethernet is configured, this is a no-op since
|
||||
// there's no network stack and no lwip callbacks to race with.
|
||||
@@ -53,8 +62,18 @@ IRAM_ATTR InterruptLock::~InterruptLock() { restore_interrupts(state_); }
|
||||
LwIPLock::LwIPLock() { cyw43_arch_lwip_begin(); }
|
||||
LwIPLock::~LwIPLock() { cyw43_arch_lwip_end(); }
|
||||
#elif defined(USE_ETHERNET)
|
||||
LwIPLock::LwIPLock() { ethernet_arch_lwip_begin(); }
|
||||
LwIPLock::~LwIPLock() { ethernet_arch_lwip_end(); }
|
||||
LwIPLock::LwIPLock() {
|
||||
__inLWIP++;
|
||||
ethernet_arch_lwip_begin();
|
||||
}
|
||||
LwIPLock::~LwIPLock() {
|
||||
ethernet_arch_lwip_end();
|
||||
__inLWIP--;
|
||||
if (__needsIRQEN && !__inLWIP) {
|
||||
__needsIRQEN = false;
|
||||
ethernet_arch_lwip_gpio_unmask();
|
||||
}
|
||||
}
|
||||
#else
|
||||
LwIPLock::LwIPLock() {}
|
||||
LwIPLock::~LwIPLock() {}
|
||||
|
||||
@@ -127,9 +127,9 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) {
|
||||
uint32_t pos = this->get_position_(x, y);
|
||||
Color mapped_color = color;
|
||||
this->map_chroma_key(mapped_color);
|
||||
this->buffer_[pos + 0] = mapped_color.r;
|
||||
this->buffer_[pos + 0] = mapped_color.b;
|
||||
this->buffer_[pos + 1] = mapped_color.g;
|
||||
this->buffer_[pos + 2] = mapped_color.b;
|
||||
this->buffer_[pos + 2] = mapped_color.r;
|
||||
if (this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) {
|
||||
this->buffer_[pos + 3] = color.w;
|
||||
}
|
||||
|
||||
@@ -32,40 +32,101 @@ void RuntimeStatsCollector::log_stats_() {
|
||||
" Period stats (last %" PRIu32 "ms): %zu active components",
|
||||
this->log_interval_, count);
|
||||
|
||||
if (count == 0) {
|
||||
return;
|
||||
// Sum component time so we can derive main-loop overhead
|
||||
// (active loop time minus time attributable to component loop()s).
|
||||
// Period sum iterates the active-in-period subset; total sum must iterate
|
||||
// all components since total_active_time_us_ includes iterations where
|
||||
// currently-idle components previously ran.
|
||||
uint64_t period_component_sum_us = 0;
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
period_component_sum_us += sorted[i]->runtime_stats_.period_time_us;
|
||||
}
|
||||
uint64_t total_component_sum_us = 0;
|
||||
for (auto *component : components) {
|
||||
total_component_sum_us += component->runtime_stats_.total_time_us;
|
||||
}
|
||||
|
||||
// Sort by period runtime (descending)
|
||||
std::sort(sorted, sorted + count, compare_period_time);
|
||||
if (count > 0) {
|
||||
// Sort by period runtime (descending)
|
||||
std::sort(sorted, sorted + count, compare_period_time);
|
||||
|
||||
// Log top components by period runtime
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
const auto &stats = sorted[i]->runtime_stats_;
|
||||
ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms",
|
||||
LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.period_count,
|
||||
stats.period_count > 0 ? stats.period_time_us / (float) stats.period_count / 1000.0f : 0.0f,
|
||||
stats.period_max_time_us / 1000.0f, stats.period_time_us / 1000.0f);
|
||||
// Log top components by period runtime
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
const auto &stats = sorted[i]->runtime_stats_;
|
||||
ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms",
|
||||
LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.period_count,
|
||||
stats.period_count > 0 ? stats.period_time_us / (float) stats.period_count / 1000.0f : 0.0f,
|
||||
stats.period_max_time_us / 1000.0f, stats.period_time_us / 1000.0f);
|
||||
}
|
||||
}
|
||||
|
||||
// Main-loop overhead for the period: active wall time minus component time.
|
||||
// active = sum of per-iteration loop time excluding yield/sleep.
|
||||
if (this->period_active_count_ > 0) {
|
||||
uint64_t active = this->period_active_time_us_;
|
||||
uint64_t overhead = active > period_component_sum_us ? active - period_component_sum_us : 0;
|
||||
// Use double for µs→ms conversion so multi-day uptimes (where total
|
||||
// microsecond counters exceed float's ~7-digit mantissa) keep resolution.
|
||||
ESP_LOGI(TAG,
|
||||
" main_loop: iters=%" PRIu64 ", active_avg=%.3fms, active_max=%.2fms, active_total=%.1fms, "
|
||||
"overhead_total=%.1fms",
|
||||
this->period_active_count_,
|
||||
static_cast<double>(active) / static_cast<double>(this->period_active_count_) / 1000.0,
|
||||
static_cast<double>(this->period_active_max_us_) / 1000.0, static_cast<double>(active) / 1000.0,
|
||||
static_cast<double>(overhead) / 1000.0);
|
||||
uint64_t before = this->period_before_time_us_;
|
||||
uint64_t tail = this->period_tail_time_us_;
|
||||
uint64_t accounted = before + tail;
|
||||
uint64_t inter = overhead > accounted ? overhead - accounted : 0;
|
||||
ESP_LOGI(TAG, " main_loop_overhead_section: before=%.1fms, tail=%.1fms, inter_component=%.1fms",
|
||||
static_cast<double>(before) / 1000.0, static_cast<double>(tail) / 1000.0,
|
||||
static_cast<double>(inter) / 1000.0);
|
||||
}
|
||||
|
||||
// Log total stats since boot (only for active components - idle ones haven't changed)
|
||||
ESP_LOGI(TAG, " Total stats (since boot): %zu active components", count);
|
||||
|
||||
// Re-sort by total runtime for all-time stats
|
||||
std::sort(sorted, sorted + count, compare_total_time);
|
||||
if (count > 0) {
|
||||
// Re-sort by total runtime for all-time stats
|
||||
std::sort(sorted, sorted + count, compare_total_time);
|
||||
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
const auto &stats = sorted[i]->runtime_stats_;
|
||||
ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms",
|
||||
LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.total_count,
|
||||
stats.total_count > 0 ? stats.total_time_us / (float) stats.total_count / 1000.0f : 0.0f,
|
||||
stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0);
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
const auto &stats = sorted[i]->runtime_stats_;
|
||||
ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms",
|
||||
LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.total_count,
|
||||
stats.total_count > 0 ? stats.total_time_us / (float) stats.total_count / 1000.0f : 0.0f,
|
||||
stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0);
|
||||
}
|
||||
}
|
||||
|
||||
if (this->total_active_count_ > 0) {
|
||||
uint64_t active = this->total_active_time_us_;
|
||||
uint64_t overhead = active > total_component_sum_us ? active - total_component_sum_us : 0;
|
||||
ESP_LOGI(TAG,
|
||||
" main_loop: iters=%" PRIu64 ", active_avg=%.3fms, active_max=%.2fms, active_total=%.1fms, "
|
||||
"overhead_total=%.1fms",
|
||||
this->total_active_count_,
|
||||
static_cast<double>(active) / static_cast<double>(this->total_active_count_) / 1000.0,
|
||||
static_cast<double>(this->total_active_max_us_) / 1000.0, static_cast<double>(active) / 1000.0,
|
||||
static_cast<double>(overhead) / 1000.0);
|
||||
uint64_t before = this->total_before_time_us_;
|
||||
uint64_t tail = this->total_tail_time_us_;
|
||||
uint64_t accounted = before + tail;
|
||||
uint64_t inter = overhead > accounted ? overhead - accounted : 0;
|
||||
ESP_LOGI(TAG, " main_loop_overhead_section: before=%.1fms, tail=%.1fms, inter_component=%.1fms",
|
||||
static_cast<double>(before) / 1000.0, static_cast<double>(tail) / 1000.0,
|
||||
static_cast<double>(inter) / 1000.0);
|
||||
}
|
||||
|
||||
// Reset period stats
|
||||
for (auto *component : components) {
|
||||
component->runtime_stats_.reset_period();
|
||||
}
|
||||
this->period_active_count_ = 0;
|
||||
this->period_active_time_us_ = 0;
|
||||
this->period_active_max_us_ = 0;
|
||||
this->period_before_time_us_ = 0;
|
||||
this->period_tail_time_us_ = 0;
|
||||
}
|
||||
|
||||
bool RuntimeStatsCollector::compare_period_time(Component *a, Component *b) {
|
||||
|
||||
@@ -29,6 +29,31 @@ class RuntimeStatsCollector {
|
||||
// Process any pending stats printing (should be called after component loop)
|
||||
void process_pending_stats(uint32_t current_time);
|
||||
|
||||
// Record the wall time of one main loop iteration excluding the yield/sleep.
|
||||
// Called once per loop from Application::loop().
|
||||
// active_us = total time between loop start and just before yield.
|
||||
// before_us = time spent in before_loop_tasks_ (scheduler + ISR enable_loop).
|
||||
// tail_us = time spent in after_loop_tasks_ + the trailing record/stats prefix.
|
||||
// Residual overhead at log time = active − Σ(component) − before − tail,
|
||||
// which captures per-iteration inter-component bookkeeping (set_current_component,
|
||||
// WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls,
|
||||
// the for-loop itself).
|
||||
void record_loop_active(uint32_t active_us, uint32_t before_us, uint32_t tail_us) {
|
||||
this->period_active_count_++;
|
||||
this->period_active_time_us_ += active_us;
|
||||
if (active_us > this->period_active_max_us_)
|
||||
this->period_active_max_us_ = active_us;
|
||||
this->total_active_count_++;
|
||||
this->total_active_time_us_ += active_us;
|
||||
if (active_us > this->total_active_max_us_)
|
||||
this->total_active_max_us_ = active_us;
|
||||
|
||||
this->period_before_time_us_ += before_us;
|
||||
this->total_before_time_us_ += before_us;
|
||||
this->period_tail_time_us_ += tail_us;
|
||||
this->total_tail_time_us_ += tail_us;
|
||||
}
|
||||
|
||||
protected:
|
||||
void log_stats_();
|
||||
// Static comparators — member functions have friend access, lambdas do not
|
||||
@@ -37,6 +62,22 @@ class RuntimeStatsCollector {
|
||||
|
||||
uint32_t log_interval_;
|
||||
uint32_t next_log_time_{0};
|
||||
|
||||
// Main loop active-time stats (wall time per iteration, excluding yield/sleep).
|
||||
// Counters are uint64_t — at sub-millisecond loop times a uint32_t can wrap in
|
||||
// a few weeks of uptime, which is well within ESPHome device lifetimes.
|
||||
uint64_t period_active_count_{0};
|
||||
uint64_t period_active_time_us_{0};
|
||||
uint32_t period_active_max_us_{0};
|
||||
uint64_t total_active_count_{0};
|
||||
uint64_t total_active_time_us_{0};
|
||||
uint32_t total_active_max_us_{0};
|
||||
|
||||
// Split of overhead sections — accumulated per iteration.
|
||||
uint64_t period_before_time_us_{0};
|
||||
uint64_t total_before_time_us_{0};
|
||||
uint64_t period_tail_time_us_{0};
|
||||
uint64_t total_tail_time_us_{0};
|
||||
};
|
||||
|
||||
} // namespace runtime_stats
|
||||
|
||||
@@ -81,7 +81,7 @@ void RX8130Component::read_time() {
|
||||
.year = static_cast<uint16_t>(bcd2dec(date[6]) + 2000),
|
||||
};
|
||||
rtc_time.recalc_timestamp_utc(false);
|
||||
if (!rtc_time.is_valid()) {
|
||||
if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) {
|
||||
ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -45,8 +45,8 @@ MODELS = {
|
||||
presets={
|
||||
CONF_HEIGHT: 240,
|
||||
CONF_WIDTH: 135,
|
||||
CONF_OFFSET_HEIGHT: 52,
|
||||
CONF_OFFSET_WIDTH: 40,
|
||||
CONF_OFFSET_HEIGHT: 40,
|
||||
CONF_OFFSET_WIDTH: 52,
|
||||
CONF_CS_PIN: "GPIO5",
|
||||
CONF_DC_PIN: "GPIO16",
|
||||
CONF_RESET_PIN: "GPIO23",
|
||||
@@ -68,8 +68,8 @@ MODELS = {
|
||||
presets={
|
||||
CONF_HEIGHT: 280,
|
||||
CONF_WIDTH: 240,
|
||||
CONF_OFFSET_HEIGHT: 0,
|
||||
CONF_OFFSET_WIDTH: 20,
|
||||
CONF_OFFSET_HEIGHT: 20,
|
||||
CONF_OFFSET_WIDTH: 0,
|
||||
}
|
||||
),
|
||||
"ADAFRUIT_S2_TFT_FEATHER_240X135": model_spec(
|
||||
@@ -77,8 +77,8 @@ MODELS = {
|
||||
presets={
|
||||
CONF_HEIGHT: 240,
|
||||
CONF_WIDTH: 135,
|
||||
CONF_OFFSET_HEIGHT: 52,
|
||||
CONF_OFFSET_WIDTH: 40,
|
||||
CONF_OFFSET_HEIGHT: 40,
|
||||
CONF_OFFSET_WIDTH: 52,
|
||||
CONF_CS_PIN: "GPIO7",
|
||||
CONF_DC_PIN: "GPIO39",
|
||||
CONF_RESET_PIN: "GPIO40",
|
||||
@@ -89,8 +89,8 @@ MODELS = {
|
||||
presets={
|
||||
CONF_HEIGHT: 320,
|
||||
CONF_WIDTH: 170,
|
||||
CONF_OFFSET_HEIGHT: 35,
|
||||
CONF_OFFSET_WIDTH: 0,
|
||||
CONF_OFFSET_HEIGHT: 0,
|
||||
CONF_OFFSET_WIDTH: 35,
|
||||
CONF_ROTATION: 270,
|
||||
CONF_CS_PIN: "GPIO10",
|
||||
CONF_DC_PIN: "GPIO13",
|
||||
@@ -102,8 +102,8 @@ MODELS = {
|
||||
presets={
|
||||
CONF_HEIGHT: 320,
|
||||
CONF_WIDTH: 172,
|
||||
CONF_OFFSET_HEIGHT: 34,
|
||||
CONF_OFFSET_WIDTH: 0,
|
||||
CONF_OFFSET_HEIGHT: 0,
|
||||
CONF_OFFSET_WIDTH: 34,
|
||||
CONF_ROTATION: 90,
|
||||
CONF_CS_PIN: "GPIO21",
|
||||
CONF_DC_PIN: "GPIO22",
|
||||
|
||||
@@ -30,6 +30,56 @@ ErrList = list[tuple[UndefinedError, SubstitutionPath, Any]]
|
||||
jinja = Jinja()
|
||||
|
||||
|
||||
def raise_first_undefined(
|
||||
errors: ErrList,
|
||||
source: Any,
|
||||
context_label: str,
|
||||
) -> None:
|
||||
"""If *errors* is non-empty, raise ``cv.Invalid`` for the first undefined variable.
|
||||
|
||||
The raised error names the missing variable, the path walked into *source*
|
||||
(for nested dicts, e.g. ``url`` or ``ref``), and the YAML source location
|
||||
when *source* carries one. Only the first error is surfaced; the user will
|
||||
re-run after fixing it and any remaining undefined variables will be
|
||||
reported then.
|
||||
|
||||
``context_label`` is the noun describing where the undefined variable
|
||||
appeared (e.g. ``"package definition"``).
|
||||
"""
|
||||
if not errors:
|
||||
return
|
||||
err, err_path, err_value = errors[0]
|
||||
if len(errors) > 1:
|
||||
# Log any further undefined variables so debug-level output covers
|
||||
# the full set, even though only the first is surfaced to the user.
|
||||
extras = ", ".join(
|
||||
f"{e.message} at '{'->'.join(str(p) for p in p_path)}'"
|
||||
for e, p_path, _ in errors[1:]
|
||||
)
|
||||
_LOGGER.debug("Additional undefined variables in %s: %s", context_label, extras)
|
||||
# Prefer the location of the offending scalar (e.g. the `url:` value) over
|
||||
# the enclosing package-definition dict so the message points at the exact
|
||||
# line/column that carries the undefined variable.
|
||||
location_node = (
|
||||
err_value
|
||||
if isinstance(err_value, ESPHomeDataBase) and err_value.esp_range is not None
|
||||
else source
|
||||
)
|
||||
location = ""
|
||||
if (
|
||||
isinstance(location_node, ESPHomeDataBase)
|
||||
and location_node.esp_range is not None
|
||||
):
|
||||
mark = location_node.esp_range.start_mark
|
||||
# DocumentLocation.line/column are 0-based (from the YAML Mark). Render
|
||||
# as 1-based to match config.line_info() and editor line numbering.
|
||||
location = f" (in {mark.document} {mark.line + 1}:{mark.column + 1})"
|
||||
field = f" at '{'->'.join(str(p) for p in err_path)}'" if err_path else ""
|
||||
raise cv.Invalid(
|
||||
f"Undefined variable in {context_label}{field}: {err.message}{location}"
|
||||
)
|
||||
|
||||
|
||||
def validate_substitution_key(value: Any) -> str:
|
||||
"""Validate and normalize a substitution key, stripping a leading ``$`` if present."""
|
||||
value = cv.string(value)
|
||||
@@ -414,6 +464,34 @@ def _warn_unresolved_variables(errors: ErrList) -> None:
|
||||
)
|
||||
|
||||
|
||||
def resolve_substitutions_block(
|
||||
substitutions: Any,
|
||||
command_line_substitutions: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve a deferred ``substitutions: !include file.yaml`` and validate the shape.
|
||||
|
||||
The caller is responsible for wrapping the call in
|
||||
``cv.prepend_path(CONF_SUBSTITUTIONS)`` for error reporting.
|
||||
``command_line_substitutions`` seeds the filename context so
|
||||
``substitutions: !include ${var}.yaml`` can reference CLI-provided vars.
|
||||
"""
|
||||
if isinstance(substitutions, IncludeFile):
|
||||
# Single-shot resolution — matches ``_walk_packages`` for the
|
||||
# ``packages: !include`` entry point. Chained includes (an include that
|
||||
# itself loads another ``!include`` at the top level) are not supported.
|
||||
substitutions, _ = resolve_include(
|
||||
substitutions,
|
||||
[],
|
||||
ContextVars(command_line_substitutions or {}),
|
||||
strict_undefined=False,
|
||||
)
|
||||
if not isinstance(substitutions, dict):
|
||||
raise cv.Invalid(
|
||||
f"Substitutions must be a key to value mapping, got {type(substitutions)}"
|
||||
)
|
||||
return substitutions
|
||||
|
||||
|
||||
def do_substitution_pass(
|
||||
config: OrderedDict, command_line_substitutions: dict[str, Any] | None = None
|
||||
) -> OrderedDict:
|
||||
@@ -429,10 +507,9 @@ def do_substitution_pass(
|
||||
# Use merge_dicts_ordered to preserve OrderedDict type for move_to_end()
|
||||
substitutions = config.pop(CONF_SUBSTITUTIONS, {})
|
||||
with cv.prepend_path(CONF_SUBSTITUTIONS):
|
||||
if not isinstance(substitutions, dict):
|
||||
raise cv.Invalid(
|
||||
f"Substitutions must be a key to value mapping, got {type(substitutions)}"
|
||||
)
|
||||
substitutions = resolve_substitutions_block(
|
||||
substitutions, command_line_substitutions
|
||||
)
|
||||
substitutions = merge_dicts_ordered(
|
||||
substitutions, command_line_substitutions or {}
|
||||
)
|
||||
|
||||
@@ -200,11 +200,11 @@ CONFIG_SCHEMA = (
|
||||
cv.hex_int, cv.Range(min=0, max=0xFFFF)
|
||||
),
|
||||
cv.Optional(CONF_DEVIATION, default="5kHz"): cv.All(
|
||||
cv.frequency, cv.float_range(min=0, max=100000)
|
||||
cv.frequency, cv.int_range(min=0, max=100000)
|
||||
),
|
||||
cv.Required(CONF_DIO1_PIN): pins.gpio_input_pin_schema,
|
||||
cv.Required(CONF_FREQUENCY): cv.All(
|
||||
cv.frequency, cv.float_range(min=137.0e6, max=1020.0e6)
|
||||
cv.frequency, cv.int_range(min=int(137e6), max=int(1020e6))
|
||||
),
|
||||
cv.Required(CONF_HW_VERSION): cv.one_of(
|
||||
"sx1261", "sx1262", "sx1268", "llcc68", lower=True
|
||||
|
||||
@@ -104,11 +104,17 @@ void SX126x::write_register_(uint16_t reg, uint8_t *data, uint8_t size) {
|
||||
delayMicroseconds(SWITCHING_DELAY_US);
|
||||
}
|
||||
|
||||
void IRAM_ATTR SX126x::gpio_intr(SX126x *arg) { arg->enable_loop_soon_any_context(); }
|
||||
|
||||
void SX126x::setup() {
|
||||
// setup pins
|
||||
this->busy_pin_->setup();
|
||||
this->rst_pin_->setup();
|
||||
this->dio1_pin_->setup();
|
||||
if (this->dio1_pin_->is_internal()) {
|
||||
static_cast<InternalGPIOPin *>(this->dio1_pin_)
|
||||
->attach_interrupt(&SX126x::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE);
|
||||
}
|
||||
|
||||
// start spi
|
||||
this->spi_setup();
|
||||
@@ -348,6 +354,9 @@ void SX126x::call_listeners_(const std::vector<uint8_t> &packet, float rssi, flo
|
||||
}
|
||||
|
||||
void SX126x::loop() {
|
||||
if (this->dio1_pin_->is_internal()) {
|
||||
this->disable_loop();
|
||||
}
|
||||
if (!this->dio1_pin_->digital_read()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "esphome/components/spi/spi.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "sx126x_reg.h"
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
@@ -100,6 +101,7 @@ class SX126x : public Component,
|
||||
Trigger<std::vector<uint8_t>, float, float> *get_packet_trigger() { return &this->packet_trigger_; }
|
||||
|
||||
protected:
|
||||
static void IRAM_ATTR gpio_intr(SX126x *arg);
|
||||
void configure_fsk_ook_();
|
||||
void configure_lora_();
|
||||
void set_packet_params_(uint8_t payload_length);
|
||||
|
||||
@@ -197,11 +197,11 @@ CONFIG_SCHEMA = (
|
||||
cv.Optional(CONF_CODING_RATE, default="CR_4_5"): cv.enum(CODING_RATE),
|
||||
cv.Optional(CONF_CRC_ENABLE, default=False): cv.boolean,
|
||||
cv.Optional(CONF_DEVIATION, default="5kHz"): cv.All(
|
||||
cv.frequency, cv.float_range(min=0, max=100000)
|
||||
cv.frequency, cv.int_range(min=0, max=100000)
|
||||
),
|
||||
cv.Optional(CONF_DIO0_PIN): pins.internal_gpio_input_pin_schema,
|
||||
cv.Required(CONF_FREQUENCY): cv.All(
|
||||
cv.frequency, cv.float_range(min=137.0e6, max=1020.0e6)
|
||||
cv.frequency, cv.int_range(min=int(137e6), max=int(1020e6))
|
||||
),
|
||||
cv.Required(CONF_MODULATION): cv.enum(MOD),
|
||||
cv.Optional(CONF_ON_PACKET): automation.validate_automation(single=True),
|
||||
|
||||
@@ -53,6 +53,8 @@ void SX127x::write_fifo_(const std::vector<uint8_t> &packet) {
|
||||
this->disable();
|
||||
}
|
||||
|
||||
void IRAM_ATTR SX127x::gpio_intr(SX127x *arg) { arg->enable_loop_soon_any_context(); }
|
||||
|
||||
void SX127x::setup() {
|
||||
// setup reset
|
||||
this->rst_pin_->setup();
|
||||
@@ -60,6 +62,7 @@ void SX127x::setup() {
|
||||
// setup dio0
|
||||
if (this->dio0_pin_) {
|
||||
this->dio0_pin_->setup();
|
||||
this->dio0_pin_->attach_interrupt(&SX127x::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE);
|
||||
}
|
||||
|
||||
// start spi
|
||||
@@ -313,6 +316,7 @@ void SX127x::call_listeners_(const std::vector<uint8_t> &packet, float rssi, flo
|
||||
}
|
||||
|
||||
void SX127x::loop() {
|
||||
this->disable_loop();
|
||||
if (this->dio0_pin_ == nullptr || !this->dio0_pin_->digital_read()) {
|
||||
return;
|
||||
}
|
||||
@@ -383,7 +387,7 @@ void SX127x::set_mode_(uint8_t modulation, uint8_t mode) {
|
||||
if (millis() - start > 20) {
|
||||
ESP_LOGE(TAG, "Set mode failure");
|
||||
this->mark_failed();
|
||||
break;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "esphome/components/spi/spi.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include <vector>
|
||||
|
||||
namespace esphome {
|
||||
@@ -86,6 +87,7 @@ class SX127x : public Component,
|
||||
Trigger<std::vector<uint8_t>, float, float> *get_packet_trigger() { return &this->packet_trigger_; }
|
||||
|
||||
protected:
|
||||
static void IRAM_ATTR gpio_intr(SX127x *arg);
|
||||
void configure_fsk_ook_();
|
||||
void configure_lora_();
|
||||
void set_mode_(uint8_t modulation, uint8_t mode);
|
||||
|
||||
@@ -5,6 +5,7 @@ import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_INPUT,
|
||||
CONF_INTERRUPT_PIN,
|
||||
CONF_INVERTED,
|
||||
CONF_MODE,
|
||||
CONF_NUMBER,
|
||||
@@ -27,6 +28,7 @@ CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(TCA9555Component),
|
||||
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
@@ -38,6 +40,8 @@ async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
if interrupt_pin := config.get(CONF_INTERRUPT_PIN):
|
||||
cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin)))
|
||||
|
||||
|
||||
def validate_mode(value):
|
||||
|
||||
@@ -24,9 +24,18 @@ void TCA9555Component::setup() {
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this->interrupt_pin_ != nullptr) {
|
||||
this->interrupt_pin_->setup();
|
||||
this->interrupt_pin_->attach_interrupt(&TCA9555Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE);
|
||||
this->set_invalidate_on_read_(false);
|
||||
}
|
||||
this->disable_loop();
|
||||
}
|
||||
void IRAM_ATTR TCA9555Component::gpio_intr(TCA9555Component *arg) { arg->enable_loop_soon_any_context(); }
|
||||
void TCA9555Component::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "TCA9555:");
|
||||
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
|
||||
LOG_I2C_DEVICE(this)
|
||||
if (this->is_failed()) {
|
||||
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
|
||||
@@ -36,6 +45,9 @@ void TCA9555Component::pin_mode(uint8_t pin, gpio::Flags flags) {
|
||||
if (flags == gpio::FLAG_INPUT) {
|
||||
// Set mode mask bit
|
||||
this->mode_mask_ |= 1 << pin;
|
||||
if (this->interrupt_pin_ == nullptr) {
|
||||
this->enable_loop();
|
||||
}
|
||||
} else if (flags == gpio::FLAG_OUTPUT) {
|
||||
// Clear mode mask bit
|
||||
this->mode_mask_ &= ~(1 << pin);
|
||||
@@ -43,7 +55,12 @@ void TCA9555Component::pin_mode(uint8_t pin, gpio::Flags flags) {
|
||||
// Write GPIO to enable input mode
|
||||
this->write_gpio_modes_();
|
||||
}
|
||||
void TCA9555Component::loop() { this->reset_pin_cache_(); }
|
||||
void TCA9555Component::loop() {
|
||||
this->reset_pin_cache_();
|
||||
if (this->interrupt_pin_ != nullptr) {
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
|
||||
bool TCA9555Component::read_gpio_outputs_() {
|
||||
if (this->is_failed())
|
||||
|
||||
@@ -24,7 +24,10 @@ class TCA9555Component : public Component,
|
||||
|
||||
void loop() override;
|
||||
|
||||
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
|
||||
|
||||
protected:
|
||||
static void IRAM_ATTR gpio_intr(TCA9555Component *arg);
|
||||
bool digital_read_hw(uint8_t pin) override;
|
||||
bool digital_read_cache(uint8_t pin) override;
|
||||
void digital_write_hw(uint8_t pin, bool value) override;
|
||||
@@ -39,6 +42,8 @@ class TCA9555Component : public Component,
|
||||
bool read_gpio_modes_();
|
||||
bool write_gpio_modes_();
|
||||
bool read_gpio_outputs_();
|
||||
|
||||
InternalGPIOPin *interrupt_pin_{nullptr};
|
||||
};
|
||||
|
||||
/// Helper class to expose a TCA9555 pin as an internal input GPIO pin.
|
||||
|
||||
@@ -315,7 +315,7 @@ void TCS34725Component::set_integration_time(TCS34725IntegrationTime integration
|
||||
my_integration_time_regval = integration_time;
|
||||
this->integration_time_auto_ = false;
|
||||
}
|
||||
this->integration_time_ = (256.f - my_integration_time_regval) * 2.4f;
|
||||
this->integration_time_ = (256.f - (float) my_integration_time_regval) * 2.4f;
|
||||
ESP_LOGI(TAG, "TCS34725I Integration time set to: %.1fms", this->integration_time_);
|
||||
}
|
||||
void TCS34725Component::set_gain(TCS34725Gain gain) {
|
||||
|
||||
@@ -114,7 +114,25 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf
|
||||
uint8_t *data, size_t len, bool final) {
|
||||
ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_OK;
|
||||
|
||||
if (index == 0 && !this->ota_backend_) {
|
||||
// First byte of a new upload: index==0 with actual data. (web_server_idf
|
||||
// fires a separate start-marker call with data==nullptr/len==0 before the
|
||||
// first real chunk; gate on len>0 so we only trigger once per upload.)
|
||||
if (index == 0 && len > 0) {
|
||||
// If a previous upload was interrupted (e.g. client closed the tab, TCP
|
||||
// reset) the backend from that session may still be open. Tear it down
|
||||
// so flash state doesn't get concatenated with the new image (which can
|
||||
// produce a technically-valid-sized but corrupted firmware that bricks
|
||||
// the device once it reboots).
|
||||
if (this->ota_backend_) {
|
||||
ESP_LOGW(TAG, "New OTA upload received while previous session was still open; aborting previous session");
|
||||
this->ota_backend_->abort();
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
// Notify listeners that the previous session was aborted before the new one starts.
|
||||
this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0);
|
||||
#endif
|
||||
this->ota_backend_.reset();
|
||||
}
|
||||
|
||||
// Initialize OTA on first call
|
||||
this->ota_init_(filename.c_str());
|
||||
|
||||
|
||||
@@ -943,7 +943,26 @@ def time_period_in_minutes_(value):
|
||||
def update_interval(value):
|
||||
if value == "never":
|
||||
return TimePeriodMilliseconds(milliseconds=SCHEDULER_DONT_RUN)
|
||||
return positive_time_period_milliseconds(value)
|
||||
result = positive_time_period_milliseconds(value)
|
||||
# 0ms was historically (mis)used as a pseudo-loop() mechanism for
|
||||
# PollingComponents. Under the hood it calls set_interval(0), which
|
||||
# causes Scheduler::call() to spin (WDT reset in the field). Coerce
|
||||
# to 1ms so existing configs keep working at ~1kHz instead of
|
||||
# spinning. Don't hard-fail so configs don't break on upgrade;
|
||||
# authors should migrate to HighFrequencyLoopRequester (C++) for
|
||||
# true run-every-loop behaviour.
|
||||
if result.total_milliseconds == 0:
|
||||
_LOGGER.warning(
|
||||
"update_interval of 0ms is not supported - coercing to 1ms. "
|
||||
"A literal 0ms schedule would spin the main loop (the scheduled "
|
||||
"item would always be due, so the scheduler would never yield "
|
||||
"back) and trigger a watchdog reset. Set update_interval to a "
|
||||
"non-zero value such as 1ms or higher. (Custom C++ components "
|
||||
"that need true run-every-loop behaviour should override loop() "
|
||||
"with HighFrequencyLoopRequester instead.)"
|
||||
)
|
||||
return TimePeriodMilliseconds(milliseconds=1)
|
||||
return result
|
||||
|
||||
|
||||
time_period = Any(time_period_str_unit, time_period_str_colon, time_period_dict)
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
|
||||
from esphome.enum import StrEnum
|
||||
|
||||
__version__ = "2026.4.0b1"
|
||||
__version__ = "2026.4.1"
|
||||
|
||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||
|
||||
@@ -85,8 +85,12 @@ void Application::setup() {
|
||||
if (component->can_proceed())
|
||||
continue;
|
||||
|
||||
// Force the status LED to blink WARNING while we wait for a slow
|
||||
// component to come up. Cleared after setup() finishes if no real
|
||||
// component has warning set.
|
||||
this->app_state_ |= STATUS_LED_WARNING;
|
||||
|
||||
do {
|
||||
uint8_t new_app_state = STATUS_LED_WARNING;
|
||||
uint32_t now = millis();
|
||||
|
||||
// Process pending loop enables to handle GPIO interrupts during setup
|
||||
@@ -96,17 +100,26 @@ void Application::setup() {
|
||||
// Update loop_component_start_time_ right before calling each component
|
||||
this->loop_component_start_time_ = millis();
|
||||
this->components_[j]->call();
|
||||
new_app_state |= this->components_[j]->get_component_state();
|
||||
this->app_state_ |= new_app_state;
|
||||
this->feed_wdt();
|
||||
}
|
||||
|
||||
this->after_loop_tasks_();
|
||||
this->app_state_ = new_app_state;
|
||||
yield();
|
||||
} while (!component->can_proceed() && !component->is_failed());
|
||||
}
|
||||
|
||||
// Setup is complete. Reconcile STATUS_LED_WARNING: the slow-setup path
|
||||
// above may have forced it on, and any status_clear_warning() calls
|
||||
// from components during setup were intentional no-ops (gated by
|
||||
// APP_STATE_SETUP_COMPLETE). Walk components once here to pick up the
|
||||
// real state. STATUS_LED_ERROR is never artificially forced, so its
|
||||
// clear path always works and needs no reconciliation. Finally, set
|
||||
// APP_STATE_SETUP_COMPLETE so subsequent warning clears go through
|
||||
// the normal walk-and-clear path.
|
||||
if (!this->any_component_has_status_flag_(STATUS_LED_WARNING))
|
||||
this->app_state_ &= ~STATUS_LED_WARNING;
|
||||
this->app_state_ |= APP_STATE_SETUP_COMPLETE;
|
||||
|
||||
ESP_LOGI(TAG, "setup() finished successfully!");
|
||||
|
||||
#ifdef USE_SETUP_PRIORITY_OVERRIDE
|
||||
@@ -196,21 +209,40 @@ void Application::process_dump_config_() {
|
||||
this->dump_config_at_++;
|
||||
}
|
||||
|
||||
void HOT Application::feed_wdt(uint32_t time) {
|
||||
static uint32_t last_feed = 0;
|
||||
// Use provided time if available, otherwise get current time
|
||||
uint32_t now = time ? time : millis();
|
||||
// Compare in milliseconds (3ms threshold)
|
||||
if (now - last_feed > 3) {
|
||||
arch_feed_wdt();
|
||||
last_feed = now;
|
||||
#ifdef USE_STATUS_LED
|
||||
if (status_led::global_status_led != nullptr) {
|
||||
status_led::global_status_led->call();
|
||||
}
|
||||
#endif
|
||||
void Application::feed_wdt() {
|
||||
// Cold entry: callers without a millis() timestamp in hand. Fetches the
|
||||
// time and takes the same rate-limit path as feed_wdt_with_time().
|
||||
uint32_t now = millis();
|
||||
if (now - this->last_wdt_feed_ > WDT_FEED_INTERVAL_MS) {
|
||||
this->feed_wdt_slow_(now);
|
||||
}
|
||||
}
|
||||
|
||||
void HOT Application::feed_wdt_slow_(uint32_t time) {
|
||||
// Callers (both feed_wdt() and feed_wdt_with_time()) have already
|
||||
// confirmed the WDT_FEED_INTERVAL_MS rate limit was exceeded.
|
||||
arch_feed_wdt();
|
||||
this->last_wdt_feed_ = time;
|
||||
#ifdef USE_STATUS_LED
|
||||
if (status_led::global_status_led != nullptr) {
|
||||
status_led::global_status_led->call();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
bool Application::any_component_has_status_flag_(uint8_t flag) const {
|
||||
// Walk all components (not just looping ones) so non-looping components'
|
||||
// status bits are respected. Only called from the slow-path clear helpers
|
||||
// (status_clear_warning_slow_path_ / status_clear_error_slow_path_) on an
|
||||
// actual set→clear transition, so walking O(N) here is paid once per
|
||||
// transition — not once per loop iteration.
|
||||
for (auto *component : this->components_) {
|
||||
if ((component->get_component_state() & flag) != 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void Application::reboot() {
|
||||
ESP_LOGI(TAG, "Forcing a reboot");
|
||||
for (auto &component : std::ranges::reverse_view(this->components_)) {
|
||||
@@ -299,7 +331,7 @@ void Application::teardown_components(uint32_t timeout_ms) {
|
||||
|
||||
while (pending_count > 0 && (now - start_time) < timeout_ms) {
|
||||
// Feed watchdog during teardown to prevent triggering
|
||||
this->feed_wdt(now);
|
||||
this->feed_wdt_with_time(now);
|
||||
|
||||
// Process components and compact the array, keeping only those still pending
|
||||
size_t still_pending = 0;
|
||||
|
||||
+82
-17
@@ -385,7 +385,24 @@ class Application {
|
||||
|
||||
void schedule_dump_config() { this->dump_config_at_ = 0; }
|
||||
|
||||
void feed_wdt(uint32_t time = 0);
|
||||
/// Minimum interval between real arch_feed_wdt() calls. Chosen to keep the
|
||||
/// rate of HAL pokes low while still being small enough that any plausible
|
||||
/// watchdog timeout (seconds) has orders of magnitude of safety margin.
|
||||
static constexpr uint32_t WDT_FEED_INTERVAL_MS = 3;
|
||||
|
||||
/// Feed the task watchdog. Cold entry — callers without a millis()
|
||||
/// timestamp in hand. Out of line to keep call sites tiny.
|
||||
void feed_wdt();
|
||||
|
||||
/// Feed the task watchdog, hot entry. Callers that already have a
|
||||
/// millis() timestamp pay only a load + sub + branch on the common
|
||||
/// (no-op) path. The actual arch feed + status LED update live in
|
||||
/// feed_wdt_slow_.
|
||||
void ESPHOME_ALWAYS_INLINE feed_wdt_with_time(uint32_t time) {
|
||||
if (static_cast<uint32_t>(time - this->last_wdt_feed_) > WDT_FEED_INTERVAL_MS) [[unlikely]] {
|
||||
this->feed_wdt_slow_(time);
|
||||
}
|
||||
}
|
||||
|
||||
void reboot();
|
||||
|
||||
@@ -401,7 +418,18 @@ class Application {
|
||||
*/
|
||||
void teardown_components(uint32_t timeout_ms);
|
||||
|
||||
uint8_t get_app_state() const { return this->app_state_; }
|
||||
/// Return the public app state status bits (STATUS_LED_* only).
|
||||
/// Internal bookkeeping bits like APP_STATE_SETUP_COMPLETE are masked
|
||||
/// out so external readers (status_led components, etc.) never see them.
|
||||
uint8_t get_app_state() const { return this->app_state_ & ~APP_STATE_SETUP_COMPLETE; }
|
||||
|
||||
/// True once Application::setup() has finished walking all components
|
||||
/// and finalized the initial status flags. Before this point, the
|
||||
/// slow-setup busy-wait may be forcing STATUS_LED_WARNING on, and
|
||||
/// status_clear_* intentionally skips its walk-and-clear step so the
|
||||
/// forced bit doesn't get wiped. Stored as a free bit on app_state_
|
||||
/// (bit 6) to avoid costing additional RAM.
|
||||
bool is_setup_complete() const { return (this->app_state_ & APP_STATE_SETUP_COMPLETE) != 0; }
|
||||
|
||||
// Helper macro for entity getter method declarations
|
||||
#ifdef USE_DEVICES
|
||||
@@ -577,6 +605,12 @@ class Application {
|
||||
bool is_socket_ready_(int fd) const { return FD_ISSET(fd, &this->read_fds_); }
|
||||
#endif
|
||||
|
||||
/// Walk all registered components looking for any whose component_state_
|
||||
/// has the given flag set. Used by Component::status_clear_*_slow_path_()
|
||||
/// (which is a friend) to decide whether to clear the corresponding bit on
|
||||
/// this->app_state_ (the app-wide "any component has this status" indicator).
|
||||
bool any_component_has_status_flag_(uint8_t flag) const;
|
||||
|
||||
/// Register a component, detecting loop() override at compile time.
|
||||
/// Uses HasLoopOverride<T> which handles ambiguous &T::loop from multiple inheritance.
|
||||
template<typename T> void register_component_(T *comp) {
|
||||
@@ -607,7 +641,7 @@ class Application {
|
||||
void enable_component_loop_(Component *component);
|
||||
void enable_pending_loops_();
|
||||
void activate_looping_component_(uint16_t index);
|
||||
inline void ESPHOME_ALWAYS_INLINE before_loop_tasks_(uint32_t loop_start_time);
|
||||
inline uint32_t ESPHOME_ALWAYS_INLINE before_loop_tasks_(uint32_t loop_start_time);
|
||||
inline void ESPHOME_ALWAYS_INLINE after_loop_tasks_() { this->in_loop_ = false; }
|
||||
|
||||
/// Process dump_config output one component per loop iteration.
|
||||
@@ -615,7 +649,10 @@ class Application {
|
||||
/// Caller must ensure dump_config_at_ < components_.size().
|
||||
void __attribute__((noinline)) process_dump_config_();
|
||||
|
||||
void feed_wdt_arch_();
|
||||
/// Slow path for feed_wdt(): actually calls arch_feed_wdt(), updates
|
||||
/// last_wdt_feed_, and re-dispatches the status LED. Out of line so the
|
||||
/// inline wrapper stays tiny.
|
||||
void feed_wdt_slow_(uint32_t time);
|
||||
|
||||
/// Perform a delay while also monitoring socket file descriptors for readiness
|
||||
#ifdef USE_HOST
|
||||
@@ -669,6 +706,7 @@ class Application {
|
||||
// 4-byte members
|
||||
uint32_t last_loop_{0};
|
||||
uint32_t loop_component_start_time_{0};
|
||||
uint32_t last_wdt_feed_{0}; // millis() of most recent arch_feed_wdt(); rate-limits feed_wdt() hot path
|
||||
|
||||
#ifdef USE_HOST
|
||||
int max_fd_{-1}; // Highest file descriptor number for select()
|
||||
@@ -807,17 +845,15 @@ inline void Application::drain_wake_notifications_() {
|
||||
}
|
||||
#endif // USE_HOST
|
||||
|
||||
inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_start_time) {
|
||||
inline uint32_t ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_start_time) {
|
||||
#ifdef USE_HOST
|
||||
// Drain wake notifications first to clear socket for next wake
|
||||
this->drain_wake_notifications_();
|
||||
#endif
|
||||
|
||||
// Process scheduled tasks
|
||||
this->scheduler.call(loop_start_time);
|
||||
|
||||
// Feed the watchdog timer
|
||||
this->feed_wdt(loop_start_time);
|
||||
// Scheduler::call feeds the WDT per item and returns the timestamp of the
|
||||
// last fired item, or the input unchanged when nothing ran.
|
||||
uint32_t last_op_end_time = this->scheduler.call(loop_start_time);
|
||||
|
||||
// Process any pending enable_loop requests from ISRs
|
||||
// This must be done before marking in_loop_ = true to avoid race conditions
|
||||
@@ -835,15 +871,35 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_
|
||||
|
||||
// Mark that we're in the loop for safe reentrant modifications
|
||||
this->in_loop_ = true;
|
||||
return last_op_end_time;
|
||||
}
|
||||
|
||||
inline void ESPHOME_ALWAYS_INLINE Application::loop() {
|
||||
uint8_t new_app_state = 0;
|
||||
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
// Capture the start of the active (non-sleeping) portion of this iteration.
|
||||
// Used to derive main-loop overhead = active time − Σ(component time) −
|
||||
// before/tail splits recorded below.
|
||||
uint32_t loop_active_start_us = micros();
|
||||
// Snapshot the cumulative component-recorded time so we can subtract the
|
||||
// slice that the scheduler spends inside its own WarnIfComponentBlockingGuard
|
||||
// (scheduler.cpp) — that time is already counted in per-component stats,
|
||||
// so charging it again to "before" would double-count.
|
||||
uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us;
|
||||
#endif
|
||||
// Get the initial loop time at the start
|
||||
uint32_t last_op_end_time = millis();
|
||||
|
||||
this->before_loop_tasks_(last_op_end_time);
|
||||
// Returned timestamp keeps us monotonic with last_wdt_feed_ (advanced by
|
||||
// the scheduler's per-item feeds) without an extra millis() call.
|
||||
last_op_end_time = this->before_loop_tasks_(last_op_end_time);
|
||||
// Guarantee a WDT touch every tick — covers configs with no looping
|
||||
// components and no scheduler work, where the per-item / per-component
|
||||
// feeds never fire. Rate-limited inline fast path, ~free when unneeded.
|
||||
this->feed_wdt_with_time(last_op_end_time);
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
uint32_t loop_before_end_us = micros();
|
||||
uint64_t loop_before_scheduled_us = ComponentRuntimeStats::global_recorded_us - loop_recorded_snap;
|
||||
#endif
|
||||
|
||||
for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_;
|
||||
this->current_loop_index_++) {
|
||||
@@ -859,18 +915,27 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
|
||||
// Use the finish method to get the current time as the end time
|
||||
last_op_end_time = guard.finish();
|
||||
}
|
||||
new_app_state |= component->get_component_state();
|
||||
this->app_state_ |= new_app_state;
|
||||
this->feed_wdt(last_op_end_time);
|
||||
this->feed_wdt_with_time(last_op_end_time);
|
||||
}
|
||||
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
uint32_t loop_tail_start_us = micros();
|
||||
#endif
|
||||
this->after_loop_tasks_();
|
||||
this->app_state_ = new_app_state;
|
||||
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
// Process any pending runtime stats printing after all components have run
|
||||
// This ensures stats printing doesn't affect component timing measurements
|
||||
if (global_runtime_stats != nullptr) {
|
||||
uint32_t loop_now_us = micros();
|
||||
// Subtract scheduled-component time from the "before" bucket so it is
|
||||
// not double-counted (it is already attributed to per-component stats).
|
||||
uint32_t loop_before_wall_us = loop_before_end_us - loop_active_start_us;
|
||||
uint32_t loop_before_overhead_us = loop_before_wall_us > loop_before_scheduled_us
|
||||
? loop_before_wall_us - static_cast<uint32_t>(loop_before_scheduled_us)
|
||||
: 0;
|
||||
global_runtime_stats->record_loop_active(loop_now_us - loop_active_start_us, loop_before_overhead_us,
|
||||
loop_now_us - loop_tail_start_us);
|
||||
global_runtime_stats->process_pending_stats(last_op_end_time);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -62,6 +62,18 @@ template<typename T, typename... X> class TemplatableFn {
|
||||
!std::convertible_to<std::invoke_result_t<F, X...>, T> ||
|
||||
!std::default_initializable<F>) = delete;
|
||||
|
||||
// Reject raw (non-callable) values with a helpful diagnostic pointing at the Python-side fix.
|
||||
// TemplatableFn stores only a function pointer (4 bytes), so constants must be wrapped in a
|
||||
// stateless lambda by codegen. External components hitting this error should use
|
||||
// `cg.templatable(value, args, type)` in their Python __init__.py before passing to the setter.
|
||||
template<typename V> TemplatableFn(V) requires(!std::invocable<V, X...>) && (!std::convertible_to<V, T (*)(X...)>) {
|
||||
static_assert(sizeof(V) == 0, "Missing cg.templatable(...) in Python codegen for this TEMPLATABLE_VALUE "
|
||||
"field. The wrapper was always required; it worked by accident because the old "
|
||||
"TemplatableValue implicitly converted raw constants. TemplatableFn cannot. See "
|
||||
"https://developers.esphome.io/blog/2026/04/09/"
|
||||
"templatablefn-4-byte-templatable-storage-for-trivially-copyable-types/");
|
||||
}
|
||||
|
||||
bool has_value() const { return this->f_ != nullptr; }
|
||||
|
||||
T value(X... x) const { return this->f_ ? this->f_(x...) : T{}; }
|
||||
|
||||
@@ -205,7 +205,9 @@ template<typename... Ts> class DelayAction : public Action<Ts...>, public Compon
|
||||
} else {
|
||||
// For delays with arguments, capture by value to preserve argument values
|
||||
// Arguments must be copied because original references may be invalid after delay
|
||||
auto f = [this, x...]() { this->play_next_(x...); };
|
||||
// `mutable` is required so captured copies of non-const reference args (e.g. std::string&)
|
||||
// are passed as non-const lvalues to play_next_(const Ts&...) where Ts may be `T&`
|
||||
auto f = [this, x...]() mutable { this->play_next_(x...); };
|
||||
App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL,
|
||||
nullptr, static_cast<uint32_t>(InternalSchedulerID::DELAY_ACTION),
|
||||
this->delay_.value(x...), std::move(f),
|
||||
|
||||
@@ -411,10 +411,23 @@ void Component::status_set_error(const LogString *message) {
|
||||
}
|
||||
void Component::status_clear_warning_slow_path_() {
|
||||
this->component_state_ &= ~STATUS_LED_WARNING;
|
||||
// Clear the app-wide STATUS_LED_WARNING bit only if setup has finished
|
||||
// AND no other component still has it set. During setup the forced
|
||||
// STATUS_LED_WARNING (from the slow-setup busy-wait) must not be wiped
|
||||
// by a transient component clear — Application::setup() reconciles
|
||||
// the warning bit once at the end before setting APP_STATE_SETUP_COMPLETE.
|
||||
// The set path is unchanged (set_status_flag_ still writes directly).
|
||||
if (App.is_setup_complete() && !App.any_component_has_status_flag_(STATUS_LED_WARNING))
|
||||
App.app_state_ &= ~STATUS_LED_WARNING;
|
||||
ESP_LOGW(TAG, "%s cleared Warning flag", LOG_STR_ARG(this->get_component_log_str()));
|
||||
}
|
||||
void Component::status_clear_error_slow_path_() {
|
||||
this->component_state_ &= ~STATUS_LED_ERROR;
|
||||
// STATUS_LED_ERROR is never artificially forced — it only ever lands
|
||||
// in app_state_ via a real set_status_flag_ call. So the walk-and-clear
|
||||
// path is always safe, including during setup.
|
||||
if (!App.any_component_has_status_flag_(STATUS_LED_ERROR))
|
||||
App.app_state_ &= ~STATUS_LED_ERROR;
|
||||
ESP_LOGE(TAG, "%s cleared Error flag", LOG_STR_ARG(this->get_component_log_str()));
|
||||
}
|
||||
void Component::status_momentary_warning(const char *name, uint32_t length) {
|
||||
@@ -493,6 +506,10 @@ void PollingComponent::stop_poller() {
|
||||
|
||||
uint32_t PollingComponent::get_update_interval() const { return this->update_interval_; }
|
||||
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
uint64_t ComponentRuntimeStats::global_recorded_us = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
#endif
|
||||
|
||||
void __attribute__((noinline, cold))
|
||||
WarnIfComponentBlockingGuard::warn_blocking(Component *component, uint32_t blocking_time) {
|
||||
bool should_warn;
|
||||
|
||||
@@ -89,6 +89,11 @@ inline constexpr uint8_t STATUS_LED_WARNING = 0x08;
|
||||
inline constexpr uint8_t STATUS_LED_ERROR = 0x10;
|
||||
// Component loop override flag uses bit 5 (set at registration time)
|
||||
inline constexpr uint8_t COMPONENT_HAS_LOOP = 0x20;
|
||||
// Bit 6 on Application::app_state_ (ONLY) — set at the end of
|
||||
// Application::setup(). Component::status_clear_*_slow_path_() uses this to
|
||||
// decide whether to propagate clears to App.app_state_. Never set on a
|
||||
// Component's component_state_.
|
||||
inline constexpr uint8_t APP_STATE_SETUP_COMPLETE = 0x40;
|
||||
// Remove before 2026.8.0
|
||||
enum class RetryResult { DONE, RETRY };
|
||||
|
||||
@@ -111,6 +116,13 @@ struct ComponentRuntimeStats {
|
||||
uint64_t total_time_us{0};
|
||||
uint32_t total_max_time_us{0};
|
||||
|
||||
// Cumulative sum of every record_time() duration since boot, across all
|
||||
// components. Used by Application::loop() to snapshot time spent inside
|
||||
// WarnIfComponentBlockingGuard (including guards constructed by the
|
||||
// scheduler at scheduler.cpp) so main-loop overhead accounting can
|
||||
// subtract scheduled-callback time from the before_loop_tasks_ wall time.
|
||||
static uint64_t global_recorded_us; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
void record_time(uint32_t duration_us) {
|
||||
this->period_count++;
|
||||
this->period_time_us += duration_us;
|
||||
@@ -120,6 +132,7 @@ struct ComponentRuntimeStats {
|
||||
this->total_time_us += duration_us;
|
||||
if (duration_us > this->total_max_time_us)
|
||||
this->total_max_time_us = duration_us;
|
||||
global_recorded_us += duration_us;
|
||||
}
|
||||
void reset_period() {
|
||||
this->period_count = 0;
|
||||
@@ -588,7 +601,7 @@ class Component {
|
||||
*/
|
||||
class PollingComponent : public Component {
|
||||
public:
|
||||
PollingComponent() : PollingComponent(0) {}
|
||||
PollingComponent() : PollingComponent(1) {}
|
||||
|
||||
/** Initialize this polling component with the given update interval in ms.
|
||||
*
|
||||
|
||||
@@ -144,6 +144,19 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
|
||||
return;
|
||||
}
|
||||
|
||||
// An interval of 0 means "fire every tick forever," which is misuse: the
|
||||
// item would always be due, causing Scheduler::call() to spin and starve
|
||||
// the main loop (WDT reset in the field). Coerce to 1ms so existing code
|
||||
// using update_interval=0ms as a pseudo-loop() continues to work at ~1kHz,
|
||||
// and warn so authors can migrate to HighFrequencyLoopRequester which is
|
||||
// the intended mechanism for running fast in the main loop. Zero-delay
|
||||
// timeouts (defer) remain legitimate one-shots and are not affected.
|
||||
if (type == SchedulerItem::INTERVAL && delay == 0) [[unlikely]] {
|
||||
ESP_LOGE(TAG, "[%s] set_interval(0) would spin main loop - coercing to 1ms (use HighFrequencyLoopRequester)",
|
||||
component ? LOG_STR_ARG(component->get_component_log_str()) : LOG_STR_LITERAL("?"));
|
||||
delay = 1;
|
||||
}
|
||||
|
||||
// Take lock early to protect scheduler_item_pool_ access and retry-cancelled check
|
||||
LockGuard guard{this->lock_};
|
||||
|
||||
@@ -520,7 +533,7 @@ void HOT Scheduler::process_defer_queue_slow_path_(uint32_t &now) {
|
||||
}
|
||||
#endif /* not ESPHOME_THREAD_SINGLE */
|
||||
|
||||
void HOT Scheduler::call(uint32_t now) {
|
||||
uint32_t HOT Scheduler::call(uint32_t now) {
|
||||
#ifndef ESPHOME_THREAD_SINGLE
|
||||
this->process_defer_queue_(now);
|
||||
#endif /* not ESPHOME_THREAD_SINGLE */
|
||||
@@ -690,6 +703,9 @@ void HOT Scheduler::call(uint32_t now) {
|
||||
this->debug_verify_no_leak_();
|
||||
}
|
||||
#endif
|
||||
// execute_item_() advances `now` as items fire; return it so the caller
|
||||
// stays monotonic with last_wdt_feed_.
|
||||
return now;
|
||||
}
|
||||
void HOT Scheduler::process_to_add_slow_path_() {
|
||||
LockGuard guard{this->lock_};
|
||||
@@ -739,7 +755,13 @@ uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) {
|
||||
App.set_current_component(item->component);
|
||||
WarnIfComponentBlockingGuard guard{item->component, now};
|
||||
item->callback();
|
||||
return guard.finish();
|
||||
uint32_t end = guard.finish();
|
||||
// Feed the watchdog after each scheduled item (both main heap and defer
|
||||
// queue paths go through here). A run of back-to-back callbacks cannot
|
||||
// starve the wdt. The inline fast path is a load + sub + branch — nearly
|
||||
// free when the 3 ms rate limit hasn't elapsed.
|
||||
App.feed_wdt_with_time(end);
|
||||
return end;
|
||||
}
|
||||
|
||||
// Common implementation for cancel operations - handles locking
|
||||
|
||||
@@ -129,7 +129,8 @@ class Scheduler {
|
||||
|
||||
// Execute all scheduled items that are ready
|
||||
// @param now Fresh timestamp from millis() - must not be stale/cached
|
||||
void call(uint32_t now);
|
||||
// @return Timestamp of the last item that ran, or `now` unchanged if none ran.
|
||||
uint32_t call(uint32_t now);
|
||||
|
||||
// Move items from to_add_ into the main heap.
|
||||
// IMPORTANT: This method should only be called from the main thread (loop task).
|
||||
|
||||
+6
-2
@@ -76,8 +76,12 @@ struct ESPTime {
|
||||
/// @copydoc strftime(const std::string &format)
|
||||
std::string strftime(const char *format);
|
||||
|
||||
/// Check if this ESPTime is valid (all fields in range and year is greater than or equal to 2019)
|
||||
bool is_valid() const { return this->year >= 2019 && this->fields_in_range(); }
|
||||
/// Check if this ESPTime is valid (year >= 2019 and the requested fields are in range).
|
||||
/// @param check_day_of_week validate day_of_week (not always available when constructing from date/time fields)
|
||||
/// @param check_day_of_year validate day_of_year (not always available when constructing from date/time fields)
|
||||
bool is_valid(bool check_day_of_week = true, bool check_day_of_year = true) const {
|
||||
return this->year >= 2019 && this->fields_in_range(check_day_of_week, check_day_of_year);
|
||||
}
|
||||
|
||||
/// Check if time fields are in range.
|
||||
/// @param check_day_of_week validate day_of_week (not always available when constructing from date/time fields)
|
||||
|
||||
@@ -113,7 +113,8 @@ def _generate_source_table_code(
|
||||
entries = ", ".join(var_names)
|
||||
lines.append(f"static const char *const {table_var}[] PROGMEM = {{{entries}}};")
|
||||
lines.append(f"const LogString *{lookup_fn}(uint8_t index) {{")
|
||||
lines.append(f' if (index == 0 || index > {count}) return LOG_STR("<unknown>");')
|
||||
cond = "index == 0" if count >= 255 else f"index == 0 || index > {count}"
|
||||
lines.append(f' if ({cond}) return LOG_STR("<unknown>");')
|
||||
lines.append(" return reinterpret_cast<const LogString *>(")
|
||||
lines.append(f" progmem_read_ptr(&{table_var}[index - 1]));")
|
||||
lines.append("}")
|
||||
|
||||
@@ -14,7 +14,7 @@ dependencies:
|
||||
espressif/esp32-camera:
|
||||
version: 2.1.6
|
||||
espressif/mdns:
|
||||
version: 1.10.0
|
||||
version: 1.11.0
|
||||
espressif/esp_wifi_remote:
|
||||
version: 1.4.0
|
||||
rules:
|
||||
|
||||
+4
-128
@@ -5,104 +5,15 @@ import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any
|
||||
import sys
|
||||
|
||||
from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.util import run_external_command, run_external_process
|
||||
from esphome.util import run_external_process
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def patch_structhash():
|
||||
# Patch platformio's structhash to not recompile the entire project when files are
|
||||
# removed/added. This might have unintended consequences, but this improves compile
|
||||
# times greatly when adding/removing components and a simple clean build solves
|
||||
# all issues
|
||||
from platformio.run import cli, helpers
|
||||
|
||||
def patched_clean_build_dir(build_dir, *args):
|
||||
from platformio import fs
|
||||
from platformio.project.helpers import get_project_dir
|
||||
|
||||
platformio_ini = Path(get_project_dir()) / "platformio.ini"
|
||||
|
||||
build_dir = Path(build_dir)
|
||||
|
||||
# if project's config is modified
|
||||
if (
|
||||
build_dir.is_dir()
|
||||
and platformio_ini.stat().st_mtime > build_dir.stat().st_mtime
|
||||
):
|
||||
fs.rmtree(build_dir)
|
||||
|
||||
if not build_dir.is_dir():
|
||||
build_dir.mkdir(parents=True)
|
||||
|
||||
helpers.clean_build_dir = patched_clean_build_dir
|
||||
cli.clean_build_dir = patched_clean_build_dir
|
||||
|
||||
|
||||
def patch_file_downloader():
|
||||
"""Patch PlatformIO's FileDownloader to retry on PackageException errors.
|
||||
|
||||
PlatformIO's FileDownloader uses HTTPSession which lacks built-in retry
|
||||
for 502/503 errors. We add retries with exponential backoff and close the
|
||||
session between attempts to force a fresh TCP connection, which may route
|
||||
to a different CDN edge node.
|
||||
"""
|
||||
from platformio.package.download import FileDownloader
|
||||
from platformio.package.exception import PackageException
|
||||
|
||||
if getattr(FileDownloader.__init__, "_esphome_patched", False):
|
||||
return
|
||||
|
||||
original_init = FileDownloader.__init__
|
||||
|
||||
def patched_init(self, *args: Any, **kwargs: Any) -> None:
|
||||
max_retries = 5
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
original_init(self, *args, **kwargs)
|
||||
return
|
||||
except PackageException as e:
|
||||
if attempt < max_retries - 1:
|
||||
# Exponential backoff: 2, 4, 8, 16 seconds
|
||||
delay = 2 ** (attempt + 1)
|
||||
_LOGGER.warning(
|
||||
"Package download failed: %s. "
|
||||
"Retrying in %d seconds... (attempt %d/%d)",
|
||||
str(e),
|
||||
delay,
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
)
|
||||
# Close the response and session to free resources
|
||||
# and force a new TCP connection on retry, which may
|
||||
# route to a different CDN edge node
|
||||
# pylint: disable=protected-access,broad-except
|
||||
try:
|
||||
if (
|
||||
hasattr(self, "_http_response")
|
||||
and self._http_response is not None
|
||||
):
|
||||
self._http_response.close()
|
||||
if hasattr(self, "_http_session"):
|
||||
self._http_session.close()
|
||||
except Exception:
|
||||
pass
|
||||
# pylint: enable=protected-access,broad-except
|
||||
time.sleep(delay)
|
||||
else:
|
||||
# Final attempt - re-raise
|
||||
raise
|
||||
|
||||
patched_init._esphome_patched = True # type: ignore[attr-defined] # pylint: disable=protected-access
|
||||
FileDownloader.__init__ = patched_init
|
||||
|
||||
|
||||
IGNORE_LIB_WARNINGS = f"(?:{'|'.join(['Hash', 'Update'])})"
|
||||
FILTER_PLATFORMIO_LINES = [
|
||||
r"Verbose mode can be enabled via `-v, --verbose` option.*",
|
||||
@@ -142,20 +53,6 @@ FILTER_PLATFORMIO_LINES = [
|
||||
]
|
||||
|
||||
|
||||
class PlatformioLogFilter(logging.Filter):
|
||||
"""Filter to suppress noisy platformio log messages."""
|
||||
|
||||
_PATTERN = re.compile(
|
||||
r"|".join(r"(?:" + pattern + r")" for pattern in FILTER_PLATFORMIO_LINES)
|
||||
)
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
# Only filter messages from platformio-related loggers
|
||||
if "platformio" not in record.name.lower():
|
||||
return True
|
||||
return self._PATTERN.match(record.getMessage()) is None
|
||||
|
||||
|
||||
def run_platformio_cli(*args, **kwargs) -> str | int:
|
||||
os.environ["PLATFORMIO_FORCE_COLOR"] = "true"
|
||||
os.environ["PLATFORMIO_BUILD_DIR"] = str(CORE.relative_pioenvs_path().absolute())
|
||||
@@ -166,30 +63,9 @@ def run_platformio_cli(*args, **kwargs) -> str | int:
|
||||
os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning")
|
||||
# Increase uv retry count to handle transient network errors (default is 3)
|
||||
os.environ.setdefault("UV_HTTP_RETRIES", "10")
|
||||
cmd = ["platformio"] + list(args)
|
||||
cmd = [sys.executable, "-m", "esphome.platformio_runner"] + list(args)
|
||||
|
||||
if not CORE.verbose:
|
||||
kwargs["filter_lines"] = FILTER_PLATFORMIO_LINES
|
||||
|
||||
if os.environ.get("ESPHOME_USE_SUBPROCESS") is not None:
|
||||
return run_external_process(*cmd, **kwargs)
|
||||
|
||||
import platformio.__main__
|
||||
|
||||
patch_structhash()
|
||||
patch_file_downloader()
|
||||
|
||||
# Add log filter to suppress noisy platformio messages
|
||||
log_filter = PlatformioLogFilter() if not CORE.verbose else None
|
||||
if log_filter:
|
||||
for handler in logging.getLogger().handlers:
|
||||
handler.addFilter(log_filter)
|
||||
try:
|
||||
return run_external_command(platformio.__main__.main, *cmd, **kwargs)
|
||||
finally:
|
||||
if log_filter:
|
||||
for handler in logging.getLogger().handlers:
|
||||
handler.removeFilter(log_filter)
|
||||
return run_external_process(*cmd, **kwargs)
|
||||
|
||||
|
||||
def run_platformio_cli_run(config, verbose, *args, **kwargs) -> str | int:
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Subprocess entry point that applies ESPHome's PlatformIO patches.
|
||||
|
||||
Invoked via ``python -m esphome.platformio_runner`` instead of
|
||||
``python -m platformio`` so that the patches (incremental rebuild
|
||||
preservation, download retries) apply inside the subprocess. Running
|
||||
PlatformIO in a subprocess keeps its ``sys.path`` mutations and other
|
||||
global state from leaking into the ESPHome process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def patch_structhash() -> None:
|
||||
"""Avoid full rebuilds when files are added or removed.
|
||||
|
||||
PlatformIO clears the build dir whenever its structure hash changes.
|
||||
We replace that with an mtime check against ``platformio.ini`` so
|
||||
incremental builds are preserved unless the project config changed.
|
||||
"""
|
||||
from platformio.run import cli, helpers
|
||||
|
||||
def patched_clean_build_dir(build_dir, *_args):
|
||||
from platformio import fs
|
||||
from platformio.project.helpers import get_project_dir
|
||||
|
||||
platformio_ini = Path(get_project_dir()) / "platformio.ini"
|
||||
build_dir = Path(build_dir)
|
||||
|
||||
if (
|
||||
build_dir.is_dir()
|
||||
and platformio_ini.stat().st_mtime > build_dir.stat().st_mtime
|
||||
):
|
||||
fs.rmtree(build_dir)
|
||||
|
||||
if not build_dir.is_dir():
|
||||
build_dir.mkdir(parents=True)
|
||||
|
||||
helpers.clean_build_dir = patched_clean_build_dir
|
||||
cli.clean_build_dir = patched_clean_build_dir
|
||||
|
||||
|
||||
def patch_file_downloader() -> None:
|
||||
"""Retry PlatformIO package downloads with exponential backoff.
|
||||
|
||||
PlatformIO's ``FileDownloader`` uses an ``HTTPSession`` without built-in
|
||||
retry for 502/503 errors. We wrap ``__init__`` to retry on
|
||||
``PackageException`` and close the session between attempts so a new
|
||||
TCP connection can route to a different CDN edge node.
|
||||
"""
|
||||
from platformio.package.download import FileDownloader
|
||||
from platformio.package.exception import PackageException
|
||||
|
||||
if getattr(FileDownloader.__init__, "_esphome_patched", False):
|
||||
return
|
||||
|
||||
original_init = FileDownloader.__init__
|
||||
|
||||
def patched_init(self, *args: Any, **kwargs: Any) -> None:
|
||||
max_retries = 5
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
original_init(self, *args, **kwargs)
|
||||
return
|
||||
except PackageException as e:
|
||||
if attempt < max_retries - 1:
|
||||
delay = 2 ** (attempt + 1)
|
||||
_LOGGER.warning(
|
||||
"Package download failed: %s. "
|
||||
"Retrying in %d seconds... (attempt %d/%d)",
|
||||
str(e),
|
||||
delay,
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
)
|
||||
# pylint: disable=protected-access,broad-except
|
||||
try:
|
||||
if (
|
||||
hasattr(self, "_http_response")
|
||||
and self._http_response is not None
|
||||
):
|
||||
self._http_response.close()
|
||||
if hasattr(self, "_http_session"):
|
||||
self._http_session.close()
|
||||
except Exception:
|
||||
pass
|
||||
# pylint: enable=protected-access,broad-except
|
||||
time.sleep(delay)
|
||||
else:
|
||||
raise
|
||||
|
||||
patched_init._esphome_patched = True # type: ignore[attr-defined] # pylint: disable=protected-access
|
||||
FileDownloader.__init__ = patched_init
|
||||
|
||||
|
||||
def main() -> int:
|
||||
patch_structhash()
|
||||
patch_file_downloader()
|
||||
|
||||
# Wrap stdout/stderr with RedirectText before PlatformIO runs:
|
||||
#
|
||||
# 1. RedirectText.isatty() unconditionally returns True. Click, tqdm, and
|
||||
# PlatformIO's own progress-bar code check ``stream.isatty()`` to
|
||||
# decide whether to emit TTY-format output (``\r`` cursor moves, ANSI
|
||||
# colors, fancy progress bars). With the wrapper in place they always
|
||||
# emit TTY format, even when our real stdout is a pipe to the parent
|
||||
# process. Downstream consumers (local terminals and the Home
|
||||
# Assistant dashboard log viewer) render the TTY control sequences
|
||||
# correctly, so the user sees real progress bars.
|
||||
#
|
||||
# 2. FILTER_PLATFORMIO_LINES is applied inside RedirectText.write() in
|
||||
# this subprocess, so noisy PlatformIO output is dropped before it
|
||||
# ever leaves the runner. This replaces the parent-side filtering
|
||||
# that was lost when we switched from in-process to subprocess — the
|
||||
# parent's ``subprocess.run`` uses ``.fileno()`` on RedirectText and
|
||||
# bypasses its ``write()`` path entirely.
|
||||
#
|
||||
# Filtering is disabled when the user passed -v / --verbose to
|
||||
# ``esphome compile``, preserving the previous in-process behavior where
|
||||
# verbose mode let all PlatformIO output through unfiltered.
|
||||
from esphome.platformio_api import FILTER_PLATFORMIO_LINES
|
||||
from esphome.util import RedirectText
|
||||
|
||||
is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[1:])
|
||||
filter_lines = None if is_verbose else FILTER_PLATFORMIO_LINES
|
||||
|
||||
sys.stdout = RedirectText(sys.stdout, filter_lines=filter_lines)
|
||||
sys.stderr = RedirectText(sys.stderr, filter_lines=filter_lines)
|
||||
|
||||
import platformio.__main__
|
||||
|
||||
return platformio.__main__.main() or 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+5
-5
@@ -133,10 +133,10 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script
|
||||
; This are common settings for the ESP32 (all variants) using Arduino.
|
||||
[common:esp32-arduino]
|
||||
extends = common:arduino
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38-1/platform-espressif32.zip
|
||||
platform_packages =
|
||||
pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.7/esp32-core-3.3.7.tar.xz
|
||||
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.3.1/esp-idf-v5.5.3.1.tar.xz
|
||||
pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.8/esp32-core-3.3.8.tar.xz
|
||||
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz
|
||||
|
||||
framework = arduino, espidf ; Arduino as an ESP-IDF component
|
||||
lib_deps =
|
||||
@@ -169,9 +169,9 @@ extra_scripts = post:esphome/components/esp32/post_build.py.script
|
||||
; This are common settings for the ESP32 (all variants) using IDF.
|
||||
[common:esp32-idf]
|
||||
extends = common:idf
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38-1/platform-espressif32.zip
|
||||
platform_packages =
|
||||
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.3.1/esp-idf-v5.5.3.1.tar.xz
|
||||
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz
|
||||
|
||||
framework = espidf
|
||||
lib_deps =
|
||||
|
||||
@@ -20,7 +20,6 @@ classifiers = [
|
||||
"Topic :: Home Automation",
|
||||
]
|
||||
|
||||
# Python 3.14 is not supported on Windows, see https://github.com/zephyrproject-rtos/windows-curses/issues/76
|
||||
requires-python = ">=3.11.0,<3.15"
|
||||
|
||||
dynamic = ["dependencies", "optional-dependencies", "version"]
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ platformio==6.1.19
|
||||
esptool==5.2.0
|
||||
click==8.3.2
|
||||
esphome-dashboard==20260408.1
|
||||
aioesphomeapi==44.12.0
|
||||
aioesphomeapi==44.16.1
|
||||
zeroconf==0.148.0
|
||||
puremagic==1.30
|
||||
ruamel.yaml==0.19.1 # dashboard_import
|
||||
|
||||
@@ -60,6 +60,10 @@ FILE_HEADER = """// This file was automatically generated with a tool.
|
||||
# Maps enum type name (e.g. ".BluetoothDeviceRequestType") to max enum value.
|
||||
_enum_max_values: dict[str, int] = {}
|
||||
|
||||
# Populated by main() before message generation.
|
||||
# Maps message name (e.g. "BluetoothLERawAdvertisement") to its descriptor.
|
||||
_message_desc_map: dict[str, Any] = {}
|
||||
|
||||
|
||||
def indent_list(text: str, padding: str = " ") -> list[str]:
|
||||
"""Indent each line of the given text with the specified padding."""
|
||||
@@ -427,6 +431,23 @@ class TypeInfo(ABC):
|
||||
Estimated size in bytes including field ID and typical data
|
||||
"""
|
||||
|
||||
def get_max_encoded_size(self) -> int | None:
|
||||
"""Get the maximum possible encoded size in bytes for this field.
|
||||
|
||||
Returns the worst-case encoded size including field ID and maximum
|
||||
possible value encoding. Returns None if the size is unbounded
|
||||
(e.g., variable-length strings without max_data_length).
|
||||
|
||||
Used by (inline_encode) validation to ensure sub-messages fit in a
|
||||
single-byte length varint (< 128 bytes).
|
||||
"""
|
||||
return None # Unbounded by default
|
||||
|
||||
|
||||
def _varint_max_size(bits: int) -> int:
|
||||
"""Return the maximum varint encoding size for a value with the given number of bits."""
|
||||
return (max(bits, 1) + 6) // 7 # ceil(bits / 7), min 1 byte for varint(0)
|
||||
|
||||
|
||||
TYPE_INFO: dict[int, TypeInfo] = {}
|
||||
|
||||
@@ -514,8 +535,30 @@ def register_type(name: int):
|
||||
return func
|
||||
|
||||
|
||||
class FixedSizeTypeMixin:
|
||||
"""Mixin for types with a known fixed encoded size (float, double, fixed32, fixed64)."""
|
||||
|
||||
def get_max_encoded_size(self) -> int:
|
||||
return self.calculate_field_id_size() + self.get_fixed_size_bytes()
|
||||
|
||||
|
||||
class VarintTypeMixin:
|
||||
"""Mixin for varint types. Subclasses set _varint_max_bits."""
|
||||
|
||||
_varint_max_bits: int = 64 # Default to worst case
|
||||
|
||||
def get_max_encoded_size(self) -> int:
|
||||
max_val = self.max_value
|
||||
if max_val is not None:
|
||||
return self.calculate_field_id_size() + _varint_max_size(
|
||||
max_val.bit_length() if max_val > 0 else 1
|
||||
)
|
||||
return self.calculate_field_id_size() + _varint_max_size(self._varint_max_bits)
|
||||
|
||||
|
||||
@register_type(1)
|
||||
class DoubleType(TypeInfo):
|
||||
class DoubleType(FixedSizeTypeMixin, TypeInfo):
|
||||
# Unsupported but defined for completeness
|
||||
cpp_type = "double"
|
||||
default_value = "0.0"
|
||||
decode_64bit = "value.as_double()"
|
||||
@@ -541,7 +584,7 @@ class DoubleType(TypeInfo):
|
||||
|
||||
|
||||
@register_type(2)
|
||||
class FloatType(TypeInfo):
|
||||
class FloatType(FixedSizeTypeMixin, TypeInfo):
|
||||
cpp_type = "float"
|
||||
default_value = "0.0f"
|
||||
decode_32bit = "value.as_float()"
|
||||
@@ -567,8 +610,9 @@ class FloatType(TypeInfo):
|
||||
|
||||
|
||||
@register_type(3)
|
||||
class Int64Type(TypeInfo):
|
||||
class Int64Type(VarintTypeMixin, TypeInfo):
|
||||
cpp_type = "int64_t"
|
||||
_varint_max_bits = 64
|
||||
default_value = "0"
|
||||
decode_varint = "static_cast<int64_t>(value)"
|
||||
encode_func = "encode_int64"
|
||||
@@ -587,8 +631,9 @@ class Int64Type(TypeInfo):
|
||||
|
||||
|
||||
@register_type(4)
|
||||
class UInt64Type(TypeInfo):
|
||||
class UInt64Type(VarintTypeMixin, TypeInfo):
|
||||
cpp_type = "uint64_t"
|
||||
_varint_max_bits = 64
|
||||
default_value = "0"
|
||||
decode_varint = "value"
|
||||
encode_func = "encode_uint64"
|
||||
@@ -607,8 +652,9 @@ class UInt64Type(TypeInfo):
|
||||
|
||||
|
||||
@register_type(5)
|
||||
class Int32Type(TypeInfo):
|
||||
class Int32Type(VarintTypeMixin, TypeInfo):
|
||||
cpp_type = "int32_t"
|
||||
_varint_max_bits = 64 # int32 is sign-extended to 64 bits in protobuf
|
||||
default_value = "0"
|
||||
decode_varint = "static_cast<int32_t>(value)"
|
||||
encode_func = "encode_int32"
|
||||
@@ -627,7 +673,7 @@ class Int32Type(TypeInfo):
|
||||
|
||||
|
||||
@register_type(6)
|
||||
class Fixed64Type(TypeInfo):
|
||||
class Fixed64Type(FixedSizeTypeMixin, TypeInfo):
|
||||
cpp_type = "uint64_t"
|
||||
default_value = "0"
|
||||
decode_64bit = "value.as_fixed64()"
|
||||
@@ -653,7 +699,7 @@ class Fixed64Type(TypeInfo):
|
||||
|
||||
|
||||
@register_type(7)
|
||||
class Fixed32Type(TypeInfo):
|
||||
class Fixed32Type(FixedSizeTypeMixin, TypeInfo):
|
||||
cpp_type = "uint32_t"
|
||||
default_value = "0"
|
||||
decode_32bit = "value.as_fixed32()"
|
||||
@@ -689,7 +735,8 @@ class Fixed32Type(TypeInfo):
|
||||
|
||||
|
||||
@register_type(8)
|
||||
class BoolType(TypeInfo):
|
||||
class BoolType(VarintTypeMixin, TypeInfo):
|
||||
_varint_max_bits = 1
|
||||
cpp_type = "bool"
|
||||
default_value = "false"
|
||||
decode_varint = "value != 0"
|
||||
@@ -807,6 +854,16 @@ class StringType(TypeInfo):
|
||||
def get_estimated_size(self) -> int:
|
||||
return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical string
|
||||
|
||||
def get_max_encoded_size(self) -> int | None:
|
||||
max_len = self.max_data_length
|
||||
if max_len is not None:
|
||||
return (
|
||||
self.calculate_field_id_size()
|
||||
+ _varint_max_size(max_len.bit_length())
|
||||
+ max_len
|
||||
)
|
||||
return None # Unbounded
|
||||
|
||||
|
||||
@register_type(11)
|
||||
class MessageType(TypeInfo):
|
||||
@@ -971,7 +1028,8 @@ class BytesType(TypeInfo):
|
||||
)
|
||||
|
||||
def get_size_calculation(self, name: str, force: bool = False) -> str:
|
||||
return f"size += ProtoSize::calc_length({self.calculate_field_id_size()}, this->{self.field_name}_len_);"
|
||||
calc_fn = "calc_length_force" if force else "calc_length"
|
||||
return f"size += ProtoSize::{calc_fn}({self.calculate_field_id_size()}, this->{self.field_name}_len_);"
|
||||
|
||||
def get_estimated_size(self) -> int:
|
||||
return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical bytes
|
||||
@@ -1052,7 +1110,8 @@ class PointerToBytesBufferType(PointerToBufferTypeBase):
|
||||
)
|
||||
|
||||
def get_size_calculation(self, name: str, force: bool = False) -> str:
|
||||
return f"size += ProtoSize::calc_length({self.calculate_field_id_size()}, this->{self.field_name}_len);"
|
||||
calc_fn = "calc_length_force" if force else "calc_length"
|
||||
return f"size += ProtoSize::{calc_fn}({self.calculate_field_id_size()}, this->{self.field_name}_len);"
|
||||
|
||||
|
||||
class PointerToStringBufferType(PointerToBufferTypeBase):
|
||||
@@ -1122,6 +1181,16 @@ class PointerToStringBufferType(PointerToBufferTypeBase):
|
||||
def get_estimated_size(self) -> int:
|
||||
return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical string
|
||||
|
||||
def get_max_encoded_size(self) -> int | None:
|
||||
max_len = self.max_data_length
|
||||
if max_len is not None:
|
||||
return (
|
||||
self.calculate_field_id_size()
|
||||
+ _varint_max_size(max_len.bit_length())
|
||||
+ max_len
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class PackedBufferTypeInfo(TypeInfo):
|
||||
"""Type for packed repeated fields that expose raw buffer instead of decoding.
|
||||
@@ -1299,14 +1368,23 @@ class FixedArrayBytesType(TypeInfo):
|
||||
self.calculate_field_id_size() + 1 + 31
|
||||
) # field ID + length byte + typical 31 bytes
|
||||
|
||||
def get_max_encoded_size(self) -> int:
|
||||
# field_id + varint(array_size) + array_size
|
||||
return (
|
||||
self.calculate_field_id_size()
|
||||
+ _varint_max_size(self.array_size.bit_length())
|
||||
+ self.array_size
|
||||
)
|
||||
|
||||
@property
|
||||
def wire_type(self) -> WireType:
|
||||
return WireType.LENGTH_DELIMITED
|
||||
|
||||
|
||||
@register_type(13)
|
||||
class UInt32Type(TypeInfo):
|
||||
class UInt32Type(VarintTypeMixin, TypeInfo):
|
||||
cpp_type = "uint32_t"
|
||||
_varint_max_bits = 32
|
||||
default_value = "0"
|
||||
decode_varint = "value"
|
||||
encode_func = "encode_uint32"
|
||||
@@ -1328,7 +1406,9 @@ class UInt32Type(TypeInfo):
|
||||
|
||||
|
||||
@register_type(14)
|
||||
class EnumType(TypeInfo):
|
||||
class EnumType(VarintTypeMixin, TypeInfo):
|
||||
_varint_max_bits = 32
|
||||
|
||||
@property
|
||||
def cpp_type(self) -> str:
|
||||
return f"enums::{self._field.type_name[1:]}"
|
||||
@@ -1379,7 +1459,7 @@ class EnumType(TypeInfo):
|
||||
|
||||
|
||||
@register_type(15)
|
||||
class SFixed32Type(TypeInfo):
|
||||
class SFixed32Type(FixedSizeTypeMixin, TypeInfo):
|
||||
cpp_type = "int32_t"
|
||||
default_value = "0"
|
||||
decode_32bit = "value.as_sfixed32()"
|
||||
@@ -1405,7 +1485,7 @@ class SFixed32Type(TypeInfo):
|
||||
|
||||
|
||||
@register_type(16)
|
||||
class SFixed64Type(TypeInfo):
|
||||
class SFixed64Type(FixedSizeTypeMixin, TypeInfo):
|
||||
cpp_type = "int64_t"
|
||||
default_value = "0"
|
||||
decode_64bit = "value.as_sfixed64()"
|
||||
@@ -1431,8 +1511,9 @@ class SFixed64Type(TypeInfo):
|
||||
|
||||
|
||||
@register_type(17)
|
||||
class SInt32Type(TypeInfo):
|
||||
class SInt32Type(VarintTypeMixin, TypeInfo):
|
||||
cpp_type = "int32_t"
|
||||
_varint_max_bits = 32 # zigzag encoding keeps it 32-bit
|
||||
default_value = "0"
|
||||
decode_varint = "decode_zigzag32(static_cast<uint32_t>(value))"
|
||||
encode_func = "encode_sint32"
|
||||
@@ -1451,8 +1532,9 @@ class SInt32Type(TypeInfo):
|
||||
|
||||
|
||||
@register_type(18)
|
||||
class SInt64Type(TypeInfo):
|
||||
class SInt64Type(VarintTypeMixin, TypeInfo):
|
||||
cpp_type = "int64_t"
|
||||
_varint_max_bits = 64
|
||||
default_value = "0"
|
||||
decode_varint = "decode_zigzag64(value)"
|
||||
encode_func = "encode_sint64"
|
||||
@@ -1500,6 +1582,91 @@ def _generate_array_dump_content(
|
||||
return o
|
||||
|
||||
|
||||
def _is_inline_encode(sub_msg_name: str) -> bool:
|
||||
"""Check if a sub-message type has the (inline_encode) option set."""
|
||||
sub_desc = _message_desc_map.get(sub_msg_name)
|
||||
if not sub_desc:
|
||||
return False
|
||||
inline_opt = getattr(pb, "inline_encode", None)
|
||||
if inline_opt is None:
|
||||
return False
|
||||
return get_opt(sub_desc, inline_opt, False)
|
||||
|
||||
|
||||
def _generate_inline_encode_block(
|
||||
field_number: int, sub_msg_name: str, element: str
|
||||
) -> str:
|
||||
"""Generate inline encode code for a sub-message with (inline_encode) = true.
|
||||
|
||||
Instead of calling encode_sub_message (function pointer indirection),
|
||||
this inlines the sub-message's field encoding directly. Uses 1-byte
|
||||
backpatch for the length (validated to be < 128 at generation time).
|
||||
|
||||
Uses a local reference alias 'sub_msg' to avoid issues with this-> replacement
|
||||
on complex element expressions.
|
||||
|
||||
Args:
|
||||
field_number: The parent field number for this sub-message
|
||||
sub_msg_name: The sub-message type name
|
||||
element: C++ expression for the element (e.g., "it" or "this->field[i]")
|
||||
"""
|
||||
sub_desc = _message_desc_map[sub_msg_name]
|
||||
tag = (field_number << 3) | 2 # wire type 2 = LENGTH_DELIMITED
|
||||
assert tag < 128, f"inline_encode requires single-byte tag, got {tag}"
|
||||
|
||||
lines = []
|
||||
lines.append(f"auto &sub_msg = {element};")
|
||||
lines.append(f"ProtoEncode::write_raw_byte(pos, {tag});")
|
||||
lines.append("uint8_t *len_pos = pos;")
|
||||
lines.append("ProtoEncode::reserve_byte(pos);")
|
||||
|
||||
# Generate inline field encoding for each sub-message field
|
||||
for field in sub_desc.field:
|
||||
if field.options.deprecated:
|
||||
continue
|
||||
ti = create_field_type_info(field, needs_decode=False, needs_encode=True)
|
||||
encode_line = ti.encode_content
|
||||
# Replace this-> with sub_msg reference for the sub-message fields
|
||||
encode_line = encode_line.replace("this->", "sub_msg.")
|
||||
lines.extend(wrap_with_ifdef(encode_line, get_field_opt(field, pb.field_ifdef)))
|
||||
|
||||
lines.append("*len_pos = static_cast<uint8_t>(pos - len_pos - 1);")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _generate_inline_size_block(
|
||||
field_number: int, sub_msg_name: str, element: str
|
||||
) -> str:
|
||||
"""Generate inline size calculation for a sub-message with (inline_encode) = true.
|
||||
|
||||
Uses a local reference alias 'sub_msg' to avoid issues with this-> replacement
|
||||
on complex element expressions like 'this->advertisements[i]'.
|
||||
|
||||
Args:
|
||||
field_number: The parent field number for this sub-message
|
||||
sub_msg_name: The sub-message type name
|
||||
element: C++ expression for the element
|
||||
"""
|
||||
sub_desc = _message_desc_map[sub_msg_name]
|
||||
|
||||
lines = []
|
||||
lines.append(f"auto &sub_msg = {element};")
|
||||
# 1 byte tag + 1 byte length (guaranteed < 128 by validation)
|
||||
lines.append("size += 2;")
|
||||
|
||||
for field in sub_desc.field:
|
||||
if field.options.deprecated:
|
||||
continue
|
||||
ti = create_field_type_info(field, needs_decode=False, needs_encode=True)
|
||||
force = get_field_opt(field, pb.force, False)
|
||||
size_line = ti.get_size_calculation(f"sub_msg.{ti.field_name}", force)
|
||||
# Replace hardcoded this-> references (e.g., FixedArrayBytesType uses this->field_len)
|
||||
size_line = size_line.replace("this->", "sub_msg.")
|
||||
lines.extend(wrap_with_ifdef(size_line, get_field_opt(field, pb.field_ifdef)))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class FixedArrayRepeatedType(TypeInfo):
|
||||
"""Special type for fixed-size repeated fields using std::array.
|
||||
|
||||
@@ -1526,6 +1693,10 @@ class FixedArrayRepeatedType(TypeInfo):
|
||||
return f"ProtoEncode::{self._ti.encode_func}(pos, {self.number}, static_cast<uint32_t>({element}), true);"
|
||||
# Repeated message elements use encode_sub_message (force=true is default)
|
||||
if isinstance(self._ti, MessageType):
|
||||
if _is_inline_encode(self._ti.cpp_type):
|
||||
return _generate_inline_encode_block(
|
||||
self.number, self._ti.cpp_type, element
|
||||
)
|
||||
return f"ProtoEncode::encode_sub_message(pos, buffer, {self.number}, {element});"
|
||||
return (
|
||||
f"ProtoEncode::{self._ti.encode_func}(pos, {self.number}, {element}, true);"
|
||||
@@ -1633,8 +1804,19 @@ class FixedArrayRepeatedType(TypeInfo):
|
||||
]
|
||||
return f"if ({non_zero_checks}) {{\n" + "\n".join(size_lines) + "\n}"
|
||||
|
||||
is_inline = isinstance(self._ti, MessageType) and _is_inline_encode(
|
||||
self._ti.cpp_type
|
||||
)
|
||||
|
||||
# When using a define, always use loop-based approach
|
||||
if self.is_define:
|
||||
if is_inline:
|
||||
o = f"for (const auto &it : {name}) {{\n"
|
||||
o += indent(
|
||||
_generate_inline_size_block(self.number, self._ti.cpp_type, "it")
|
||||
)
|
||||
o += "\n}"
|
||||
return o
|
||||
o = f"for (const auto &it : {name}) {{\n"
|
||||
o += f" {self._ti.get_size_calculation('it', True)}\n"
|
||||
o += "}"
|
||||
@@ -1642,6 +1824,14 @@ class FixedArrayRepeatedType(TypeInfo):
|
||||
|
||||
# For fixed arrays, we always encode all elements
|
||||
|
||||
if is_inline:
|
||||
o = f"for (const auto &it : {name}) {{\n"
|
||||
o += indent(
|
||||
_generate_inline_size_block(self.number, self._ti.cpp_type, "it")
|
||||
)
|
||||
o += "\n}"
|
||||
return o
|
||||
|
||||
# Special case for single-element arrays - no loop needed
|
||||
if self.array_size == 1:
|
||||
return self._ti.get_size_calculation(f"{name}[0]", True)
|
||||
@@ -1714,6 +1904,15 @@ class FixedArrayWithLengthRepeatedType(FixedArrayRepeatedType):
|
||||
|
||||
def get_size_calculation(self, name: str, force: bool = False) -> str:
|
||||
# Calculate size only for active elements
|
||||
if isinstance(self._ti, MessageType) and _is_inline_encode(self._ti.cpp_type):
|
||||
o = f"for (uint16_t i = 0; i < {name}_len; i++) {{\n"
|
||||
o += indent(
|
||||
_generate_inline_size_block(
|
||||
self.number, self._ti.cpp_type, f"{name}[i]"
|
||||
)
|
||||
)
|
||||
o += "\n}"
|
||||
return o
|
||||
o = f"for (uint16_t i = 0; i < {name}_len; i++) {{\n"
|
||||
o += f" {self._ti.get_size_calculation(f'{name}[i]', True)}\n"
|
||||
o += "}"
|
||||
@@ -2222,6 +2421,28 @@ def calculate_message_estimated_size(desc: descriptor.DescriptorProto) -> int:
|
||||
return total_size
|
||||
|
||||
|
||||
def calculate_message_max_size(desc: descriptor.DescriptorProto) -> int | None:
|
||||
"""Calculate the maximum possible encoded size for a message.
|
||||
|
||||
Returns None if any field has unbounded size (e.g., variable-length strings).
|
||||
Used to validate that (inline_encode) messages fit in a single-byte length varint.
|
||||
"""
|
||||
total_size = 0
|
||||
|
||||
for field in desc.field:
|
||||
if field.options.deprecated:
|
||||
continue
|
||||
|
||||
ti = create_field_type_info(field, needs_decode=False, needs_encode=True)
|
||||
max_size = ti.get_max_encoded_size()
|
||||
if max_size is None:
|
||||
return None
|
||||
|
||||
total_size += max_size
|
||||
|
||||
return total_size
|
||||
|
||||
|
||||
def build_message_type(
|
||||
desc: descriptor.DescriptorProto,
|
||||
base_class_fields: dict[str, list[descriptor.FieldDescriptorProto]],
|
||||
@@ -2451,13 +2672,35 @@ def build_message_type(
|
||||
prot = "void decode(const uint8_t *buffer, size_t length);"
|
||||
public_content.append(prot)
|
||||
|
||||
# Check if this message uses inline_encode — if so, skip generating standalone
|
||||
# encode/calculate_size methods since the encoding is inlined into the parent.
|
||||
inline_opt = getattr(pb, "inline_encode", None)
|
||||
is_inline_only = (
|
||||
message_id is None # Not a service message (no id)
|
||||
and inline_opt is not None
|
||||
and get_opt(desc, inline_opt, False)
|
||||
)
|
||||
|
||||
# Check if this message wants speed-optimized encode/calculate_size.
|
||||
# When set, __attribute__((optimize("O2"))) is added to the definitions
|
||||
# so GCC inlines the small ProtoEncode helpers even under -Os.
|
||||
is_speed_optimized = get_opt(desc, pb.speed_optimized, False)
|
||||
speed_attr = (
|
||||
'__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)\n'
|
||||
if is_speed_optimized
|
||||
else ""
|
||||
)
|
||||
|
||||
# Only generate encode method if this message needs encoding and has fields
|
||||
if needs_encode and encode:
|
||||
if needs_encode and encode and not is_inline_only:
|
||||
# Add PROTO_ENCODE_DEBUG_ARG after pos in all proto_* calls
|
||||
encode_debug = [
|
||||
line.replace("(pos,", "(pos PROTO_ENCODE_DEBUG_ARG,") for line in encode
|
||||
line.replace("(pos,", "(pos PROTO_ENCODE_DEBUG_ARG,").replace(
|
||||
"(pos)", "(pos PROTO_ENCODE_DEBUG_ARG)"
|
||||
)
|
||||
for line in encode
|
||||
]
|
||||
o = f"uint8_t *{desc.name}::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {{\n"
|
||||
o = f"{speed_attr}uint8_t *{desc.name}::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {{\n"
|
||||
o += " uint8_t *__restrict__ pos = buffer.get_pos();\n"
|
||||
o += indent("\n".join(encode_debug)) + "\n"
|
||||
o += " return pos;\n"
|
||||
@@ -2470,8 +2713,8 @@ def build_message_type(
|
||||
# If no fields to encode or message doesn't need encoding, the default implementation in ProtoMessage will be used
|
||||
|
||||
# Add calculate_size method only if this message needs encoding and has fields
|
||||
if needs_encode and size_calc:
|
||||
o = f"uint32_t {desc.name}::calculate_size() const {{\n"
|
||||
if needs_encode and size_calc and not is_inline_only:
|
||||
o = f"{speed_attr}uint32_t {desc.name}::calculate_size() const {{\n"
|
||||
o += " uint32_t size = 0;\n"
|
||||
o += indent("\n".join(size_calc)) + "\n"
|
||||
o += " return size;\n"
|
||||
@@ -2830,6 +3073,32 @@ def main() -> None:
|
||||
if not enum.options.deprecated and enum.value:
|
||||
_enum_max_values[f".{enum.name}"] = max(v.number for v in enum.value)
|
||||
|
||||
# Build message descriptor map for inline_encode lookups
|
||||
mt = file.message_type
|
||||
_message_desc_map.update({m.name: m for m in mt if not m.options.deprecated})
|
||||
|
||||
# Validate inline_encode messages fit in single-byte length varint
|
||||
inline_encode_opt = getattr(pb, "inline_encode", None)
|
||||
if inline_encode_opt is not None:
|
||||
for m in mt:
|
||||
if m.options.deprecated:
|
||||
continue
|
||||
if not get_opt(m, inline_encode_opt, False):
|
||||
continue
|
||||
max_size = calculate_message_max_size(m)
|
||||
if max_size is None:
|
||||
raise ValueError(
|
||||
f"Message '{m.name}' has (inline_encode) = true but contains "
|
||||
f"fields with unbounded size. Inline encoding requires all "
|
||||
f"fields to have bounded maximum size."
|
||||
)
|
||||
if max_size >= 128:
|
||||
raise ValueError(
|
||||
f"Message '{m.name}' has (inline_encode) = true but max "
|
||||
f"encoded size is {max_size} bytes (>= 128). Inline encoding "
|
||||
f"requires sub-messages that fit in a single-byte length varint."
|
||||
)
|
||||
|
||||
# Build dynamic ifdef mappings early so we can emit USE_API_VARINT64 before includes
|
||||
enum_ifdef_map, message_ifdef_map, message_source_map, used_messages = (
|
||||
build_type_usage_map(file)
|
||||
@@ -3048,8 +3317,6 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint
|
||||
|
||||
content += "\n} // namespace enums\n\n"
|
||||
|
||||
mt = file.message_type
|
||||
|
||||
# Identify empty SOURCE_CLIENT messages that don't need class generation
|
||||
for m in mt:
|
||||
if m.options.deprecated:
|
||||
|
||||
@@ -8,10 +8,16 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.esp32 import VARIANTS
|
||||
from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS
|
||||
from esphome.components.esp32 import VARIANT_ESP32, VARIANTS
|
||||
from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS, KEY_VARIANT
|
||||
from esphome.components.esp32.gpio import validate_gpio_pin
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ESPHOME, PlatformFramework
|
||||
from esphome.const import (
|
||||
CONF_ESPHOME,
|
||||
CONF_IGNORE_PIN_VALIDATION_ERROR,
|
||||
CONF_NUMBER,
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
@@ -149,6 +155,73 @@ def test_execute_from_psram_p4_sdkconfig(
|
||||
assert "CONFIG_SPIRAM_RODATA" not in sdkconfig
|
||||
|
||||
|
||||
def test_ignore_pin_validation_error_on_clean_pin_warns(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A pin that passes validation but sets `ignore_pin_validation_error: true`
|
||||
should log a warning nudging the user to remove the flag, and not raise."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32}
|
||||
)
|
||||
|
||||
pin = {CONF_NUMBER: 4, CONF_IGNORE_PIN_VALIDATION_ERROR: True}
|
||||
with caplog.at_level("WARNING"):
|
||||
result = validate_gpio_pin(pin)
|
||||
|
||||
assert result[CONF_NUMBER] == 4
|
||||
assert "GPIO4 has no validation errors to ignore" in caplog.text
|
||||
|
||||
|
||||
def test_ignore_pin_validation_error_on_dirty_pin_suppresses(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A pin that fails validation with `ignore_pin_validation_error: true` should
|
||||
log the suppression warning and not raise (existing behavior)."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32}
|
||||
)
|
||||
|
||||
# GPIO6 is a flash pin on ESP32 -> pin_validation raises cv.Invalid
|
||||
pin = {CONF_NUMBER: 6, CONF_IGNORE_PIN_VALIDATION_ERROR: True}
|
||||
with caplog.at_level("WARNING"):
|
||||
result = validate_gpio_pin(pin)
|
||||
|
||||
assert result[CONF_NUMBER] == 6
|
||||
assert "Ignoring validation error on pin 6" in caplog.text
|
||||
|
||||
|
||||
def test_dirty_pin_without_ignore_flag_raises(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""A pin that fails validation without the ignore flag should still raise."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32}
|
||||
)
|
||||
|
||||
pin = {CONF_NUMBER: 6, CONF_IGNORE_PIN_VALIDATION_ERROR: False}
|
||||
with pytest.raises(cv.Invalid, match="flash interface"):
|
||||
validate_gpio_pin(pin)
|
||||
|
||||
|
||||
def test_clean_pin_without_ignore_flag_does_not_warn(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A clean pin without the ignore flag should pass silently."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32}
|
||||
)
|
||||
|
||||
pin = {CONF_NUMBER: 4, CONF_IGNORE_PIN_VALIDATION_ERROR: False}
|
||||
with caplog.at_level("WARNING"):
|
||||
result = validate_gpio_pin(pin)
|
||||
|
||||
assert result[CONF_NUMBER] == 4
|
||||
assert "has no validation errors to ignore" not in caplog.text
|
||||
|
||||
|
||||
def test_execute_from_psram_disabled_sdkconfig(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Tests for the _final_validate buffer size calculation in mipi_spi."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.display import CONF_SHOW_TEST_CARD
|
||||
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
|
||||
from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA
|
||||
from esphome.const import CONF_BUFFER_SIZE, PlatformFramework
|
||||
from esphome.types import ConfigType
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
|
||||
def _validated(config: ConfigType) -> ConfigType:
|
||||
"""Run the component config schema followed by the final validation."""
|
||||
config = CONFIG_SCHEMA(config)
|
||||
FINAL_VALIDATE_SCHEMA(config)
|
||||
return config
|
||||
|
||||
|
||||
def _custom_config(
|
||||
width: int,
|
||||
height: int,
|
||||
color_depth: str | int | None = None,
|
||||
**extra: Any,
|
||||
) -> ConfigType:
|
||||
"""Build a minimal valid custom-model config with the given dimensions."""
|
||||
config: ConfigType = {
|
||||
"model": "custom",
|
||||
"dc_pin": 18,
|
||||
"dimensions": {"width": width, "height": height},
|
||||
"init_sequence": [[0xA0, 0x01]],
|
||||
}
|
||||
if color_depth is not None:
|
||||
config["color_depth"] = color_depth
|
||||
config.update(extra)
|
||||
return config
|
||||
|
||||
|
||||
# The auto buffer-size selection inside _final_validate targets ~20 kB of
|
||||
# pixel buffer. For a buffer of ``depth_bytes * width * height``, it picks the
|
||||
# smallest integer ``x`` in range(2, 8) such that
|
||||
# ``min(20000, buffer // 4) / buffer >= 1 / x`` (falling back to ``x = 8``).
|
||||
# The test cases below cover the full range of possible outcomes (1/4 .. 1/8).
|
||||
@pytest.mark.parametrize(
|
||||
("width", "height", "color_depth", "expected"),
|
||||
[
|
||||
# 16-bit color depth -- buffer = 2 * width * height
|
||||
# 128*160*2 = 40960 B -> fraction = 10240/40960 = 0.25 -> x = 4
|
||||
pytest.param(128, 160, "16bit", 1.0 / 4, id="16bit_tiny"),
|
||||
# 200*224*2 = 89600 B -> fraction = 20000/89600 ≈ 0.2232 -> x = 5
|
||||
pytest.param(200, 224, "16bit", 1.0 / 5, id="16bit_small"),
|
||||
# 240*224*2 = 107520 B -> fraction ≈ 0.1860 -> x = 6
|
||||
pytest.param(240, 224, "16bit", 1.0 / 6, id="16bit_medium"),
|
||||
# 200*320*2 = 128000 B -> fraction = 0.15625 -> x = 7
|
||||
pytest.param(200, 320, "16bit", 1.0 / 7, id="16bit_large"),
|
||||
# 240*320*2 = 153600 B -> fraction ≈ 0.1302 -> default x = 8
|
||||
pytest.param(240, 320, "16bit", 1.0 / 8, id="16bit_xlarge"),
|
||||
# 320*480*2 = 307200 B -> fraction ≈ 0.0651 -> default x = 8
|
||||
pytest.param(320, 480, "16bit", 1.0 / 8, id="16bit_huge"),
|
||||
# 8-bit color depth -- buffer = width * height
|
||||
# 320*240 = 76800 B -> fraction = 19200/76800 = 0.25 -> x = 4
|
||||
pytest.param(320, 240, "8bit", 1.0 / 4, id="8bit_tiny"),
|
||||
# 400*224 = 89600 B -> fraction ≈ 0.2232 -> x = 5
|
||||
pytest.param(400, 224, "8bit", 1.0 / 5, id="8bit_small"),
|
||||
# 480*224 = 107520 B -> fraction ≈ 0.1860 -> x = 6
|
||||
pytest.param(480, 224, "8bit", 1.0 / 6, id="8bit_medium"),
|
||||
# 400*320 = 128000 B -> fraction = 0.15625 -> x = 7
|
||||
pytest.param(400, 320, "8bit", 1.0 / 7, id="8bit_large"),
|
||||
# 480*320 = 153600 B -> fraction ≈ 0.1302 -> default x = 8
|
||||
pytest.param(480, 320, "8bit", 1.0 / 8, id="8bit_xlarge"),
|
||||
],
|
||||
)
|
||||
def test_buffer_size_auto_selected(
|
||||
width: int,
|
||||
height: int,
|
||||
color_depth: str,
|
||||
expected: float,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""Without PSRAM or an explicit buffer_size, a fraction is chosen from the display size.
|
||||
|
||||
Without any drawing method and without LVGL, final validation also auto-enables
|
||||
``show_test_card``, which in turn makes the component require a buffer and therefore
|
||||
triggers the buffer-size selection path.
|
||||
"""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
config = _validated(_custom_config(width, height, color_depth))
|
||||
|
||||
# Sanity check: final validation should have enabled the test card for us,
|
||||
# which is what causes the buffer-size calculation to actually run.
|
||||
assert config.get(CONF_SHOW_TEST_CARD) is True
|
||||
assert config[CONF_BUFFER_SIZE] == pytest.approx(expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"buffer_size",
|
||||
[0.125, 0.25, 0.5, 1.0],
|
||||
ids=["one_eighth", "one_quarter", "half", "full"],
|
||||
)
|
||||
def test_explicit_buffer_size_is_preserved(
|
||||
buffer_size: float,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""An explicitly configured buffer_size is never overridden by final validation."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
config = _validated(
|
||||
_custom_config(240, 320, "16bit", buffer_size=buffer_size),
|
||||
)
|
||||
|
||||
assert config[CONF_BUFFER_SIZE] == pytest.approx(buffer_size)
|
||||
|
||||
|
||||
def test_buffer_size_not_set_when_psram_enabled(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
set_component_config,
|
||||
) -> None:
|
||||
"""When PSRAM is enabled the auto buffer-size selection is skipped."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
# Presence of the psram domain in the full config is what _final_validate checks.
|
||||
set_component_config("psram", True)
|
||||
|
||||
config = _validated(_custom_config(240, 320, "16bit"))
|
||||
|
||||
assert CONF_BUFFER_SIZE not in config
|
||||
|
||||
|
||||
def test_buffer_size_not_set_when_buffer_not_required(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
set_component_config,
|
||||
) -> None:
|
||||
"""With LVGL present and no drawing methods, no buffer fraction is chosen.
|
||||
|
||||
LVGL suppresses the automatic show_test_card injection, which means
|
||||
``requires_buffer`` is False and the early-return branch fires.
|
||||
"""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
set_component_config("lvgl", [])
|
||||
|
||||
config = _validated(_custom_config(240, 320, "16bit"))
|
||||
|
||||
assert CONF_BUFFER_SIZE not in config
|
||||
# And no test card should have been auto-enabled either.
|
||||
assert not config.get(CONF_SHOW_TEST_CARD)
|
||||
|
||||
|
||||
def test_buffer_size_selected_when_lvgl_with_test_card(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
set_component_config,
|
||||
) -> None:
|
||||
"""LVGL present + an explicit drawing method still triggers buffer sizing.
|
||||
|
||||
When LVGL is enabled, ``show_test_card`` is not injected automatically,
|
||||
but users can still request it explicitly -- in that case ``requires_buffer``
|
||||
is True and the buffer-size heuristic still runs.
|
||||
"""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
set_component_config("lvgl", [])
|
||||
|
||||
# 128x160 @ 16bit -> expected 1/4 (see test_buffer_size_auto_selected).
|
||||
config = _validated(
|
||||
_custom_config(128, 160, "16bit", show_test_card=True),
|
||||
)
|
||||
|
||||
assert config[CONF_BUFFER_SIZE] == pytest.approx(1.0 / 4)
|
||||
@@ -1,12 +1,21 @@
|
||||
"""Tests for the packages component."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass, merge_packages
|
||||
from esphome.components.substitutions import do_substitution_pass
|
||||
from esphome.components.packages import (
|
||||
CONFIG_SCHEMA,
|
||||
_substitute_package_definition,
|
||||
_walk_packages,
|
||||
do_packages_pass,
|
||||
is_package_definition,
|
||||
merge_packages,
|
||||
)
|
||||
from esphome.components.substitutions import ContextVars, do_substitution_pass
|
||||
import esphome.config as config_module
|
||||
from esphome.config import resolve_extend_remove
|
||||
from esphome.config_helpers import Extend, Remove
|
||||
@@ -37,7 +46,7 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.util import OrderedDict
|
||||
from esphome.yaml_util import add_context
|
||||
from esphome.yaml_util import IncludeFile, add_context, load_yaml
|
||||
|
||||
# Test strings
|
||||
TEST_DEVICE_NAME = "test_device_name"
|
||||
@@ -79,6 +88,44 @@ def packages_pass(config):
|
||||
return config
|
||||
|
||||
|
||||
_INCLUDE_FILE = "INCLUDE_FILE"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
# IncludeFile objects are package definitions
|
||||
(_INCLUDE_FILE, True),
|
||||
# Git URL shorthand strings are package definitions
|
||||
("github://esphome/firmware/base.yaml@main", True),
|
||||
# Remote package dicts (with url key) are package definitions
|
||||
({"url": "https://github.com/esphome/firmware", "file": "base.yaml"}, True),
|
||||
# Plain config dicts are NOT package definitions (they are config fragments)
|
||||
({"wifi": {"ssid": "test"}}, False),
|
||||
# None is not a package definition
|
||||
(None, False),
|
||||
# Lists are not package definitions
|
||||
([{"wifi": {"ssid": "test"}}], False),
|
||||
# Empty dicts are not package definitions
|
||||
({}, False),
|
||||
],
|
||||
ids=[
|
||||
"include_file",
|
||||
"git_shorthand",
|
||||
"remote_package",
|
||||
"config_fragment",
|
||||
"none",
|
||||
"list",
|
||||
"empty_dict",
|
||||
],
|
||||
)
|
||||
def test_is_package_definition(value: object, expected: bool) -> None:
|
||||
"""Test that is_package_definition correctly identifies package definitions."""
|
||||
if value is _INCLUDE_FILE:
|
||||
value = MagicMock(spec=IncludeFile)
|
||||
assert is_package_definition(value) is expected
|
||||
|
||||
|
||||
def test_package_unused(basic_esphome, basic_wifi) -> None:
|
||||
"""
|
||||
Ensures do_package_pass does not change a config if packages aren't used.
|
||||
@@ -1061,6 +1108,51 @@ def test_packages_invalid_type_raises() -> None:
|
||||
do_packages_pass(config)
|
||||
|
||||
|
||||
@patch("esphome.components.packages.resolve_include")
|
||||
def test_packages_include_file_resolves_to_list(mock_resolve_include) -> None:
|
||||
"""When packages: is an IncludeFile that resolves to a list, it is processed correctly."""
|
||||
include_file = MagicMock(spec=IncludeFile)
|
||||
package_content = {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}}
|
||||
mock_resolve_include.return_value = ([package_content], None)
|
||||
|
||||
config = {CONF_PACKAGES: include_file}
|
||||
result = do_packages_pass(config)
|
||||
result = merge_packages(result)
|
||||
|
||||
assert result == {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}}
|
||||
|
||||
|
||||
@patch("esphome.components.packages.resolve_include")
|
||||
def test_packages_include_file_resolves_to_dict(mock_resolve_include) -> None:
|
||||
"""When packages: is an IncludeFile that resolves to a dict, it is processed correctly."""
|
||||
include_file = MagicMock(spec=IncludeFile)
|
||||
package_content = {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}}
|
||||
mock_resolve_include.return_value = ({"network": package_content}, None)
|
||||
|
||||
config = {CONF_PACKAGES: include_file}
|
||||
result = do_packages_pass(config)
|
||||
result = merge_packages(result)
|
||||
|
||||
assert result == {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}}
|
||||
|
||||
|
||||
@patch("esphome.components.packages.resolve_include")
|
||||
def test_packages_include_file_resolves_to_invalid_type_raises(
|
||||
mock_resolve_include,
|
||||
) -> None:
|
||||
"""When packages: is an IncludeFile that resolves to an invalid type, cv.Invalid is raised."""
|
||||
include_file = MagicMock(spec=IncludeFile)
|
||||
mock_resolve_include.return_value = ("not_a_dict_or_list", None)
|
||||
|
||||
config = {CONF_PACKAGES: include_file}
|
||||
with pytest.raises(
|
||||
cv.Invalid, match="Packages must be a key to value mapping or list"
|
||||
) as exc_info:
|
||||
do_packages_pass(config)
|
||||
|
||||
assert exc_info.value.path == [CONF_PACKAGES]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_package",
|
||||
[
|
||||
@@ -1107,6 +1199,134 @@ def test_invalid_package_contents_masked_by_deprecation(
|
||||
do_packages_pass(config)
|
||||
|
||||
|
||||
def test_named_dict_with_include_files_no_false_deprecation_warning(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Package errors in named dicts must not trigger the deprecated fallback."""
|
||||
good_include = MagicMock(spec=IncludeFile)
|
||||
bad_include = MagicMock(spec=IncludeFile)
|
||||
|
||||
config = {
|
||||
CONF_PACKAGES: {
|
||||
"good_pkg": good_include,
|
||||
"bad_pkg": bad_include,
|
||||
},
|
||||
}
|
||||
|
||||
call_count = 0
|
||||
|
||||
def failing_callback(package_config: dict, context: object) -> dict:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
# First package processes fine
|
||||
return {CONF_WIFI: {CONF_SSID: "test"}}
|
||||
# Second package has an error (e.g. jinja syntax error)
|
||||
raise cv.Invalid("simulated jinja error in bad_pkg")
|
||||
|
||||
with (
|
||||
caplog.at_level(logging.WARNING),
|
||||
pytest.raises(cv.Invalid, match="simulated jinja error"),
|
||||
):
|
||||
_walk_packages(config, failing_callback)
|
||||
|
||||
# Must NOT emit the deprecated single-package warning
|
||||
assert "deprecated" not in caplog.text.lower()
|
||||
|
||||
|
||||
def test_validate_deprecated_false_raises_directly(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""With validate_deprecated=False, errors raise directly without fallback.
|
||||
|
||||
This is the codepath used for remote packages where _process_remote_package
|
||||
returns already-resolved dicts that is_package_definition cannot detect.
|
||||
"""
|
||||
config = {
|
||||
CONF_PACKAGES: {
|
||||
"pkg_a": {CONF_WIFI: {CONF_SSID: "test"}},
|
||||
"pkg_b": {CONF_WIFI: {CONF_SSID: "test2"}},
|
||||
},
|
||||
}
|
||||
|
||||
call_count = 0
|
||||
|
||||
def failing_callback(package_config: dict, context: object) -> dict:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return package_config
|
||||
raise cv.Invalid("nested error")
|
||||
|
||||
with (
|
||||
caplog.at_level(logging.WARNING),
|
||||
pytest.raises(cv.Invalid, match="nested error"),
|
||||
):
|
||||
_walk_packages(config, failing_callback, validate_deprecated=False)
|
||||
|
||||
assert "deprecated" not in caplog.text.lower()
|
||||
|
||||
|
||||
def test_error_on_first_declared_package_still_detected() -> None:
|
||||
"""When the first declared package errors, it's the last processed in reverse.
|
||||
|
||||
All other entries are already resolved to dicts, but the failing entry
|
||||
retains its original IncludeFile value since assignment was skipped.
|
||||
"""
|
||||
config = {
|
||||
CONF_PACKAGES: {
|
||||
"first_pkg": MagicMock(spec=IncludeFile),
|
||||
"second_pkg": MagicMock(spec=IncludeFile),
|
||||
"third_pkg": MagicMock(spec=IncludeFile),
|
||||
},
|
||||
}
|
||||
|
||||
call_count = 0
|
||||
|
||||
def fail_on_last(package_config: dict, context: object) -> dict:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
# Reverse iteration: third_pkg (1), second_pkg (2), first_pkg (3)
|
||||
if call_count < 3:
|
||||
return {CONF_WIFI: {CONF_SSID: "test"}}
|
||||
raise cv.Invalid("error in first_pkg")
|
||||
|
||||
with pytest.raises(cv.Invalid, match="error in first_pkg"):
|
||||
_walk_packages(config, fail_on_last)
|
||||
|
||||
|
||||
def test_deprecated_single_package_fallback_still_works(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The deprecated single-package form still falls back at the top level.
|
||||
|
||||
When a dict's values are plain config fragments (not package definitions)
|
||||
and the callback fails, the deprecated fallback wraps the dict in a list
|
||||
and retries with a deprecation warning.
|
||||
"""
|
||||
config = {
|
||||
CONF_PACKAGES: {
|
||||
CONF_WIFI: {CONF_SSID: "test", CONF_PASSWORD: "secret"},
|
||||
},
|
||||
}
|
||||
|
||||
attempt = 0
|
||||
|
||||
def fail_then_succeed(package_config: dict, context: object) -> dict:
|
||||
nonlocal attempt
|
||||
attempt += 1
|
||||
if attempt == 1:
|
||||
# First attempt: treating as named dict fails
|
||||
raise cv.Invalid("not a valid package")
|
||||
# Second attempt: after fallback wraps as list, succeeds
|
||||
return package_config
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_walk_packages(config, fail_then_succeed)
|
||||
|
||||
assert "deprecated" in caplog.text.lower()
|
||||
|
||||
|
||||
def test_merge_packages_invalid_nested_type_raises() -> None:
|
||||
"""Invalid nested packages type during merge raises cv.Invalid."""
|
||||
config = {
|
||||
@@ -1181,3 +1401,85 @@ def test_raw_config_contains_merged_esphome_from_package(tmp_path) -> None:
|
||||
"CORE.raw_config should contain esphome section after package merge"
|
||||
)
|
||||
assert CORE.raw_config[CONF_ESPHOME][CONF_NAME] == TEST_DEVICE_NAME
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _substitute_package_definition
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_substitute_package_definition_local_dict_returned_unchanged() -> None:
|
||||
"""A plain local config dict is not substituted and is returned as-is."""
|
||||
pkg = {CONF_WIFI: {CONF_SSID: "test"}}
|
||||
result = _substitute_package_definition(pkg, ContextVars())
|
||||
assert result is pkg
|
||||
|
||||
|
||||
def test_substitute_package_definition_string_resolved_with_context() -> None:
|
||||
"""A string package definition has its variables substituted."""
|
||||
ctx = ContextVars({"variant": "esp32"})
|
||||
result = _substitute_package_definition("device-${variant}.yaml", ctx)
|
||||
assert result == "device-esp32.yaml"
|
||||
|
||||
|
||||
def test_substitute_package_definition_undefined_in_string() -> None:
|
||||
"""An undefined variable in a package URL string raises cv.Invalid."""
|
||||
with pytest.raises(cv.Invalid, match="Undefined variable in package definition"):
|
||||
_substitute_package_definition(
|
||||
"github://org/repo/${undefined_var}/pkg.yaml", ContextVars()
|
||||
)
|
||||
|
||||
|
||||
def test_substitute_package_definition_undefined_in_remote_dict_field() -> None:
|
||||
"""An undefined variable inside a remote-dict field names the offending field."""
|
||||
with pytest.raises(cv.Invalid) as exc_info:
|
||||
_substitute_package_definition(
|
||||
{CONF_URL: "github://${typo}/repo"}, ContextVars()
|
||||
)
|
||||
err = str(exc_info.value)
|
||||
assert "'typo' is undefined" in err
|
||||
assert CONF_URL in err
|
||||
|
||||
|
||||
def test_substitute_package_definition_undefined_in_remote_dict_non_first_field() -> (
|
||||
None
|
||||
):
|
||||
"""The field path joins correctly for non-first dict fields (e.g. ``ref``)."""
|
||||
with pytest.raises(cv.Invalid) as exc_info:
|
||||
_substitute_package_definition(
|
||||
{
|
||||
CONF_URL: "github://org/repo",
|
||||
CONF_REF: "branch-${branch_typo}",
|
||||
},
|
||||
ContextVars(),
|
||||
)
|
||||
err = str(exc_info.value)
|
||||
assert "'branch_typo' is undefined" in err
|
||||
assert CONF_REF in err
|
||||
|
||||
|
||||
def test_substitute_package_definition_includes_source_location(tmp_path: Path) -> None:
|
||||
"""A package loaded from YAML surfaces file/line/col in the cv.Invalid message.
|
||||
|
||||
Line/column are rendered 1-based (matching config.line_info() and editor
|
||||
line numbering) and point at the offending scalar, not the enclosing dict.
|
||||
"""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text(
|
||||
"packages:\n broken: github://org/repo/${undefined_var}/pkg.yaml\n"
|
||||
)
|
||||
config = load_yaml(yaml_file)
|
||||
package_config = config[CONF_PACKAGES]["broken"]
|
||||
|
||||
with pytest.raises(cv.Invalid) as exc_info:
|
||||
_substitute_package_definition(package_config, ContextVars())
|
||||
|
||||
err = str(exc_info.value)
|
||||
assert "main.yaml" in err
|
||||
# The offending value lives on line 2 (1-based). Column depends on the YAML
|
||||
# loader, so we only pin line and check that a 1-based column is present.
|
||||
match = re.search(r"main\.yaml (\d+):(\d+)", err)
|
||||
assert match, err
|
||||
line, col = int(match.group(1)), int(match.group(2))
|
||||
assert line == 2, f"expected 1-based line 2, got {line} (err={err!r})"
|
||||
assert col >= 1, f"expected 1-based column ≥ 1, got {col} (err={err!r})"
|
||||
|
||||
@@ -50,6 +50,13 @@ button:
|
||||
- platform: template
|
||||
name: Canbus Actions
|
||||
on_press:
|
||||
- canbus.send:
|
||||
can_id: 0x601
|
||||
data: [0, 1, 2]
|
||||
- canbus.send:
|
||||
can_id: 0x1FFFFFFF
|
||||
use_extended_id: true
|
||||
data: [0, 1, 2]
|
||||
- canbus.send: "abc"
|
||||
- canbus.send: [0, 1, 2]
|
||||
- canbus.send: !lambda return {0, 1, 2};
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: 6
|
||||
mosi_pin: 7
|
||||
miso_pin: 2
|
||||
cs_pin: 10
|
||||
interrupt_pin: 3
|
||||
reset_pin: 4
|
||||
clock_speed: 10Mhz
|
||||
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"
|
||||
on_connect:
|
||||
- logger.log: "Ethernet connected!"
|
||||
on_disconnect:
|
||||
- logger.log: "Ethernet disconnected!"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user