Compare commits

...

83 Commits

Author SHA1 Message Date
Jesse Hills 6ca5b31fab Merge pull request #15933 from esphome/bump-2026.4.2
2026.4.2
2026-04-23 14:10:10 +12:00
Jesse Hills 00b71208a6 Bump version to 2026.4.2 2026-04-23 11:18:39 +12:00
Keith Burzinski 76eb8f697f [usb_uart] Derive TX output chunk count from buffer_size config (#15909) 2026-04-23 11:18:39 +12:00
Jonathan Swoboda 2a3bd8bc85 [io_expanders] Self-heal interrupt-driven expanders when INT stays asserted across the read (#15923) 2026-04-23 11:18:39 +12:00
Keith Burzinski 629da4d878 [esp32] Add Secure Boot V1 ECDSA signing scheme for pre-rev-3.0 ESP32 (#15882) 2026-04-23 11:18:39 +12:00
Jonathan Swoboda 5c2ceb63e0 [ld2412] Fix null deref in set_basic_config when entities unconfigured (#15893) 2026-04-23 11:18:39 +12:00
Jonathan Swoboda 92cb6dd7fd [core] Fix Pvariable placement new losing subclass identity (#15881) 2026-04-23 11:18:39 +12:00
Jonathan Swoboda 06e5931ad7 [image] Fix rodata bloat for multi-frame RGB565+alpha animations (#15873) 2026-04-23 11:18:39 +12:00
Clyde Stubbs dc5b06285d [lvgl] Fix update of textarea attached to keyboard (#15866) 2026-04-23 11:18:38 +12:00
Clyde Stubbs 3d0a2421a6 [lvgl] Fix overloads for setting images on styles (#15864) 2026-04-23 11:18:38 +12:00
Clyde Stubbs 22f6791dea [lvgl] Fix format of hello world page (#15868) 2026-04-23 11:18:38 +12:00
Jesse Hills 572fb83015 Merge pull request #15859 from esphome/bump-2026.4.1
2026.4.1
2026-04-20 13:52:45 +12:00
Clyde Stubbs 0d3db2b670 [lvgl] Fix angles for arc (#15860) 2026-04-20 12:08:35 +12:00
J. Nick Koston bab9cd3e7a [runtime_stats] Track main loop active time and report overhead (#15743) 2026-04-20 11:20:39 +12:00
Jesse Hills 36812591eb Bump version to 2026.4.1 2026-04-20 10:20:56 +12:00
Javier Peletier 1862c6115f [packages] Improve error messages with include stack and fix missing path propagation (#15844)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-04-20 10:20:56 +12:00
J. Nick Koston ef780886c3 [substitutions] Fix substitutions: !include file.yaml regression (#15850) 2026-04-20 10:20:56 +12:00
J. Nick Koston 602305b20d [core] Default PollingComponent() to 1ms when codegen is bypassed (#15831) 2026-04-20 10:20:56 +12:00
dependabot[bot] 78701debec Bump aioesphomeapi from 44.16.0 to 44.16.1 (#15836)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 10:20:56 +12:00
J. Nick Koston 08ac61ae94 [core] Feed WDT unconditionally in main loop to fix empty-config panic (#15830) 2026-04-20 10:20:16 +12:00
Clyde Stubbs 6d5340f253 [lvgl] Fix crash with snow on rotated display (#15822) 2026-04-20 10:18:05 +12:00
Clyde Stubbs e2dfef5ddc [runtime_image] Fix RGB order (#15813) 2026-04-20 10:18:05 +12:00
Jonathan Swoboda 1d88027618 [esp32] Downgrade unneeded ignore_pin_validation_error to a warning (#15811) 2026-04-20 10:18:05 +12:00
J. Nick Koston 9841deec31 [core] Fix DelayAction compile error with non-const reference args (#15814) 2026-04-20 10:18:05 +12:00
Jonathan Swoboda ed5852c2d6 [ethernet] Fix SPI3_HOST default breaking compile on variants without SPI3 (#15809)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-04-20 10:18:05 +12:00
J. Nick Koston b26601a3dc [core] coerce set_interval(0) / update_interval: 0ms to 1ms (#15799) 2026-04-20 10:18:05 +12:00
Clyde Stubbs f5806818cd [image] Fix byte order handling (#15800) 2026-04-20 10:18:05 +12:00
Clyde Stubbs c3e739eba9 [mipi_spi] Drawing fixes for native display (#15802) 2026-04-20 10:18:05 +12:00
Jonathan Swoboda b167b64f06 [lvgl] Guard lv_image_set_src wrapper with LV_USE_IMAGE (#15789) 2026-04-20 10:18:05 +12:00
Jonathan Swoboda 722cfae04c [esp32] Accept unquoted minimum_chip_revision values (#15785) 2026-04-20 10:18:05 +12:00
J. Nick Koston 9cb2b562b9 [ili9xxx] Guard against null buffer in display_() when allocation fails (#15786) 2026-04-20 10:18:05 +12:00
J. Nick Koston 81fb6712fe [bundle] Force-resolve nested IncludeFile during file discovery (#15762) 2026-04-20 10:18:05 +12:00
Jonathan Swoboda 227dfa3730 [qmc5883l] Move per-update log line from DEBUG to VERBOSE (#15781) 2026-04-20 10:18:05 +12:00
J. Nick Koston aa80bdbbc6 [time] Fix RTC is_valid() rejecting valid times after day_of_year cleanup (#15763) 2026-04-20 10:18:05 +12:00
J. Nick Koston 914ed10bcc [core] Diagnose missing cg.templatable in codegen for TEMPLATABLE_VALUE fields (#15758) 2026-04-20 10:18:05 +12:00
Boris Krivonog 92c99a7d41 [mitsubishi_cn105] use HEAT_COOL mode to enable temperature slider (#15748) 2026-04-20 10:18:05 +12:00
Clyde Stubbs af1aaba547 [lvgl] Clean the build if lv_conf.h changes (#15777) 2026-04-20 10:18:05 +12:00
dependabot[bot] 5a2b7546f6 Bump aioesphomeapi from 44.15.0 to 44.16.0 (#15757)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 10:18:05 +12:00
Jonathan Swoboda 4047d5af5f [sx126x][sx127x] Fix frequency precision loss from float32 codegen (#15753) 2026-04-20 10:18:05 +12:00
Jonathan Swoboda 6857e1ceb4 [st7789v] Fix swapped offset_width/offset_height in model presets (#15755) 2026-04-20 10:18:04 +12:00
J. Nick Koston 4479212008 [core] Inline feed_wdt hot path with out-of-line slow path (#15656)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-20 10:18:04 +12:00
J. Nick Koston cb90ac45c3 [core] Fix app_state_ status bits clobbered for non-looping components (#15658) 2026-04-20 10:18:04 +12:00
Jesse Hills 82c0cb8929 Merge pull request #15745 from esphome/bump-2026.4.0
2026.4.0
2026-04-15 22:44:27 +12:00
Jesse Hills 2bdd9f6217 Bump version to 2026.4.0 2026-04-15 20:44:30 +12:00
Jesse Hills 767a8c49b0 Merge pull request #15739 from esphome/bump-2026.4.0b3
2026.4.0b3
2026-04-15 13:10:09 +12:00
Jesse Hills 4c43f7e9d0 Bump version to 2026.4.0b3 2026-04-15 10:58:30 +12:00
Edward Firmo 3ef140e25d [nextion] Fix command spacing pacer never throttling sends (#15664)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-04-15 10:58:30 +12:00
J. Nick Koston 0a568a3e1e [light] Avoid addressable transition stall at low gamma-corrected values (#15726) 2026-04-15 10:58:30 +12:00
Alexey Spirkov ef44491c69 [i2s_audio] Add PDM mics support for ESP32-P4 (#15333)
Co-authored-by: Alexey Spirkov <dev@alsp.net>
2026-04-15 10:58:30 +12:00
J. Nick Koston 089a2c99e2 [globals] Fix TemplatableFn deprecation warning for globals.set (#15733) 2026-04-15 10:58:30 +12:00
J. Nick Koston 311812c8cc [esphome] Skip missing extra flash images in upload_using_esptool (#15723)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-15 10:58:30 +12:00
J. Nick Koston a77ab59436 [web_server] Reset OTA backend on new upload to avoid brick after interrupted OTA (#15720) 2026-04-15 10:58:30 +12:00
J. Nick Koston 89fbfc6f71 [adc] Place ADC oneshot control functions in IRAM for cache safety (#15717) 2026-04-15 10:58:29 +12:00
J. Nick Koston 28f3bcdba3 [api] Add speed_optimized to SubscribeLogsResponse (#15698)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-04-15 10:58:29 +12:00
Jonathan Swoboda 445715b9fd [esp32] Update the recommended platform to 55.03.38-1 (#15705) 2026-04-15 10:58:29 +12:00
Kevin Ahrendt 8843c36ec6 [micro_wake_word] Bugfix: Use es-nn v1.1.2 (last known working version) (#15703) 2026-04-15 10:58:29 +12:00
Diorcet Yann bd63f63b36 [esp32] Fix some compiler warnings & bugs (#15610)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-04-15 10:58:29 +12:00
dependabot[bot] 033e144e06 Bump aioesphomeapi from 44.14.0 to 44.15.0 (#15699)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-15 10:58:29 +12:00
J. Nick Koston 20d49f9a7c [api] Add speed_optimized proto option for hot encode paths (#15691)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-04-15 10:58:29 +12:00
dependabot[bot] 3b2caa1f5b Bump aioesphomeapi from 44.13.3 to 44.14.0 (#15695)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-15 10:58:29 +12:00
Jonathan Swoboda c3769e4fce [core] Fix PlatformIO progress bar rendering in subprocess mode (#15681) 2026-04-15 10:58:29 +12:00
Javier Peletier 6d894dd6ee [packages] fix support packages: !include mypackages.yaml (#15677) 2026-04-15 10:58:29 +12:00
Jesse Hills 0c06d78a4f Merge pull request #15675 from esphome/bump-2026.4.0b2
2026.4.0b2
2026-04-13 12:44:27 +12:00
Jesse Hills a408b5a4fe Bump version to 2026.4.0b2 2026-04-13 08:48:19 +12:00
Clyde Stubbs e264c97454 [lvgl] Fix use of rotation on host SDL (#15611)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-04-13 08:48:19 +12:00
J. Nick Koston 8790dec137 [packages] Fix false deprecation warning and wrong error paths in nested packages (#15605) 2026-04-13 08:48:19 +12:00
Jonathan Swoboda 6480868e6e [esp32] Bump platform to 55.03.38, Arduino to 3.3.8, ESP-IDF to 5.5.4 (#15666) 2026-04-13 08:48:19 +12:00
Jonathan Swoboda 0578e43352 [canbus] Fix canbus.send can_id compile error (#15668) 2026-04-13 08:48:19 +12:00
Jonathan Swoboda 2a89d4835f [mdns] Bump espressif/mdns to 1.11.0 (#15670) 2026-04-13 08:48:19 +12:00
dependabot[bot] 5084c61016 Bump aioesphomeapi from 44.13.2 to 44.13.3 (#15641)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 08:48:19 +12:00
dependabot[bot] b45f94d511 Bump aioesphomeapi from 44.13.1 to 44.13.2 (#15637)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 08:48:18 +12:00
J. Nick Koston 66a4752e13 [rp2040] Fix W5500 Ethernet pbuf corruption by mirroring LWIPMutex semantics (#15624)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-13 08:48:18 +12:00
Jonathan Swoboda 4d4f78de81 [sx127x][cc1101][sx126x] Use GPIO interrupt to wake loop (#15627) 2026-04-13 08:48:18 +12:00
Kevin Ahrendt 0faa641c8a [micro_wake_word] Pin esp-nn version (#15628) 2026-04-13 08:48:18 +12:00
J. Nick Koston 0f16d27a72 [api] Add (inline_encode) proto option for sub-message inlining (#15599) 2026-04-13 08:48:18 +12:00
J. Nick Koston 835ee456a5 [mcp23016] Add interrupt pin support (#15616) 2026-04-13 08:48:18 +12:00
J. Nick Koston 17f3b7dbd5 [pca6416a] Add interrupt pin support (#15614) 2026-04-13 08:48:18 +12:00
J. Nick Koston 171a429526 [tca9555] Add interrupt pin support (#15613) 2026-04-13 08:48:18 +12:00
Jesse Hills e4ee2b7c04 [hbridge] Move light pin switching to loop (#15615) 2026-04-13 08:48:18 +12:00
Jonathan Swoboda c85a062e23 [sx127x][cc1101] Disable loop when packet mode is inactive (#15606) 2026-04-13 08:48:18 +12:00
J. Nick Koston 873378fa1f [gdk101] Increase reset retries for slow-booting sensor MCU (#15584) 2026-04-13 08:48:18 +12:00
dependabot[bot] 4f00ad409e Bump aioesphomeapi from 44.12.0 to 44.13.1 (#15600)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 08:48:18 +12:00
J. Nick Koston 20b516ff11 [api] Fix ListEntitiesRequest not read due to LWIP rcvevent tracking (#15589) 2026-04-13 08:48:18 +12:00
164 changed files with 3921 additions and 661 deletions
+1 -1
View File
@@ -1 +1 @@
f31f13994768b5b07e29624406c9b053bf4bb26e1623ac2bc1e9d4a9477502d6
10c432ae818f9ed7fd4a0176a04467b1f2634363f5ec985045a6d72747f60b90
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.4.0b1
PROJECT_NUMBER = 2026.4.2
# 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
View File
@@ -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
View File
@@ -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("<"):
+14 -1
View File
@@ -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"
+6 -2
View File
@@ -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"];
}
+24 -12
View File
@@ -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));
+1
View File
@@ -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
+2
View File
@@ -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 {
+46 -35
View File
@@ -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;
}
-2
View File
@@ -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
+6
View File
@@ -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) {
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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;
}
-1
View File
@@ -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]))
+17 -1
View File
@@ -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);
+1
View File
@@ -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);
+1 -1
View File
@@ -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;
}
+110 -31
View File
@@ -128,23 +128,30 @@ ASSERTION_LEVELS = {
SIGNING_SCHEMES = {
"rsa3072": "CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME",
"ecdsa256": "CONFIG_SECURE_SIGNED_APPS_ECDSA_V2_SCHEME",
"ecdsa_v1": "CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME",
}
# Chip variants that only support one signing scheme for Secure Boot V2.
# Chip variants that only support one V2 signing scheme.
# Based on SOC_SECURE_BOOT_V2_RSA / SOC_SECURE_BOOT_V2_ECC in soc_caps.h.
# Variants not listed in either set support both RSA and ECDSA
# Variants not listed in either set support both RSA and ECDSA V2
# (e.g. C5, C6, H2, P4). New variants should be added to the
# appropriate set if they only support one scheme.
SIGNED_OTA_RSA_ONLY_VARIANTS = {
VARIANT_ESP32,
# Note: VARIANT_ESP32 is not listed here because it supports V2 RSA only
# when minimum_chip_revision >= 3.0, which requires special handling.
SIGNED_OTA_V2_RSA_ONLY_VARIANTS = {
VARIANT_ESP32S2,
VARIANT_ESP32S3,
VARIANT_ESP32C3,
}
SIGNED_OTA_ECC_ONLY_VARIANTS = {
SIGNED_OTA_V2_ECC_ONLY_VARIANTS = {
VARIANT_ESP32C2,
VARIANT_ESP32C61,
}
# V1 ECDSA (Secure Boot V1) is only supported on the original ESP32.
# Based on SOC_SECURE_BOOT_V1 in soc_caps.h.
SIGNED_OTA_V1_ECDSA_VARIANTS = {
VARIANT_ESP32,
}
COMPILER_OPTIMIZATIONS = {
"DEBUG": "CONFIG_COMPILER_OPTIMIZATION_DEBUG",
@@ -671,11 +678,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 +703,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 +723,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 +751,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",
}
@@ -991,25 +998,73 @@ def final_validate(config):
if signed_ota := advanced.get(CONF_SIGNED_OTA_VERIFICATION):
scheme = signed_ota[CONF_SIGNING_SCHEME]
variant = config[CONF_VARIANT]
scheme_variant_conflicts = {
"ecdsa256": (SIGNED_OTA_RSA_ONLY_VARIANTS, "rsa3072"),
"rsa3072": (SIGNED_OTA_ECC_ONLY_VARIANTS, "ecdsa256"),
}
if (conflict := scheme_variant_conflicts.get(scheme)) and variant in conflict[
0
]:
min_rev = advanced.get(CONF_MINIMUM_CHIP_REVISION)
scheme_path = [
CONF_FRAMEWORK,
CONF_ADVANCED,
CONF_SIGNED_OTA_VERIFICATION,
CONF_SIGNING_SCHEME,
]
# V1 ECDSA is only available on the original ESP32
if scheme == "ecdsa_v1" and variant not in SIGNED_OTA_V1_ECDSA_VARIANTS:
errs.append(
cv.Invalid(
f"Signing scheme '{scheme}' is not supported on "
f"{VARIANT_FRIENDLY[variant]}. Use '{conflict[1]}' instead.",
path=[
CONF_FRAMEWORK,
CONF_ADVANCED,
CONF_SIGNED_OTA_VERIFICATION,
CONF_SIGNING_SCHEME,
],
f"Signing scheme 'ecdsa_v1' is only supported on "
f"{VARIANT_FRIENDLY[VARIANT_ESP32]}. "
f"Use 'rsa3072' or 'ecdsa256' instead.",
path=scheme_path,
)
)
elif variant == VARIANT_ESP32:
# On ESP32, V2 RSA requires minimum_chip_revision >= 3.0
# Note: string comparison works here because cv.one_of constrains
# min_rev to known ESP32_CHIP_REVISIONS values ("0.0".."3.1").
if scheme == "rsa3072" and (min_rev is None or min_rev < "3.0"):
errs.append(
cv.Invalid(
f"Signing scheme 'rsa3072' on {VARIANT_FRIENDLY[variant]} "
f"requires minimum_chip_revision: '3.0' or higher "
f"(Secure Boot V2 RSA needs chip revision 3.0+). "
f"For older chip revisions, use 'ecdsa_v1' instead.",
path=scheme_path,
)
)
# ESP32 does not support V2 ECDSA (no SOC_SECURE_BOOT_V2_ECC)
elif scheme == "ecdsa256":
errs.append(
cv.Invalid(
f"Signing scheme 'ecdsa256' is not supported on "
f"{VARIANT_FRIENDLY[variant]}. Use 'rsa3072' (with "
f"minimum_chip_revision: '3.0') or 'ecdsa_v1' instead.",
path=scheme_path,
)
)
# V1 on rev 3.0+ -- suggest V2 RSA for stronger security
elif scheme == "ecdsa_v1" and min_rev is not None and min_rev >= "3.0":
_LOGGER.info(
"Using Secure Boot V1 ECDSA on %s rev %s. "
"Consider using 'rsa3072' (Secure Boot V2 RSA) for "
"stronger security on chip revision 3.0+.",
VARIANT_FRIENDLY[variant],
min_rev,
)
else:
# Non-ESP32 variants: check V2 scheme-variant compatibility
scheme_variant_conflicts = {
"ecdsa256": (SIGNED_OTA_V2_RSA_ONLY_VARIANTS, "rsa3072"),
"rsa3072": (SIGNED_OTA_V2_ECC_ONLY_VARIANTS, "ecdsa256"),
}
if (
conflict := scheme_variant_conflicts.get(scheme)
) and variant in conflict[0]:
errs.append(
cv.Invalid(
f"Signing scheme '{scheme}' is not supported on "
f"{VARIANT_FRIENDLY[variant]}. Use '{conflict[1]}' instead.",
path=scheme_path,
)
)
if CONF_OTA not in full_config:
_LOGGER.warning(
"Signed OTA verification is enabled but no OTA component is configured. "
@@ -1058,6 +1113,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 +1127,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 +1225,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 +1277,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 +1336,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 +2137,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):
+16
View File
@@ -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,
+9 -3
View File
@@ -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
+120 -3
View File
@@ -5,6 +5,7 @@ import json # noqa: E402
import os # noqa: E402
import pathlib # noqa: E402
import shutil # noqa: E402
import subprocess # noqa: E402
from glob import glob # noqa: E402
@@ -25,6 +26,114 @@ def _parse_sdkconfig(sdkconfig_path):
return options
def _generate_v1_verification_key(env):
"""Generate the V1 ECDSA verification key binary and assembly source file.
Secure Boot V1 embeds the public verification key directly in the app binary
as a compiled object (via a .S assembly file). The ESP-IDF CMake build generates
these files via custom commands, but PlatformIO's SCons bridge does not execute
them. This function replicates that logic:
1. Extracts the raw public key from the PEM signing key using espsecure.
2. Generates the .S assembly source that embeds the key bytes.
"""
build_dir = pathlib.Path(env.subst("$BUILD_DIR"))
project_dir = pathlib.Path(env.subst("$PROJECT_DIR"))
pioenv = env.subst("$PIOENV")
sdkconfig = _parse_sdkconfig(project_dir / f"sdkconfig.{pioenv}")
if sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME") != "y":
return
bin_path = build_dir / "signature_verification_key.bin"
asm_path = build_dir / "signature_verification_key.bin.S"
# Determine the source of the verification key
if sdkconfig.get("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES") == "y":
# Extract public key from the signing key
signing_key = sdkconfig.get("CONFIG_SECURE_BOOT_SIGNING_KEY")
if not signing_key:
return
signing_key_path = pathlib.Path(signing_key)
if not signing_key_path.exists():
print(f"Error: V1 ECDSA signing key not found: {signing_key_path}")
env.Exit(1)
return
if not bin_path.exists() or bin_path.stat().st_mtime < signing_key_path.stat().st_mtime:
python_exe = env.subst("$PYTHONEXE")
result = subprocess.run(
[python_exe, "-m", "espsecure", "extract_public_key",
"--keyfile", str(signing_key_path), str(bin_path)],
capture_output=True, text=True,
)
if result.returncode != 0:
print(f"Error extracting V1 verification key: {result.stderr}")
env.Exit(1)
return
print(f"Extracted V1 ECDSA verification key from {signing_key_path.name}")
else:
# User-provided verification key -- should already be a raw binary file
verification_key = sdkconfig.get("CONFIG_SECURE_BOOT_VERIFICATION_KEY")
if not verification_key:
return
verification_key_path = pathlib.Path(verification_key)
if not verification_key_path.exists():
print(f"Error: Verification key not found: {verification_key_path}")
env.Exit(1)
return
shutil.copyfile(str(verification_key_path), str(bin_path))
if not bin_path.exists():
return
# Generate the .S assembly file from the binary key data.
# Replicates ESP-IDF's data_file_embed_asm.cmake with RENAME_TO=signature_verification_key_bin.
# The file is needed in both the app build dir and the bootloader build dir, since
# the bootloader also embeds the verification key when CONFIG_SECURE_SIGNED_ON_BOOT_NO_SECURE_BOOT
# is enabled. PlatformIO's SCons bridge does not execute the CMake custom commands that
# normally generate these files.
data = bin_path.read_bytes()
varname = "signature_verification_key_bin"
lines = []
lines.append(f"/* Data converted from {bin_path.name} */")
lines.append(".data")
lines.append("#if !defined (__APPLE__) && !defined (__linux__)")
lines.append(".section .rodata.embedded")
lines.append("#endif")
lines.append(f"\n.global {varname}")
lines.append(f"{varname}:")
lines.append(f"\n.global _binary_{varname}_start")
lines.append(f"_binary_{varname}_start: /* for objcopy compatibility */")
# Format binary data as .byte lines (16 bytes per line)
for i in range(0, len(data), 16):
chunk = data[i:i + 16]
hex_bytes = ", ".join(f"0x{b:02x}" for b in chunk)
lines.append(f".byte {hex_bytes}")
lines.append(f"\n.global _binary_{varname}_end")
lines.append(f"_binary_{varname}_end: /* for objcopy compatibility */")
lines.append(f"\n.global {varname}_length")
lines.append(f"{varname}_length:")
lines.append(f".long {len(data)}")
lines.append("")
lines.append('#if defined (__linux__)')
lines.append('.section .note.GNU-stack,"",@progbits')
lines.append("#endif")
asm_content = "\n".join(lines) + "\n"
# Write to app build dir and bootloader build dir
asm_path.write_text(asm_content)
bootloader_dir = build_dir / "bootloader"
if bootloader_dir.is_dir():
bootloader_bin = bootloader_dir / "signature_verification_key.bin"
bootloader_asm = bootloader_dir / "signature_verification_key.bin.S"
shutil.copyfile(str(bin_path), str(bootloader_bin))
bootloader_asm.write_text(asm_content)
def sign_firmware(source, target, env):
"""
Sign the firmware binary using espsecure.py if signed OTA verification is enabled.
@@ -55,9 +164,12 @@ def sign_firmware(source, target, env):
env.Exit(1)
return
# ESPHome only exposes RSA3072 and ECDSA256 (both Secure Boot V2 schemes),
# so the espsecure signature version is always 2.
sign_version = "2"
# Determine espsecure signature version from the signing scheme:
# V1 ECDSA (Secure Boot V1) uses --version 1, V2 RSA/ECDSA use --version 2.
if sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME") == "y":
sign_version = "1"
else:
sign_version = "2"
firmware_name = os.path.basename(env.subst("$PROGNAME")) + ".bin"
firmware_path = build_dir / firmware_name
@@ -217,6 +329,11 @@ def esp32_copy_ota_bin(source, target, env):
print(f"Copied firmware to {new_file_name}")
# Generate V1 ECDSA verification key files before build starts.
# Workaround for PlatformIO not executing CMake custom commands that extract
# the public key and generate the .S assembly file for Secure Boot V1.
_generate_v1_verification_key(env) # noqa: F821
# Run signing first, then merge, then ota copy
env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", sign_firmware) # noqa: F821
env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", merge_factory_bin) # noqa: F821
@@ -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
+1 -1
View File
@@ -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;
+6 -1
View File
@@ -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
+1 -1
View File
@@ -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;
+11 -8
View File
@@ -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
@@ -753,7 +756,7 @@ async def write_image(config, all_frames=False):
for col in range(width):
encoder.encode(pixels[row * width + col])
encoder.end_row()
encoder.end_image()
encoder.end_image()
rhs = [HexInt(x) for x in encoder.data]
prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs)
+1 -1
View File
@@ -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;
+24 -18
View File
@@ -766,32 +766,38 @@ void LD2412Component::get_distance_resolution_() { this->send_command_(CMD_QUERY
void LD2412Component::query_light_control_() { this->send_command_(CMD_QUERY_LIGHT_CONTROL, nullptr, 0); }
void LD2412Component::set_basic_config() {
uint8_t min_gate = 1;
uint8_t max_gate = TOTAL_GATES;
uint16_t timeout = DEFAULT_PRESENCE_TIMEOUT;
uint8_t out_pin_level = 0x01;
#ifdef USE_NUMBER
if (!this->min_distance_gate_number_->has_state() || !this->max_distance_gate_number_->has_state() ||
!this->timeout_number_->has_state()) {
return;
if (this->min_distance_gate_number_ != nullptr) {
if (!this->min_distance_gate_number_->has_state())
return;
min_gate = static_cast<int>(this->min_distance_gate_number_->state);
}
if (this->max_distance_gate_number_ != nullptr) {
if (!this->max_distance_gate_number_->has_state())
return;
max_gate = static_cast<int>(this->max_distance_gate_number_->state) + 1;
}
if (this->timeout_number_ != nullptr) {
if (!this->timeout_number_->has_state())
return;
timeout = static_cast<int>(this->timeout_number_->state);
}
#endif
#ifdef USE_SELECT
if (!this->out_pin_level_select_->has_state()) {
return;
if (this->out_pin_level_select_ != nullptr) {
if (!this->out_pin_level_select_->has_state())
return;
out_pin_level = find_uint8(OUT_PIN_LEVELS_BY_STR, this->out_pin_level_select_->current_option().c_str());
}
#endif
uint8_t value[5] = {
#ifdef USE_NUMBER
lowbyte(static_cast<int>(this->min_distance_gate_number_->state)),
lowbyte(static_cast<int>(this->max_distance_gate_number_->state) + 1),
lowbyte(static_cast<int>(this->timeout_number_->state)),
highbyte(static_cast<int>(this->timeout_number_->state)),
#else
1, TOTAL_GATES, DEFAULT_PRESENCE_TIMEOUT, 0,
#endif
#ifdef USE_SELECT
find_uint8(OUT_PIN_LEVELS_BY_STR, this->out_pin_level_select_->current_option().c_str()),
#else
0x01, // Default value if not using select
#endif
lowbyte(min_gate), lowbyte(max_gate), lowbyte(timeout), highbyte(timeout), out_pin_level,
};
this->set_config_mode_(true);
this->send_command_(CMD_BASIC_CONF, value, sizeof(value));
+57 -6
View File
@@ -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
+4 -2
View File
@@ -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()
+3 -1
View File
@@ -89,10 +89,12 @@
id: hello_world_label_
text: "Hello World!"
align: center
- obj:
- container:
id: hello_world_qrcode_
outline_width: 0
border_width: 0
height: 100
width: 100
hidden: !lambda |-
return lv_obj_get_width(lv_screen_active()) < 300 && lv_obj_get_height(lv_screen_active()) < 400;
widgets:
+11 -9
View File
@@ -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_));
}
}
+12 -5
View File
@@ -76,16 +76,23 @@ 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);
}
inline void lv_style_set_bg_image_src(lv_style_t *style, image::Image *image) {
::lv_style_set_bg_image_src(style, image->get_lv_image_dsc());
}
inline void lv_style_set_bitmap_mask_src(lv_style_t *style, image::Image *image) {
::lv_style_set_bitmap_mask_src(style, image->get_lv_image_dsc());
}
#endif // USE_LVGL_IMAGE
#ifdef USE_LVGL_ANIMIMG
+5 -2
View File
@@ -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)
+15 -11
View File
@@ -52,19 +52,23 @@ class KeyboardType(WidgetType):
if mode := config.get(CONF_MODE):
await w.set_property(CONF_MODE, await KEYBOARD_MODES.process(mode))
if textarea := config.get(CONF_TEXTAREA):
# If a textarea is configured, it must be generated before the keyboard can attach it.
# If not yet configured, defer the attachment code.
if not is_widget_completed(textarea):
# Can only happen for an initial config, where the keyboard is configured before the
# textarea, so it's ok to always emit into the global context
async def add_textarea():
async with LvContext():
await w.set_property(
CONF_TEXTAREA,
(await get_widgets(config, CONF_TEXTAREA))[0].obj,
)
async def add_textarea():
async with LvContext():
await w.set_property(
CONF_TEXTAREA, (await get_widgets(config, CONF_TEXTAREA))[0].obj
)
if is_widget_completed(textarea):
await add_textarea()
else:
CORE.add_job(add_textarea)
else:
# Handles updates in automations, and properly ordered initial config. Code is generated
# into the enclosing context (main or lambda)
await w.set_property(
CONF_TEXTAREA, (await get_widgets(config, CONF_TEXTAREA))[0].obj
)
keyboard_spec = KeyboardType()
+4
View File
@@ -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):
+17
View File
@@ -24,11 +24,25 @@ 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_();
// Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the
// I2C read leave INT asserted without re-firing a falling edge, which would strand us with
// stale state forever; keep looping until the line is released so we self-heal.
if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) {
this->disable_loop();
}
}
bool MCP23016::digital_read_hw(uint8_t pin) { return this->read_reg_(MCP23016_GP1, &this->input_mask_); }
@@ -37,6 +51,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);
}
+4
View File
@@ -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 {
@@ -21,7 +21,10 @@ template<uint8_t N> class MCP23XXXBase : public Component, public gpio_expander:
void loop() override {
this->reset_pin_cache_();
if (this->interrupt_pin_ != nullptr) {
// Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the
// I2C read leave INT asserted without re-firing a falling edge, which would strand us with
// stale state forever; keep looping until the line is released so we self-heal.
if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) {
this->disable_loop();
}
}
+1 -1
View File
@@ -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};
+2 -1
View File
@@ -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) {
+3 -3
View File
@@ -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
)
+11 -11
View File
@@ -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);
+74 -34
View File
@@ -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
+10 -5
View File
@@ -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_;
+54 -10
View File
@@ -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
)
+9 -1
View File
@@ -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):
+21
View File
@@ -49,11 +49,25 @@ 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_();
// Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the
// I2C read leave INT asserted without re-firing a falling edge, which would strand us with
// stale state forever; keep looping until the line is released so we self-heal.
if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) {
this->disable_loop();
}
}
void PCA6416AComponent::dump_config() {
@@ -62,6 +76,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 +116,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 +127,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);
}
+4
View File
@@ -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.
+4 -2
View File
@@ -50,8 +50,10 @@ void IRAM_ATTR PCA9554Component::gpio_intr(PCA9554Component *arg) { arg->enable_
void PCA9554Component::loop() {
// Invalidate the cache so the next digital_read() triggers a fresh I2C read
this->reset_pin_cache_();
if (this->interrupt_pin_ != nullptr) {
// Interrupt-driven: disable loop until next interrupt fires
// Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the
// I2C read leave INT asserted without re-firing a falling edge, which would strand us with
// stale state forever; keep looping until the line is released so we self-heal.
if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) {
this->disable_loop();
}
}
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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;
}
+4 -2
View File
@@ -31,8 +31,10 @@ void IRAM_ATTR PCF8574Component::gpio_intr(PCF8574Component *arg) { arg->enable_
void PCF8574Component::loop() {
// Invalidate the cache so the next digital_read() triggers a fresh I2C read
this->reset_pin_cache_();
if (this->interrupt_pin_ != nullptr) {
// Interrupt-driven: disable loop until next interrupt fires
// Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the
// I2C read leave INT asserted without re-firing a falling edge, which would strand us with
// stale state forever; keep looping until the line is released so we self-heal.
if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) {
this->disable_loop();
}
}
@@ -82,7 +82,10 @@ void PI4IOE5V6408Component::pin_mode(uint8_t pin, gpio::Flags flags) {
void PI4IOE5V6408Component::loop() {
this->reset_pin_cache_();
if (this->interrupt_pin_ != nullptr) {
// Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the
// I2C read leave INT asserted without re-firing a falling edge, which would strand us with
// stale state forever; keep looping until the line is released so we self-heal.
if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) {
this->disable_loop();
}
}
+2 -2
View File
@@ -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)
+25 -6
View File
@@ -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
+1 -1
View File
@@ -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;
}
+10 -10
View File
@@ -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",
+81 -4
View File
@@ -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 {}
)
+2 -2
View File
@@ -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
+9
View File
@@ -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;
}
+2
View File
@@ -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);
+2 -2
View File
@@ -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),
+5 -1
View File
@@ -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;
}
}
}
+2
View File
@@ -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);
+4
View File
@@ -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):
+21 -1
View File
@@ -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,15 @@ 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_();
// Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the
// I2C read leave INT asserted without re-firing a falling edge, which would strand us with
// stale state forever; keep looping until the line is released so we self-heal.
if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) {
this->disable_loop();
}
}
bool TCA9555Component::read_gpio_outputs_() {
if (this->is_failed())
+5
View File
@@ -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.
+1 -1
View File
@@ -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) {
+12 -1
View File
@@ -116,12 +116,23 @@ CONFIG_SCHEMA = cv.ensure_list(
async def to_code(config):
# The output chunk pool/queue are compile-time-sized templates shared by all
# USBUartChannel instances, so use the largest buffer_size across every channel
# of every device. Each chunk is 64 bytes (USB FS MPS); add one extra slot
# because LockFreeQueue<T,N> is a ring buffer that wastes one entry.
max_buffer_size = max(
channel[CONF_BUFFER_SIZE]
for device in config
for channel in device[CONF_CHANNELS]
)
output_chunk_count = max_buffer_size // 64 + 1
cg.add_define("USB_UART_OUTPUT_CHUNK_COUNT", output_chunk_count)
for device in config:
var = await register_usb_client(device)
for index, channel in enumerate(device[CONF_CHANNELS]):
chvar = cg.new_Pvariable(channel[CONF_ID], index, channel[CONF_BUFFER_SIZE])
await cg.register_parented(chvar, var)
cg.add(chvar.set_rx_buffer_size(channel[CONF_BUFFER_SIZE]))
cg.add(chvar.set_stop_bits(channel[CONF_STOP_BITS]))
cg.add(chvar.set_data_bits(channel[CONF_DATA_BITS]))
cg.add(chvar.set_parity(channel[CONF_PARITY]))
+3 -2
View File
@@ -132,8 +132,9 @@ class USBUartChannel : public uart::UARTComponent, public Parented<USBUartCompon
friend class USBUartTypeCH34X;
public:
// Number of output chunk slots per channel (8 × 64 bytes = 512 bytes peak, lazily allocated)
static constexpr uint8_t USB_OUTPUT_CHUNK_COUNT = 8;
// Number of output chunk slots per channel, derived from buffer_size config.
// Computed as ceil(buffer_size / 64) + 1 in Python codegen; defaults to 5 (256 / 64 + 1).
static constexpr uint8_t USB_OUTPUT_CHUNK_COUNT = USB_UART_OUTPUT_CHUNK_COUNT;
USBUartChannel(uint8_t index, uint16_t buffer_size) : index_(index), input_buffer_(RingBuffer(buffer_size)) {}
void write_array(const uint8_t *data, size_t len) override;
@@ -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());
+20 -1
View File
@@ -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
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.4.0b1"
__version__ = "2026.4.2"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
+50 -18
View File
@@ -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
View File
@@ -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
+12
View File
@@ -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{}; }
+3 -1
View File
@@ -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),
+17
View File
@@ -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;
+14 -1
View File
@@ -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.
*
+1
View File
@@ -284,6 +284,7 @@
#define ESPHOME_WIFI_POWER_SAVE_LISTENERS 2
#define USE_WIFI_RUNTIME_POWER_SAVE
#define USB_HOST_MAX_REQUESTS 16
#define USB_UART_OUTPUT_CHUNK_COUNT 5
#ifdef USE_ARDUINO
#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 7)
+24 -2
View File
@@ -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
+2 -1
View File
@@ -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
View File
@@ -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)
+33 -17
View File
@@ -606,33 +606,43 @@ def Pvariable(id_: ID, rhs: SafeExpType, type_: "MockObj" = None) -> "MockObj":
if isinstance(rhs, MockObj) and rhs.is_new_expr:
# For 'new' allocations, use placement new into static storage
# to avoid heap fragmentation on embedded devices.
the_type = id_.type
#
# Storage must be sized and aligned for the actual instantiated class,
# which may be a subclass of id_.type (e.g. `cv.declare_id(BaseClass)`
# combined with `SubClass.new()` — used by ili9xxx, waveshare_epaper,
# etc. to select a model-specific constructor). Using id_.type would
# run the base-class default constructor instead, silently losing any
# subclass initialization. Template args live on the CallExpression
# and are re-emitted below.
call_expr = rhs.base
assert isinstance(call_expr, CallExpression), (
f"Expected CallExpression for placement new, got {type(call_expr)}"
)
actual_type = rhs.new_type if rhs.new_type is not None else id_.type
if call_expr.template_args is not None:
actual_type = f"{actual_type}{call_expr.template_args}"
pointer_type = id_.type
# Extract component namespace from type for memory analysis attribution
component_ns = _extract_component_ns(str(the_type))
component_ns = _extract_component_ns(str(actual_type))
storage_name = f"{component_ns}__{id_.id}__pstorage"
# Declare aligned byte array for the object storage
CORE.add_global(
RawStatement(
f"alignas({the_type}) static unsigned char {storage_name}[sizeof({the_type})];"
f"alignas({actual_type}) static unsigned char {storage_name}[sizeof({actual_type})];"
)
)
# Pointer declaration uses id_.type to preserve the declared base-class
# pointer type for downstream callers (polymorphism through base ptr).
CORE.add_global(
AssignmentExpression(
f"static {the_type}",
f"static {pointer_type}",
"*const ",
id_,
MockObj(f"reinterpret_cast<{the_type} *>({storage_name})"),
MockObj(f"reinterpret_cast<{pointer_type} *>({storage_name})"),
)
)
# Extract args from the CallExpression and rebuild as placement new.
# Template args are already encoded in the_type (e.g. GlobalsComponent<int>),
# so we only pass the constructor args, not template_args.
call_expr = rhs.base
assert isinstance(call_expr, CallExpression), (
f"Expected CallExpression for placement new, got {type(call_expr)}"
)
placement_new = CallExpression(f"new({id_.id}) {the_type}", *call_expr.args)
placement_new = CallExpression(f"new({id_.id}) {actual_type}", *call_expr.args)
CORE.add(ExpressionStatement(placement_new))
else:
decl = VariableDeclarationExpression(id_.type, "*", id_, static=True)
@@ -869,12 +879,16 @@ class MockObj(Expression):
Mostly consists of magic methods that allow ESPHome's codegen syntax.
"""
__slots__ = ("base", "op", "is_new_expr")
__slots__ = ("base", "op", "is_new_expr", "new_type")
def __init__(self, base, op=".", is_new_expr=False) -> None:
def __init__(self, base, op=".", is_new_expr=False, new_type=None) -> None:
self.base = base
self.op = op
self.is_new_expr = is_new_expr
# For `is_new_expr=True` objects, `new_type` holds the class name being
# constructed (e.g. "ili9xxx::ILI9XXXST7789V"). Needed by Pvariable so
# placement new uses the actual subclass rather than id_.type.
self.new_type = new_type
def __getattr__(self, attr: str) -> "MockObj":
# prevent python dunder methods being replaced by mock objects
@@ -889,7 +903,9 @@ class MockObj(Expression):
def __call__(self, *args: SafeExpType) -> "MockObj":
call = CallExpression(self.base, *args)
return MockObj(call, self.op, is_new_expr=self.is_new_expr)
return MockObj(
call, self.op, is_new_expr=self.is_new_expr, new_type=self.new_type
)
def __str__(self):
return str(self.base)
@@ -903,7 +919,7 @@ class MockObj(Expression):
@property
def new(self) -> "MockObj":
return MockObj(f"new {self.base}", "->", is_new_expr=True)
return MockObj(f"new {self.base}", "->", is_new_expr=True, new_type=self.base)
def template(self, *args: SafeExpType) -> "MockObj":
"""Apply template parameters to this object."""
+2 -1
View File
@@ -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("}")
+1 -1
View File
@@ -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:

Some files were not shown because too many files have changed in this diff Show More