Compare commits

...

131 Commits

Author SHA1 Message Date
J. Nick Koston
4ea417966d [core] Poison brace-depth tracking once it goes negative
Address Copilot review: the prior comment promised that a negative depth would never re-enable splitting, but an arithmetically-balanced later { could bring depth back to 0 and resume flushing mid-stream. Track an explicit 'poisoned' flag set once depth < 0 that permanently disables further flushes for the rest of the input. Adds a regression test where a leading } and a later { would have re-enabled splitting without the poison flag.
2026-04-17 19:01:12 -05:00
J. Nick Koston
6446f309c1 [core] Replace mutable-list-flag pattern with _ComponentGroup dataclass
The mutable-list-of-bool trick for rebinding-safe flag mutation works but reads poorly. Replace with a _ComponentGroup dataclass carrying lines + unsafe + no_split fields. cpp_main_section now reads as a straight iteration over typed groups; no apologetic comments needed.
2026-04-17 18:56:59 -05:00
J. Nick Koston
093c34d4a4 [core] Review cleanup: docstring accuracy, rationale comments, unsafe+no_split test
- Fix the ComponentMarker docstring's incomplete 'either placement-news or mutates a global' claim — acknowledge that function-local patterns also exist and note the bare-local detection covers them.
- Document that _emits_bare_local's RawExpression detection is intentionally safety-biased: false negatives break compilation, false positives just keep a slightly larger IIFE. Note the CallExpression(..., RawExpression) negative case explicitly.
- Explain the mutable-list-flag pattern in cpp_main_section — dataclass would read cleaner but the pattern is localized.
- Add regression test for a group with BOTH IIFEUnsafeStatement and a bare-local: unsafe wins (flat emission) because a return inside any IIFE, even a single big one, only exits the lambda.
2026-04-17 18:55:39 -05:00
J. Nick Koston
3ab935bebb [core] Expose IIFE_MAX_STATEMENTS constant and derive test sizes from it
Tests were hardcoding 120 statements and expecting 3 sub-chunks from a 50-cap. Extract the cap as a named module constant and compute the test-input size from it, so bumping the cap doesn't silently invalidate the tests.
2026-04-17 18:50:41 -05:00
J. Nick Koston
bb0067f517 [core] Strengthen RawExpression-as-arg test
Exercise the actual CallExpression(..., RawExpression) pattern that components use for passing raw arguments. The previous test used RawStatement filler which didn't exercise the detection path we wanted to assert doesn't trigger.
2026-04-17 18:49:04 -05:00
J. Nick Koston
550f6e7c72 [core] Detect bare-local emission and disable sub-split for those groups
A component's group is wrapped in a single IIFE with no sub-splitting when its to_code emits any of: scope-brace RawStatement, direct RawExpression via cg.add (raw bare-local or field-assignment like 'tz.field = x'), or typed AssignmentExpression (cg.variable). Detection is content-aware so entity_helpers' inline-comment RawStatements and RawExpression-as-CallExpression-arg don't false-positive. Adds 4 regression tests covering each detection path and the non-triggering inverse cases.
2026-04-17 18:46:30 -05:00
J. Nick Koston
5f2582efcd [safe_mode] Fix setup()-exit return getting trapped in IIFE
safe_mode emits `if (should_enter_safe_mode(...)) return` via
cg.add(RawExpression(...)) to short-circuit the rest of setup() and
boot into safe mode. With setup() split into per-component IIFEs,
that `return` was only exiting the lambda, so the rest of setup() ran
anyway — breaking safe-mode recovery.

Add IIFEUnsafeStatement, a Statement wrapper that marks its containing
component's block for flat emission (no IIFE). safe_mode wraps its
return expression in it. cpp_main_section detects any such statement
in a group and emits that group flat so control-flow constructs like
`return` still affect setup() itself.

IIFEUnsafeStatement.__str__ routes its inner through statement() so
bare Expression subclasses pick up the terminating semicolon. Reported
by @swoboda1337.
2026-04-17 18:12:14 -05:00
J. Nick Koston
e26ce59797 for progmem 2026-04-17 16:18:39 -05:00
J. Nick Koston
9fa6d224c2 [core] Tighten docstrings and inline comments 2026-04-17 15:35:53 -05:00
J. Nick Koston
91b238aa97 [core] Fix grammar in ComponentMarker docstring 2026-04-17 15:34:15 -05:00
J. Nick Koston
00f08ba6ed [core] Drop per-component begin/end labels from generated main.cpp
The labels were there to help humans scanning the generated main.cpp
find component boundaries, but they were:

- Unreliable: CORE.flush_tasks can interleave coroutines on each
  await, so a component's later statements can land in another
  component's begin/end block.
- Load-bearing for a pile of complexity: a tuple return from
  _wrap_in_iifes, a has_iife flag, a comment-only detector to
  suppress trailing end-markers for comment-only components, and
  a brittle `"[]()" in line` check that could false-positive on
  YAML dumps containing lambda syntax.
- Not actually needed — generated main.cpp is a build artifact
  rarely read by anyone, and cg.LineComment("name:") already puts
  the component name at the start of its block.

ComponentMarker stays as a pure chunking sentinel — it tells
cpp_main_section where component boundaries are (for grouping) but
produces no C++ output. _wrap_in_iifes returns a plain list again.
Added a regression test for the now-defused case of a comment
containing "[]()" that was previously flagged by review.
2026-04-17 15:19:48 -05:00
J. Nick Koston
f82401a504 [core] Address Copilot review: robust brace depth, accurate docstrings
- Count { and } characters per line instead of matching whole-line
  tokens. Current codegen only emits scope braces as standalone lines
  (from cg.with_local_variable()), but the defensive change is robust
  against future codegen emitting inline control flow like
  `if (cond) {` or `} else {` on one line.
- Add a regression test covering those inline-brace patterns.
- Fix stale docstrings on ComponentMarker and cpp_main_section that
  still claimed "stack frame released on return" and described the
  IIFEs as "noinline". The IIFEs have no noinline attribute and rely
  on scope-based lifetime shortening rather than guaranteed frames.
2026-04-17 15:06:42 -05:00
J. Nick Koston
178f23a7aa [core] Use begin/end marker pairs around each component's IIFE
Rename the bracket markers from "// === X ===" (same on both sides)
to "// === begin X ===" and "// === end X ===" so the generated
main.cpp reads unambiguously when scanning by component. Comment-only
components still get a single "begin X" marker since they have no
IIFE to close.
2026-04-17 15:06:42 -05:00
J. Nick Koston
864d31aa65 [core] Put ComponentMarker outside the IIFE as a visual bracket
The marker comment was being emitted as the first line *inside* each
IIFE:

  []() {
    // === logger ===
    // logger:
    //   ...
    ...
  }();

That works but buries the component label inside the lambda body, so
scanning generated main.cpp to find "where does component X's setup
live" is harder than it needs to be. Emit the marker before and after
the IIFE instead:

  // === logger ===
  []() {
    // logger:
    //   ...
    ...
  }();
  // === logger ===

Comment-only components (e.g. sha256, async_tcp, empty platforms like
binary_sensor:) don't grow a useless trailing duplicate marker —
when there's no IIFE to bracket, the marker is emitted once.
2026-04-17 15:06:42 -05:00
J. Nick Koston
936694af2c [core] Don't emit IIFE for comment-only chunks
Some components (sha256, async_tcp, network, empty text_sensor:, etc.)
emit only a ComponentMarker plus config-dump comments and no actual
C++ statements. Wrapping those in a `[]() { ... }();` IIFE is pure
clutter in the generated main.cpp — the IIFE has no body.

When _wrap_in_iifes sees a chunk whose lines are all // comments,
emit them verbatim instead of wrapping. Peak stack and flash are
unchanged on apollo and neargaragedoor since GCC was already
eliding the empty IIFEs; this just makes the generated code read
cleanly to humans.
2026-04-17 15:06:42 -05:00
J. Nick Koston
6a7c9af870 [core] Drop noinline from IIFE chunks and rename helper
Additional measurements showed GCC's -Os inliner re-inlines most IIFE
chunks back into setup() by choice, and the structural scoping alone
captures nearly all of the peak-stack benefit on esp32 without the
flash cost of forcing all chunks to stay as real functions.

Apollo (esp32-s3, -Os) with vs without noinline:
  peak setup stack     176 B (noinline)  vs  304 B (scope-only)
  flash delta         +388 B (noinline)  vs   -504 B (scope-only)
  chunks kept          86               vs    20

Issue #15796 is an LVGL-setup class of bug that has only surfaced on
esp32 after years in the field; the extra guarantee that noinline
provides is not worth the flash cost in practice. Also rename the
helper from _wrap_in_noinline_iifes to _wrap_in_iifes to match.
2026-04-17 15:06:42 -05:00
J. Nick Koston
29dcf9fc51 [core] Use __attribute__((noinline)) on IIFE lambdas to honor attribute
The C++ standard-attribute spelling [[gnu::noinline]] placed between a
lambda's parameter list and body binds to the return type, not the
call operator. GCC 14 silently ignores it and emits -Wattributes
warnings at every chunk site. Switch to GCC's __attribute__((...))
syntax which binds to operator() as intended.

Measured impact on apollo-r-pro-1-eth (esp32-s3, -Os) vs the broken
[[gnu::noinline]] version: setup() frame 160 B -> 32 B, peak stack
304 B -> 176 B (another -42%). Flash grows by 888 B because all 86
chunks now stay as separate functions instead of GCC inlining the
small ones (which it was free to do when the attribute was ignored).

Net vs baseline -Os: peak stack 1264 B -> 176 B (-86%); flash
+388 B (<0.05% of a typical esp32 partition).
2026-04-17 15:06:42 -05:00
J. Nick Koston
6b67224286 [core] Chunk setup() into per-component noinline IIFEs
Generated setup() is a single monolithic function whose stack frame
scales super-linearly with config size. On a 5,943-line apollo build
the frame reached 1,264 B at -Os; extrapolation onto larger configs
(e.g. the 16k-line LVGL config in #15796) plausibly overflows the
8 KB loop task stack before safe_mode can increment its boot counter.

Emit a ComponentMarker sentinel at the start of each component's
to_code output, then have cpp_main_section wrap each component's
block (and sub-splits of up to 50 statements within each block) in a
noinline IIFE lambda. Each lambda's ENTRY frame is released on
return, bounding peak stack to setup() frame + max chunk frame.

Measured on apollo-r-pro-1-eth (esp32-s3, -Os):

  setup() frame        1264 B  ->  160 B
  max chunk frame      n/a     ->  144 B
  peak setup stack     1264 B  ->  304 B  (-76%)
  total flash      792,471 B   ->  791,995 B  (-476 B)

The brace-depth guard in _wrap_in_noinline_iifes ensures we never
split between the RawStatement("{") / RawStatement("}") pair emitted
by cg.with_local_variable() (currently only wifi), so scoped locals
stay intact.
2026-04-17 15:06:41 -05:00
dependabot[bot]
70ea527161 Bump ruff from 0.15.10 to 0.15.11 (#15790)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-04-17 10:17:51 -05:00
J. Nick Koston
34c35c84d5 [core] Fix DelayAction compile error with non-const reference args (#15814) 2026-04-17 14:31:31 +00:00
Jonathan Swoboda
bcbfc843ae [ethernet] Fix SPI3_HOST default breaking compile on variants without SPI3 (#15809)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-04-17 14:05:30 +00:00
J. Nick Koston
d4fe46bb24 [core] Expose App.wake_loop_isrsafe() on ESP8266 (#15797) 2026-04-17 02:46:12 -10:00
J. Nick Koston
523c6f2376 [core] coerce set_interval(0) / update_interval: 0ms to 1ms (#15799) 2026-04-17 02:45:50 -10:00
Clyde Stubbs
b018ac67bc [image] Fix byte order handling (#15800) 2026-04-17 22:11:05 +10:00
Clyde Stubbs
1a529a62aa [mipi_spi] Drawing fixes for native display (#15802) 2026-04-17 21:17:16 +10:00
Edvard Filistovič
6a46437a5f [wifi] Guard retry_phase_to_log_string with log level check to fix warning (#15801)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-04-17 10:58:39 +00:00
Diorcet Yann
cfe8c0eeee [wireguard] Bump esp_wireguard to 0.4.5 for ESP-IDF v6 (#15804) 2026-04-17 06:20:55 -04:00
J. Nick Koston
b232fc91ab [runtime_stats] Track main loop active time and report overhead (#15743) 2026-04-16 14:07:26 -10:00
Yves Fischer
ac50f33388 Fix typo in devcontainer.json (#15791) 2026-04-16 18:27:50 -04:00
Jonathan Swoboda
ff52bb3029 [lvgl] Guard lv_image_set_src wrapper with LV_USE_IMAGE (#15789) 2026-04-16 18:16:58 -04:00
J. Nick Koston
627e440bd6 [libretiny] Make IRAM_ATTR functional on RTL87xx and LN882H (#15766) 2026-04-16 19:38:49 +00:00
Jonathan Swoboda
6bb90a1268 [esp32] Accept unquoted minimum_chip_revision values (#15785) 2026-04-16 19:07:04 +00:00
J. Nick Koston
7d8add70a7 [ili9xxx] Guard against null buffer in display_() when allocation fails (#15786) 2026-04-16 09:01:55 -10:00
rwalker777
9094392870 [gpio] Keep interrupts enabled for gpio binary_sensor shared with deep_sleep wakeup pin (#15020)
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@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-04-16 09:01:32 -10:00
J. Nick Koston
c6ad23fbc0 [bundle] Force-resolve nested IncludeFile during file discovery (#15762) 2026-04-16 08:45:33 -10:00
Jonathan Swoboda
6af7a9ed8f [qmc5883l] Move per-update log line from DEBUG to VERBOSE (#15781) 2026-04-16 14:36:06 -04:00
SaVi
0b051289f5 [core] Add missing exception chaining (raise from) across codebase (#15648)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-04-16 15:19:33 +00:00
Clyde Stubbs
d8329dba22 [mipi_spi] Add Waveshare C6 LCD 1.47 (#15776) 2026-04-16 11:17:51 -04:00
guillempages
ee70a4aa72 [tm1637] Add set_brightness method (#15322)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-04-16 09:46:27 -04:00
tomaszduda23
04a58159d0 [zephyr_ble_server] add support for on_numeric_comparison_request (#14400)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-04-16 09:43:03 -04:00
J. Nick Koston
4c758fa1da [time] Fix RTC is_valid() rejecting valid times after day_of_year cleanup (#15763) 2026-04-16 09:40:22 -04:00
J. Nick Koston
c8e21802db [core] Diagnose missing cg.templatable in codegen for TEMPLATABLE_VALUE fields (#15758) 2026-04-16 09:36:55 -04:00
Boris Krivonog
b40ffacb8d [mitsubishi_cn105] use HEAT_COOL mode to enable temperature slider (#15748) 2026-04-16 09:35:24 -04:00
Clyde Stubbs
e0118dd8eb [lvgl] Clean the build if lv_conf.h changes (#15777) 2026-04-16 09:19:42 -04:00
J. Nick Koston
e7194dce75 [core] Deduplicate entity type boilerplate with X-macro pattern (#15618) 2026-04-15 17:45:01 -10:00
J. Nick Koston
01b5bef37f [status_led] Disable loop when idle (#15642) 2026-04-15 17:44:42 -10:00
dependabot[bot]
403a9f7b7e Bump github/codeql-action from 4.35.1 to 4.35.2 (#15759)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-15 10:12:30 -10:00
dependabot[bot]
10f52f2056 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-15 09:07:49 -10:00
Jonathan Swoboda
274c01ca74 [sx126x][sx127x] Fix frequency precision loss from float32 codegen (#15753) 2026-04-15 14:32:33 -04:00
Jonathan Swoboda
3b82c6e38b [st7789v] Fix swapped offset_width/offset_height in model presets (#15755) 2026-04-15 14:32:11 -04:00
Jesse Hills
f59a1011df Merge branch 'release' into dev 2026-04-15 22:45:16 +12:00
Jesse Hills
193e7d476d Pin GitHub Actions to commit SHAs
Replace mutable tag references with immutable commit SHAs
to prevent supply-chain attacks via compromised tags.
Version comments are preserved for readability.
2026-04-15 13:12:20 +12:00
Jesse Hills
1b3e7d5ec4 Merge branch 'beta' into dev 2026-04-15 13:10:45 +12:00
Edward Firmo
2db2b89eb1 [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 07:47:44 +12:00
J. Nick Koston
e48c7165c5 [light] Avoid addressable transition stall at low gamma-corrected values (#15726) 2026-04-15 07:45:42 +12:00
J. Nick Koston
506edaadd5 [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-14 19:08:30 +00:00
J. Nick Koston
3f82a3a519 [core] Inline Millis64Impl::compute() on single-threaded platforms (#15684)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-14 08:20:31 -10:00
J. Nick Koston
79cee864cb [esphome][ota] Disable loop while idle, wake on listening-socket activity (#15636) 2026-04-14 08:20:14 -10:00
Alexey Spirkov
9f5ed938e5 [i2s_audio] Add PDM mics support for ESP32-P4 (#15333)
Co-authored-by: Alexey Spirkov <dev@alsp.net>
2026-04-14 14:07:16 -04:00
J. Nick Koston
4729efbd04 [light] Deduplicate color_uncorrect channel math via shared helper (#15727) 2026-04-14 07:50:28 -10:00
J. Nick Koston
da9fbb8044 [core] Fix app_state_ status bits clobbered for non-looping components (#15658) 2026-04-14 07:50:11 -10:00
J. Nick Koston
cf01163c8c [core] Add uint32_to_str helper and use in preferences (#15597) 2026-04-14 07:49:44 -10:00
J. Nick Koston
5ba8c644e4 [ld24xx] Replace heap-allocated SensorWithDedup with inline SensorWithDedup (#15676) 2026-04-14 07:49:27 -10:00
Kevin Ahrendt
c833ff4a84 [audio] Add/configure microDecoder library in preparation for use in future PRs (#15679)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-14 13:49:18 -04:00
J. Nick Koston
2a530a4bf4 [core] Optimize format_hex_internal by splitting separator loop (#15594) 2026-04-14 07:48:33 -10:00
J. Nick Koston
6b4b653462 [globals] Fix TemplatableFn deprecation warning for globals.set (#15733) 2026-04-14 09:18:38 -04:00
J. Nick Koston
edb16a27d3 [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-13 16:58:48 -10:00
J. Nick Koston
21df5d9bf6 [web_server] Reset OTA backend on new upload to avoid brick after interrupted OTA (#15720) 2026-04-13 13:59:45 -10:00
J. Nick Koston
73c972a604 [adc] Place ADC oneshot control functions in IRAM for cache safety (#15717) 2026-04-13 13:59:32 -10:00
Jonathan Swoboda
8cdffef82a [heatpumpir] Bump tonia/HeatpumpIR to 1.0.41 (#15711) 2026-04-13 17:06:56 -04:00
dependabot[bot]
4034809281 Bump actions/create-github-app-token from 3.0.0 to 3.1.1 (#15712)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 11:00:46 -10:00
dependabot[bot]
ce6bffb65c Bump actions/cache from 5.0.4 to 5.0.5 (#15713)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 11:00:24 -10:00
dependabot[bot]
e8bc4bedb4 Bump actions/cache from 5.0.4 to 5.0.5 in /.github/actions/restore-python (#15714)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 11:00:11 -10:00
J. Nick Koston
b85a7ef317 [scheduler] Force-inline process_to_add() fast path (#15685) 2026-04-13 08:40:58 -10:00
J. Nick Koston
9f7e310526 [scheduler] Force-inline cleanup_() fast path (#15683) 2026-04-13 08:40:39 -10:00
J. Nick Koston
af7cb1d81e [scheduler] Force-inline process_defer_queue_() fast path (#15686) 2026-04-13 08:40:25 -10:00
J. Nick Koston
53ce2a2f7f [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-14 06:25:05 +12:00
Jonathan Swoboda
fb0283e0ee [esp32] Update the recommended platform to 55.03.38-1 (#15705) 2026-04-13 14:18:52 -04:00
Jonathan Swoboda
5d0cfc31fa [core] Move FILTER_PLATFORMIO_LINES into platformio_runner (#15707) 2026-04-13 14:18:44 -04:00
J. Nick Koston
f30f0a0edc [zephyr] Remove redundant yield() from main loop (#15694) 2026-04-13 09:43:17 -04:00
Kevin Ahrendt
6aa538a61d [micro_wake_word] Bugfix: Use es-nn v1.1.2 (last known working version) (#15703) 2026-04-13 09:42:02 -04:00
Diorcet Yann
7918a93a7f [esp32] Fix some compiler warnings & bugs (#15610)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-04-13 09:40:49 -04:00
Diorcet Yann
fe6ecb24b4 [bme68x_bsec2] use esphome-libs wrappers for ESP32 (#15697) 2026-04-13 07:49:13 -04:00
dependabot[bot]
6db787d5e4 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-13 08:12:57 +00:00
J. Nick Koston
5b4385a084 [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-13 07:42:31 +00:00
J. Nick Koston
4f69c3b850 [benchmark] Add SubscribeLogsResponse encode benchmarks (#15696) 2026-04-13 02:03:53 -05:00
J. Nick Koston
c62a75ee17 [benchmark] Use -Os to match firmware optimization level (#15688) 2026-04-13 01:40:33 -05:00
dependabot[bot]
d4e9c62d92 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-13 06:23:49 +00:00
Jonathan Swoboda
ac8a2467a5 [core] Fix PlatformIO progress bar rendering in subprocess mode (#15681) 2026-04-12 22:51:55 -04:00
Jesse Hills
dc1dd9ebb7 Merge branch 'beta' into dev 2026-04-13 12:45:02 +12:00
schrob
41c9ed28cd [esp32] Use static stack memory for loop task instead of heap (#15659)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-04-12 23:23:01 +00:00
Jesse Hills
5608aa10a5 [CI] Don't run label workflow on closed/merged PRs (#15678) 2026-04-12 12:46:49 -10:00
Javier Peletier
daa68a2a60 [packages] fix support packages: !include mypackages.yaml (#15677) 2026-04-13 09:48:30 +12:00
Clyde Stubbs
8754bbfa89 [lvgl] Fix use of rotation on host SDL (#15611)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-04-12 20:29:11 +00:00
J. Nick Koston
6d92cc3d2b [packages] Fix false deprecation warning and wrong error paths in nested packages (#15605) 2026-04-13 08:24:23 +12:00
Jonathan Swoboda
2f684bf4f3 [esp32] Bump platform to 55.03.38, Arduino to 3.3.8, ESP-IDF to 5.5.4 (#15666) 2026-04-12 10:07:04 -10:00
Jonathan Swoboda
45af21bf38 [canbus] Fix canbus.send can_id compile error (#15668) 2026-04-12 09:58:51 -10:00
Jonathan Swoboda
e6318a2d16 [mdns] Bump espressif/mdns to 1.11.0 (#15670) 2026-04-12 09:54:30 -10:00
Jonathan Swoboda
bef4c8a86c [cc1101] Extract chip configuration into configure() method (#15635) 2026-04-11 17:36:27 -04:00
Farmer-shin
6e67864510 [epaper_spi] Add Waveshare 3.97inch E-Paper Display (#15466) 2026-04-11 21:27:25 +10:00
dependabot[bot]
c2af4874f9 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-11 08:58:20 +00:00
dependabot[bot]
2001b91280 Bump resvg-py from 0.3.0 to 0.3.1 (#15640)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-11 08:57:39 +00:00
dependabot[bot]
5460ee7edd 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-10 15:55:15 -10:00
J. Nick Koston
40081e5ae7 [rp2040] Fix W5500 Ethernet pbuf corruption by mirroring LWIPMutex semantics (#15624)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-10 13:13:05 -10:00
Jonathan Swoboda
a7c5b0ab46 [sx127x][cc1101][sx126x] Use GPIO interrupt to wake loop (#15627) 2026-04-10 16:26:09 -04:00
dependabot[bot]
e1a813e11f Bump peter-evans/create-pull-request from 8.1.0 to 8.1.1 (#15630)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 10:21:01 -10:00
dependabot[bot]
1dfeef0265 Bump actions/github-script from 8.0.0 to 9.0.0 (#15632)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 10:20:43 -10:00
dependabot[bot]
395610c117 Bump docker/build-push-action from 7.0.0 to 7.1.0 in /.github/actions/build-image (#15633)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 10:20:17 -10:00
dependabot[bot]
ae96f82b82 Bump actions/upload-artifact from 7.0.0 to 7.0.1 (#15631)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 10:20:04 -10:00
dependabot[bot]
2c610abcd0 Bump resvg-py from 0.2.6 to 0.3.0 (#15629)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 10:19:52 -10:00
Kevin Ahrendt
d3591c8d9e [micro_wake_word] Pin esp-nn version (#15628) 2026-04-10 15:21:26 -04:00
J. Nick Koston
ec420d5792 [api] Add (inline_encode) proto option for sub-message inlining (#15599) 2026-04-10 15:33:56 +12:00
J. Nick Koston
17209df7b5 [mcp23016] Add interrupt pin support (#15616) 2026-04-10 15:29:52 +12:00
J. Nick Koston
9cf9b02ba2 [pca6416a] Add interrupt pin support (#15614) 2026-04-10 15:29:26 +12:00
J. Nick Koston
c90fa2378a [tca9555] Add interrupt pin support (#15613) 2026-04-10 15:29:00 +12:00
Jesse Hills
c04dfa922e [hbridge] Move light pin switching to loop (#15615) 2026-04-10 14:02:49 +12:00
Jesse Hills
668007707d [CI] Add org fork detection warning to auto-label PR workflow (#15588) 2026-04-10 12:13:22 +12:00
dependabot[bot]
ab71f5276f Bump ruff from 0.15.9 to 0.15.10 (#15609)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-04-09 19:36:25 +00:00
Jonathan Swoboda
d062f62656 [sx127x][cc1101] Disable loop when packet mode is inactive (#15606) 2026-04-09 15:00:52 -04:00
J. Nick Koston
03db32d045 [core] Add CodSpeed benchmarks for hot helper functions (#15593) 2026-04-09 07:48:32 -10:00
J. Nick Koston
8f6d489a9a [ci] Use --base-only for memory impact builds (#15598) 2026-04-09 11:48:33 -04:00
J. Nick Koston
dd07fba943 [socket] Document ready() contract: callers must drain or track (#15590) 2026-04-09 11:48:18 -04:00
J. Nick Koston
6f5d642a31 [gdk101] Increase reset retries for slow-booting sensor MCU (#15584) 2026-04-09 11:48:10 -04:00
dependabot[bot]
2721f08bcc 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-09 10:03:58 +00:00
J. Nick Koston
eafc5df3f2 [safe_mode] Combine related OTA rollback log messages (#15592) 2026-04-09 05:30:39 +00:00
J. Nick Koston
46d0c29be5 [safe_mode] Use loop component start time instead of millis() (#15591) 2026-04-09 05:20:32 +00:00
J. Nick Koston
abdbbf4dd2 [api] Fix ListEntitiesRequest not read due to LWIP rcvevent tracking (#15589) 2026-04-09 02:14:01 +00:00
Jesse Hills
4dc0599a7d Merge branch 'beta' into dev 2026-04-09 13:41:27 +12:00
Jesse Hills
52c35ec09c Bump version to 2026.5.0-dev 2026-04-09 11:28:48 +12:00
J. Nick Koston
76490e45bc [ci] Fix status-check-labels workflow flooding CI queue (#15585) 2026-04-08 13:08:29 -10:00
Angel Nunez Mencias
0a8130858c [ade7953_spi] Fix SPI mode on esp-idf (#14824)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-04-08 22:57:53 +00:00
177 changed files with 4468 additions and 1707 deletions

View File

@@ -1 +1 @@
10c432ae818f9ed7fd4a0176a04467b1f2634363f5ec985045a6d72747f60b90
075ed2142432dc59883bb52db8ac11270f952851d6400deae080f5468c7cb592

View File

@@ -12,7 +12,7 @@
"--privileged",
"-e",
"GIT_EDITOR=code --wait"
// uncomment and edit the path in order to pass though local USB serial to the conatiner
// uncomment and edit the path in order to pass through local USB serial to the container
// , "--device=/dev/ttyACM0"
],
"appPort": 6052,

View File

@@ -47,7 +47,7 @@ runs:
- name: Build and push to ghcr by digest
id: build-ghcr
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
@@ -73,7 +73,7 @@ runs:
- name: Build and push to dockerhub by digest
id: build-dockerhub
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false

View File

@@ -22,7 +22,7 @@ runs:
python-version: ${{ inputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: venv
# yamllint disable-line rule:line-length

View File

@@ -4,6 +4,7 @@ module.exports = {
CODEOWNERS_MARKER: '<!-- codeowners-request -->',
TOO_BIG_MARKER: '<!-- too-big-request -->',
DEPRECATED_COMPONENT_MARKER: '<!-- deprecated-component-request -->',
ORG_FORK_MARKER: '<!-- maintainer-access-warning -->',
MANAGED_LABELS: [
'new-component',

View File

@@ -281,6 +281,24 @@ async function detectDeprecatedComponents(github, context, changedFiles) {
return { labels, deprecatedInfo };
}
// Strategy: Detect when maintainers cannot modify the PR branch
function detectMaintainerAccess(context) {
const pr = context.payload.pull_request;
// Only relevant for cross-repo PRs (forks)
if (!pr.head.repo || pr.head.repo.full_name === pr.base.repo.full_name) {
return null;
}
if (pr.maintainer_can_modify) {
return null;
}
const isOrgFork = pr.head.repo.owner.type === 'Organization';
console.log(`Maintainer cannot modify PR branch (${isOrgFork ? 'org fork: ' + pr.head.repo.owner.login : 'user disabled'})`);
return { isOrgFork, orgName: pr.head.repo.owner.login };
}
// Strategy: Requirements detection
async function detectRequirements(allLabels, prFiles, context) {
const labels = new Set();
@@ -329,5 +347,6 @@ module.exports = {
detectTests,
detectPRTemplateCheckboxes,
detectDeprecatedComponents,
detectMaintainerAccess,
detectRequirements
};

View File

@@ -12,9 +12,10 @@ const {
detectTests,
detectPRTemplateCheckboxes,
detectDeprecatedComponents,
detectMaintainerAccess,
detectRequirements
} = require('./detectors');
const { handleReviews } = require('./reviews');
const { handleReviews, handleMaintainerAccessComment } = require('./reviews');
const { applyLabels, removeOldLabels } = require('./labels');
// Fetch API data
@@ -114,7 +115,8 @@ module.exports = async ({ github, context }) => {
codeOwnerLabels,
testLabels,
checkboxLabels,
deprecatedResult
deprecatedResult,
maintainerAccess
] = await Promise.all([
detectMergeBranch(context),
detectComponentPlatforms(changedFiles, apiData),
@@ -127,7 +129,8 @@ module.exports = async ({ github, context }) => {
detectCodeOwner(github, context, changedFiles),
detectTests(changedFiles),
detectPRTemplateCheckboxes(context),
detectDeprecatedComponents(github, context, changedFiles)
detectDeprecatedComponents(github, context, changedFiles),
detectMaintainerAccess(context)
]);
// Extract deprecated component info
@@ -177,8 +180,11 @@ module.exports = async ({ github, context }) => {
console.log('Computed labels:', finalLabels.join(', '));
// Handle reviews
await handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD);
// Handle reviews and org fork comment
await Promise.all([
handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD),
handleMaintainerAccessComment(github, context, maintainerAccess)
]);
// Apply labels
await applyLabels(github, context, finalLabels);

View File

@@ -2,7 +2,8 @@ const {
BOT_COMMENT_MARKER,
CODEOWNERS_MARKER,
TOO_BIG_MARKER,
DEPRECATED_COMPONENT_MARKER
DEPRECATED_COMPONENT_MARKER,
ORG_FORK_MARKER
} = require('./constants');
// Generate review messages
@@ -136,6 +137,63 @@ async function handleReviews(github, context, finalLabels, originalLabelCount, d
}
}
// Handle maintainer access warning comment
async function handleMaintainerAccessComment(github, context, maintainerAccess) {
if (!maintainerAccess) {
return;
}
const { owner, repo } = context.repo;
const pr_number = context.issue.number;
const prAuthor = context.payload.pull_request.user.login;
// Check if we already posted the warning (iterate pages to exit early)
let existingComment;
for await (const { data: comments } of github.paginate.iterator(
github.rest.issues.listComments,
{ owner, repo, issue_number: pr_number }
)) {
existingComment = comments.find(comment =>
comment.user.type === 'Bot' &&
comment.body && comment.body.includes(ORG_FORK_MARKER)
);
if (existingComment) {
break;
}
}
if (existingComment) {
console.log('Maintainer access warning comment already exists, skipping');
return;
}
let body;
if (maintainerAccess.isOrgFork) {
body = `${ORG_FORK_MARKER}\n### ⚠️ Organization Fork Detected\n\n` +
`Hey there @${prAuthor},\n` +
`It looks like this PR was submitted from a fork owned by the **${maintainerAccess.orgName}** organization. ` +
`GitHub does not allow maintainers to push changes to pull request branches when the fork is owned by an organization. ` +
`This means we won't be able to make small adjustments or fixups to your PR directly.\n\n` +
`To allow maintainer collaboration, please re-submit this PR from a personal fork instead.\n\n` +
`See: [Setting up the local repository](https://developers.esphome.io/contributing/development-environment/?h=org#set-up-the-local-repository) for more details.`;
} else {
body = `${ORG_FORK_MARKER}\n### ⚠️ Maintainer Access Disabled\n\n` +
`Hey there @${prAuthor},\n` +
`It looks like this PR does not have the "Allow edits from maintainers" option enabled. ` +
`This means we won't be able to make small adjustments or fixups to your PR directly.\n\n` +
`Please enable this option in the PR sidebar to allow maintainer collaboration.`;
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: pr_number,
body
});
console.log('Created maintainer access warning comment');
}
module.exports = {
handleReviews
handleReviews,
handleMaintainerAccessComment
};

View File

@@ -20,20 +20,20 @@ env:
jobs:
label:
runs-on: ubuntu-latest
if: github.event.action != 'labeled' || github.event.sender.type != 'Bot'
if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot')
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v2
with:
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
- name: Auto Label PR
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |

View File

@@ -47,7 +47,7 @@ jobs:
fi
- if: failure()
name: Review PR
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
await github.rest.pulls.createReview({
@@ -62,7 +62,7 @@ jobs:
run: git diff
- if: failure()
name: Archive artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: generated-proto-files
path: |
@@ -70,7 +70,7 @@ jobs:
esphome/components/api/api_pb2_service.*
- if: success()
name: Dismiss review
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
let reviews = await github.rest.pulls.listReviews({

View File

@@ -42,7 +42,7 @@ jobs:
- if: failure() && github.event.pull_request.head.repo.full_name == github.repository
name: Request changes
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
await github.rest.pulls.createReview({
@@ -55,7 +55,7 @@ jobs:
- if: success() && github.event.pull_request.head.repo.full_name == github.repository
name: Dismiss review
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
let reviews = await github.rest.pulls.listReviews({

View File

@@ -47,7 +47,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: venv
# yamllint disable-line rule:line-length
@@ -159,7 +159,7 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Save Python virtual environment cache
if: github.ref == 'refs/heads/dev'
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: venv
key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
@@ -198,7 +198,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Restore components graph cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .temp/components_graph.json
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
@@ -231,7 +231,7 @@ jobs:
echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT
- name: Save components graph cache
if: github.ref == 'refs/heads/dev'
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .temp/components_graph.json
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
@@ -253,7 +253,7 @@ jobs:
python-version: "3.13"
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: venv
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
@@ -387,14 +387,14 @@ jobs:
- name: Cache platformio
if: github.ref == 'refs/heads/dev'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev'
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
@@ -466,14 +466,14 @@ jobs:
- name: Cache platformio
if: github.ref == 'refs/heads/dev'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev'
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
@@ -555,14 +555,14 @@ jobs:
- name: Cache platformio
if: github.ref == 'refs/heads/dev'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev'
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
@@ -817,7 +817,7 @@ jobs:
- name: Restore cached memory analysis
id: cache-memory-analysis
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true'
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: memory-analysis-target.json
key: ${{ steps.cache-key.outputs.cache-key }}
@@ -841,7 +841,7 @@ jobs:
- name: Cache platformio
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.platformio
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}
@@ -868,7 +868,8 @@ jobs:
python script/test_build_components.py \
-e compile \
-c "$component_list" \
-t "$platform" 2>&1 | \
-t "$platform" \
--base-only 2>&1 | \
tee /dev/stderr | \
python script/ci_memory_impact_extract.py \
--output-env \
@@ -882,7 +883,7 @@ jobs:
- name: Save memory analysis to cache
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success'
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: memory-analysis-target.json
key: ${{ steps.cache-key.outputs.cache-key }}
@@ -903,7 +904,7 @@ jobs:
fi
- name: Upload memory analysis JSON
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: memory-analysis-target
path: memory-analysis-target.json
@@ -929,7 +930,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache platformio
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.platformio
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}
@@ -954,7 +955,8 @@ jobs:
python script/test_build_components.py \
-e compile \
-c "$component_list" \
-t "$platform" 2>&1 | \
-t "$platform" \
--base-only 2>&1 | \
tee /dev/stderr | \
python script/ci_memory_impact_extract.py \
--output-env \
@@ -967,7 +969,7 @@ jobs:
--platform "$platform"
- name: Upload memory analysis JSON
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: memory-analysis-pr
path: memory-analysis-pr.json

View File

@@ -34,7 +34,7 @@ jobs:
CODEOWNERS
- name: Check codeowner approval and update label
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
with:

View File

@@ -33,7 +33,7 @@ jobs:
ref: ${{ github.event.pull_request.base.sha }}
- name: Request reviews from component codeowners
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { loadCodeowners, getEffectiveOwners } = require('./.github/scripts/codeowners.js');

View File

@@ -58,7 +58,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -86,6 +86,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
with:
category: "/language:${{matrix.language}}"

View File

@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Add external component comment
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |

View File

@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Notify codeowners for component issues
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const owner = context.repo.owner;

View File

@@ -8,4 +8,4 @@ on:
jobs:
lock:
uses: esphome/workflows/.github/workflows/lock.yml@main
uses: esphome/workflows/.github/workflows/lock.yml@3c4e8446aa1029f1c346a482034b3ee1489077ca # 2026.4.0

View File

@@ -18,7 +18,7 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const {

View File

@@ -138,7 +138,7 @@ jobs:
# version: ${{ needs.init.outputs.tag }}
- name: Upload digests
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digests-${{ matrix.platform.arch }}
path: /tmp/digests
@@ -221,7 +221,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
@@ -229,7 +229,7 @@ jobs:
repositories: home-assistant-addon
- name: Trigger Workflow
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
@@ -256,7 +256,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
@@ -264,7 +264,7 @@ jobs:
repositories: esphome-schema
- name: Trigger Workflow
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
@@ -287,7 +287,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
@@ -295,7 +295,7 @@ jobs:
repositories: version-notifier
- name: Trigger Workflow
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |

View File

@@ -2,30 +2,29 @@ name: Status check labels
on:
pull_request:
types: [labeled, unlabeled]
types: [opened, reopened, labeled, unlabeled, synchronize]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
check:
name: Check ${{ matrix.label }}
name: Check blocking labels
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
label:
- needs-docs
- merge-after-release
- chained-pr
steps:
- name: Check for ${{ matrix.label }} label
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
- name: Check for blocking labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const blockingLabels = ['needs-docs', 'merge-after-release', 'chained-pr'];
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number
});
const hasLabel = labels.find(label => label.name === '${{ matrix.label }}');
if (hasLabel) {
core.setFailed('Pull request cannot be merged, it is labeled as ${{ matrix.label }}');
const labelNames = labels.map(l => l.name);
const found = blockingLabels.filter(bl => labelNames.includes(bl));
if (found.length > 0) {
core.setFailed(`Pull request cannot be merged, it has blocking label(s): ${found.join(', ')}`);
}

View File

@@ -41,7 +41,7 @@ jobs:
python script/run-in-env.py pre-commit run --all-files
- name: Commit changes
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
commit-message: "Synchronise Device Classes from Home Assistant"
committer: esphomebot <esphome@openhomefoundation.org>

View File

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

View File

@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.4.0
PROJECT_NUMBER = 2026.5.0-dev
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a

View File

@@ -569,6 +569,7 @@ def wrap_to_code(name, comp):
@functools.wraps(comp.to_code)
async def wrapped(conf):
cg.add(cg.ComponentMarker(name))
cg.add(cg.LineComment(f"{name}:"))
if comp.config_schema is not None:
conf_str = yaml_util.dump(conf)

View File

@@ -199,11 +199,10 @@ def validate_automation(extra_schema=None, extra_validators=None, single=False):
return cv.Schema([schema])(value)
except cv.Invalid as err2:
if "extra keys not allowed" in str(err2) and len(err2.path) == 2:
# pylint: disable=raise-missing-from
raise err
raise err from None
if "Unable to find action" in str(err):
raise err2
raise cv.MultipleInvalid([err, err2])
raise err2 from None
raise cv.MultipleInvalid([err, err2]) from None
elif isinstance(value, dict):
if CONF_THEN in value:
return [schema(value)]

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("<"):

View File

@@ -10,8 +10,10 @@
# pylint: disable=unused-import
from esphome.cpp_generator import ( # noqa: F401
ArrayInitializer,
ComponentMarker,
Expression,
FlashStringLiteral,
IIFEUnsafeStatement,
LineComment,
LogStringLiteral,
MockObj,

View File

@@ -8,6 +8,9 @@ namespace ade7953_base {
static const char *const TAG = "ade7953";
constexpr uint16_t CONFIG_DEFAULT = 0x8004u;
constexpr uint16_t CONFIG_LOCK_BIT = 0x8000u;
static const float ADE_POWER_FACTOR = 154.0f;
static const float ADE_WATTSEC_POWER_FACTOR = ADE_POWER_FACTOR * ADE_POWER_FACTOR / 3600;
@@ -18,7 +21,12 @@ void ADE7953::setup() {
// The chip might take up to 100ms to initialise
this->set_timeout(100, [this]() {
// this->ade_write_8(0x0010, 0x04);
// Lock communication interface (SPI or I2C)
uint16_t config_v = CONFIG_DEFAULT;
this->ade_read_16(CONFIG_16, &config_v);
config_v &= static_cast<uint16_t>(~CONFIG_LOCK_BIT); // Clear the lock bit
this->ade_write_16(CONFIG_16, config_v);
// Configure optimum settings according to datasheet
this->ade_write_8(0x00FE, 0xAD);
this->ade_write_16(0x0120, 0x0030);
// Set gains

View File

@@ -9,31 +9,35 @@
namespace esphome {
namespace ade7953_base {
static const uint8_t PGA_V_8 =
static constexpr uint8_t PGA_V_8 =
0x007; // PGA_V, (R/W) Default: 0x00, Unsigned, Voltage channel gain configuration (Bits[2:0])
static const uint8_t PGA_IA_8 =
static constexpr uint8_t PGA_IA_8 =
0x008; // PGA_IA, (R/W) Default: 0x00, Unsigned, Current Channel A gain configuration (Bits[2:0])
static const uint8_t PGA_IB_8 =
static constexpr uint8_t PGA_IB_8 =
0x009; // PGA_IB, (R/W) Default: 0x00, Unsigned, Current Channel B gain configuration (Bits[2:0])
static const uint32_t AIGAIN_32 =
static constexpr uint16_t CONFIG_16 = 0x102; // CONFIG, (R/W) Default: 0x8004, Unsigned, Configuration register
static constexpr uint16_t AIGAIN_32 =
0x380; // AIGAIN, (R/W) Default: 0x400000, Unsigned,Current channel gain (Current Channel A)(32 bit)
static const uint32_t AVGAIN_32 = 0x381; // AVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit)
static const uint32_t AWGAIN_32 =
static constexpr uint16_t AVGAIN_32 =
0x381; // AVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit)
static constexpr uint16_t AWGAIN_32 =
0x382; // AWGAIN, (R/W) Default: 0x400000, Unsigned,Active power gain (Current Channel A)(32 bit)
static const uint32_t AVARGAIN_32 =
static constexpr uint16_t AVARGAIN_32 =
0x383; // AVARGAIN, (R/W) Default: 0x400000, Unsigned, Reactive power gain (Current Channel A)(32 bit)
static const uint32_t AVAGAIN_32 =
static constexpr uint16_t AVAGAIN_32 =
0x384; // AVAGAIN, (R/W) Default: 0x400000, Unsigned,Apparent power gain (Current Channel A)(32 bit)
static const uint32_t BIGAIN_32 =
static constexpr uint16_t BIGAIN_32 =
0x38C; // BIGAIN, (R/W) Default: 0x400000, Unsigned,Current channel gain (Current Channel B)(32 bit)
static const uint32_t BVGAIN_32 = 0x38D; // BVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit)
static const uint32_t BWGAIN_32 =
static constexpr uint16_t BVGAIN_32 =
0x38D; // BVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit)
static constexpr uint16_t BWGAIN_32 =
0x38E; // BWGAIN, (R/W) Default: 0x400000, Unsigned,Active power gain (Current Channel B)(32 bit)
static const uint32_t BVARGAIN_32 =
static constexpr uint16_t BVARGAIN_32 =
0x38F; // BVARGAIN, (R/W) Default: 0x400000, Unsigned, Reactive power gain (Current Channel B)(32 bit)
static const uint32_t BVAGAIN_32 =
static constexpr uint16_t BVAGAIN_32 =
0x390; // BVAGAIN, (R/W) Default: 0x400000, Unsigned,Apparent power gain (Current Channel B)(32 bit)
class ADE7953 : public PollingComponent, public sensor::Sensor {

View File

@@ -7,6 +7,9 @@ namespace ade7953_spi {
static const char *const TAG = "ade7953";
// Datasheet requires at least 1.2µs after clearing CONFIG LOCK_BIT before raising CS
constexpr uint8_t CONFIG_LOCK_SETTLE_US = 2;
void AdE7953Spi::setup() {
this->spi_setup();
ade7953_base::ADE7953::setup();
@@ -32,6 +35,9 @@ bool AdE7953Spi::ade_write_16(uint16_t reg, uint16_t value) {
this->write_byte16(reg);
this->transfer_byte(0);
this->write_byte16(value);
if (reg == ade7953_base::CONFIG_16) {
delayMicroseconds(CONFIG_LOCK_SETTLE_US);
}
this->disable();
return false;
}

View File

@@ -12,7 +12,7 @@ namespace esphome {
namespace ade7953_spi {
class AdE7953Spi : public ade7953_base::ADE7953,
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_HIGH, spi::CLOCK_PHASE_LEADING,
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_HIGH, spi::CLOCK_PHASE_TRAILING,
spi::DATA_RATE_1MHZ> {
public:
void setup() override;

View File

@@ -195,7 +195,10 @@ class APIFrameHelper {
}
// Get the frame footer size required by this protocol
uint8_t frame_footer_size() const { return frame_footer_size_; }
// Check if socket has data ready to read
// Check if socket has buffered data ready to read.
// Contract: callers must read until it would block (EAGAIN/EWOULDBLOCK)
// or track that they stopped early and retry without this check.
// See Socket::ready() for details.
bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); }
// Release excess memory from internal buffers after initial sync
void release_buffers() {

View File

@@ -83,7 +83,7 @@ def angle_to_position(value, min=-360, max=360):
value = angle(min=min, max=max)(value)
return (RESOLUTION + round(value * ANGLE_TO_POSITION)) % RESOLUTION
except cv.Invalid as e:
raise cv.Invalid(f"When using angle, {e.error_message}")
raise cv.Invalid(f"When using angle, {e.error_message}") from e
def percent_to_position(value):
@@ -164,7 +164,7 @@ def has_valid_range_config():
except cv.Invalid as e:
raise cv.Invalid(
f"The range between start and end position is invalid. It was was {range} but {e.error_message}"
)
) from e
return validator

View File

@@ -1,7 +1,11 @@
from dataclasses import dataclass
import esphome.codegen as cg
from esphome.components.esp32 import add_idf_component, include_builtin_idf_component
from esphome.components.esp32 import (
add_idf_component,
add_idf_sdkconfig_option,
include_builtin_idf_component,
)
import esphome.config_validation as cv
from esphome.const import CONF_BITS_PER_SAMPLE, CONF_NUM_CHANNELS, CONF_SAMPLE_RATE
from esphome.core import CORE
@@ -27,6 +31,7 @@ class AudioData:
flac_support: bool = False
mp3_support: bool = False
opus_support: bool = False
micro_decoder_support: bool = False
def _get_data() -> AudioData:
@@ -50,6 +55,11 @@ def request_opus_support() -> None:
_get_data().opus_support = True
def request_micro_decoder_support() -> None:
"""Request micro-decoder library support for audio decoding."""
_get_data().micro_decoder_support = True
CONF_MIN_BITS_PER_SAMPLE = "min_bits_per_sample"
CONF_MAX_BITS_PER_SAMPLE = "max_bits_per_sample"
CONF_MIN_CHANNELS = "min_channels"
@@ -208,6 +218,19 @@ async def to_code(config):
)
data = _get_data()
if data.micro_decoder_support:
add_idf_component(name="esphome/micro-decoder", ref="0.1.1")
# All codecs are enabled by default in micro-decoder, so disable the ones that aren't requested to save flash
if not data.flac_support:
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_FLAC", False)
if not data.mp3_support:
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_MP3", False)
if not data.opus_support:
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_OPUS", False)
# Legacy audio_decoder.cpp support defines and components
if data.flac_support:
cg.add_define("USE_AUDIO_FLAC_SUPPORT")
add_idf_component(name="esphome/micro-flac", ref="0.1.1")

View File

@@ -116,7 +116,7 @@ def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]:
raise cv.Invalid(
f"Unable to determine audio file type of '{path}'. "
f"Try re-encoding the file into a supported format. Details: {e}"
)
) from e
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["NONE"]
if file_type == "wav":

View File

@@ -332,8 +332,9 @@ def parse_multi_click_timing_str(value):
try:
state = cv.boolean(parts[0])
except cv.Invalid:
# pylint: disable=raise-missing-from
raise cv.Invalid(f"First word must either be ON or OFF, not {parts[0]}")
raise cv.Invalid(
f"First word must either be ON or OFF, not {parts[0]}"
) from None
if parts[1] != "for":
raise cv.Invalid(f"Second word must be 'for', got {parts[1]}")
@@ -350,7 +351,9 @@ def parse_multi_click_timing_str(value):
try:
length = cv.positive_time_period_milliseconds(parts[4])
except cv.Invalid as err:
raise cv.Invalid(f"Multi Click Grammar Parsing length failed: {err}")
raise cv.Invalid(
f"Multi Click Grammar Parsing length failed: {err}"
) from err
return {CONF_STATE: state, key: str(length)}
if parts[3] != "to":
@@ -359,12 +362,16 @@ def parse_multi_click_timing_str(value):
try:
min_length = cv.positive_time_period_milliseconds(parts[2])
except cv.Invalid as err:
raise cv.Invalid(f"Multi Click Grammar Parsing minimum length failed: {err}")
raise cv.Invalid(
f"Multi Click Grammar Parsing minimum length failed: {err}"
) from err
try:
max_length = cv.positive_time_period_milliseconds(parts[4])
except cv.Invalid as err:
raise cv.Invalid(f"Multi Click Grammar Parsing minimum length failed: {err}")
raise cv.Invalid(
f"Multi Click Grammar Parsing maximum length failed: {err}"
) from err
return {
CONF_STATE: state,

View File

@@ -65,3 +65,8 @@ async def to_code(config):
@pins.PIN_SCHEMA_REGISTRY.register("bk72xx", PIN_SCHEMA)
async def pin_to_code(config):
return await libretiny.gpio.component_pin_to_code(config)
# Called by writer.py; delegates to the shared libretiny implementation.
def copy_files() -> None:
libretiny.copy_files()

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;
}

View File

@@ -17,6 +17,7 @@ CODEOWNERS = ["@neffs", "@kbx81"]
DOMAIN = "bme68x_bsec2"
BSEC2_LIBRARY_VERSION = "1.10.2610"
BME68x_LIBRARY_VERSION = "v1.3.40408"
CONF_ALGORITHM_OUTPUT = "algorithm_output"
CONF_BME68X_BSEC2_ID = "bme68x_bsec2_id"
@@ -170,7 +171,9 @@ async def to_code_base(config):
with open(path, encoding="utf-8") as f:
bsec2_iaq_config = f.read()
except Exception as e:
raise core.EsphomeError(f"Could not open binary configuration file {path}: {e}")
raise core.EsphomeError(
f"Could not open binary configuration file {path}: {e}"
) from e
# Convert retrieved BSEC2 config to an array of ints
rhs = [int(x) for x in bsec2_iaq_config.split(",")]
@@ -184,16 +187,31 @@ async def to_code_base(config):
if core.CORE.using_arduino:
cg.add_library("Wire", None)
cg.add_library("SPI", None)
cg.add_library(
"BME68x Sensor library",
None,
"https://github.com/boschsensortec/Bosch-BME68x-Library#v1.3.40408",
)
cg.add_library(
"BSEC2 Software Library",
None,
f"https://github.com/boschsensortec/Bosch-BSEC2-Library.git#{BSEC2_LIBRARY_VERSION}",
)
if core.CORE.is_esp32:
from esphome.components.esp32 import add_idf_component
add_idf_component(
name="boschsensortec/Bosch-BME68x-Library",
repo="https://github.com/esphome-libs/Bosch-BME68x-Library",
ref=BME68x_LIBRARY_VERSION,
)
add_idf_component(
name="boschsensortec/Bosch-BSEC2-Library",
repo="https://github.com/esphome-libs/Bosch-BSEC2-Library",
ref=BSEC2_LIBRARY_VERSION,
)
else:
cg.add_library(
"BME68x Sensor library",
None,
f"https://github.com/boschsensortec/Bosch-BME68x-Library#{BME68x_LIBRARY_VERSION}",
)
cg.add_library(
"BSEC2 Software Library",
None,
f"https://github.com/boschsensortec/Bosch-BSEC2-Library.git#{BSEC2_LIBRARY_VERSION}",
)
cg.add_define("USE_BSEC2")

View File

@@ -106,6 +106,30 @@ void IRAM_ATTR CC1101Component::gpio_intr(CC1101Component *arg) { arg->enable_lo
void CC1101Component::setup() {
this->spi_setup();
if (this->gdo0_pin_ != nullptr) {
this->gdo0_pin_->setup();
}
this->configure();
if (this->is_failed()) {
return;
}
// 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);
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);
}
});
}
}
void CC1101Component::configure() {
// Manual reset sequence per CC1101 datasheet section 19.1.2
this->cs_->digital_write(true);
delayMicroseconds(1);
this->cs_->digital_write(false);
@@ -128,11 +152,6 @@ void CC1101Component::setup() {
return;
}
// Setup GDO0 pin if configured
if (this->gdo0_pin_ != nullptr) {
this->gdo0_pin_->setup();
}
this->initialized_ = true;
for (uint8_t i = 0; i <= static_cast<uint8_t>(Register::TEST0); i++) {
@@ -142,21 +161,11 @@ void CC1101Component::setup() {
this->write_(static_cast<Register>(i));
}
this->set_output_power(this->output_power_requested_);
if (!this->enter_rx_()) {
this->mark_failed();
return;
}
// 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);
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);
}
});
}
}
void CC1101Component::call_listeners_(const std::vector<uint8_t> &packet, float freq_offset, float rssi, uint8_t lqi) {
@@ -273,7 +282,7 @@ void CC1101Component::begin_rx() {
void CC1101Component::reset() {
this->strobe_(Command::RES);
this->setup();
this->configure();
}
void CC1101Component::set_idle() {

View File

@@ -25,6 +25,7 @@ class CC1101Component : public Component,
void setup() override;
void loop() override;
void dump_config() override;
void configure();
// Actions
void begin_tx();

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;
}

View File

@@ -43,3 +43,11 @@ wave_4_26.extend(
},
},
)
ssd1677.extend(
"waveshare-3.97in",
width=800,
height=480,
mirror_x=True,
)

View File

@@ -1222,7 +1222,7 @@ FRAMEWORK_SCHEMA = cv.Schema(
cv.Optional(CONF_IGNORE_EFUSE_CUSTOM_MAC, default=False): cv.boolean,
cv.Optional(CONF_IGNORE_EFUSE_MAC_CRC, default=False): cv.boolean,
cv.Optional(CONF_MINIMUM_CHIP_REVISION): cv.one_of(
*ESP32_CHIP_REVISIONS
*ESP32_CHIP_REVISIONS, string=True
),
cv.Optional(CONF_SRAM1_AS_IRAM, default=False): cv.boolean,
# DHCP server is needed for WiFi AP mode. When WiFi component is used,

View File

@@ -61,6 +61,9 @@ uint32_t arch_get_cpu_freq_hz() {
}
TaskHandle_t loop_task_handle = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static StackType_t
loop_task_stack[ESPHOME_LOOP_TASK_STACK_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
void loop_task(void *pv_params) {
setup();
@@ -73,9 +76,11 @@ extern "C" void app_main() {
initArduino();
esp32::setup_preferences();
#if CONFIG_FREERTOS_UNICORE
xTaskCreate(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, &loop_task_handle);
loop_task_handle = xTaskCreateStatic(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, loop_task_stack,
&loop_task_tcb);
#else
xTaskCreatePinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, &loop_task_handle, 1);
loop_task_handle = xTaskCreateStaticPinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1,
loop_task_stack, &loop_task_tcb, 1);
#endif
}

View File

@@ -4,7 +4,6 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <nvs_flash.h>
#include <cinttypes>
#include <cstring>
#include <vector>
@@ -12,9 +11,6 @@ namespace esphome::esp32 {
static const char *const TAG = "preferences";
// Buffer size for converting uint32_t to string: max "4294967295" (10 chars) + null terminator + 1 padding
static constexpr size_t KEY_BUFFER_SIZE = 12;
struct NVSData {
uint32_t key;
SmallInlineBuffer<8> data; // Most prefs fit in 8 bytes (covers fan, cover, select, etc.)
@@ -51,8 +47,8 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) {
}
}
char key_str[KEY_BUFFER_SIZE];
snprintf(key_str, sizeof(key_str), "%" PRIu32, this->key);
char key_str[UINT32_MAX_STR_SIZE];
uint32_to_str(key_str, this->key);
size_t actual_len;
esp_err_t err = nvs_get_blob(this->nvs_handle, key_str, nullptr, &actual_len);
if (err != 0) {
@@ -108,8 +104,8 @@ bool ESP32Preferences::sync() {
uint32_t last_key = 0;
for (const auto &save : s_pending_save) {
char key_str[KEY_BUFFER_SIZE];
snprintf(key_str, sizeof(key_str), "%" PRIu32, save.key);
char key_str[UINT32_MAX_STR_SIZE];
uint32_to_str(key_str, save.key);
ESP_LOGVV(TAG, "Checking if NVS data %s has changed", key_str);
if (this->is_changed_(this->nvs_handle, save, key_str)) {
esp_err_t err = nvs_set_blob(this->nvs_handle, key_str, save.data.data(), save.data.size());

View File

@@ -78,6 +78,14 @@ def ota_esphome_final_validate(config):
else:
new_ota_conf.append(ota_conf)
if len(merged_ota_esphome_configs_by_port) > 1:
raise cv.Invalid(
f"Only a single port is supported for '{CONF_OTA}' "
f"'{CONF_PLATFORM}: {CONF_ESPHOME}'. Got ports "
f"{sorted(merged_ota_esphome_configs_by_port.keys())}. Consolidate "
f"onto a single port; configs sharing a port are merged automatically."
)
new_ota_conf.extend(merged_ota_esphome_configs_by_port.values())
full_conf[CONF_OTA] = new_ota_conf
@@ -147,6 +155,8 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_auth_password(config[CONF_PASSWORD]))
cg.add_define("USE_OTA_PASSWORD")
cg.add_define("USE_OTA_VERSION", config[CONF_VERSION])
# Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it.
cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME")
await cg.register_component(var, config)
await ota_to_code(var, config)

View File

@@ -15,6 +15,9 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/util.h"
#ifdef USE_LWIP_FAST_SELECT
#include "esphome/core/lwip_fast_select.h"
#endif
#include <cerrno>
#include <cstdio>
@@ -28,6 +31,17 @@ static constexpr size_t OTA_BUFFER_SIZE = 1024; // buffer size
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer
// Single-instance pointer — multi-port configs are rejected in final_validate.
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
static ESPHomeOTAComponent *global_esphome_ota_component = nullptr;
// Called from any context (LwIP TCP/IP task, RP2040 user-IRQ).
extern "C" void esphome_wake_ota_component_any_context() {
if (global_esphome_ota_component != nullptr) {
global_esphome_ota_component->enable_loop_soon_any_context();
}
}
void ESPHomeOTAComponent::setup() {
this->server_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections
if (this->server_ == nullptr) {
@@ -65,6 +79,14 @@ void ESPHomeOTAComponent::setup() {
this->server_failed_(LOG_STR("listen"));
return;
}
// loop() self-disables on its first idle tick; no explicit disable_loop() needed here.
global_esphome_ota_component = this;
#ifdef USE_LWIP_FAST_SELECT
// Filter fast-select wakes to this listener only. If the sock lookup returns nullptr,
// no wakes fire and loop() falls back to the self-disable safety net.
esphome_fast_select_set_ota_listener_sock(esphome_lwip_get_sock(this->server_->get_fd()));
#endif
}
void ESPHomeOTAComponent::dump_config() {
@@ -81,13 +103,15 @@ void ESPHomeOTAComponent::dump_config() {
}
void ESPHomeOTAComponent::loop() {
// Skip handle_handshake_() call if no client connected and no incoming connections
// This optimization reduces idle loop overhead when OTA is not active
// Note: No need to check server_ for null as the component is marked failed in setup()
// if server_ creation fails
if (this->client_ != nullptr || this->server_->ready()) {
this->handle_handshake_();
// Self-disabling idle loop. Runs when a wake path marks us pending-enable (fast-select
// listener filter, raw-TCP accept_fn_, or host select), finds no work, and goes back
// to sleep. cleanup_connection_() deliberately leaves the loop enabled for one more
// iteration so a connection queued mid-session is still caught here.
if (this->client_ == nullptr && !this->server_->ready()) {
this->disable_loop();
return;
}
this->handle_handshake_();
}
static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01;
@@ -566,6 +590,9 @@ void ESPHomeOTAComponent::cleanup_connection_() {
#ifdef USE_OTA_PASSWORD
this->cleanup_auth_();
#endif
// Intentionally no disable_loop() — letting loop() run one more iteration catches
// any connection that queued on the listener mid-session (otherwise the wake flag,
// set while we were in LOOP state, would be lost to enable_pending_loops_()).
}
void ESPHomeOTAComponent::yield_and_feed_watchdog_() {

View File

@@ -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

View File

@@ -325,7 +325,7 @@ def download_gfont(value):
raise cv.Invalid(
f"Could not download font at {url}, please check the fonts exists "
f"at google fonts ({e})"
)
) from e
match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", req.text)
if match is None:
raise cv.Invalid(

View File

@@ -60,20 +60,35 @@ CONFIG_SCHEMA = (
)
async def to_code(config):
var = await binary_sensor.new_binary_sensor(config)
await cg.register_component(var, config)
def _pin_shared_only_with_deep_sleep(pin_num: int) -> bool:
"""Check if pin is shared exclusively with deep_sleep (wakeup pin)."""
pin_key = (CORE.target_platform, CORE.target_platform, pin_num)
pin_users = pins.PIN_SCHEMA_REGISTRY.pins_used.get(pin_key, [])
if len(pin_users) != 2:
return False
return any(path and path[0] == "deep_sleep" for path, _, _ in pin_users)
pin = await cg.gpio_pin_expression(config[CONF_PIN])
cg.add(var.set_pin(pin))
# Check for ESP8266 GPIO16 interrupt limitation
# GPIO16 on ESP8266 is a special pin that doesn't support interrupts through
# the Arduino attachInterrupt() function. This is the only known GPIO pin
# across all supported platforms that has this limitation, so we handle it
# here instead of in the platform-specific code.
def _final_validate(config):
use_interrupt = config[CONF_USE_INTERRUPT]
if use_interrupt and CORE.is_esp8266 and config[CONF_PIN][CONF_NUMBER] == 16:
if not use_interrupt:
return config
pin_num = config[CONF_PIN][CONF_NUMBER]
# Expander pins (e.g. PCF8574, MCP23017) don't support direct interrupt
# attachment — only internal/native GPIO pins do.
if pins.PIN_SCHEMA_REGISTRY.get_key(config[CONF_PIN]) != CORE.target_platform:
_LOGGER.info(
"GPIO binary_sensor '%s': Pin is not an internal GPIO, "
"falling back to polling mode.",
config.get(CONF_NAME, config[CONF_ID]),
)
config[CONF_USE_INTERRUPT] = False
return config
# GPIO16 on ESP8266 doesn't support interrupts through attachInterrupt().
if CORE.is_esp8266 and pin_num == 16:
_LOGGER.warning(
"GPIO binary_sensor '%s': GPIO16 on ESP8266 doesn't support interrupts. "
"Falling back to polling mode (same as in ESPHome <2025.7). "
@@ -81,22 +96,45 @@ async def to_code(config):
"performance with interrupts.",
config.get(CONF_NAME, config[CONF_ID]),
)
use_interrupt = False
config[CONF_USE_INTERRUPT] = False
return config
# Check if pin is shared with other components (allow_other_uses)
# When a pin is shared, interrupts can interfere with other components
# (e.g., duty_cycle sensor) that need to monitor the pin's state changes
if use_interrupt and config[CONF_PIN].get(CONF_ALLOW_OTHER_USES, False):
_LOGGER.info(
"GPIO binary_sensor '%s': Disabling interrupts because pin %s is shared with other components. "
"The sensor will use polling mode for compatibility with other pin uses.",
config.get(CONF_NAME, config[CONF_ID]),
config[CONF_PIN][CONF_NUMBER],
)
use_interrupt = False
# (e.g., duty_cycle sensor) that need to monitor the pin's state changes.
# Exception: deep_sleep wakeup pins are compatible with interrupts when
# the pin is only shared between this sensor and deep_sleep (count == 2).
if config[CONF_PIN].get(CONF_ALLOW_OTHER_USES, False):
if not _pin_shared_only_with_deep_sleep(pin_num):
_LOGGER.info(
"GPIO binary_sensor '%s': Disabling interrupts because pin %s is shared "
"with other components. The sensor will use polling mode for "
"compatibility with other pin uses.",
config.get(CONF_NAME, config[CONF_ID]),
pin_num,
)
config[CONF_USE_INTERRUPT] = False
else:
_LOGGER.debug(
"GPIO binary_sensor '%s': Pin %s is shared with deep_sleep, "
"keeping interrupts enabled.",
config.get(CONF_NAME, config[CONF_ID]),
pin_num,
)
if use_interrupt:
return config
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
var = await binary_sensor.new_binary_sensor(config)
await cg.register_component(var, config)
pin = await cg.gpio_pin_expression(config[CONF_PIN])
cg.add(var.set_pin(pin))
if config[CONF_USE_INTERRUPT]:
cg.add(var.set_interrupt_type(config[CONF_INTERRUPT_TYPE]))
else:
# Only generate call when disabling interrupts (default is true)
cg.add(var.set_use_interrupt(use_interrupt))
cg.add(var.set_use_interrupt(False))

View File

@@ -46,11 +46,6 @@ void GPIOBinarySensorStore::setup(InternalGPIOPin *pin, Component *component) {
}
void GPIOBinarySensor::setup() {
if (this->store_.use_interrupt_ && !this->pin_->is_internal()) {
ESP_LOGD(TAG, "GPIO is not internal, falling back to polling mode");
this->store_.use_interrupt_ = false;
}
if (this->store_.use_interrupt_) {
auto *internal_pin = static_cast<InternalGPIOPin *>(this->pin_);
this->store_.setup(internal_pin, this);

View File

@@ -127,6 +127,6 @@ async def to_code(config):
cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE]))
cg.add_build_flag("-Wno-error=overloaded-virtual")
cg.add_library("tonia/HeatpumpIR", "1.0.40")
cg.add_library("tonia/HeatpumpIR", "1.0.41")
if CORE.is_libretiny or CORE.is_esp32:
CORE.add_platformio_option("lib_ignore", ["IRremoteESP8266"])

View File

@@ -283,7 +283,7 @@ async def to_code(config):
try:
return Image.open(path)
except Exception as e:
raise core.EsphomeError(f"Could not load image file {path}: {e}")
raise core.EsphomeError(f"Could not load image file {path}: {e}") from e
# make a wide horizontal combined image.
images = [load_image(x) for x in config[CONF_COLOR_PALETTE_IMAGES]]

View File

@@ -1,5 +1,7 @@
#pragma once
#include <cstdint>
namespace esphome {
namespace ili9xxx {

View File

@@ -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;

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

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;

View File

@@ -360,8 +360,8 @@ void LD2410Component::handle_periodic_data_() {
*/
#ifdef USE_SENSOR
SAFE_PUBLISH_SENSOR(this->moving_target_distance_sensor_,
encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW]))
SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY])
encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW]));
SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY]);
SAFE_PUBLISH_SENSOR(this->still_target_distance_sensor_,
encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW]));
SAFE_PUBLISH_SENSOR(this->still_target_energy_sensor_, this->buffer_data_[STILL_ENERGY]);
@@ -375,26 +375,26 @@ void LD2410Component::handle_periodic_data_() {
Moving energy: 20~28th bytes
*/
for (uint8_t i = 0; i < TOTAL_GATES; i++) {
SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i])
SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i]);
}
/*
Still energy: 29~37th bytes
*/
for (uint8_t i = 0; i < TOTAL_GATES; i++) {
SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i])
SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i]);
}
/*
Light sensor: 38th bytes
*/
SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR])
SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR]);
} else {
for (auto &gate_move_sensor : this->gate_move_sensors_) {
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor)
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor);
}
for (auto &gate_still_sensor : this->gate_still_sensors_) {
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor)
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor);
}
SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_)
SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_);
}
#endif
#ifdef USE_BINARY_SENSOR
@@ -786,13 +786,12 @@ void LD2410Component::set_light_out_control() {
}
#ifdef USE_SENSOR
// These could leak memory, but they are only set once prior to 'setup()' and should never be used again.
void LD2410Component::set_gate_move_sensor(uint8_t gate, sensor::Sensor *s) {
this->gate_move_sensors_[gate] = new SensorWithDedup<uint8_t>(s);
this->gate_move_sensors_[gate].set_sensor(s);
}
void LD2410Component::set_gate_still_sensor(uint8_t gate, sensor::Sensor *s) {
this->gate_still_sensors_[gate] = new SensorWithDedup<uint8_t>(s);
this->gate_still_sensors_[gate].set_sensor(s);
}
#endif

View File

@@ -129,8 +129,8 @@ class LD2410Component : public Component, public uart::UARTDevice {
std::array<number::Number *, TOTAL_GATES> gate_still_threshold_numbers_{};
#endif
#ifdef USE_SENSOR
std::array<SensorWithDedup<uint8_t> *, TOTAL_GATES> gate_move_sensors_{};
std::array<SensorWithDedup<uint8_t> *, TOTAL_GATES> gate_still_sensors_{};
std::array<SensorWithDedup<uint8_t>, TOTAL_GATES> gate_move_sensors_{};
std::array<SensorWithDedup<uint8_t>, TOTAL_GATES> gate_still_sensors_{};
#endif
};

View File

@@ -397,12 +397,12 @@ void LD2412Component::handle_periodic_data_() {
*/
#ifdef USE_SENSOR
SAFE_PUBLISH_SENSOR(this->moving_target_distance_sensor_,
encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW]))
SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY])
encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW]));
SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY]);
SAFE_PUBLISH_SENSOR(this->still_target_distance_sensor_,
encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW]))
SAFE_PUBLISH_SENSOR(this->still_target_energy_sensor_, this->buffer_data_[STILL_ENERGY])
if (this->detection_distance_sensor_ != nullptr) {
encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW]));
SAFE_PUBLISH_SENSOR(this->still_target_energy_sensor_, this->buffer_data_[STILL_ENERGY]);
if (this->detection_distance_sensor_.has_sensor()) {
int new_detect_distance = 0;
if (target_state != 0x00 && (target_state & MOVE_BITMASK)) {
new_detect_distance =
@@ -410,7 +410,7 @@ void LD2412Component::handle_periodic_data_() {
} else if (target_state != 0x00) {
new_detect_distance = encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW]);
}
this->detection_distance_sensor_->publish_state_if_not_dup(new_detect_distance);
this->detection_distance_sensor_.publish_state_if_not_dup(new_detect_distance);
}
if (engineering_mode) {
// Engineering mode needs at least LIGHT_SENSOR + 1 bytes
@@ -423,27 +423,27 @@ void LD2412Component::handle_periodic_data_() {
Moving energy: 20~28th bytes
*/
for (uint8_t i = 0; i < TOTAL_GATES; i++) {
SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i])
SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i]);
}
/*
Still energy: 29~37th bytes
*/
for (uint8_t i = 0; i < TOTAL_GATES; i++) {
SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i])
SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i]);
}
/*
Light sensor value
*/
SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR])
SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR]);
}
} else {
for (auto &gate_move_sensor : this->gate_move_sensors_) {
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor)
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor);
}
for (auto &gate_still_sensor : this->gate_still_sensors_) {
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor)
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor);
}
SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_)
SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_);
}
#endif
// the radar module won't tell us when it's done, so we just have to keep polling...
@@ -846,12 +846,11 @@ void LD2412Component::set_light_out_control() {
}
#ifdef USE_SENSOR
// These could leak memory, but they are only set once prior to 'setup()' and should never be used again.
void LD2412Component::set_gate_move_sensor(uint8_t gate, sensor::Sensor *s) {
this->gate_move_sensors_[gate] = new SensorWithDedup<uint8_t>(s);
this->gate_move_sensors_[gate].set_sensor(s);
}
void LD2412Component::set_gate_still_sensor(uint8_t gate, sensor::Sensor *s) {
this->gate_still_sensors_[gate] = new SensorWithDedup<uint8_t>(s);
this->gate_still_sensors_[gate].set_sensor(s);
}
#endif

View File

@@ -133,8 +133,8 @@ class LD2412Component : public Component, public uart::UARTDevice {
std::array<number::Number *, TOTAL_GATES> gate_still_threshold_numbers_{};
#endif
#ifdef USE_SENSOR
std::array<SensorWithDedup<uint8_t> *, TOTAL_GATES> gate_move_sensors_{};
std::array<SensorWithDedup<uint8_t> *, TOTAL_GATES> gate_still_sensors_{};
std::array<SensorWithDedup<uint8_t>, TOTAL_GATES> gate_move_sensors_{};
std::array<SensorWithDedup<uint8_t>, TOTAL_GATES> gate_still_sensors_{};
#endif
};

View File

@@ -565,6 +565,7 @@ void LD2450Component::handle_periodic_data_() {
SAFE_PUBLISH_SENSOR(this->still_target_count_sensor_, still_target_count);
// Moving Target Count
SAFE_PUBLISH_SENSOR(this->moving_target_count_sensor_, moving_target_count);
#endif
#ifdef USE_BINARY_SENSOR
@@ -872,33 +873,32 @@ void LD2450Component::query_target_tracking_mode_() { this->send_command_(CMD_QU
void LD2450Component::query_zone_() { this->send_command_(CMD_QUERY_ZONE, nullptr, 0); }
#ifdef USE_SENSOR
// These could leak memory, but they are only set once prior to 'setup()' and should never be used again.
void LD2450Component::set_move_x_sensor(uint8_t target, sensor::Sensor *s) {
this->move_x_sensors_[target] = new SensorWithDedup<int16_t>(s);
this->move_x_sensors_[target].set_sensor(s);
}
void LD2450Component::set_move_y_sensor(uint8_t target, sensor::Sensor *s) {
this->move_y_sensors_[target] = new SensorWithDedup<int16_t>(s);
this->move_y_sensors_[target].set_sensor(s);
}
void LD2450Component::set_move_speed_sensor(uint8_t target, sensor::Sensor *s) {
this->move_speed_sensors_[target] = new SensorWithDedup<int16_t>(s);
this->move_speed_sensors_[target].set_sensor(s);
}
void LD2450Component::set_move_angle_sensor(uint8_t target, sensor::Sensor *s) {
this->move_angle_sensors_[target] = new SensorWithDedup<float>(s);
this->move_angle_sensors_[target].set_sensor(s);
}
void LD2450Component::set_move_distance_sensor(uint8_t target, sensor::Sensor *s) {
this->move_distance_sensors_[target] = new SensorWithDedup<uint16_t>(s);
this->move_distance_sensors_[target].set_sensor(s);
}
void LD2450Component::set_move_resolution_sensor(uint8_t target, sensor::Sensor *s) {
this->move_resolution_sensors_[target] = new SensorWithDedup<uint16_t>(s);
this->move_resolution_sensors_[target].set_sensor(s);
}
void LD2450Component::set_zone_target_count_sensor(uint8_t zone, sensor::Sensor *s) {
this->zone_target_count_sensors_[zone] = new SensorWithDedup<uint8_t>(s);
this->zone_target_count_sensors_[zone].set_sensor(s);
}
void LD2450Component::set_zone_still_target_count_sensor(uint8_t zone, sensor::Sensor *s) {
this->zone_still_target_count_sensors_[zone] = new SensorWithDedup<uint8_t>(s);
this->zone_still_target_count_sensors_[zone].set_sensor(s);
}
void LD2450Component::set_zone_moving_target_count_sensor(uint8_t zone, sensor::Sensor *s) {
this->zone_moving_target_count_sensors_[zone] = new SensorWithDedup<uint8_t>(s);
this->zone_moving_target_count_sensors_[zone].set_sensor(s);
}
#endif
#ifdef USE_TEXT_SENSOR

View File

@@ -182,15 +182,15 @@ class LD2450Component : public Component, public uart::UARTDevice {
ZoneOfNumbers zone_numbers_[MAX_ZONES];
#endif
#ifdef USE_SENSOR
std::array<SensorWithDedup<int16_t> *, MAX_TARGETS> move_x_sensors_{};
std::array<SensorWithDedup<int16_t> *, MAX_TARGETS> move_y_sensors_{};
std::array<SensorWithDedup<int16_t> *, MAX_TARGETS> move_speed_sensors_{};
std::array<SensorWithDedup<float> *, MAX_TARGETS> move_angle_sensors_{};
std::array<SensorWithDedup<uint16_t> *, MAX_TARGETS> move_distance_sensors_{};
std::array<SensorWithDedup<uint16_t> *, MAX_TARGETS> move_resolution_sensors_{};
std::array<SensorWithDedup<uint8_t> *, MAX_ZONES> zone_target_count_sensors_{};
std::array<SensorWithDedup<uint8_t> *, MAX_ZONES> zone_still_target_count_sensors_{};
std::array<SensorWithDedup<uint8_t> *, MAX_ZONES> zone_moving_target_count_sensors_{};
std::array<SensorWithDedup<int16_t>, MAX_TARGETS> move_x_sensors_{};
std::array<SensorWithDedup<int16_t>, MAX_TARGETS> move_y_sensors_{};
std::array<SensorWithDedup<int16_t>, MAX_TARGETS> move_speed_sensors_{};
std::array<SensorWithDedup<float>, MAX_TARGETS> move_angle_sensors_{};
std::array<SensorWithDedup<uint16_t>, MAX_TARGETS> move_distance_sensors_{};
std::array<SensorWithDedup<uint16_t>, MAX_TARGETS> move_resolution_sensors_{};
std::array<SensorWithDedup<uint8_t>, MAX_ZONES> zone_target_count_sensors_{};
std::array<SensorWithDedup<uint8_t>, MAX_ZONES> zone_still_target_count_sensors_{};
std::array<SensorWithDedup<uint8_t>, MAX_ZONES> zone_moving_target_count_sensors_{};
#endif
#ifdef USE_TEXT_SENSOR
std::array<text_sensor::TextSensor *, MAX_TARGETS> direction_text_sensors_{};

View File

@@ -11,28 +11,20 @@
#define SUB_SENSOR_WITH_DEDUP(name, dedup_type) \
protected: \
ld24xx::SensorWithDedup<dedup_type> *name##_sensor_{nullptr}; \
ld24xx::SensorWithDedup<dedup_type> name##_sensor_{}; \
\
public: \
void set_##name##_sensor(sensor::Sensor *sensor) { \
this->name##_sensor_ = new ld24xx::SensorWithDedup<dedup_type>(sensor); \
}
void set_##name##_sensor(sensor::Sensor *sensor) { this->name##_sensor_.set_sensor(sensor); }
#endif
#define LOG_SENSOR_WITH_DEDUP_SAFE(tag, name, sensor) \
if ((sensor) != nullptr) { \
LOG_SENSOR(tag, name, (sensor)->sens); \
if ((sensor).has_sensor()) { \
LOG_SENSOR(tag, name, (sensor).get_sensor()); \
}
#define SAFE_PUBLISH_SENSOR(sensor, value) \
if ((sensor) != nullptr) { \
(sensor)->publish_state_if_not_dup(value); \
}
#define SAFE_PUBLISH_SENSOR(sensor, value) (sensor).publish_state_if_not_dup(value)
#define SAFE_PUBLISH_SENSOR_UNKNOWN(sensor) \
if ((sensor) != nullptr) { \
(sensor)->publish_state_unknown(); \
}
#define SAFE_PUBLISH_SENSOR_UNKNOWN(sensor) (sensor).publish_state_unknown()
#define highbyte(val) (uint8_t)((val) >> 8)
#define lowbyte(val) (uint8_t)((val) &0xff)
@@ -70,25 +62,33 @@ inline void format_version_str(const uint8_t *version, std::span<char, 20> buffe
}
#ifdef USE_SENSOR
// Helper class to store a sensor with a deduplicator & publish state only when the value changes
/// Sensor with deduplication — sensor may be null, null check is internal.
/// Stored inline, no heap allocation. Does nothing when no sensor is set.
template<typename T> class SensorWithDedup {
public:
SensorWithDedup(sensor::Sensor *sens) : sens(sens) {}
void set_sensor(sensor::Sensor *sens) {
this->sens_ = sens;
this->dedup_ = {};
}
void publish_state_if_not_dup(T state) {
if (this->publish_dedup.next(state)) {
this->sens->publish_state(static_cast<float>(state));
if (this->sens_ != nullptr && this->dedup_.next(state)) {
this->sens_->publish_state(static_cast<float>(state));
}
}
void publish_state_unknown() {
if (this->publish_dedup.next_unknown()) {
this->sens->publish_state(NAN);
if (this->sens_ != nullptr && this->dedup_.next_unknown()) {
this->sens_->publish_state(NAN);
}
}
sensor::Sensor *sens;
Deduplicator<T> publish_dedup;
bool has_sensor() const { return this->sens_ != nullptr; }
sensor::Sensor *get_sensor() const { return this->sens_; }
protected:
sensor::Sensor *sens_{nullptr};
Deduplicator<T> dedup_;
};
#endif
} // namespace esphome::ld24xx

View File

@@ -1,5 +1,6 @@
import json
import logging
from pathlib import Path
import esphome.codegen as cg
import esphome.config_validation as cv
@@ -24,6 +25,7 @@ from esphome.const import (
)
from esphome.core import CORE
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.helpers import copy_file_if_changed
from esphome.storage_json import StorageJSON
from . import gpio # noqa
@@ -465,6 +467,11 @@ async def component_to_code(config):
# it for project source files only. GCC uses the last -O flag.
build_src_flags += " -Os"
cg.add_platformio_option("build_src_flags", build_src_flags)
# IRAM_ATTR is a no-op on BK72xx (SDK masks FIQ+IRQ around flash ops).
# On other families, patch_linker.py routes .sram.text into the right
# RAM-executable output section and prints a post-link placement summary.
if FAMILY_COMPONENT[config[CONF_FAMILY]] != COMPONENT_BK72XX:
cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"])
# dummy version code
cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)"))
# decrease web server stack size (16k words -> 4k words)
@@ -549,3 +556,13 @@ async def component_to_code(config):
_configure_lwip(config)
await cg.register_component(var, config)
# Called by writer.py
def copy_files() -> None:
script_dir = Path(__file__).parent
patch_linker_file = script_dir / "patch_linker.py.script"
copy_file_if_changed(
patch_linker_file,
CORE.relative_build_path("patch_linker.py"),
)

View File

@@ -79,6 +79,11 @@ async def to_code(config):
@pins.PIN_SCHEMA_REGISTRY.register("{COMPONENT_LOWER}", PIN_SCHEMA)
async def pin_to_code(config):
return await libretiny.gpio.component_pin_to_code(config)
# Called by writer.py; delegates to the shared libretiny implementation.
def copy_files() -> None:
libretiny.copy_files()
'''
BASE_CODE_BOARDS = '''

View File

@@ -0,0 +1,171 @@
# pylint: disable=E0602
Import("env") # noqa
import os
import re
import subprocess
# ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a per-family
# section routed into RAM-executable memory (see esphome/core/hal.h).
#
# This script is NOT loaded on BK72xx (IRAM_ATTR is a no-op there; the SDK
# masks FIQ+IRQ around flash writes). On the remaining families:
# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it.
# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it.
# - LN882H: stock linker has no glob for ".sram.text", so we inject
# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH).
#
# All families also get a post-link summary showing where IRAM_ATTR landed.
_MARKER = "/* esphome .sram.text */"
# Strong assignments (not PROVIDE) so the symbols are always emitted in the
# ELF; PROVIDE symbols with no references can be garbage-collected.
_KEEP_LINE = (
" __esphome_sram_text_start = .; "
"KEEP(*(.sram.text*)) "
"__esphome_sram_text_end = .; "
+ _MARKER + "\n"
)
_LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)")
def _detect(env):
prefix = "USE_LIBRETINY_VARIANT_"
# CPPDEFINES may hold strings or (name, value) tuples; BUILD_FLAGS holds
# the raw "-DNAME" strings. PlatformIO populates both, but the exact order
# vs. extra_scripts varies, so check both to be robust.
for token in env.get("CPPDEFINES", []):
if isinstance(token, (list, tuple)):
token = token[0]
if isinstance(token, str) and token.startswith(prefix):
return token[len(prefix):]
for flag in env.get("BUILD_FLAGS", []):
if isinstance(flag, str) and "-D" + prefix in flag:
name = flag.split("-D", 1)[1].split("=", 1)[0].strip()
if name.startswith(prefix):
return name[len(prefix):]
return None
KNOWN_VARIANTS = frozenset({
"LN882H",
"RTL8710B",
"RTL8720C",
})
def _inject_keep(host_section):
"""Return a patcher that injects _KEEP_LINE at the top of `host_section`."""
def patch(content):
if _MARKER in content:
return content
return host_section.sub(r"\1" + _KEEP_LINE, content, count=1)
return patch
# Variants not listed here intentionally have no .ld patcher:
# - RTL8710B: hal.h uses section(".image2.ram.text") which the stock linker
# already routes into .ram_image2.text (> BD_RAM).
# - RTL8720C: stock linker already consumes *(.sram.text*).
# - BK72xx (all): SDK masks FIQ+IRQ around flash writes, IRAM_ATTR is no-op.
_PATCHERS_BY_VARIANT = {
"LN882H": (_inject_keep(_LN_COPY),),
}
def _patchers_for(variant):
return _PATCHERS_BY_VARIANT.get(variant, ())
def _pre_link(target, source, env):
build_dir = env.subst("$BUILD_DIR")
ld_files = [f for f in os.listdir(build_dir) if f.endswith(".ld")]
patched = 0
for name in ld_files:
path = os.path.join(build_dir, name)
with open(path, "r", encoding="utf-8") as fh:
original = fh.read()
if _MARKER in original:
patched += 1
continue
content = original
for fn in _patchers:
content = fn(content)
if content != original:
with open(path, "w", encoding="utf-8") as fh:
fh.write(content)
print("ESPHome: patched {} for IRAM_ATTR placement".format(name))
patched += 1
if not patched:
raise RuntimeError(
"ESPHome: no .ld in {} was patched for IRAM_ATTR. Update the "
"regex in patch_linker.py.script (_PATCHERS_BY_VARIANT).".format(
build_dir
)
)
# Substrings matched against demangled names as a fallback on RTL8720C,
# where we cannot inject __esphome_sram_text_start/end markers.
_FALLBACK_SUBSTRINGS = ("wake_loop_any_context", "wake_loop_isrsafe",
"enable_loop_soon_any_context")
def _post_link(target, source, env):
"""Print where IRAM_ATTR ended up so users can confirm at a glance."""
elf = env.subst("$BUILD_DIR/${PROGNAME}.elf")
if not os.path.isfile(elf):
return
nm = env.subst("$NM")
try:
out = subprocess.check_output(
[nm, "--defined-only", "--demangle", elf], text=True
)
except (OSError, subprocess.CalledProcessError) as exc:
print("ESPHome: IRAM_ATTR summary unavailable (nm failed: {})".format(exc))
return
start = end = None
fallback = []
for line in out.splitlines():
parts = line.split(maxsplit=2)
if len(parts) != 3:
continue
addr_str, _kind, name = parts
if name == "__esphome_sram_text_start":
start = int(addr_str, 16)
elif name == "__esphome_sram_text_end":
end = int(addr_str, 16)
elif "veneer" not in name and any(s in name for s in _FALLBACK_SUBSTRINGS):
fallback.append(int(addr_str, 16))
print("ESPHome: IRAM_ATTR placement summary ({}):".format(_variant))
if start is not None and end is not None:
print(" .sram.text: {} bytes at 0x{:08x} - 0x{:08x}".format(end - start, start, end))
elif fallback:
lo, hi = min(fallback), max(fallback)
print(" IRAM symbols at 0x{:08x} - 0x{:08x} (approx {} bytes)".format(lo, hi, hi - lo))
else:
print(" no IRAM_ATTR symbols found")
if (_variant := _detect(env)) is None:
raise RuntimeError(
"ESPHome: could not determine LibreTiny variant from build flags. "
"patch_linker.py needs USE_LIBRETINY_VARIANT_* to route IRAM_ATTR "
"into SRAM; without it, ISR handlers would silently end up in flash."
)
if _variant not in KNOWN_VARIANTS:
raise RuntimeError(
"ESPHome: unknown LibreTiny variant {!r}; patch_linker.py does not "
"know how to route IRAM_ATTR into SRAM for this family. Update "
"patch_linker.py.script before shipping firmware.".format(_variant)
)
if _patchers := _patchers_for(_variant):
# LibreTiny writes the processed .ld templates into $BUILD_DIR during its
# own builder setup, which may run after this script. Register the patch
# as a pre-link action so it executes once the linker scripts exist.
env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", _pre_link)
# Post-link summary for every family that reaches this script.
env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", _post_link)

View File

@@ -3,7 +3,6 @@
#include "preferences.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cinttypes>
#include <cstring>
#include <vector>
@@ -11,9 +10,6 @@ namespace esphome::libretiny {
static const char *const TAG = "preferences";
// Buffer size for converting uint32_t to string: max "4294967295" (10 chars) + null terminator + 1 padding
static constexpr size_t KEY_BUFFER_SIZE = 12;
struct NVSData {
uint32_t key;
SmallInlineBuffer<8> data; // Most prefs fit in 8 bytes (covers fan, cover, select, etc.)
@@ -50,8 +46,8 @@ bool LibreTinyPreferenceBackend::load(uint8_t *data, size_t len) {
}
}
char key_str[KEY_BUFFER_SIZE];
snprintf(key_str, sizeof(key_str), "%" PRIu32, this->key);
char key_str[UINT32_MAX_STR_SIZE];
uint32_to_str(key_str, this->key);
fdb_blob_make(this->blob, data, len);
size_t actual_len = fdb_kv_get_blob(this->db, key_str, this->blob);
if (actual_len != len) {
@@ -92,8 +88,8 @@ bool LibreTinyPreferences::sync() {
uint32_t last_key = 0;
for (const auto &save : s_pending_save) {
char key_str[KEY_BUFFER_SIZE];
snprintf(key_str, sizeof(key_str), "%" PRIu32, save.key);
char key_str[UINT32_MAX_STR_SIZE];
uint32_to_str(key_str, save.key);
ESP_LOGVV(TAG, "Checking if FDB data %s has changed", key_str);
if (this->is_changed_(&this->db, save, key_str)) {
ESP_LOGV(TAG, "sync: key: %s, len: %zu", key_str, save.data.size());

View File

@@ -22,4 +22,20 @@ uint8_t ESPColorCorrection::gamma_uncorrect_(uint8_t value) const {
return (target - a <= b - target) ? lo : lo + 1;
}
Color ESPColorCorrection::color_uncorrect(Color color) const {
// uncorrected = corrected^(1/gamma) / (max_brightness * local_brightness)
return Color(this->color_uncorrect_red(color.red), this->color_uncorrect_green(color.green),
this->color_uncorrect_blue(color.blue), this->color_uncorrect_white(color.white));
}
uint8_t ESPColorCorrection::color_uncorrect_channel_(uint8_t value, uint8_t max_brightness) const {
if (max_brightness == 0 || this->local_brightness_ == 0)
return 0;
// Use 32-bit intermediates: when max_brightness and local_brightness_ are small but non-zero,
// (uncorrected / max_brightness) * 255 can exceed 65535 before the std::min(255) clamp runs.
uint32_t uncorrected = this->gamma_uncorrect_(value) * 255UL;
uint32_t res = ((uncorrected / max_brightness) * 255UL) / this->local_brightness_;
return static_cast<uint8_t>(std::min(res, uint32_t(255)));
}
} // namespace esphome::light

View File

@@ -46,38 +46,18 @@ class ESPColorCorrection {
uint8_t res = esp_scale8_twice(white, this->max_brightness_.white, this->local_brightness_);
return this->gamma_correct_(res);
}
inline Color color_uncorrect(Color color) const ESPHOME_ALWAYS_INLINE {
// uncorrected = corrected^(1/gamma) / (max_brightness * local_brightness)
return Color(this->color_uncorrect_red(color.red), this->color_uncorrect_green(color.green),
this->color_uncorrect_blue(color.blue), this->color_uncorrect_white(color.white));
}
Color color_uncorrect(Color color) const;
inline uint8_t color_uncorrect_red(uint8_t red) const ESPHOME_ALWAYS_INLINE {
if (this->max_brightness_.red == 0 || this->local_brightness_ == 0)
return 0;
uint16_t uncorrected = this->gamma_uncorrect_(red) * 255UL;
uint16_t res = ((uncorrected / this->max_brightness_.red) * 255UL) / this->local_brightness_;
return (uint8_t) std::min(res, uint16_t(255));
return this->color_uncorrect_channel_(red, this->max_brightness_.red);
}
inline uint8_t color_uncorrect_green(uint8_t green) const ESPHOME_ALWAYS_INLINE {
if (this->max_brightness_.green == 0 || this->local_brightness_ == 0)
return 0;
uint16_t uncorrected = this->gamma_uncorrect_(green) * 255UL;
uint16_t res = ((uncorrected / this->max_brightness_.green) * 255UL) / this->local_brightness_;
return (uint8_t) std::min(res, uint16_t(255));
return this->color_uncorrect_channel_(green, this->max_brightness_.green);
}
inline uint8_t color_uncorrect_blue(uint8_t blue) const ESPHOME_ALWAYS_INLINE {
if (this->max_brightness_.blue == 0 || this->local_brightness_ == 0)
return 0;
uint16_t uncorrected = this->gamma_uncorrect_(blue) * 255UL;
uint16_t res = ((uncorrected / this->max_brightness_.blue) * 255UL) / this->local_brightness_;
return (uint8_t) std::min(res, uint16_t(255));
return this->color_uncorrect_channel_(blue, this->max_brightness_.blue);
}
inline uint8_t color_uncorrect_white(uint8_t white) const ESPHOME_ALWAYS_INLINE {
if (this->max_brightness_.white == 0 || this->local_brightness_ == 0)
return 0;
uint16_t uncorrected = this->gamma_uncorrect_(white) * 255UL;
uint16_t res = ((uncorrected / this->max_brightness_.white) * 255UL) / this->local_brightness_;
return (uint8_t) std::min(res, uint16_t(255));
return this->color_uncorrect_channel_(white, this->max_brightness_.white);
}
protected:
@@ -85,6 +65,9 @@ class ESPColorCorrection {
uint8_t gamma_correct_(uint8_t value) const;
/// Reverse gamma: binary search the forward PROGMEM table
uint8_t gamma_uncorrect_(uint8_t value) const;
/// Shared body of color_uncorrect_{red,green,blue,white}. Kept out-of-line
/// to avoid duplicating two 16-bit divides at every call site.
uint8_t color_uncorrect_channel_(uint8_t value, uint8_t max_brightness) const;
const uint16_t *gamma_table_{nullptr};
Color max_brightness_{255, 255, 255, 255};

View File

@@ -65,3 +65,8 @@ async def to_code(config):
@pins.PIN_SCHEMA_REGISTRY.register("ln882x", PIN_SCHEMA)
async def pin_to_code(config):
return await libretiny.gpio.component_pin_to_code(config)
# Called by writer.py; delegates to the shared libretiny implementation.
def copy_files() -> None:
libretiny.copy_files()

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
@@ -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()

View File

@@ -76,16 +76,17 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) {
}
#endif
#if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE)
// Shortcut / overload, so that the source of an image can easily be updated
// from within a lambda.
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { lv_image_set_src(obj, image->get_lv_image_dsc()); }
#if LV_USE_IMAGE
// Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda.
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); }
#endif // LV_USE_IMAGE
inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) {
lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector);
::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector);
}
inline void lv_obj_set_style_bg_image_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) {
lv_obj_set_style_bg_image_src(obj, image->get_lv_image_dsc(), selector);
::lv_obj_set_style_bg_image_src(obj, image->get_lv_image_dsc(), selector);
}
#endif // USE_LVGL_IMAGE
#ifdef USE_LVGL_ANIMIMG

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
)

View File

@@ -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();

View File

@@ -15,7 +15,7 @@ from esphome.components.mipi import (
import esphome.config_validation as cv
from .amoled import CO5300
from .ili import ILI9488_A
from .ili import ILI9488_A, ST7789V
from .jc import AXS15231
DriverChip(
@@ -243,3 +243,15 @@ ST7789P.extend(
),
),
)
ST7789V.extend(
"WAVESHARE-ESP32-C6-LCD-1.47",
width=172,
height=320,
offset_width=34,
invert_colors=True,
data_rate="40MHz",
reset_pin=21,
cs_pin=14,
dc_pin={"number": 15, "ignore_strapping_warning": True},
)

View File

@@ -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);

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;
}

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;
}

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)

View File

@@ -65,3 +65,8 @@ async def to_code(config):
@pins.PIN_SCHEMA_REGISTRY.register("rtl87xx", PIN_SCHEMA)
async def pin_to_code(config):
return await libretiny.gpio.component_pin_to_code(config)
# Called by writer.py; delegates to the shared libretiny implementation.
def copy_files() -> None:
libretiny.copy_files()

View File

@@ -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) {

View File

@@ -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

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;
}

View File

@@ -87,7 +87,10 @@ async def to_code(config):
config[CONF_REBOOT_TIMEOUT],
config[CONF_BOOT_IS_GOOD_AFTER],
)
cg.add(RawExpression(f"if ({condition}) return"))
# Wrap in IIFEUnsafeStatement so cpp_main_section emits this
# component's block flat rather than inside an IIFE lambda —
# the `return` must exit setup() itself, not just the lambda.
cg.add(cg.IIFEUnsafeStatement(RawExpression(f"if ({condition}) return")))
CORE.data[CONF_SAFE_MODE] = {}
CORE.data[CONF_SAFE_MODE][KEY_PAST_SAFE_MODE] = True

View File

@@ -55,11 +55,13 @@ void SafeModeComponent::dump_config() {
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
const esp_partition_t *last_invalid = esp_ota_get_last_invalid_partition();
if (last_invalid != nullptr) {
ESP_LOGW(TAG, "OTA rollback detected! Rolled back from partition '%s'", last_invalid->label);
ESP_LOGW(TAG, "The device reset before the boot was marked successful");
ESP_LOGW(TAG,
"OTA rollback detected! Rolled back from partition '%s'\n"
" The device reset before the boot was marked successful",
last_invalid->label);
if (esp_reset_reason() == ESP_RST_BROWNOUT) {
ESP_LOGW(TAG, "Last reset was due to brownout - check your power supply!");
ESP_LOGW(TAG, "See https://esphome.io/guides/faq.html#brownout-detector-was-triggered");
ESP_LOGW(TAG, "Last reset was due to brownout - check your power supply!\n"
" See https://esphome.io/guides/faq.html#brownout-detector-was-triggered");
}
}
#endif
@@ -86,7 +88,8 @@ void SafeModeComponent::mark_successful() {
}
void SafeModeComponent::loop() {
if (!this->boot_successful_ && (millis() - this->safe_mode_start_time_) > this->safe_mode_boot_is_good_after_) {
if (!this->boot_successful_ &&
(App.get_loop_component_start_time() - this->safe_mode_start_time_) > this->safe_mode_boot_is_good_after_) {
// successful boot, reset counter
ESP_LOGI(TAG, "Boot seems successful; resetting boot loop counter");
this->mark_successful();

View File

@@ -84,7 +84,7 @@ def get_firmware(value):
req = requests.get(url, timeout=30)
req.raise_for_status()
except requests.exceptions.RequestException as e:
raise cv.Invalid(f"Could not download firmware file ({url}): {e}")
raise cv.Invalid(f"Could not download firmware file ({url}): {e}") from e
h = hashlib.new("sha256")
h.update(req.content)

View File

@@ -112,6 +112,8 @@ class BSDSocketImpl {
int setblocking(bool blocking);
int loop() { return 0; }
/// Check if the socket has buffered data ready to read.
/// See the ready() contract in socket.h — callers must drain or track remaining data.
bool ready() const;
int get_fd() const { return this->fd_; }

View File

@@ -11,6 +11,10 @@
#include "esphome/core/wake.h"
#include "esphome/core/log.h"
#ifdef USE_OTA_PLATFORM_ESPHOME
extern "C" void esphome_wake_ota_component_any_context();
#endif
#ifdef USE_ESP8266
#include <coredecls.h> // For esp_schedule()
#elif defined(USE_RP2040)
@@ -854,6 +858,10 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) {
tcp_err(newpcb, LWIPRawListenImpl::s_queued_err_fn);
tcp_recv(newpcb, LWIPRawListenImpl::s_queued_recv_fn);
LWIP_LOG("Accepted connection, queue size: %d", this->accepted_socket_count_);
#ifdef USE_OTA_PLATFORM_ESPHOME
// Must run before wake_loop_any_context() so flags are visible when the main task wakes.
esphome_wake_ota_component_any_context();
#endif
// Wake the main loop immediately so it can accept the new connection.
esphome::wake_loop_any_context();
return ERR_OK;

View File

@@ -96,6 +96,8 @@ class LWIPRawImpl : public LWIPRawCommon {
errno = ENOSYS;
return -1;
}
// Check if the socket has buffered data ready to read.
// See the ready() contract in socket.h — callers must drain or track remaining data.
// Intentionally unlocked — this is a polling check called every loop iteration.
// A stale read at worst delays processing by one loop tick; the actual I/O in
// read() holds the lwip lock and re-checks properly. See esphome#10681.

View File

@@ -78,6 +78,8 @@ class LwIPSocketImpl {
int setblocking(bool blocking);
int loop() { return 0; }
/// Check if the socket has buffered data ready to read.
/// See the ready() contract in socket.h — callers must drain or track remaining data.
bool ready() const;
int get_fd() const { return this->fd_; }

View File

@@ -53,6 +53,19 @@ bool socket_ready_fd(int fd, bool loop_monitored);
// Inline ready() — defined here because it depends on socket_ready/socket_ready_fd
// declared above, while the impl headers are included before those declarations.
//
// Contract (applies to ALL socket implementations — each platform implements
// ready() differently, but this contract holds regardless of the mechanism):
// ready() checks if the socket has buffered data ready to read. When it returns
// true, the caller MUST read until it would block (EAGAIN/EWOULDBLOCK), or until
// read() returns 0 to indicate EOF / connection closed, or track that it stopped
// early and retry without calling ready(). The next call to ready() will only
// report new data correctly if all callers fulfill this contract. Failing to
// drain the socket may cause ready() to return false while data remains readable.
//
// In practice each socket is owned by a single component, so this contract is
// straightforward to fulfill — but the owning component must be aware of it,
// especially if it limits how many messages it processes per loop iteration.
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
inline bool Socket::ready() const {
#ifdef USE_LWIP_FAST_SELECT

View File

@@ -173,7 +173,7 @@ def _read_audio_file_and_type(file_config):
raise cv.Invalid(
f"Unable to determine audio file type of '{path}'. "
f"Try re-encoding the file into a supported format. Details: {e}"
)
) from e
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["NONE"]
if file_type in ("wav"):

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",

View File

@@ -7,6 +7,11 @@ namespace status_led {
static const char *const TAG = "status_led";
static constexpr uint32_t ERROR_PERIOD_MS = 250;
static constexpr uint32_t ERROR_ON_MS = 150;
static constexpr uint32_t WARNING_PERIOD_MS = 1500;
static constexpr uint32_t WARNING_ON_MS = 250;
StatusLED *global_status_led = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
StatusLED::StatusLED(GPIOPin *pin) : pin_(pin) { global_status_led = this; }
@@ -19,12 +24,18 @@ void StatusLED::dump_config() {
LOG_PIN(" Pin: ", this->pin_);
}
void StatusLED::loop() {
if ((App.get_app_state() & STATUS_LED_ERROR) != 0u) {
this->pin_->digital_write(millis() % 250u < 150u);
} else if ((App.get_app_state() & STATUS_LED_WARNING) != 0u) {
this->pin_->digital_write(millis() % 1500u < 250u);
const uint32_t app_state = App.get_app_state();
// Use millis() rather than App.get_loop_component_start_time() because this loop is also
// dispatched from Application::feed_wdt() during long blocking operations, where the cached
// per-component timestamp doesn't advance and would freeze the blink pattern.
const uint32_t now = millis();
if ((app_state & STATUS_LED_ERROR) != 0u) {
this->pin_->digital_write(now % ERROR_PERIOD_MS < ERROR_ON_MS);
} else if ((app_state & STATUS_LED_WARNING) != 0u) {
this->pin_->digital_write(now % WARNING_PERIOD_MS < WARNING_ON_MS);
} else {
this->pin_->digital_write(false);
this->disable_loop();
}
}
float StatusLED::get_setup_priority() const { return setup_priority::HARDWARE; }

View File

@@ -35,8 +35,9 @@ def validate_acceleration(value):
try:
value = float(value)
except ValueError:
# pylint: disable=raise-missing-from
raise cv.Invalid(f"Expected acceleration as floating point number, got {value}")
raise cv.Invalid(
f"Expected acceleration as floating point number, got {value}"
) from None
if value <= 0:
raise cv.Invalid("Acceleration must be larger than 0 steps/s^2!")
@@ -55,8 +56,9 @@ def validate_speed(value):
try:
value = float(value)
except ValueError:
# pylint: disable=raise-missing-from
raise cv.Invalid(f"Expected speed as floating point number, got {value}")
raise cv.Invalid(
f"Expected speed as floating point number, got {value}"
) from None
if value <= 0:
raise cv.Invalid("Speed must be larger than 0 steps/s!")

View File

@@ -188,7 +188,7 @@ def _expand_substitutions(
f"\nRelevant context:\n{err.context_trace_str()}"
f"\nSee {'->'.join(str(x) for x in path)}",
path,
)
) from err
else:
if isinstance(orig_value, ESPHomeDataBase):
value = _restore_data_base(value, orig_value)

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

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