Compare commits

...
Author SHA1 Message Date
J. Nick Koston 6bac1ed950 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 17:27:05 -05:00
J. Nick Koston 57241e40b8 Make failure_reason public now that two modules import it, cover interrupt() on a finished bar 2026-08-23 17:26:47 -05:00
J. Nick Koston 28f0a7b155 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 17:17:59 -05:00
J. Nick Koston 531bd8ec62 Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter 2026-08-23 17:17:59 -05:00
J. Nick Koston 76ddbe5ebd Merge branch 'esp8266-native-idedata-helpers' into esp8266-native-converter-hardening 2026-08-23 17:17:59 -05:00
J. Nick Koston 40e6e406d7 Merge remote-tracking branch 'origin/dev' into esp8266-native-idedata-helpers 2026-08-23 17:17:52 -05:00
J. Nick Koston 6cfbe5b166 Normalize captured CPPDEFINES to a CppDefine named tuple at capture time 2026-08-23 17:15:29 -05:00
J. Nick Koston ac66e3a96c Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 17:08:16 -05:00
J. Nick Koston e7fc3937bf Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter 2026-08-23 17:08:16 -05:00
J. Nick Koston 70f4555ec8 Route captured LINKFLAGS to the link line and make Prepend a real front-insert 2026-08-23 17:08:04 -05:00
J. Nick Koston 6cfb915aeb Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 16:54:56 -05:00
J. Nick Koston 04a44d6e63 Let ProgressBar own the mid-row interrupt, share the part-file naming, fold duplicate except arms 2026-08-23 16:54:33 -05:00
J. Nick Koston cb10904fa3 Keep the combined bar steady during a backoff sleep 2026-08-23 16:49:54 -05:00
J. Nick Koston 81b1678498 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 16:35:56 -05:00
J. Nick Koston ba0d9e77db Never log a blank prefetch reason, keep the download error over a tick failure
Both prefetch warning loops format through _failure_reason so a
message-less exception cannot log blank. A bar-frame write failure in
the failure path cannot displace the download error, the failure list
is annotated BaseException (cancellations are entries too), the Ctrl-C
docstring notes an in-progress extraction runs to completion, and the
wave-prefetch docstring carries the superseded-sibling caveat.
2026-08-23 16:35:48 -05:00
J. Nick Koston f6ae5e4892 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 16:31:10 -05:00
J. Nick Koston b0b174f3e2 Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter 2026-08-23 16:31:08 -05:00
J. Nick Koston d8857080f0 Merge branch 'esp8266-native-idedata-helpers' into esp8266-native-converter-hardening 2026-08-23 16:31:06 -05:00
J. Nick Koston c285af3857 Cover the Flash line through the shared bar 2026-08-23 16:31:03 -05:00
J. Nick Koston de00530c0e Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 16:25:20 -05:00
J. Nick Koston acf5ce718c Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter 2026-08-23 16:25:18 -05:00
J. Nick Koston 5904928f0c Merge branch 'esp8266-native-idedata-helpers' into esp8266-native-converter-hardening 2026-08-23 16:25:16 -05:00
J. Nick Koston dceeee69d3 Restore the size summary to its dev shape
The defensive spiral (#18668, closed) guarded inputs that cannot occur:
gen_esp32part validates partitions.csv before a build can succeed, and
esp_idf_size.json's realistic failures do not produce the handled
shapes. This PR's real change stands alone again: the bar moves to
build_helpers as print_size_line, with dev behavior everywhere else.
2026-08-23 16:25:11 -05:00
J. Nick Koston d405db1268 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 16:17:45 -05:00
J. Nick Koston 8cac10d319 Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter 2026-08-23 16:17:43 -05:00
J. Nick Koston 030a3b7ab3 Merge branch 'esp8266-native-idedata-helpers' into esp8266-native-converter-hardening 2026-08-23 16:17:41 -05:00
J. Nick Koston dfc26e9f09 Mirror the named-file arm and tightened checks 2026-08-23 16:17:37 -05:00
J. Nick KostonandGitHub 98f138f6b2 [esp32_ble_tracker] Scan at the default window while a GATT connection is active (#18609) 2026-08-24 09:16:01 +12:00
J. Nick Koston 385681121f Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 16:11:59 -05:00
J. Nick Koston c395861fa3 Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter 2026-08-23 16:11:57 -05:00
J. Nick Koston 30b2bbfe3a Merge branch 'esp8266-native-idedata-helpers' into esp8266-native-converter-hardening 2026-08-23 16:11:55 -05:00
J. Nick Koston 3af17d5c92 Mirror the named missing-table raise 2026-08-23 16:11:52 -05:00
J. Nick Koston 89ffacaffb Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 16:09:46 -05:00
J. Nick Koston 6eff668dba Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter 2026-08-23 16:09:44 -05:00
J. Nick Koston 350202efa6 Merge branch 'esp8266-native-idedata-helpers' into esp8266-native-converter-hardening 2026-08-23 16:09:42 -05:00
J. Nick Koston 14388c859a Mirror the backstop-ownership trim from the standalone 2026-08-23 16:09:26 -05:00
J. Nick Koston fe274653bd Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 16:06:46 -05:00
J. Nick Koston 7beb0078c2 Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter 2026-08-23 16:06:44 -05:00
J. Nick Koston e26dbb6d75 Merge branch 'esp8266-native-idedata-helpers' into esp8266-native-converter-hardening 2026-08-23 16:06:42 -05:00
J. Nick Koston 7aa0f30ceb Mirror the unreachable-arm removal from the standalone 2026-08-23 16:06:38 -05:00
J. Nick Koston 5a749c4b5d Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 15:58:59 -05:00
J. Nick Koston 3de58dae99 Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter 2026-08-23 15:58:57 -05:00
J. Nick Koston fd2971bd70 Merge branch 'esp8266-native-idedata-helpers' into esp8266-native-converter-hardening 2026-08-23 15:58:55 -05:00
J. Nick Koston 8a9bb7741a Mirror the truncated-size-report classification 2026-08-23 15:58:52 -05:00
J. Nick Koston 56dc9124ab Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 15:52:28 -05:00
J. Nick Koston 92b655c5a0 Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter 2026-08-23 15:52:26 -05:00
J. Nick Koston c474421399 Merge branch 'esp8266-native-idedata-helpers' into esp8266-native-converter-hardening 2026-08-23 15:52:24 -05:00
J. Nick Koston ca611a3159 Mirror the size-summary visibility refinements from the standalone
Corrupt shapes at any level warn as malformed while a simply-absent
DRAM/DIRAM region stays at debug, the backstop warning carries its
traceback, and the image_size warning names its file.
2026-08-23 15:51:30 -05:00
J. Nick Koston 832c6feecc Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 15:45:44 -05:00
J. Nick Koston 41b0fe6747 Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter 2026-08-23 15:45:42 -05:00
J. Nick Koston e580e267ce Merge branch 'esp8266-native-idedata-helpers' into esp8266-native-converter-hardening 2026-08-23 15:45:40 -05:00
J. Nick Koston d3590ffd7c Port the standalone size-summary hardening into the chain copy
The full #18668 state, adapted to the shared bar: _ram_bar/_flash_bar
return validated (used, total) pairs printed through print_size_line,
the partition parser returns None for legitimate absence and raises
only for a broken table, skips follow the warn/debug visibility rule,
and run_compile passes the table path unconditionally. Ported now so
the merge-time conflict resolves itself; the chain-only exact-format
and RAM-vs-Flash isolation tests stay.
2026-08-23 15:45:36 -05:00
J. Nick KostonandGitHub 9179fe26ce [api] Fix loop stall that triggers the task watchdog when entity list sends block (#18577) 2026-08-24 08:38:19 +12:00
J. Nick KostonandGitHub e6c49e2bd9 [core] Retry transient network errors when downloading external files (#18538) 2026-08-24 08:25:39 +12:00
J. Nick Koston 8018a0d930 Cover the bar-less logging guard and the uncancelled backoff slice 2026-08-23 15:25:29 -05:00
J. Nick Koston 81be569a85 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 15:09:03 -05:00
J. Nick Koston 641c80b193 Use the caplog fixture where the deleted helper was 2026-08-23 15:09:00 -05:00
J. Nick Koston e2eae002e6 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 15:06:42 -05:00
J. Nick Koston 065867f815 Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter 2026-08-23 15:06:40 -05:00
J. Nick Koston 5aebd7cb41 Match SCons' remaining CPPDEFINES spellings, drop dead test scaffolding
A bare 2-tuple is one name=value pair (not -DFOO -D1), a dict maps
names to values, and a None value is a bare define, per processDefines.
The unused caplog_at_info helper and the progress kwarg the download
stubs declared ahead of their PR are gone.
2026-08-23 15:05:43 -05:00
guillempagesGitHubJ. Nick Kostonpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>J. Nick Koston
d84293931b [online_image] Support image format auto-detection (#16337)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-08-23 19:58:01 +00:00
J. Nick Koston 759ddfc91d Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 14:42:17 -05:00
J. Nick Koston 062c542c36 Give worker log records their own line, harden the cancellation sentinel
A logging filter installed for the batch's duration ends a partial bar
row (under the bar's lock) before any record is emitted, so a mirror
retry warning from a worker is no longer overwritten by the next CR
frame. The Ctrl-C sentinel derives from BaseException so a broad
except Exception in the download layers cannot convert an abort into a
retry, an abandoned job is reported like a failure instead of reading
as a completed download, and the mirror-sweep backoff sleeps through
the cancellation tick so a Ctrl-C during a retry window aborts at the
boundary instead of after it. The docstring states the parked-socket
worst case.
2026-08-23 14:42:00 -05:00
Boris KrivonogandGitHub 2c32ac2221 [mitsubishi_cn105] Mark configurable classes as final (#18272) 2026-08-23 19:40:08 +00:00
J. Nick Koston 03a443c2b7 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 14:31:24 -05:00
J. Nick Koston 0c97e2b354 Route single-archive waves through the batch runner
A dependency chain (api -> noise-c -> libsodium) discovers one archive
per wave, so the <2 gate sent every chained download through the legacy
per-file path. Sized registry archives now always use the batch runner:
one download method, one bar; the per-file path remains only for
unknown-size, git, and local sources.
2026-08-23 14:31:18 -05:00
J. Nick Koston 3a35c75ec1 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 13:57:09 -05:00
J. Nick Koston 8536d54998 Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter 2026-08-23 13:57:07 -05:00
J. Nick Koston 10bccf3fc0 Merge branch 'esp8266-native-idedata-helpers' into esp8266-native-converter-hardening 2026-08-23 13:57:05 -05:00
J. Nick Koston b070d1631c Merge remote-tracking branch 'origin/dev' into esp8266-native-idedata-helpers 2026-08-23 13:57:03 -05:00
J. Nick Koston c822b80c95 Cover env access warnings and the unmodelled-subscript degrade 2026-08-23 13:49:03 -05:00
J. Nick Koston ce0a0da42a Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 13:36:06 -05:00
J. Nick Koston 2199fcc851 Restore the malformed-manifest raises lost in the merge resolution 2026-08-23 13:35:52 -05:00
J. Nick Koston 9662fea8b9 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 13:35:07 -05:00
J. Nick Koston b4031dc53a Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter
# Conflicts:
#	esphome/platformio/library.py
2026-08-23 13:35:04 -05:00
J. Nick Koston eb958bd0a0 Quote CPPDEFINES, warn on env access, degrade unmodelled subscripts, realify InvalidLibrary
CPPDEFINES joins the quoted buckets (a spaced define no longer splits
across tokens) and its tuple branch validates the pair elements. The
fake env warns on attribute access rather than call, so hasattr/
truthiness branches are diagnosable, with dunder probes excluded; an
unmodelled subscript warns and returns '' instead of a KeyError
discarding the whole capture. Malformed platforms/frameworks values
raise plain InvalidLibrary, giving the non-platform warning branches a
real producer, and their tests use real manifests instead of
monkeypatched raisers. The version-less dependency drop moves to debug:
bundled names (Wire, SPI) made it per-build noise nobody can act on.
2026-08-23 13:34:19 -05:00
J. Nick Koston c376d32db4 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 13:17:12 -05:00
J. Nick Koston 59fc108aa7 Cover the prefetch blanket guard 2026-08-23 13:17:08 -05:00
J. Nick Koston e40537cc34 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 13:09:53 -05:00
J. Nick Koston 9d761102e5 Size the prefetch from the registry, fold the batch API, guard the prefetch
The registry response already carries each package file's size, so the
combined bar is sized from URLSource instead of a HEAD sweep; the probe,
its all-or-nothing fallback, and its duplicate Content-Length parsing are
gone, and archives without a reported size simply stay on the sequential
path. run_batch_downloads now takes (name, size, fetch) jobs and builds
the (now private) progress bar itself, collecting failures from future
results instead of a shared list; both call sites use functools.partial
instead of lambda factories. The prefetch touches only URLSource, so the
progress parameter comes off Source/Git/Local/ConvertedLibrary, and the
wave prefetch is wrapped best-effort like its ESP-IDF twin, which also
dedupes by dest only after the verifiability check. ProgressBar.done()
itself now skips the newline when no frame was drawn or the 100% frame
already ended the line, collapsing the batch wrapper's guard and fixing
the stray blank line after OTA uploads.
2026-08-23 13:08:32 -05:00
J. Nick Koston 1372ec8ad2 Trim the shape-key comment 2026-08-23 13:07:33 -05:00
dde6906f98 [modbus_client] Add continuous option to the read and send actions (#18542)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-23 13:01:34 -05:00
J. Nick Koston c4ddad0237 Trim comments and docstrings 2026-08-23 12:57:59 -05:00
J. Nick Koston 6d4d9aa1ce Trim comments and docstrings 2026-08-23 12:57:17 -05:00
J. Nick Koston caff71c17a Trim comments and docstrings 2026-08-23 12:56:31 -05:00
J. Nick Koston 8e4c9c31e6 Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter 2026-08-23 12:55:57 -05:00
J. Nick Koston a478ae4a46 Trim comments and docstrings 2026-08-23 12:55:52 -05:00
J. Nick Koston 5adaee5408 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 12:33:53 -05:00
J. Nick Koston b903c884fd Absorb the converter-hardening split (#18685); this PR keeps only the download parallelization 2026-08-23 12:33:21 -05:00
J. Nick Koston 9731c9527a Harden the shared library converter
Split out of #18662: the converter/manifest hardening without the
download parallelization, which stays there.

The PIO extra-script shim moves from espidf/ to platformio/ and gains
type checks for every manifest field, capture of the Append variants and
CPPPATH, warn-once diagnostics for unmodelled env reads, and uniform
shlex quoting so captured values survive the lex_build_flags round-trip.
The dependency walk normalizes every spelling PIO accepts, splits the
routine cross-platform skip (IncompatiblePlatform) from real manifest
problems, validates manifest shape once, and the shared flag lexer
warns and drops trailing or empty glued arguments.
2026-08-23 12:32:44 -05:00
J. Nick Koston 1959913347 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 12:15:18 -05:00
J. Nick Koston 035555b24c Drop an empty glued flag argument at the lex funnel
shlex.split('-D ""') left a bare -D that gcc resolves by eating the
next flag; the funnel now warns and drops it for every consumer, which
also retires raise_on_empty_arg_flags, whose staged ESP8266 caller no
longer has anything to catch.
2026-08-23 12:15:04 -05:00
J. Nick Koston 5137c3f16b Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 12:05:05 -05:00
J. Nick Koston ea3ff0e372 Abort in-flight batch downloads on Ctrl-C, make the deferral test hermetic
cancel_futures only drops queued jobs, and the non-daemon workers are
joined at interpreter exit, so a Ctrl-C during the ESP-IDF toolchain
fetch waited out every in-flight archive. The tracker each job reports
through now checks a cancellation flag set when the wait raises, so
downloads abort at their next progress tick and the resumable .part
files keep the bytes already fetched.

The wave-deferral test stubs _content_lengths instead of issuing real
HEAD requests that passed only by failing, the dead hasattr branch in
the HEAD-probe test is gone, and the .C suffix comment names the SCons
divergence instead of claiming parity.
2026-08-23 12:04:49 -05:00
J. Nick Koston dd7a25b302 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 11:36:54 -05:00
J. Nick Koston 7c0e03f309 Capture the Append variants, warn on unmodelled env reads, quote every lexed bucket
Prepend/AppendUnique/PrependUnique write the same captured keys and now
alias Append instead of no-op'ing away a link directory. env.get on an
unmodelled var warns once so a script taking the default branch is
diagnosable. LIBS/LINKFLAGS/CPPFLAGS entries are shlex-quoted like the
path buckets, since each is one argv token in SCons and re-lexing must
not split a spaced value. The HEAD probes follow the happy-eyeballs
convention, and the tool-prefetch backstop keeps its traceback at debug.
2026-08-23 11:36:36 -05:00
J. Nick KostonandGitHub 4efd308345 [tests] Fix flaky pty log probe test on macOS (#18681) 2026-08-23 11:26:55 -05:00
esphome[bot]andGitHub f741c274d5 Bump aioesphomeapi from 45.13.1 to 46.0.0 (#18683) 2026-08-23 15:55:42 +00:00
J. Nick Koston e4a54b27ed Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 10:48:53 -05:00
J. Nick Koston 7b3330d5fe Merge branch 'esp8266-native-idedata-helpers' into esp8266-native-library-converter 2026-08-23 10:48:53 -05:00
J. Nick Koston e681c441d9 Close the residual idedata and size-summary silent-failure findings
A list-of-non-dicts compile DB now raises by name inside the best-effort
tuple. The launcher rejection moved into parse_entry itself so no caller
can record ccache as the compiler, and the shape key includes the entry's
directory since relative includes resolve against it. format_bar raises
on a non-positive total so every consumer must handle the meaningless
bar; the espidf summary validates the RAM region by name (a malformed
region can no longer suppress a computable Flash line) and the blanket
backstop logs the traceback at debug. A new test pins that the CI
trigger paths exist on disk, so a rename fails loudly.
2026-08-23 10:48:39 -05:00
J. Nick Koston bac6530203 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 10:37:14 -05:00
J. Nick Koston 355d4788b8 Assert lexed tokens, not raw flag strings
shlex.quote wraps backslash paths on Windows, so the raw emitted strings
are platform-varying; lexing like the consumer does pins the behavior
that matters and passes on both separators.
2026-08-23 10:37:11 -05:00
J. Nick Koston 585d818e7a Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 10:33:02 -05:00
J. Nick Koston 80b492f638 Quote emitted -I/-L paths so spaced directories survive re-lexing
lex_build_flags shlex-splits build.flags, so an unquoted spaced path
became two garbage tokens; shlex.quote at the emit site round-trips it
back to one.
2026-08-23 10:31:59 -05:00
J. Nick Koston 4a874a374a Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 10:16:06 -05:00
J. Nick Koston 54cd17591d Type-check extraScript, capture CPPPATH, surface version-less dependency drops
A non-string extraScript now fails naming the library instead of an opaque
TypeError, matching the hardening on every other manifest field. CPPPATH
joins the captured keys and translates to -I flags anchored like LIBPATH,
since the pipeline already routes -I to include dirs. Version-less
dependency drops log at INFO until the arduino-backend reconciliation
lands, and the wave-deferral comment now claims only what the guard
actually saves.
2026-08-23 10:15:52 -05:00
J. Nick Koston 98b595c95d Merge branch 'esp8266-native-idedata-helpers' into esp8266-native-library-converter 2026-08-23 10:13:22 -05:00
J. Nick Koston e16cfded5d Merge remote-tracking branch 'origin/dev' into esp8266-native-idedata-helpers 2026-08-23 10:12:45 -05:00
J. Nick Koston 746b294ed7 Harden idedata against non-list compile DBs and stale caches, blanket the size summary
A compile DB that parses as valid JSON but is not a list now raises
EsphomeError inside IDEDATA_BEST_EFFORT_ERRORS instead of a TypeError that
fails an already-linked build. Cached idedata is validated on read against
the write path's guarantees (dict shape, no launcher as compiler, include
union present) so caches from older versions regenerate instead of serving
degraded data, and the served copy is re-stamped with the current ELF path.
Response-file shape sentinels key on the output object rather than the
source file, so two objects built from one source keep both include sets.

The size summary mirrors the standalone hardening PR: a blank partition
size cell raises by name, and a blanket guard keeps unexpected nested
shapes from raising past a build that already linked.
2026-08-23 10:12:40 -05:00
J. Nick Koston da3c6dec7f Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-22 23:41:56 -05:00
J. Nick Koston cfac36c647 Merge branch 'esp8266-native-idedata-helpers' into esp8266-native-library-converter 2026-08-22 23:41:56 -05:00
J. Nick Koston d179f43e7c Correct the zero-partition comment: the skip fails CI extraction loudly, it does not feed it 2026-08-22 23:41:55 -05:00
J. Nick Koston 0e4f4237fd Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-22 23:23:40 -05:00
J. Nick Koston 96a74cd217 Merge branch 'esp8266-native-idedata-helpers' into esp8266-native-library-converter 2026-08-22 23:23:40 -05:00
J. Nick Koston dc64c93f39 [core] Register the OTA component in dummy_main like its siblings 2026-08-22 23:23:39 -05:00
J. Nick Koston 5793a5f08a Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-22 23:21:06 -05:00
J. Nick Koston 9d0d1b42d5 Merge branch 'esp8266-native-idedata-helpers' into esp8266-native-library-converter 2026-08-22 23:21:06 -05:00
J. Nick Koston 8230c16f12 Order the launcher strip before the empty guard, never dedupe rsp commands, harden the size summary 2026-08-22 23:21:04 -05:00
J. Nick Koston 42f59f94c9 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-22 22:58:47 -05:00
J. Nick Koston 35bb8388a2 Defer superseded wave downloads, dedupe tool dests, validate captured buckets, right-size the log levels 2026-08-22 22:58:44 -05:00
J. Nick Koston 5ac6e2b3c6 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-22 22:39:47 -05:00
J. Nick Koston b08026e413 Trigger the IDF infra job on the extraScript shim now that it exists 2026-08-22 22:39:34 -05:00
J. Nick Koston f6c6708f24 Merge branch 'esp8266-native-idedata-helpers' into esp8266-native-library-converter 2026-08-22 22:39:33 -05:00
J. Nick Koston 48c6948ae8 Guard empty compile commands, refuse a compile DB with no ESPHome TU, share the launcher rejection 2026-08-22 22:39:16 -05:00
J. Nick Koston e9466ff738 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-22 22:05:03 -05:00
J. Nick Koston 030e79fa5a Keep the empty-argument flag check next to the lexer that leaves the bare token 2026-08-22 22:04:47 -05:00
J. Nick Koston da9c0bdd14 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-22 21:59:57 -05:00
J. Nick Koston a1e6370e8b [core] Harden the shared library converter and download registry archives in parallel 2026-08-22 21:59:19 -05:00
J. Nick Koston dada0f2c2b [core] Extract the shared idedata and size-summary helpers into build_helpers 2026-08-22 21:58:41 -05:00
J. Nick Koston 18206967a1 Validate captured CPPDEFINES, warn on empty idedata includes, name the idedata consumers 2026-08-22 21:40:57 -05:00
J. Nick Koston d9e18ee982 Support subscript env reads in extra scripts; make the INFO-capture fixture real 2026-08-22 21:27:31 -05:00
J. Nick Koston 227f5c67e2 Merge remote-tracking branch 'origin/dev' into esp8266-native-shared-helpers
# Conflicts:
#	tests/unit_tests/test_main.py
2026-08-22 21:23:40 -05:00
J. Nick Koston d6d80ee323 Consolidate the batch-download scaffold, share the idedata error set, dedupe compdb tokenizing 2026-08-22 21:12:20 -05:00
J. Nick Koston 2acba9b364 Materialize the tracker mock before the prefetch threads race its creation 2026-08-22 20:01:13 -05:00
J. Nick Koston f4bc7dda35 Fault the cache read directly so the unreadable-cache test holds on Windows 2026-08-22 19:55:16 -05:00
J. Nick Koston 7d246893be Surface prefetch failures, harden the idedata and build-info cache reads 2026-08-22 19:46:42 -05:00
J. Nick Koston bff8bea83e Degrade garbage Content-Length probes; never prefetch an unverifiable tool archive 2026-08-22 17:10:19 -05:00
J. Nick Koston 49d060fdfd Correct the _content_lengths contract docs and pin the unknown-size fallback 2026-08-22 16:41:40 -05:00
J. Nick Koston f2ff1cc986 Keep per-file bars when sizes are unknown; harden the prefetch probes
An unknown Content-Length (probe failure, non-2xx, or a CDN omitting it
on HEAD) now skips the parallel prefetch so the sequential downloads
keep their per-file bars, instead of a silent multi-MB batch; the HEAD
helper returns None for unknown, distinct from a genuine zero, and
catches only RequestException. The cache probe logs its failure at
debug. The espidf tool prefetch reads sizes with .get so a missing size
only drops the bar instead of aborting the resumable prefetch. The
idedata warning logs its traceback at debug for parser regressions, and
framework_helpers joins the ESP-IDF infra CI triggers it is imported
by.
2026-08-22 16:10:03 -05:00
J. Nick Koston 86ac8f661b Fold the dependency-entry validation into one helper
The name and version checks were duplicated across the dict-shorthand
and list spellings with four identical warnings; _valid_dependency_entry
carries them once.
2026-08-22 15:51:38 -05:00
J. Nick Koston eec01ea793 Diagnosable prefetch failures, Ctrl-C-safe pool, typed dependency versions
The library prefetch pool now cancels queued futures on interrupt like
the espidf pool it mirrors, prefetch and HEAD failures log their cause
at debug (a suppressed bar is traceable), and non-2xx HEAD statuses read
as unknown size. normalize_dependencies rejects a non-string version in
both spellings (a container would raise from set.add(); an int fails
opaquely in the registry), and the walk's version-less skip logs the
dependency at debug until the arduino-backend reconciliation lands.
2026-08-22 15:46:38 -05:00
J. Nick Koston 1eda5a8534 Skip already-cached archives in the download prefetch
The wave logged 'Downloading N libraries' before checking the
extraction markers, so a warm build announced downloads it never made.
URLSource gains an is_cached probe (best-effort in the prefetch) and a
fully warm wave stays silent.
2026-08-22 15:33:37 -05:00
J. Nick Koston 1cf5df3d59 Merge the espidf parallel-prefetch branch and give library downloads its combined bar
Brings in #18513 (BatchDownloadProgress and the download_with_resume
progress callback) and extends the plumbing one level:
download_from_mirrors passes a progress callback to both target kinds,
Source/ConvertedLibrary.download thread it through, and the library
wave prefetch draws one combined bar sized by parallel HEAD requests
(no sizes, no bar). The thread-local bar suppression this replaces is
removed; a batch caller passing a tracker also owns the messaging, so
the per-file Downloading line only prints for solo downloads.
2026-08-22 15:19:58 -05:00
J. Nick Koston f4482275cb Pin sortedness on a nested fixture in the comment-order test 2026-08-22 15:03:07 -05:00
J. Nick Koston fd7e340e81 Type-hint the to_code test stub 2026-08-22 14:54:39 -05:00
J. Nick Koston 84a8ac3772 Use a docstring instead of pass in the to_code test stub 2026-08-22 14:54:24 -05:00
J. Nick Koston 4a459e3d0d Lift the remaining function-local imports in test_main to top level 2026-08-22 14:52:38 -05:00
J. Nick Koston 3b091676bf Drop the unreachable nameless-dependency check in the walk
normalize_dependencies guarantees every entry carries a non-empty
string name, so only the version can be absent.
2026-08-22 14:49:39 -05:00
J. Nick Koston 121795fa8d Trim comment and docstring essays 2026-08-22 14:44:12 -05:00
J. Nick Koston dbd6f323c0 Extract the build-info staleness check so its branches are testable
copy_src_tree is always mocked in the writer tests, so the new
missing/stale/unreadable debug branches were unreachable; the decision
moves into _build_info_stale with a test covering every branch.
2026-08-22 14:41:30 -05:00
J. Nick Koston 11875ec040 Dump the main.cpp config comment with sorted keys
voluptuous fills schema defaults in set-iteration order, so the
validated dict's key order changes with the process hash seed; the
unsorted dump churned the comment block in main.cpp and relinked the
firmware on every esphome run for any config using a defaults-heavy
action (logger.log in a button's on_press was enough). Affects the
PlatformIO path identically.
2026-08-22 14:37:42 -05:00
J. Nick Koston afeb1a5ea1 Name the changed source when the writer flags a rebuild
Every sources_changed site now logs which file (or which build-info
staleness) triggered it, so a config that relinks on every run can be
diagnosed from one -v compile instead of diffing the build tree.
2026-08-22 14:31:36 -05:00
J. Nick Koston fb06b4d631 Download a wave of registry libraries in parallel
The walk now drains its frontier, prefetches the wave's registry
archives with a small thread pool (deduped by URL, progress bars
suppressed per thread since parallel bars would interleave), and then
processes the wave sequentially; the sequential download() call stays
authoritative so failures surface with resume exactly as before. Same
approach as the espidf tool prefetch in #18513.
2026-08-22 14:10:40 -05:00
J. Nick Koston 65609bb94b Move the backend-agnostic library hardening down from the arduino backend PR
The typed IncompatiblePlatform exception, dependency_is_usable,
warn_properties_depends, the lex_build_flags helper (espidf switches to
it), the normalize_dependencies validation, the manifest shape check,
the component-level drop-warning split, and the SCons case-sensitive
.C/.C++ suffixes all harden the shared converter independently of the
arduino backend, so they belong in this PR; the provides hook and the
version-less reconciliation stay with the backend that needs them.
2026-08-22 12:46:16 -05:00
J. Nick Koston f71867ed2d Merge remote-tracking branch 'origin/dev' into esp8266-native-shared-helpers 2026-08-22 12:15:30 -05:00
J. Nick Koston e99e0cc8b8 Trim comment essays and hoist function-local test imports 2026-08-22 11:34:32 -05:00
J. Nick Koston 2c229debe8 Best-effort bad encodings, dedupe append warnings, tolerate structurally odd compile DBs 2026-08-20 21:09:38 -05:00
J. Nick Koston 6b6344b454 Cover the healthy post-compile idedata path 2026-08-20 20:53:30 -05:00
J. Nick Koston 73ab04330e Treat sys.exit(0) as script success, fail on an unreadable declared script 2026-08-20 20:48:30 -05:00
J. Nick Koston 91eed262c8 Repr zero-arg script exceptions, surface a None idedata after an IDF build 2026-08-20 20:46:15 -05:00
J. Nick Koston c95e0b90f5 Raise on a traversing extraScript, survive sys.exit in vendored scripts, fix the caveats wording 2026-08-20 19:51:26 -05:00
J. Nick Koston 5cf2135038 Cover the warn-once dedup branch 2026-08-20 19:29:27 -05:00
J. Nick Koston 5bd328e6c4 Fail on a declared-but-absent extraScript, warn once per unsupported env method, split and widen tests 2026-08-20 19:25:33 -05:00
J. Nick Koston ef2f16713a Widen the post-compile idedata guard to every failure the comment promises 2026-08-20 19:02:35 -05:00
J. Nick Koston 790c6d6353 Drop the never-hitting parse memo, guard the post-compile idedata refresh, pin the kind map 2026-08-20 18:40:29 -05:00
J. Nick Koston bc6b997350 Restore the lazy shlex import, pin the probe convention, assert the shim diagnostics 2026-08-20 18:17:56 -05:00
J. Nick Koston 9b12934307 Reject a launcher compile DB by name before the probe, discard partial script captures, log dropped env keys 2026-08-20 17:52:54 -05:00
J. Nick Koston d33a097152 Name every skipped size line, best-effort script compile, unusable idedata returns None 2026-08-20 17:29:47 -05:00
J. Nick Koston 466f890e1f Clean errors for non-string flags, visible size-summary skips, partial extra-script capture 2026-08-20 17:12:19 -05:00
J. Nick Koston 4ab2a7ba98 Join flag args per entry like ParseFlags, pin the launcher strip log, record the asm merge decision 2026-08-20 16:54:28 -05:00
J. Nick Koston a0f0ad8729 Restore the underscore in test_parse_library_json 2026-08-20 16:30:13 -05:00
J. Nick Koston 57a9c71da8 Cover the malformed build.flags rejection 2026-08-20 16:25:47 -05:00
J. Nick Koston 1483b7c10b Detect launchers by a closed denylist instead of guessing compiler names 2026-08-20 16:24:21 -05:00
J. Nick Koston 7fe0847e5c Update espidf test to the public parse_library_json name 2026-08-20 16:13:19 -05:00
J. Nick Koston 80815f1dc7 Make parse_library_json public alongside its properties sibling 2026-08-20 16:12:52 -05:00
J. Nick Koston 12c1b15578 Reject malformed build.flags by name and pin the idedata cache-hit path 2026-08-20 15:59:03 -05:00
J. Nick Koston 7a3f700206 Act on bad-compiler detection, atomic logged idedata cache, memoized entry parsing, callable board_mcu 2026-08-20 15:41:39 -05:00
J. Nick Koston 5a4337c240 Share the size line format, derive source extensions from a kind map, close_fds on the ccache probe 2026-08-20 15:21:31 -05:00
J. Nick Koston 2564ad0deb Warn once per non-compiler path, accept versioned compiler names, and pin the trigger table 2026-08-20 13:57:30 -05:00
J. Nick Koston e82453e80e Complete the unit-test package chain so test modules import fully qualified 2026-08-20 13:42:15 -05:00
J. Nick Koston 3eec5085da Give the format_bar tests a unique basename 2026-08-20 13:39:46 -05:00
J. Nick Koston 57c8648210 Drop the test package init that collides with script/build_helpers.py on the CI path 2026-08-20 13:38:54 -05:00
J. Nick Koston 9bd548ba92 Move the extraScript machinery into the platformio package and tidy the extraction 2026-08-20 13:35:07 -05:00
J. Nick Koston ab58f1080a Cover the tidy-project idedata assembly and hoist its helper import 2026-08-20 12:50:56 -05:00
J. Nick Koston 47942fb166 Note the upstream spelling in the format_bar citation 2026-08-20 12:47:54 -05:00
J. Nick Koston f326c3f04d Move the shared helpers into a neutral build_helpers package 2026-08-20 12:43:34 -05:00
J. Nick Koston 3b51ec2ae7 Extract the shared native-build helpers from the ESP-IDF backend 2026-08-20 12:36:42 -05:00
J. Nick KostonandGitHub 28384673f6 Merge branch 'dev' into espidf_parallel_tool_prefetch 2026-08-19 16:32:48 -05:00
J. Nick Koston b361342bde Do not end a progress bar that never drew a frame 2026-08-19 15:13:04 -05:00
J. Nick KostonandGitHub d34eed6d2d Merge branch 'dev' into espidf_parallel_tool_prefetch 2026-08-19 14:41:44 -05:00
J. Nick Koston 12238eec3b Log prefetch failures after the bar, assert on the shared progress instance 2026-08-19 13:57:15 -05:00
J. Nick Koston fe53ff6b6b Finish the batch bar, cancel queued archives on Ctrl-C, report known sizes 2026-08-19 13:28:42 -05:00
J. Nick Koston d0f38d7644 [espidf] Download tool archives in parallel during the install prefetch 2026-08-19 12:03:39 -05:00
109 changed files with 6239 additions and 1675 deletions
+14 -1
View File
@@ -857,7 +857,20 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
toolchain.create_factory_bin()
toolchain.create_ota_bin()
toolchain.create_elf_copy()
toolchain.get_idedata()
from esphome.build_helpers.idedata import IDEDATA_BEST_EFFORT_ERRORS
try:
if toolchain.get_idedata() is None:
_LOGGER.warning("No idedata was generated for this build")
except IDEDATA_BEST_EFFORT_ERRORS as err:
# The firmware already built; an idedata failure must not fail
# a successful build.
_LOGGER.warning(
"Could not generate idedata: %s (IDE, clang-tidy, and "
"memory-analysis data will be unavailable for this build)",
err,
)
_LOGGER.debug("Idedata failure detail", exc_info=True)
else:
from esphome.platformio import toolchain
+1
View File
@@ -0,0 +1 @@
"""Build helpers shared by the native (non-PlatformIO) toolchains."""
@@ -1,10 +1,10 @@
"""Derive idedata from an ESP-IDF native-toolchain ``compile_commands.json``.
"""Derive idedata from a native (non-PlatformIO) build's ``compile_commands.json``.
PlatformIO exposes a curated ``pio run -t idedata`` JSON; the native ESP-IDF
toolchain has no such command, but its CMake build emits
``build/compile_commands.json`` (CMAKE_EXPORT_COMPILE_COMMANDS). This module
turns that file into the same fields consumers (IDE integration, clang-tidy)
expect:
PlatformIO exposes a curated ``pio run -t idedata`` JSON; the native
toolchains have no such command, but each build produces a
``compile_commands.json`` (CMAKE_EXPORT_COMPILE_COMMANDS for ESP-IDF, ninja's
compdb tool otherwise). This module turns that file into the same fields
consumers (IDE integration, clang-tidy) expect:
{cc_path, cxx_path, cxx_flags, defines, includes: {build, toolchain}}
"""
@@ -18,6 +18,19 @@ from pathlib import Path
import shlex
import subprocess
from esphome.core import EsphomeError
from esphome.helpers import write_file
# Everything idedata generation may raise after a successful link; idedata
# is a bonus artifact, so consumers warn instead of failing the build
IDEDATA_BEST_EFFORT_ERRORS = (
EsphomeError,
LookupError,
OSError,
RuntimeError,
ValueError,
)
_LOGGER = logging.getLogger(__name__)
# C++ translation-unit suffixes used to identify ESPHome source files.
@@ -30,12 +43,8 @@ _ESPHOME_SRC_MARKER = "/src/esphome/"
def _is_esphome_src(file: str) -> bool:
"""Whether ``file`` is an ESPHome C++ translation unit.
``compile_commands.json`` ``file`` paths use the OS-native separator, so on
Windows they contain backslashes; normalize to ``/`` before testing the
marker, otherwise no source matches and the build-include union is empty.
"""
"""Whether ``file`` is an ESPHome C++ translation unit; normalized to
``/`` first since Windows compile DBs use backslashes."""
return _ESPHOME_SRC_MARKER in file.replace("\\", "/") and file.endswith(
_CXX_SUFFIXES
)
@@ -106,11 +115,8 @@ def _expand_response_files(tokens: list[str], directory: Path) -> list[str]:
def _pick_entry(entries: list[dict]) -> dict:
"""Pick a representative ESPHome C++ translation unit.
All ESPHome sources share the same component flags/defines, so any one of
them yields the cxx_path / cxx_flags / defines we need.
"""
"""Pick a representative ESPHome C++ TU; all share the same component
flags/defines."""
for entry in entries:
if _is_esphome_src(entry["file"]):
return entry
@@ -120,25 +126,46 @@ def _pick_entry(entries: list[dict]) -> dict:
raise ValueError("no C++ translation unit found in compile_commands.json")
def _parse_entry(entry: dict) -> tuple[str, list[str], list[str], list[str]]:
# Compiler launchers that may prefix a compile command; a closed launcher
# denylist beats enumerating compiler names, an open set.
_LAUNCHER_STEMS = frozenset({"ccache", "sccache", "distcc", "icecc", "buildcache"})
def _is_launcher(token: str) -> bool:
return Path(token).stem.lower() in _LAUNCHER_STEMS
def parse_entry(
entry: dict, launcher: str | None = None
) -> tuple[str, list[str], list[str], list[str]]:
"""Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags)."""
directory = Path(entry["directory"])
tokens = _expand_response_files(_split_command(entry["command"]), directory)
def _include(raw: str) -> str:
# Include paths in compile_commands are interpreted relative to the
# entry's ``directory`` (e.g. build-local ``-Iconfig``); resolve them
# so the cached idedata is usable regardless of the consumer's cwd.
# Emit forward slashes (``normpath`` yields ``\`` on Windows) so the
# paths match the absolute, already-forward-slash entries in the JSON.
# Resolve against the entry's ``directory`` so cached idedata works
# from any cwd; emit forward slashes to match the JSON's own entries
raw = raw.strip()
if raw and not Path(raw).is_absolute():
raw = os.path.normpath(directory / raw)
return raw.replace("\\", "/")
# A launcher-wrapped command ("ccache g++ ...") names the compiler second
if launcher is not None and tokens[:1] == [launcher]:
tokens = tokens[1:]
if not tokens:
# An empty command, or one that was only the launcher; fail by name
raise ValueError(f"empty compile command for {entry.get('file')}")
if _is_launcher(tokens[0]) and len(tokens) > 1 and not tokens[1].startswith("-"):
# Stale DB built with a launcher this run no longer configures; the
# real compiler is the next token
_LOGGER.warning("Stripping unconfigured launcher %s", tokens[0])
tokens = tokens[1:]
# token0 is the compiler path; the rest of the command already uses forward
# slashes on Windows, so normalize it too for a consistent idedata file.
cxx_path = tokens[0].replace("\\", "/")
# Enforced here so no caller can record ccache as the compiler
reject_launcher_compiler(cxx_path)
defines: list[str] = []
includes: list[str] = []
cxx_flags: list[str] = []
@@ -168,7 +195,7 @@ def _parse_entry(entry: dict) -> tuple[str, list[str], list[str], list[str]]:
return cxx_path, defines, includes, cxx_flags
def _get_toolchain_includes(cxx_path: str) -> list[str]:
def get_toolchain_includes(cxx_path: str) -> list[str]:
"""Query the compiler for its builtin ``#include <...>`` search dirs."""
result = subprocess.run(
[cxx_path, "-E", "-x", "c++", "-", "-v"],
@@ -219,26 +246,128 @@ def _cc_path_from_cxx(cxx_path: str) -> str:
return f"{stem}{suffix}"
def idedata_from_build(compile_commands: Path) -> dict:
def _cache_usable(cached: object) -> bool:
"""Check a cached idedata dict against the guarantees of the write path.
Caches written by older versions predate the launcher rejection and the
include-union shape; serving one would bypass both. The dict check also
keeps "in" from substring-matching a bare JSON string.
"""
if not isinstance(cached, dict) or "cc_path" not in cached:
return False
cxx_path = cached.get("cxx_path")
if not isinstance(cxx_path, str) or _is_launcher(cxx_path):
return False
includes = cached.get("includes")
return isinstance(includes, dict) and isinstance(includes.get("build"), list)
def load_or_build_idedata(
compile_commands: Path,
elf_path: Path,
cache: Path,
launcher: str | None = None,
) -> dict | None:
"""Return idedata for a compile_commands.json build, cached on mtime.
Shared by the native ESP-IDF and ESP8266 Arduino toolchains. Returns None
when the compile DB doesn't exist yet (nothing was built). ``launcher``
is the compiler-launcher path (ccache) the build was generated with, if
any; commands in the compile DB are prefixed with it.
"""
if not compile_commands.is_file():
_LOGGER.debug("No %s yet; skipping idedata generation", compile_commands)
return None
if cache.is_file() and cache.stat().st_mtime >= compile_commands.stat().st_mtime:
try:
cached = json.loads(cache.read_text(encoding="utf-8"))
except (ValueError, OSError) as err:
# A recurring cause (interrupted write, disk full) would otherwise
# look like unexplained slow builds
_LOGGER.warning("Discarding unreadable idedata cache %s: %s", cache, err)
else:
if _cache_usable(cached):
# Re-stamp so a relocated build dir cannot serve a stale ELF path
cached["prog_path"] = str(elf_path)
return cached
_LOGGER.debug("Regenerating idedata: cache %s fails validation", cache)
data = idedata_from_build(compile_commands, launcher)
data["prog_path"] = str(elf_path)
cache.parent.mkdir(parents=True, exist_ok=True)
# Atomic so a crash mid-write cannot leave a truncated cache
write_file(cache, json.dumps(data, indent=2) + "\n")
return data
def reject_launcher_compiler(cxx_path: str) -> None:
"""Reject a compile DB naming a launcher (ccache) as the compiler; it
must never be probed, cached, or consumed."""
if _is_launcher(cxx_path):
raise EsphomeError(
f"compile_commands.json names the launcher {cxx_path} as the "
"compiler; the compile database is unusable"
)
def idedata_from_build(compile_commands: Path, launcher: str | None = None) -> dict:
"""Parse compile_commands.json into the idedata fields consumers expect.
A single ESP-IDF compile entry only carries its own component's REQUIRES
include set, but consumers (clang-tidy) analyze ESPHome headers that
transitively pull in other components. So take cxx_path / cxx_flags /
defines from a representative ESPHome TU, but union the include dirs across
all ESPHome TUs to get a project-wide superset (as PlatformIO's idedata
provides).
A single compile entry only carries the include set its own translation
unit was built with (per-component under ESP-IDF), but consumers
(clang-tidy) analyze ESPHome headers that transitively pull in other
components. So take cxx_path / cxx_flags / defines from a representative
ESPHome TU, but union the include dirs across all ESPHome TUs to get a
project-wide superset (as PlatformIO's idedata provides).
"""
entries = json.loads(Path(compile_commands).read_text(encoding="utf-8"))
cxx_path, defines, _, cxx_flags = _parse_entry(_pick_entry(entries))
if not isinstance(entries, list) or not all(isinstance(e, dict) for e in entries):
# A TypeError here would escape IDEDATA_BEST_EFFORT_ERRORS
raise EsphomeError(f"{compile_commands} is not a compile-command list")
build_includes: dict[str, None] = {}
representative = _pick_entry(entries)
cxx_path, defines, rep_includes, cxx_flags = parse_entry(representative, launcher)
# Seed with the representative's includes so it is not parsed twice
has_esphome_tu = _is_esphome_src(representative["file"])
build_includes: dict[str, None] = dict.fromkeys(
rep_includes if has_esphome_tu else ()
)
def _shape(entry: dict) -> str:
# directory + command minus TU-specific paths: same shape means the
# same include set, so tokenize once per shape. Response-file
# commands never dedupe (the .rsp contents differ per object)
command = entry["command"]
directory = entry.get("directory", "")
if "@" in command:
return f"unique:{directory}|{entry.get('output') or command}"
stripped = command.replace(entry.get("file", ""), "").replace(
entry.get("output", ""), ""
)
return f"{directory}|{stripped}"
seen_shapes = {_shape(representative)}
for entry in entries:
if not _is_esphome_src(entry["file"]):
if entry is representative or not _is_esphome_src(entry["file"]):
continue
for inc in _parse_entry(entry)[2]:
has_esphome_tu = True
if (shape := _shape(entry)) in seen_shapes:
_LOGGER.debug("Include union: %s shares a command shape", entry["file"])
continue
seen_shapes.add(shape)
for inc in parse_entry(entry, launcher)[2]:
build_includes.setdefault(inc, None)
if not has_esphome_tu:
# An arbitrary fallback TU breaks clang-tidy/IDE consumers, and a
# warning would be cached into permanence; call sites downgrade this
raise EsphomeError(
f"No ESPHome translation unit found in {compile_commands}; "
"refusing to cache unusable idedata"
)
return {
"cc_path": _cc_path_from_cxx(cxx_path),
"cxx_path": cxx_path,
@@ -246,6 +375,6 @@ def idedata_from_build(compile_commands: Path) -> dict:
"defines": defines,
"includes": {
"build": list(build_includes),
"toolchain": _get_toolchain_includes(cxx_path),
"toolchain": get_toolchain_includes(cxx_path),
},
}
+24
View File
@@ -0,0 +1,24 @@
"""The PlatformIO-format size bar shared by the native toolchains."""
from __future__ import annotations
def format_bar(used: int, total: int) -> str:
"""Match PlatformIO's ``_format_availale_bytes`` (sic, pioupload.py) exactly."""
pct_raw = used / total if total else 0
blocks = 10
filled = min(int(round(blocks * pct_raw)), blocks)
progress = "=" * filled
return (
f"[{progress:<{blocks}}] {pct_raw: 6.1%} "
f"(used {used:d} bytes from {total:d} bytes)"
)
def print_size_line(label: str, used: int, total: int) -> None:
"""One PlatformIO-format summary line (``RAM``/``Flash``).
The label padding is part of the format: ``script/ci_memory_impact_extract.py``
matches these lines verbatim.
"""
print(f"{label + ':':<7}{format_bar(used, total)}")
+8 -8
View File
@@ -412,15 +412,15 @@ void APIConnection::finalize_iterator_sync_() {
}
void APIConnection::process_iterator_batch_(ComponentIterator &iterator) {
size_t initial_size = this->deferred_batch_.size();
size_t max_batch = MAX_INITIAL_PER_BATCH;
while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < max_batch) {
iterator.advance();
}
// Budget by remaining batch capacity so a pass cannot overfill the batch;
// stops early on a refused send and resumes next loop pass
size_t batch_size = this->deferred_batch_.size();
if (batch_size < MAX_INITIAL_BATCH_SIZE)
iterator.try_advance(MAX_INITIAL_BATCH_SIZE - batch_size);
// If the batch is full, process it immediately
// Note: iterator.advance() already calls schedule_batch_() via schedule_message_()
if (this->deferred_batch_.size() >= max_batch) {
// Flush immediately once enough is queued (not guaranteed every pass);
// partial batches go out via the batch timer or finalize_iterator_sync_()
if (this->deferred_batch_.size() >= MAX_INITIAL_BATCH_SIZE) {
this->process_batch_();
}
}
+4 -4
View File
@@ -53,11 +53,11 @@ void log_dropped_message(const char *tag, int line, const LogString *what);
// Keepalive timeout in milliseconds
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
// Maximum number of entities to process in a single batch during initial state/info sending
static constexpr size_t MAX_INITIAL_PER_BATCH = 34;
// Deferred batch size cap during initial state/info sync
static constexpr size_t MAX_INITIAL_BATCH_SIZE = 34;
// Verify MAX_MESSAGES_PER_BATCH (defined in api_frame_helper.h) can hold the initial batch
static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH,
"MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_PER_BATCH");
static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_BATCH_SIZE,
"MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_BATCH_SIZE");
#ifdef USE_BENCHMARK
class APIConnection;
+1 -1
View File
@@ -36,7 +36,7 @@ static constexpr uint16_t MAX_MESSAGE_SIZE = 32768; // 32 KiB for ESP32 and oth
static constexpr uint16_t RX_BUF_NULL_TERMINATOR = 1;
// Maximum number of messages to batch in a single write operation
// Must be >= MAX_INITIAL_PER_BATCH in api_connection.h (enforced by static_assert there)
// Must be >= MAX_INITIAL_BATCH_SIZE in api_connection.h (enforced by static_assert there)
static constexpr size_t MAX_MESSAGES_PER_BATCH = 34;
// Max client name length (e.g., "Home Assistant 2026.1.0.dev0" = 28 chars)
+9 -1
View File
@@ -95,9 +95,17 @@ bool ListEntitiesIterator::on_end() { return this->client_->send_list_info_done(
ListEntitiesIterator::ListEntitiesIterator(APIConnection *client) : client_(client) {}
#ifdef USE_API_USER_DEFINED_ACTIONS
// Yield after every Nth service; bounds direct (non-batched) writes per loop pass
static constexpr uint8_t SERVICE_YIELD_INTERVAL = 3;
bool ListEntitiesIterator::on_service(UserServiceDescriptor *service) {
auto resp = service->encode_list_service_response();
return this->client_->send_message(resp);
if (!this->client_->send_message(resp))
return false;
// at_ is this service's index
if ((this->at_ + 1) % SERVICE_YIELD_INTERVAL == 0)
this->yield_after_step_();
return true;
}
#endif
+27 -16
View File
@@ -206,32 +206,36 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType:
interval = config[CONF_INTERVAL]
window = config[CONF_WINDOW]
if window > interval:
raise cv.Invalid(
f"Scan window ({window}) needs to be smaller than scan interval ({interval})"
)
# Labels are reused in every error below; the optional one names its key.
windows = [("Scan window", window)]
if (connection_window := config.get(CONF_CONNECTION_SCAN_WINDOW)) is not None:
windows.append((CONF_CONNECTION_SCAN_WINDOW, connection_window))
for name, value in windows:
if value > interval:
raise cv.Invalid(
f"{name} ({value}) needs to be smaller than scan interval ({interval})"
)
# BLE scan interval/window are programmed in 0.625 ms units as a 16-bit value; the
# controller only accepts 2.5 ms .. 10240 ms (0x0004 .. 0x4000). Reject out-of-range
# values here instead of letting the unit conversion silently overflow.
for name, value in (("interval", interval), ("window", window)):
for name, value in (("Scan interval", interval), *windows):
if value.total_microseconds < 2500 or value.total_microseconds > 10_240_000:
raise cv.Invalid(
f"Scan {name} ({value}) must be between 2.5 ms and 10240 ms"
)
raise cv.Invalid(f"{name} ({value}) must be between 2.5 ms and 10240 ms")
# Validate what actually reaches the controller: both values are truncated to
# whole 0.625 ms units, so a window/interval pair that differs by less than one
# unit collapses to the same value — silently programming a 100 % duty cycle
# (radio permanently on) from a config that asked for less.
interval_units = to_ble_units(interval)
window_units = to_ble_units(window)
if window_units == interval_units and window < interval:
raise cv.Invalid(
f"Scan window ({window}) and interval ({interval}) both truncate to "
f"{interval_units} x 0.625 ms, which the controller scans at a 100 % duty "
f"cycle. Separate them by at least 0.625 ms."
)
for name, value in windows:
if to_ble_units(value) == interval_units and value < interval:
raise cv.Invalid(
f"{name} ({value}) and interval ({interval}) both truncate to "
f"{interval_units} x 0.625 ms, which the controller scans at a 100 % duty "
f"cycle. Separate them by at least 0.625 ms."
)
if interval.total_microseconds * 3 > duration.total_microseconds:
raise cv.Invalid(
@@ -247,11 +251,14 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType:
# their own; also the fallback for esp32's conditional default.
DEFAULT_SCAN_WINDOW = "30ms"
CONF_CONNECTION_SCAN_WINDOW = "connection_scan_window"
def scan_parameters_schema(
interval_default: str,
*,
window_default: str | Callable[[], TimePeriod] = DEFAULT_SCAN_WINDOW,
connection_window: bool = False,
) -> cv.All:
"""Build the scan_parameters value schema shared by all BLE trackers.
@@ -263,7 +270,9 @@ def scan_parameters_schema(
can adjust it once sibling keys are resolved). The `active` option
(default on) is unconditional: active scanning is part of the tracker
contract — every current proxy client assumes it, so a passive-only
tracker must not share this schema.
tracker must not share this schema. connection_window opts in to the
`connection_scan_window` option for trackers that can fall back to a
smaller window while a GATT connection is active.
"""
schema = {
cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds,
@@ -272,6 +281,8 @@ def scan_parameters_schema(
cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean,
cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
}
if connection_window:
schema[cv.Optional(CONF_CONNECTION_SCAN_WINDOW)] = cv.positive_time_period
return cv.All(cv.Schema(schema), validate_scan_parameters)
@@ -7,6 +7,7 @@ import logging
from esphome import automation
import esphome.codegen as cg
from esphome.components import ble_device_base, esp32_ble, ota
from esphome.components.ble_device_base import CONF_CONNECTION_SCAN_WINDOW
from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW
from esphome.components.esp32 import (
add_idf_sdkconfig_option,
@@ -73,8 +74,9 @@ def _get_required_features() -> set[BLEFeatures]:
# Slot counters sizing the tracker's StaticVector storage; one request per
# registered listener or client.
CLIENT_COUNT_DEFINE = "ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT"
_request_listener_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT")
_request_client_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT")
_request_client_slot = cg.slot_counter(CLIENT_COUNT_DEFINE)
def register_ble_features(features: set[BLEFeatures]) -> None:
@@ -147,6 +149,7 @@ class TrackerData:
"""Per-run validation state, namespaced under DOMAIN in CORE.data."""
scan_window_defaulted: bool = False
connection_window_injected: bool = False
def _get_data() -> TrackerData:
@@ -175,17 +178,34 @@ def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType:
honors the window strictly (>= 5.5.5); without the arbiter a full-duty
scan would starve wifi outright, and a user-set window is never touched.
Raising to the interval cannot invalidate the already-validated
parameters, so no re-validation is needed.
parameters, so no re-validation is needed. The connection window is
checked against the window here, after the raise.
"""
params = config[CONF_SCAN_PARAMETERS]
if (
_get_data().scan_window_defaulted
and config.get(CONF_SOFTWARE_COEXISTENCE)
and idf_version() >= IDF_SCAN_WINDOW_FIX_VERSION
):
params = config[CONF_SCAN_PARAMETERS]
# Copy so the config dump shows a plain value instead of a YAML
# anchor/alias pair pointing at the interval.
params[CONF_WINDOW] = copy.copy(params[CONF_INTERVAL])
# Arm the connection-time fallback unless the user set one. Injected
# after validation; safe because it equals the validated window default.
if CONF_CONNECTION_SCAN_WINDOW not in params:
params[CONF_CONNECTION_SCAN_WINDOW] = cv.positive_time_period(
ble_device_base.DEFAULT_SCAN_WINDOW
)
_get_data().connection_window_injected = True
if (
connection_window := params.get(CONF_CONNECTION_SCAN_WINDOW)
) is not None and connection_window > params[CONF_WINDOW]:
# A larger value would widen the scan during connections.
raise cv.Invalid(
f"{CONF_CONNECTION_SCAN_WINDOW} ({connection_window}) needs to be "
f"smaller than the scan window ({params[CONF_WINDOW]})",
path=[CONF_SCAN_PARAMETERS, CONF_CONNECTION_SCAN_WINDOW],
)
return config
@@ -194,7 +214,7 @@ def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType:
# window/interval pairs that collapse to the same 0.625 ms unit count.
# The window default is conditional (see _scan_window_default above).
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema(
"320ms", window_default=_scan_window_default
"320ms", window_default=_scan_window_default, connection_window=True
)
# Codegen helpers are owned by ble_device_base; kept under the historical names
@@ -288,6 +308,25 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_scan_duration(params[CONF_DURATION]))
cg.add(var.set_scan_interval(ble_device_base.to_ble_units(params[CONF_INTERVAL])))
cg.add(var.set_scan_window(ble_device_base.to_ble_units(params[CONF_WINDOW])))
if (connection_window := params.get(CONF_CONNECTION_SCAN_WINDOW)) is not None:
# Emitted at FINAL so a scan-only build, where the guarded C++ path
# compiles out, skips the call entirely.
window_units = ble_device_base.to_ble_units(connection_window)
@coroutine_with_priority(CoroPriority.FINAL)
async def _emit_connection_scan_window() -> None:
if cg.get_slot_count(CLIENT_COUNT_DEFINE):
cg.add(var.set_connection_scan_window(window_units))
elif not _get_data().connection_window_injected:
# Warn only for a user-set value; the injected default drops silently.
_LOGGER.warning(
"'%s' has no effect because this build has no BLE client "
"components (for example bluetooth_proxy with active "
"connections, or ble_client)",
CONF_CONNECTION_SCAN_WINDOW,
)
CORE.add_job(_emit_connection_scan_window)
cg.add(var.set_scan_active(params[CONF_ACTIVE]))
cg.add(var.set_scan_continuous(params[CONF_CONTINUOUS]))
@@ -122,6 +122,9 @@ void ESP32BLETracker::loop() {
// - start_scan_(): scanner_state_ becomes IDLE via set_scanner_state_() in cleanup_scan_state_()
// - try_promote_discovered_clients_(): client enters DISCOVERED via set_state(), or
// connecting client finishes (state change), or scanner reaches RUNNING/IDLE
// - connection-window restart: scan_params_ is only written in start_scan_()
// (which changes scanner state via set_scanner_state_()), and
// counts.active/disconnecting only change on client state changes
//
// All conditions that affect the logic below are tied to state changes that increment
// state_version_, so the fast path is safe.
@@ -144,6 +147,19 @@ void ESP32BLETracker::loop() {
(this->scan_set_param_failed_ && this->scanner_state_ == ScannerState::RUNNING)) {
this->handle_scanner_failure_();
}
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
// The programmed window no longer matches the connection state (typically
// the last connection dropped): restart so the right window applies now
// instead of at the end of the scan period. Continuous only (a user-started
// scan would not restart); !disconnecting matches the restart gate below.
if (this->scanner_state_ == ScannerState::RUNNING && this->scan_continuous_ && !counts.disconnecting &&
this->scan_params_.scan_window != this->desired_scan_window_(counts.active)) {
// Same logical scan period continues: no on_scan_end sweeps for this
// restart. Only armed when the stop was issued.
this->skip_next_scan_end_ = this->stop_scan_();
}
#endif
/*
Avoid starting the scanner if:
@@ -195,19 +211,23 @@ void ESP32BLETracker::stop_scan() {
// reason at D themselves, and the user-facing stop action is deliberate.
ESP_LOGV(TAG, "Stopping scan.");
this->scan_continuous_ = false;
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
// The window-change restart is abandoned with continuous scanning.
this->skip_next_scan_end_ = false;
#endif
this->stop_scan_();
}
void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_(); }
void ESP32BLETracker::stop_scan_() {
bool ESP32BLETracker::stop_scan_() {
if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) {
// IDLE means there is nothing to stop; STOPPING means a stop is already in
// flight and will finish on its own. Neither is an error.
if (this->scanner_state_ != ScannerState::IDLE && this->scanner_state_ != ScannerState::STOPPING) {
ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_));
}
return;
return false;
}
// Reset timeout state machine when stopping scan
this->scan_timeout_state_ = ScanTimeoutState::INACTIVE;
@@ -215,8 +235,9 @@ void ESP32BLETracker::stop_scan_() {
esp_err_t err = esp_ble_gap_stop_scanning();
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ble_gap_stop_scanning failed: %d", err);
return;
return false;
}
return true;
}
void ESP32BLETracker::start_scan_(bool first) {
@@ -230,16 +251,11 @@ void ESP32BLETracker::start_scan_(bool first) {
}
this->set_scanner_state_(ScannerState::STARTING);
ESP_LOGV(TAG, "Starting scan, set scanner state to STARTING.");
if (!first) {
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
for (auto *listener : this->listeners_)
listener->on_scan_end();
if (!first)
this->notify_scan_end_();
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
this->skip_next_scan_end_ = false;
#endif
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
for (auto *listener : this->neutral_listeners_)
listener->on_scan_end();
#endif
}
#ifdef USE_ESP32_BLE_DEVICE
this->discovered_log_.clear();
#endif
@@ -247,7 +263,17 @@ void ESP32BLETracker::start_scan_(bool first) {
this->scan_params_.own_addr_type = BLE_ADDR_TYPE_PUBLIC;
this->scan_params_.scan_filter_policy = BLE_SCAN_FILTER_ALLOW_ALL;
this->scan_params_.scan_interval = this->scan_interval_;
this->scan_params_.scan_window = this->scan_window_;
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
// Count fresh: an automation can start a scan before loop() refreshes the counts.
const uint32_t window = this->desired_scan_window_(this->count_client_states_().active);
if (window != this->scan_window_) {
// Guarantee the connection airtime instead of scanning wall to wall.
ESP_LOGV(TAG, "Connection active, using %" PRIu32 " unit scan window", window);
}
#else
const uint32_t window = this->scan_window_;
#endif
this->scan_params_.scan_window = window;
// Start timeout monitoring in loop() instead of using scheduler
// This prevents false reboots when the loop is blocked
@@ -408,6 +434,11 @@ void ESP32BLETracker::dump_config() {
" Continuous Scanning: %s",
this->scan_duration_, this->scan_interval_ * 0.625f, this->scan_window_ * 0.625f,
this->scan_active_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_));
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
if (this->connection_scan_window_ != 0) {
ESP_LOGCONFIG(TAG, " Connection Scan Window: %.1f ms", this->connection_scan_window_ * 0.625f);
}
#endif
ESP_LOGCONFIG(TAG,
" Scanner State: %s\n"
" Connecting: %d, discovered: %d, disconnecting: %d, active: %d",
@@ -487,6 +518,18 @@ void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) {
// Reset timeout state machine instead of cancelling scheduler timeout
this->scan_timeout_state_ = ScanTimeoutState::INACTIVE;
this->notify_scan_end_();
this->set_scanner_state_(ScannerState::IDLE);
}
void ESP32BLETracker::notify_scan_end_() {
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
// Window-change restart continues the same scan period; the flag stays set
// across the stop and is cleared by the restart in start_scan_.
if (this->skip_next_scan_end_)
return;
#endif
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
for (auto *listener : this->listeners_)
listener->on_scan_end();
@@ -495,8 +538,6 @@ void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) {
for (auto *listener : this->neutral_listeners_)
listener->on_scan_end();
#endif
this->set_scanner_state_(ScannerState::IDLE);
}
void ESP32BLETracker::handle_scanner_failure_() {
@@ -534,6 +575,8 @@ void ESP32BLETracker::try_promote_discovered_clients_() {
}
ESP_LOGD(TAG, "Promoting client to connect");
// A connect ends the scan period a window-change restart was continuing.
this->skip_next_scan_end_ = false;
#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE
this->update_coex_preference_(true);
#endif
@@ -169,6 +169,9 @@ class ESP32BLETracker final : public Component,
void set_scan_duration(uint32_t scan_duration) { scan_duration_ = scan_duration; }
void set_scan_interval(uint32_t scan_interval) { scan_interval_ = scan_interval; }
void set_scan_window(uint32_t scan_window) { scan_window_ = scan_window; }
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
void set_connection_scan_window(uint32_t scan_window) { connection_scan_window_ = scan_window; }
#endif
void set_scan_active(bool scan_active) { scan_active_ = scan_active; }
bool get_scan_active() const { return scan_active_; }
void set_scan_continuous(bool scan_continuous) { scan_continuous_ = scan_continuous; }
@@ -226,7 +229,10 @@ class ESP32BLETracker final : public Component,
ScannerState get_scanner_state() const { return this->scanner_state_; }
protected:
void stop_scan_();
/// Returns true when a stop was issued to the controller.
bool stop_scan_();
/// Fire on_scan_end on every listener unless a window-change restart suppressed it.
void notify_scan_end_();
/// Start a single scan by setting up the parameters and doing some esp-idf calls.
void start_scan_(bool first);
/// Called when a `ESP_GAP_BLE_SCAN_RESULT_EVT` event is received.
@@ -313,6 +319,15 @@ class ESP32BLETracker final : public Component,
uint32_t scan_duration_;
uint32_t scan_interval_;
uint32_t scan_window_;
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
/// Window used while a GATT connection is active; set by the user, or
/// defaulted when the window was raised to full duty (0 = no fallback).
uint32_t connection_scan_window_{0};
/// The window to scan at for the given number of active GATT connections.
uint32_t desired_scan_window_(uint8_t active) const {
return (this->connection_scan_window_ != 0 && active > 0) ? this->connection_scan_window_ : this->scan_window_;
}
#endif
esp_bt_status_t scan_start_failed_{ESP_BT_STATUS_SUCCESS};
esp_bt_status_t scan_set_param_failed_{ESP_BT_STATUS_SUCCESS};
@@ -330,15 +345,20 @@ class ESP32BLETracker final : public Component,
/// state_version_ to detect if any state changed since last iteration.
uint8_t last_processed_version_{0};
ScannerState scanner_state_{ScannerState::IDLE};
bool scan_continuous_;
bool scan_active_;
// Packed 1-bit flags.
bool scan_continuous_ : 1;
bool scan_active_ : 1;
#ifdef USE_OTA_STATE_LISTENER
bool scan_continuous_before_ota_{false};
bool scan_continuous_before_ota_ : 1 {false};
#endif
bool ble_was_disabled_ : 1 {true};
bool parse_advertisements_ : 1 {false};
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
/// Suppress the window-change restart's on_scan_end sweeps (stop and start).
bool skip_next_scan_end_ : 1 {false};
#endif
bool ble_was_disabled_{true};
bool parse_advertisements_{false};
#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE
bool coex_prefer_ble_{false};
bool coex_prefer_ble_ : 1 {false};
#endif
// Scan timeout state machine
enum class ScanTimeoutState : uint8_t {
@@ -346,10 +366,10 @@ class ESP32BLETracker final : public Component,
MONITORING, // Actively monitoring for timeout
EXCEEDED_WAIT, // Timeout exceeded, waiting one loop before reboot
};
ScanTimeoutState scan_timeout_state_{ScanTimeoutState::INACTIVE};
uint32_t scan_start_time_{0};
/// Precomputed timeout value: scan_duration_ * 2000
uint32_t scan_timeout_ms_{0};
ScanTimeoutState scan_timeout_state_{ScanTimeoutState::INACTIVE};
};
// NOLINTNEXTLINE
@@ -196,6 +196,9 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const std::string &url, c
}
container->feed_wdt();
// IDF is the only backend reusing the container across redirect hops;
// drop the previous hop's headers (Arduino/host collect only the final response)
container->response_headers_.clear();
container->content_length = esp_http_client_fetch_headers(client);
container->set_chunked(esp_http_client_is_chunked_response(client));
container->feed_wdt();
@@ -9,7 +9,7 @@
namespace esphome::mitsubishi_cn105 {
template<typename... Ts>
class SetRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
class SetRemoteTemperatureAction final : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
public:
TEMPLATABLE_VALUE(float, temperature)
@@ -17,12 +17,12 @@ class SetRemoteTemperatureAction : public Action<Ts...>, public Parented<Mitsubi
};
template<typename... Ts>
class ClearRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
class ClearRemoteTemperatureAction final : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
public:
void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); }
};
template<typename... Ts> class VaneControlAction : public Action<Ts...> {
template<typename... Ts> class VaneControlAction final : public Action<Ts...> {
public:
using ApplyFn = void (*)(VaneCall &, const std::remove_cvref_t<Ts> &...);
@@ -74,7 +74,7 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() {
traits.add_supported_fan_mode(p.second);
}
traits.set_supported_swing_modes(this->supported_swing_modes_);
traits.set_supported_swing_modes(this->swing_mode_manager_.supported_swing_modes());
const bool use_fahrenheit = this->parent_->get_temperature_mapping().get_use_fahrenheit();
traits.set_temperature_unit(use_fahrenheit ? TemperatureUnit::FAHRENHEIT : TemperatureUnit::CELSIUS);
@@ -109,33 +109,11 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) {
}
if (const auto swing_mode = call.get_swing_mode()) {
auto vane = this->last_non_swing_vane_mode_;
auto wide = this->last_non_swing_wide_vane_mode_;
switch (*swing_mode) {
case climate::CLIMATE_SWING_BOTH:
vane = MitsubishiCN105::VaneMode::SWING;
wide = MitsubishiCN105::WideVaneMode::SWING;
break;
case climate::CLIMATE_SWING_VERTICAL:
vane = MitsubishiCN105::VaneMode::SWING;
break;
case climate::CLIMATE_SWING_HORIZONTAL:
wide = MitsubishiCN105::WideVaneMode::SWING;
break;
case climate::CLIMATE_SWING_OFF:
default:
break;
if (const auto vane = this->swing_mode_manager_.vane_from(*swing_mode)) {
this->parent_->set_vane_mode(*vane);
}
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) {
this->parent_->set_vane_mode(vane);
}
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) {
this->parent_->set_wide_vane_mode(wide);
if (const auto wide = this->swing_mode_manager_.wide_vane_from(*swing_mode)) {
this->parent_->set_wide_vane_mode(*wide);
}
}
@@ -166,64 +144,39 @@ void MitsubishiCN105Climate::apply_values_() {
ESP_LOGD(TAG, "Unable to map fan mode");
}
if (!this->supported_swing_modes_.empty()) {
bool vertical_swinging = false;
bool horizontal_swinging = false;
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) {
if (status.vane_mode == MitsubishiCN105::VaneMode::SWING) {
vertical_swinging = true;
} else if (status.vane_mode != MitsubishiCN105::VaneMode::UNKNOWN) {
this->last_non_swing_vane_mode_ = status.vane_mode;
}
}
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) {
if (status.wide_vane_mode == MitsubishiCN105::WideVaneMode::SWING) {
horizontal_swinging = true;
} else if (status.wide_vane_mode != MitsubishiCN105::WideVaneMode::UNKNOWN) {
this->last_non_swing_wide_vane_mode_ = status.wide_vane_mode;
}
}
if (vertical_swinging && horizontal_swinging) {
this->swing_mode = climate::CLIMATE_SWING_BOTH;
} else if (vertical_swinging) {
this->swing_mode = climate::CLIMATE_SWING_VERTICAL;
} else if (horizontal_swinging) {
this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL;
} else {
this->swing_mode = climate::CLIMATE_SWING_OFF;
}
if (const auto swing_mode =
this->swing_mode_manager_.update_and_get_swing_mode(status.vane_mode, status.wide_vane_mode)) {
this->swing_mode = *swing_mode;
}
this->publish_state();
}
void MitsubishiCN105Climate::set_supported_swing_mode(climate::ClimateSwingMode mode) {
this->supported_swing_modes_.clear();
climate::ClimateSwingModeMask supported_swing_modes;
switch (mode) {
case climate::CLIMATE_SWING_VERTICAL:
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF);
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_VERTICAL);
supported_swing_modes.insert(climate::CLIMATE_SWING_OFF);
supported_swing_modes.insert(climate::CLIMATE_SWING_VERTICAL);
break;
case climate::CLIMATE_SWING_HORIZONTAL:
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF);
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_HORIZONTAL);
supported_swing_modes.insert(climate::CLIMATE_SWING_OFF);
supported_swing_modes.insert(climate::CLIMATE_SWING_HORIZONTAL);
break;
case climate::CLIMATE_SWING_BOTH:
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF);
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_VERTICAL);
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_HORIZONTAL);
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_BOTH);
supported_swing_modes.insert(climate::CLIMATE_SWING_OFF);
supported_swing_modes.insert(climate::CLIMATE_SWING_VERTICAL);
supported_swing_modes.insert(climate::CLIMATE_SWING_HORIZONTAL);
supported_swing_modes.insert(climate::CLIMATE_SWING_BOTH);
break;
case climate::CLIMATE_SWING_OFF:
default:
break;
}
this->swing_mode_manager_.set_supported_swing_modes(supported_swing_modes);
}
} // namespace esphome::mitsubishi_cn105
@@ -6,10 +6,13 @@
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/components/climate/climate.h"
#include "mitsubishi_cn105_swing_mode_manager.h"
namespace esphome::mitsubishi_cn105 {
class MitsubishiCN105Climate : public climate::Climate, public Component, public Parented<MitsubishiCN105Component> {
class MitsubishiCN105Climate final : public climate::Climate,
public Component,
public Parented<MitsubishiCN105Component> {
public:
void setup() override;
void dump_config() override;
@@ -25,14 +28,12 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public
protected:
void apply_values_();
climate::ClimateSwingModeMask supported_swing_modes_{};
MitsubishiCN105::VaneMode last_non_swing_vane_mode_{MitsubishiCN105::VaneMode::AUTO};
MitsubishiCN105::WideVaneMode last_non_swing_wide_vane_mode_{MitsubishiCN105::WideVaneMode::CENTER};
SwingModeManager swing_mode_manager_;
};
// Legacy climate action compatibility. Remove in 2027.2.0.
template<typename... Ts>
class LegacySetRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
class LegacySetRemoteTemperatureAction final : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
public:
TEMPLATABLE_VALUE(float, temperature)
@@ -41,7 +42,7 @@ class LegacySetRemoteTemperatureAction : public Action<Ts...>, public Parented<M
// Legacy climate action compatibility. Remove in 2027.2.0.
template<typename... Ts>
class LegacyClearRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
class LegacyClearRemoteTemperatureAction final : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
public:
void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); }
};
@@ -80,7 +80,7 @@ struct VaneCall {
MitsubishiCN105Component *parent_;
};
class MitsubishiCN105Component : public Component, public uart::UARTDevice {
class MitsubishiCN105Component final : public Component, public uart::UARTDevice {
public:
explicit MitsubishiCN105Component() : hp_(*this) {}
@@ -0,0 +1,86 @@
#pragma once
#include <optional>
#include "esphome/components/climate/climate.h"
#include "mitsubishi_cn105.h"
namespace esphome::mitsubishi_cn105 {
class SwingModeManager final {
public:
const climate::ClimateSwingModeMask &supported_swing_modes() const { return this->supported_swing_modes_; }
void set_supported_swing_modes(const climate::ClimateSwingModeMask &supported_swing_modes) {
this->supported_swing_modes_ = supported_swing_modes;
}
std::optional<MitsubishiCN105::VaneMode> vane_from(climate::ClimateSwingMode swing_mode) const {
if (!this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) {
return std::nullopt;
}
switch (swing_mode) {
case climate::CLIMATE_SWING_BOTH:
case climate::CLIMATE_SWING_VERTICAL:
return MitsubishiCN105::VaneMode::SWING;
default:
return this->last_non_swing_vane_mode_;
}
}
std::optional<MitsubishiCN105::WideVaneMode> wide_vane_from(climate::ClimateSwingMode swing_mode) const {
if (!this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) {
return std::nullopt;
}
switch (swing_mode) {
case climate::CLIMATE_SWING_BOTH:
case climate::CLIMATE_SWING_HORIZONTAL:
return MitsubishiCN105::WideVaneMode::SWING;
default:
return this->last_non_swing_wide_vane_mode_;
}
}
std::optional<climate::ClimateSwingMode> update_and_get_swing_mode(MitsubishiCN105::VaneMode vane_mode,
MitsubishiCN105::WideVaneMode wide_vane_mode) {
if (this->supported_swing_modes_.empty()) {
return std::nullopt;
}
bool vertical_swinging = false;
bool horizontal_swinging = false;
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) {
if (vane_mode == MitsubishiCN105::VaneMode::SWING) {
vertical_swinging = true;
} else if (vane_mode != MitsubishiCN105::VaneMode::UNKNOWN) {
this->last_non_swing_vane_mode_ = vane_mode;
}
}
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) {
if (wide_vane_mode == MitsubishiCN105::WideVaneMode::SWING) {
horizontal_swinging = true;
} else if (wide_vane_mode != MitsubishiCN105::WideVaneMode::UNKNOWN) {
this->last_non_swing_wide_vane_mode_ = wide_vane_mode;
}
}
if (vertical_swinging && horizontal_swinging) {
return climate::CLIMATE_SWING_BOTH;
}
if (vertical_swinging) {
return climate::CLIMATE_SWING_VERTICAL;
}
if (horizontal_swinging) {
return climate::CLIMATE_SWING_HORIZONTAL;
}
return climate::CLIMATE_SWING_OFF;
}
private:
climate::ClimateSwingModeMask supported_swing_modes_{};
MitsubishiCN105::VaneMode last_non_swing_vane_mode_{MitsubishiCN105::VaneMode::AUTO};
MitsubishiCN105::WideVaneMode last_non_swing_wide_vane_mode_{MitsubishiCN105::WideVaneMode::CENTER};
};
} // namespace esphome::mitsubishi_cn105
@@ -7,9 +7,9 @@
namespace esphome::mitsubishi_cn105 {
class MitsubishiCN105VerticalVaneDirectionSelect : public select::Select,
public Component,
public Parented<MitsubishiCN105Component> {
class MitsubishiCN105VerticalVaneDirectionSelect final : public select::Select,
public Component,
public Parented<MitsubishiCN105Component> {
public:
void setup() override;
void publish_vane_state(MitsubishiCN105::VaneMode mode);
+76 -3
View File
@@ -1,17 +1,23 @@
from __future__ import annotations
import logging
from typing import Any, Literal
from typing import Any, Literal, NamedTuple
from esphome import pins
import esphome.codegen as cg
from esphome.components import uart
import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS, CONF_DISABLE_CRC, CONF_FLOW_CONTROL_PIN, CONF_ID
from esphome.const import (
CONF_ADDRESS,
CONF_CONTINUOUS,
CONF_DISABLE_CRC,
CONF_FLOW_CONTROL_PIN,
CONF_ID,
)
from esphome.cpp_generator import MockObj
from esphome.cpp_helpers import gpio_pin_expression
import esphome.final_validate as fv
from esphome.types import ConfigType
from esphome.types import ConfigType, TemplateArgsType
_LOGGER = logging.getLogger(__name__)
@@ -48,6 +54,73 @@ CONF_TURNAROUND_TIME = "turnaround_time"
MODBUS_ROLES = ["client", "server"]
class _CommandOption(NamedTuple):
"""One per-command option forwarded to the hub (modbus::CommandOptions)."""
conf_key: str
field: str # the C++ field, and so the set_<field>() setter name
validator: Any # the static (non-templatable) validator for the key
cpp_type: Any # the C++ type the value is generated as
default: Any
# Per-direction command options. Single-sourcing the schema and the setter generation here keeps
# them from drifting; the C++ side must add the matching field per the rules documented on
# CommandOptions (modbus.h).
_COMMAND_OPTIONS: dict[str, list[_CommandOption]] = {
"read": [_CommandOption(CONF_CONTINUOUS, "continuous", cv.boolean, bool, False)],
"write": [],
}
def _command_options(direction: str) -> list[_CommandOption]:
try:
return _COMMAND_OPTIONS[direction]
except KeyError:
raise ValueError(f"unknown command-options direction {direction!r}") from None
def command_options_schema(
*, direction: Literal["read", "write"], templatable: bool = False
) -> dict[cv.Optional, Any]:
"""Schema fragment for the per-command options a component forwards to the hub
(modbus::CommandOptions). Extend this into any schema that queues commands. Keys are
direction-specific so a schema never offers an option the hub would strip (e.g.
continuous on a write); the write side has no options yet. For actions (templatable=True the
keys also accept lambdas), register the values with register_templatable_command_options().
"""
return {
cv.Optional(option.conf_key, default=option.default): (
cv.templatable(option.validator) if templatable else option.validator
)
for option in _command_options(direction)
}
async def register_templatable_command_options(
var: MockObj, config: ConfigType, args: TemplateArgsType, direction: str
) -> None:
"""Generate the set_<option>() calls for the given direction's command options present in config.
Pass the same direction the action's command_options_schema() used, so the keys generated match
the ones the schema offered - a write action never emits a read option's setter. Options the
schema did not add are simply absent. The consumer's C++ class declares a matching
TEMPLATABLE_VALUE per option (e.g. TEMPLATABLE_VALUE(bool, continuous)).
"""
for option in _command_options(direction):
if option.conf_key not in config:
continue
value = config[option.conf_key]
# Skip codegen when the value is its C++ zero (TemplatableFn::value() returns T{} when
# unset): behaviourally identical, and saves a thunk plus a setup() call per action.
if cg.is_template(value) or value != type(value)():
cg.add(
getattr(var, f"set_{option.field}")(
await cg.templatable(value, args, option.cpp_type)
)
)
CONFIG_SCHEMA = cv.typed_schema(
{
"client": cv.Schema(
+10 -13
View File
@@ -146,7 +146,7 @@ bool ModbusClientHub::tx_buffer_empty() {
// other states are mid-transaction or owed bookkeeping, not queued sends - and a READY continuous
// poll does not count either, since it ranks below every one-shot, so a new send goes out first.
for (const auto &cmd : this->tx_buffer_) {
if (cmd.state == FrameState::READY && !cmd.continuous)
if (cmd.state == FrameState::READY && !cmd.options.continuous)
return false;
}
return true;
@@ -946,7 +946,7 @@ bool ModbusDeviceCommand::notify_retired() {
bool ModbusDeviceCommand::response(std::span<const uint8_t> response_pdu) {
this->state = this->state == FrameState::WAITING_RETIRED ? FrameState::RETIRED : FrameState::RECEIVED_RESPONSE;
// A continuous poll is never consumed by its own response; a one-shot consumes one request here.
if (!this->continuous)
if (!this->options.continuous)
this->decrement_pending();
if (this->device == nullptr)
return false;
@@ -1070,15 +1070,12 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
return false;
}
// Normalize the caller's options in place (the param is a by-value copy) so everything stored or
// merged below carries effective options, never the raw request.
// continuous is ignored for every mutating code (re-writing a value forever is never intended).
const bool mutates = priority == CommandPriority::WRITE;
bool continuous = false;
if (options.continuous) {
if (mutates) {
ESP_LOGV(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address);
} else {
continuous = true;
}
if (options.continuous && priority == CommandPriority::WRITE) {
ESP_LOGW(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address);
options.continuous = false;
}
// A duplicate of a live entry with the same owner is not queued twice; it resolves against that
@@ -1104,10 +1101,10 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
}
return false; // dropped: no entry, no callbacks - the refusal is the return value
}
if (continuous) {
if (options.continuous) {
item.make_continuous(true);
ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", now polled continuously", address);
} else if (item.continuous) {
} else if (item.options.continuous) {
// A one-shot duplicate downgrades the poll to a one-shot: it runs one more cycle to serve this
// request, then stops (mirrors continuous incoming converting a one-shot the other way).
item.make_continuous(false);
@@ -1140,7 +1137,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
#endif
ESP_LOGV(TAG, "Adding frame to tx queue: %" PRIu8 ":%s", address,
format_hex_pretty_to(hex_buf, pdu.data(), pdu.size()));
this->tx_buffer_.emplace_back(device, address, pdu, continuous, this->next_seq_++);
this->tx_buffer_.emplace_back(device, address, pdu, options, this->next_seq_++);
return true;
}
+31 -17
View File
@@ -118,6 +118,14 @@ enum class FrameState : uint8_t {
};
// Per-command send options. Append-only; pass via designated initializers ({.continuous = true}).
// The queue entry stores this struct whole, so a new field arrives at the queue with no plumbing -
// but it arrives inert. Every new field must define three rules before it does anything:
// 1. normalization in queue_pdu() (is it valid for this function code? e.g. continuous is
// stripped for mutating codes),
// 2. a merge rule for when a duplicate send absorbs into a live entry (continuous
// upgrades/downgrades via make_continuous(); a new field needs its own answer),
// 3. teardown: retire() resets the whole struct; silent_retire() leaves it, relying on the sweep
// to erase the entry.
struct CommandOptions {
// A continuous poll lives in the queue until cancelled or failed; ignored for mutating codes.
bool continuous{false};
@@ -126,26 +134,29 @@ struct CommandOptions {
struct ModbusDeviceCommand {
ModbusClientDevice *device;
ModbusFrame frame;
FrameState state{FrameState::READY};
// A continuous poll is a subscription: pending fixed at 1, removed only by cancellation or failure.
bool continuous{false};
// Accepted requests this entry stands for, capped at max_pending(); drains one terminal each.
uint8_t pending{1};
// Place-in-line stamp (hub's free-running counter); selection takes the oldest for round-robin
// fairness within a class. Meant to wrap.
// fairness within a class. Meant to wrap. Declared ahead of the byte fields so the tail packs
// densely and a growing CommandOptions eats trailing padding before enlarging the struct.
uint16_t seq{0};
FrameState state{FrameState::READY};
// Accepted requests this entry stands for, capped at max_pending(); drains one terminal each.
// A continuous poll is a subscription: pending fixed at 1, removed only by cancellation or failure.
uint8_t pending{1};
// The entry's LIVE effective options, not a record of the caller's request: queue_pdu() normalizes
// before storing, duplicate absorption mutates continuous via make_continuous(), and retire() resets
// the struct (silent_retire() leaves it, relying on the sweep to erase the entry). See the
// CommandOptions comment for the rules a new field must define.
CommandOptions options;
// Build a command from a PDU span (caller bounds it to MAX_PDU_SIZE); fully initialized here.
// Build a command from a PDU span (caller bounds it to MAX_PDU_SIZE) and pre-normalized options;
// fully initialized here.
ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, std::span<const uint8_t> pdu,
bool continuous = false, uint16_t seq = 0)
: device(device),
frame(address, pdu.data(), static_cast<uint16_t>(pdu.size())),
continuous(continuous),
seq(seq) {}
CommandOptions options = {}, uint16_t seq = 0)
: device(device), frame(address, pdu.data(), static_cast<uint16_t>(pdu.size())), seq(seq), options(options) {}
// Transmit ordering class, derived (never stored): a continuous poll ranks below every one-shot.
CommandPriority priority() const {
return this->continuous ? CommandPriority::CONTINUOUS : classify(this->frame.pdu()[0]);
return this->options.continuous ? CommandPriority::CONTINUOUS : classify(this->frame.pdu()[0]);
}
// Wire-derived class: mutating codes rank WRITE; exception-flagged codes are excluded.
static CommandPriority classify(uint8_t function_code) {
@@ -161,7 +172,7 @@ struct ModbusDeviceCommand {
uint8_t max_pending() const {
const uint8_t fc = this->frame.pdu()[0];
const bool requeueable = !helpers::is_function_code_exception(fc) && helpers::is_function_code_read_only(fc);
return (requeueable && !this->continuous) ? 2 : 1;
return (requeueable && !this->options.continuous) ? 2 : 1;
}
// Device-scoped clear: detach with no callback (device-less, pending 0). An entry still waiting for
// a response keeps its state as a reply-ignoring shell that resolves silently; any other goes RETIRED.
@@ -196,11 +207,11 @@ struct ModbusDeviceCommand {
// retroactively inflating that no-op.
void make_continuous(bool continuous) {
if (continuous) {
this->continuous = true;
this->options.continuous = true;
this->pending = 1;
} else {
this->increment_pending();
this->continuous = false;
this->options.continuous = false;
}
}
// Address-scoped clear: keep pending and device so the sweep delivers one on_not_sent() per un-run
@@ -218,7 +229,7 @@ struct ModbusDeviceCommand {
} else if (!this->waiting_state()) { // an already-retired shell stays put; off the wire -> RETIRED
this->state = FrameState::RETIRED;
}
this->continuous = false;
this->options = {}; // reset every option so a future field is torn down without editing here
}
// True while the entry is still waiting for a response; the erase pass exempts these even at pending 0.
@@ -253,6 +264,9 @@ struct ModbusDeviceCommand {
bool notify_retired();
/// True if this command carries the same wire frame (address + PDU) as the given one.
/// Cancellation matches the exact frame, not the action instance: a continuous poll whose
/// start_address (or other field) is templated produces one poll per distinct frame, and a later
/// cancel built from different argument values will not reach the polls it does not byte-match.
bool same_frame(uint8_t address, std::span<const uint8_t> pdu) const {
const auto own_pdu = this->frame.pdu();
return own_pdu.size() == pdu.size() && this->frame.address() == address &&
+57 -13
View File
@@ -7,6 +7,7 @@ from esphome.components import modbus
import esphome.config_validation as cv
from esphome.const import (
CONF_ADDRESS,
CONF_CONTINUOUS,
CONF_COUNT,
CONF_ID,
CONF_ON_ERROR,
@@ -156,16 +157,45 @@ _ACTION_BASE_SCHEMA = cv.Schema(
}
)
MODBUS_CLIENT_SEND_SCHEMA = _ACTION_BASE_SCHEMA.extend(
{
cv.Required(CONF_PDU): cv.templatable(
cv.All(
cv.ensure_list(cv.hex_uint8_t),
cv.Length(min=1, max=modbus.MAX_PDU_SIZE),
)
),
cv.Optional(CONF_ON_RESPONSE): _handler_schema(),
}
# The write codes recognised by modbus::helpers::is_function_code_write() - keep in sync. 0x17
# (read/write multiple) is included: it mutates, so the hub treats it as a write despite its read half.
_WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17})
def _no_continuous_on_write(config: ConfigType) -> ConfigType:
"""Reject `continuous: true` on a static write PDU: continuous polling only applies to reads.
Only the fully-static case is decidable here; the hub strips the flag from mutating PDUs at
runtime, so a templated pdu or continuous falls through to that backstop."""
pdu = config[CONF_PDU]
if (
isinstance(pdu, list)
and config.get(CONF_CONTINUOUS) is True
# Masking the exception bit (0x90 -> 0x10) makes this check stricter than the runtime hub,
# whose classify() treats an exception-flagged code as a read and leaves continuous in place.
and pdu[0] & 0x7F in _WRITE_FUNCTION_CODES
):
raise cv.Invalid(
f"'{CONF_CONTINUOUS}: true' does not apply to a write PDU (function code "
f"0x{pdu[0]:02X}); continuous polling only applies to reads",
path=[CONF_CONTINUOUS],
)
return config
MODBUS_CLIENT_SEND_SCHEMA = cv.All(
_ACTION_BASE_SCHEMA.extend(
{
cv.Required(CONF_PDU): cv.templatable(
cv.All(
cv.ensure_list(cv.hex_uint8_t),
cv.Length(min=1, max=modbus.MAX_PDU_SIZE),
)
),
**modbus.command_options_schema(direction="read", templatable=True),
cv.Optional(CONF_ON_RESPONSE): _handler_schema(),
}
),
_no_continuous_on_write,
)
@@ -174,6 +204,7 @@ async def register_client_action(
config: ConfigType,
args: TemplateArgsType,
response_args: TemplateArgsType,
command_direction: str = "read",
) -> cg.MockObj:
"""Wire the shared action plumbing: hub parent, templated device address, outcome triggers.
@@ -235,6 +266,12 @@ async def register_client_action(
await automation.build_automation(
var.get_not_sent_trigger(), [(_PDU_SPAN, "request")], not_sent_conf
)
# Wire any command options the action's schema opted into (e.g. continuous on reads). Pass the
# matching direction so a write action never generates a read option's setter; the write side
# has no options yet, so this is a no-op there.
await modbus.register_templatable_command_options(
var, config, args, command_direction
)
return var
@@ -318,6 +355,7 @@ def _read_schema(max_count: int) -> cv.All:
cv.Optional(CONF_COUNT, default=1): cv.templatable(
cv.int_range(min=1, max=max_count)
),
**modbus.command_options_schema(direction="read", templatable=True),
}
),
_no_address_overflow(CONF_COUNT),
@@ -379,7 +417,9 @@ async def read_input_registers_to_code(config, action_id, template_arg, args):
async def _write_single_to_code(config, action_id, template_arg, args, value_type):
var = cg.new_Pvariable(action_id, template_arg)
cg.add(var.set_value(await cg.templatable(config[CONF_VALUE], args, value_type)))
return await register_client_action(var, config, args, [])
return await register_client_action(
var, config, args, [], command_direction="write"
)
@automation.register_action(
@@ -458,7 +498,9 @@ async def write_multiple_registers_to_code(config, action_id, template_arg, args
arr_id = ID(f"{action_id}_values", is_declaration=True, type=cg.uint16)
arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*values))
cg.add(var.set_values_static(arr, len(values)))
return await register_client_action(var, config, args, [])
return await register_client_action(
var, config, args, [], command_direction="write"
)
@automation.register_action(
@@ -482,7 +524,9 @@ async def write_multiple_coils_to_code(config, action_id, template_arg, args):
arr_id = ID(f"{action_id}_values", is_declaration=True, type=cg.uint8)
arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*packed))
cg.add(var.set_values_static(arr, len(values)))
return await register_client_action(var, config, args, [])
return await register_client_action(
var, config, args, [], command_direction="write"
)
# Read/write multiple registers (FC 0x17) writes one register block and reads another in a single
@@ -68,8 +68,8 @@ template<typename... Ts> class ClientActionBase : public Action<Ts...>, public m
/// resolves through on_sent() alone), so resolve refusals here via on_not_sent.
/// Takes a span, not a PduBuffer: the builders return right-sized buffers (a read PDU is 5 bytes), and
/// a PduBuffer parameter would widen each one to the 253-byte maximum just to cross the call.
void send_or_resolve_(std::span<const uint8_t> pdu) {
if (!this->queue_pdu(pdu))
void send_or_resolve_(std::span<const uint8_t> pdu, modbus::CommandOptions options = {}) {
if (!this->queue_pdu(pdu, options))
this->on_not_sent(pdu);
}
@@ -80,6 +80,26 @@ template<typename... Ts> class ClientActionBase : public Action<Ts...>, public m
retry_func_t retry_func_{nullptr};
};
/// The read-side per-command options (modbus::CommandOptions), declared once for every action that
/// sends a read. Each option is templatable, so it cannot be built in Python the way modbus_controller
/// builds its static struct; declaring the values here instead of per action means a new read option
/// costs one TEMPLATABLE_VALUE plus one field below, and every read action picks it up.
/// The read/write split mirrors _COMMAND_OPTIONS in the modbus component's Python
/// (command_options_schema(direction="read") adds exactly these keys). When a write-side option
/// arrives it gets a WriteCommandOptions twin, so write actions never carry read-only members.
template<typename... Ts> class ReadCommandOptions {
public:
// Poll: re-queue after each success until downgraded (replay with false) or failed. The hub strips
// it for mutating function codes at the door (see modbus::CommandOptions).
TEMPLATABLE_VALUE(bool, continuous)
protected:
/// The options for this send, with every templatable value resolved against the action's arguments.
modbus::CommandOptions command_options_(const Ts &...x) const {
return {.continuous = this->continuous_.value(x...)};
}
};
/// modbus_client.send: fire a raw PDU (function code + data; the hub adds address and CRC). The reply is
/// delivered raw - on_response(request, response) - deliberately bypassing the typed dispatch, so
/// non-standard/custom transactions pass through untouched.
@@ -87,7 +107,8 @@ template<typename... Ts> class ClientActionBase : public Action<Ts...>, public m
/// modbus::helpers::create_*_pdu() builders and return it directly (smaller builder results convert).
/// A PduBuffer drops bytes past modbus::MAX_PDU_SIZE without reporting it (the hub's oversize check
/// cannot fire - that limit is the capacity), so an over-long lambda-built PDU is silently truncated.
template<typename... Ts> class ModbusClientSendAction : public ClientActionBase<Ts...> {
template<typename... Ts>
class ModbusClientSendAction : public ClientActionBase<Ts...>, public ReadCommandOptions<Ts...> {
public:
TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu)
@@ -95,7 +116,7 @@ template<typename... Ts> class ModbusClientSendAction : public ClientActionBase<
return &this->response_trigger_;
}
void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(x...)); }
void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(x...), this->command_options_(x...)); }
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
this->response_trigger_.trigger(request_pdu, response_pdu);
@@ -140,7 +161,8 @@ template<typename... Ts> class TypedClientActionBase : public ClientActionBase<T
/// modbus_client.read_holding_registers / read_input_registers: on_response delivers the registers in
/// host byte order as `values` (only valid for the duration of the trigger).
template<typename... Ts> class ReadRegistersAction : public TypedClientActionBase<Ts...> {
template<typename... Ts>
class ReadRegistersAction : public TypedClientActionBase<Ts...>, public ReadCommandOptions<Ts...> {
public:
explicit ReadRegistersAction(bool holding) : holding_(holding) {}
TEMPLATABLE_VALUE(uint16_t, start_address)
@@ -152,7 +174,8 @@ template<typename... Ts> class ReadRegistersAction : public TypedClientActionBas
const auto function_code =
this->holding_ ? modbus::FunctionCode::READ_HOLDING_REGISTERS : modbus::FunctionCode::READ_INPUT_REGISTERS;
this->send_or_resolve_(
modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...)));
modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...)),
this->command_options_(x...));
}
void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) override {
@@ -167,7 +190,7 @@ template<typename... Ts> class ReadRegistersAction : public TypedClientActionBas
/// modbus_client.read_coils / read_discrete_inputs: on_response delivers the bits as a PackedBits view
/// (bit 0 = the bit at start_address; only valid for the duration of the trigger).
template<typename... Ts> class ReadBitsAction : public TypedClientActionBase<Ts...> {
template<typename... Ts> class ReadBitsAction : public TypedClientActionBase<Ts...>, public ReadCommandOptions<Ts...> {
public:
explicit ReadBitsAction(bool coils) : coils_(coils) {}
TEMPLATABLE_VALUE(uint16_t, start_address)
@@ -179,7 +202,8 @@ template<typename... Ts> class ReadBitsAction : public TypedClientActionBase<Ts.
const auto function_code =
this->coils_ ? modbus::FunctionCode::READ_COILS : modbus::FunctionCode::READ_DISCRETE_INPUTS;
this->send_or_resolve_(
modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...)));
modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...)),
this->command_options_(x...));
}
void on_read_bits(modbus::EntityType entity_type, uint16_t start_address, modbus::PackedBits bits,
modbus::ResponseStatus status) override {
+11 -2
View File
@@ -4,8 +4,16 @@ from esphome.components import runtime_image
from esphome.components.const import CONF_REQUEST_HEADERS
from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestComponent
from esphome.components.image import CONF_TRANSPARENCY, add_metadata
from esphome.components.runtime_image import IMAGE_FORMATS
import esphome.config_validation as cv
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_ON_ERROR, CONF_TYPE, CONF_URL
from esphome.const import (
CONF_BUFFER_SIZE,
CONF_FORMAT,
CONF_ID,
CONF_ON_ERROR,
CONF_TYPE,
CONF_URL,
)
from esphome.core import ID, Lambda
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType
@@ -31,7 +39,6 @@ ReleaseImageAction = online_image_ns.class_(
"OnlineImageReleaseAction", automation.Action, cg.Parented.template(OnlineImage)
)
ONLINE_IMAGE_SCHEMA = (
runtime_image.runtime_image_schema(OnlineImage)
.extend(
@@ -39,6 +46,8 @@ ONLINE_IMAGE_SCHEMA = (
# Online Image specific options
cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent),
cv.Required(CONF_URL): cv.url,
# AUTO (Content-Type detection) is online_image specific; not in the shared registry
cv.Required(CONF_FORMAT): cv.one_of(*IMAGE_FORMATS, "AUTO", upper=True),
cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536),
cv.Optional(CONF_REQUEST_HEADERS): cv.All(
cv.Schema({cv.string: cv.templatable(cv.string)})
@@ -1,9 +1,11 @@
#include "online_image.h"
#include "esphome/components/runtime_image/image_decoder.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
static const char *const TAG = "online_image";
static const char *const CONTENT_TYPE_HEADER_NAME = "content-type";
static const char *const ETAG_HEADER_NAME = "etag";
static const char *const IF_NONE_MATCH_HEADER_NAME = "if-none-match";
static const char *const LAST_MODIFIED_HEADER_NAME = "last-modified";
@@ -62,7 +64,8 @@ void OnlineImage::update() {
// Add Accept header based on image format
const char *accept_mime_type;
switch (this->get_format()) {
runtime_image::ImageFormat format = this->get_format();
switch (format) {
#ifdef USE_RUNTIME_IMAGE_BMP
case runtime_image::BMP:
accept_mime_type = "image/bmp,*/*;q=0.8";
@@ -89,8 +92,8 @@ void OnlineImage::update() {
headers.push_back(http_request::Header{header.first, header.second.value()});
}
this->downloader_ = this->parent_->get(this->url_, headers, {ETAG_HEADER_NAME, LAST_MODIFIED_HEADER_NAME});
this->downloader_ =
this->parent_->get(this->url_, headers, {ETAG_HEADER_NAME, LAST_MODIFIED_HEADER_NAME, CONTENT_TYPE_HEADER_NAME});
if (this->downloader_ == nullptr) {
ESP_LOGE(TAG, "Download failed.");
this->end_connection_();
@@ -115,17 +118,54 @@ void OnlineImage::update() {
ESP_LOGD(TAG, "Starting download");
size_t total_size = this->downloader_->content_length;
ESP_LOGV(TAG, "Content-Length: %zu", total_size);
if (format == runtime_image::AUTO) {
// Try to auto-detect format from Content-Type header
auto content_type_header = this->downloader_->get_response_header(CONTENT_TYPE_HEADER_NAME);
const char *content_type = content_type_header.c_str();
ESP_LOGV(TAG, "Content-Type: %s", content_type);
// Includes aliases seen from real servers (older IIS, CDNs, S3)
if (str_contains_ignore_case(content_type, "image/bmp") ||
str_contains_ignore_case(content_type, "image/x-ms-bmp") ||
str_contains_ignore_case(content_type, "image/x-bmp")) {
format = runtime_image::BMP;
} else if (str_contains_ignore_case(content_type, "image/jpeg") ||
str_contains_ignore_case(content_type, "image/jpg")) {
format = runtime_image::JPEG;
} else if (str_contains_ignore_case(content_type, "image/png") ||
str_contains_ignore_case(content_type, "image/x-png")) {
format = runtime_image::PNG;
} else if (str_contains_ignore_case(content_type, "image/")) {
ESP_LOGW(TAG, "Unsupported image type: '%s'", content_type);
this->end_connection_();
this->download_error_callback_.call();
return;
} else {
// TODO: implement auto-detection in runtime_image by sniffing the first few bytes of the image data
if (content_type_header.empty()) {
ESP_LOGW(TAG, "Server sent no Content-Type header; cannot determine image format. Set `format:` explicitly");
} else {
ESP_LOGE(TAG, "Could not determine image format from Content-Type: '%s'. Set `format:` explicitly",
content_type);
}
this->end_connection_();
this->download_error_callback_.call();
return;
}
}
ESP_LOGD(TAG, "Using image format: %d", format);
// Initialize decoder with the known format
if (!this->begin_decode(total_size)) {
ESP_LOGE(TAG, "Failed to initialize decoder for format %d", this->get_format());
if (!this->begin_decode(total_size, format)) {
ESP_LOGE(TAG, "Failed to initialize decoder for format %d", format);
this->end_connection_();
this->download_error_callback_.call();
return;
}
// JPEG requires the complete image in the download buffer before decoding
if (this->get_format() == runtime_image::JPEG && total_size > this->download_buffer_.size()) {
if (format == runtime_image::JPEG && total_size > this->download_buffer_.size()) {
this->download_buffer_.resize(total_size);
}
+23 -4
View File
@@ -58,6 +58,18 @@ class Format:
"""Add defines and libraries needed for this format."""
class AUTOFormat(Format):
"""AUTO format - detect from MIME type."""
def __init__(self):
super().__init__("AUTO", None)
def actions(self) -> None:
# dict.fromkeys dedupes the JPG/JPEG alias so each format runs once
for image_format in dict.fromkeys(IMAGE_FORMATS.values()):
image_format.actions()
class BMPFormat(Format):
"""BMP format decoder configuration."""
@@ -102,18 +114,25 @@ class PNGFormat(Format):
cg.add_library("pngle", "1.1.0")
# Registry of available formats
# Decodable formats only; platforms that support runtime detection accept
# "AUTO" in their own schema and get_format() resolves it
_JPEG_FORMAT = JPEGFormat()
IMAGE_FORMATS = {
"BMP": BMPFormat(),
"JPEG": JPEGFormat(),
"JPEG": _JPEG_FORMAT,
"JPG": _JPEG_FORMAT, # Alias for JPEG
"PNG": PNGFormat(),
"JPG": JPEGFormat(), # Alias for JPEG
}
AUTO_FORMAT = AUTOFormat()
def get_format(format_name: str) -> Format | None:
"""Get a format instance by name."""
return IMAGE_FORMATS.get(format_name.upper())
name = format_name.upper()
if name == "AUTO":
return AUTO_FORMAT
return IMAGE_FORMATS.get(name)
def enable_format(format_name: str) -> Format | None:
@@ -6,7 +6,8 @@ namespace esphome::runtime_image {
* @brief Image format types that can be decoded dynamically.
*/
enum ImageFormat {
/** Automatically detect from data. Not implemented yet. */
/** Format is supplied per decode, e.g. detected from the Content-Type header
* by online_image; sniffing the image data is not implemented. */
AUTO,
/** JPEG format. */
JPEG,
@@ -171,22 +171,27 @@ void RuntimeImage::draw(int x, int y, display::Display *display, Color color_on,
// If no image is loaded and no placeholder, nothing to draw
}
bool RuntimeImage::begin_decode(size_t expected_size) {
bool RuntimeImage::begin_decode(size_t expected_size, ImageFormat format) {
if (this->is_decoding()) {
ESP_LOGW(TAG, "Decoding already in progress");
return false;
}
if (format == AUTO && this->format_ != AUTO) {
// Fall back to the configured format before the reuse check below
format = this->format_;
}
// An idle decoder for a different format cannot be reused
if (this->decoder_ != nullptr && this->decoder_->get_format() != this->format_) {
ESP_LOGD(TAG, "Decoder format mismatch: current: %d, new: %d", this->decoder_->get_format(), this->format_);
if (this->decoder_ != nullptr && this->decoder_->get_format() != format) {
ESP_LOGD(TAG, "Decoder format mismatch: current: %d, new: %d", this->decoder_->get_format(), format);
this->decoder_ = nullptr;
}
if (!this->decoder_) {
this->decoder_ = this->create_decoder_(this->format_);
this->decoder_ = this->create_decoder_(format);
if (!this->decoder_) {
ESP_LOGE(TAG, "Failed to create decoder for format %d", this->format_);
ESP_LOGE(TAG, "Failed to create decoder for format %d", format);
return false;
}
}
@@ -364,6 +369,9 @@ std::unique_ptr<ImageDecoder> RuntimeImage::create_decoder_(ImageFormat format)
case PNG:
return make_unique<PngDecoder>(this);
#endif
case AUTO:
ESP_LOGE(TAG, "Image format could not be determined; set `format:` explicitly in the configuration");
return nullptr;
default:
ESP_LOGE(TAG, "Unsupported image format: %d", format);
return nullptr;
@@ -62,9 +62,10 @@ class RuntimeImage : public image::Image {
* @brief Begin decoding an image.
*
* @param expected_size Optional hint about the expected data size.
* @param format The image format to decode (defaults to AUTO, which uses the value set at construction).
* @return true if decoder was successfully initialized.
*/
bool begin_decode(size_t expected_size = 0);
bool begin_decode(size_t expected_size = 0, ImageFormat format = AUTO);
/**
* @brief Feed data to the decoder.
@@ -103,6 +104,7 @@ class RuntimeImage : public image::Image {
/**
* @brief Get the image format.
*/
/// Configured format; a format resolved per decode lives on the active decoder
ImageFormat get_format() const { return this->format_; }
/**
@@ -35,7 +35,6 @@ class ListEntitiesIterator final : public ComponentIterator {
#undef ENTITY_TYPE_
#undef ENTITY_CONTROLLER_TYPE_
// NOLINTEND(bugprone-macro-parentheses)
bool completed() { return this->state_ == IteratorState::NONE; }
protected:
const WebServer *web_server_;
+2 -8
View File
@@ -214,8 +214,8 @@ void DeferredUpdateEventSource::process_deferred_queue_() {
void DeferredUpdateEventSource::loop() {
process_deferred_queue_();
if (!this->entities_iterator_.completed())
this->entities_iterator_.advance();
// One step per loop; refusals retry next pass
this->entities_iterator_.try_advance(1);
}
void DeferredUpdateEventSource::deferrable_send_state(void *source, const char *event_type,
@@ -321,12 +321,6 @@ void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource
#endif
source->entities_iterator_.begin(ws->include_internal_);
// just dump them all up-front and take advantage of the deferred queue
// on second thought that takes too long, but leaving the commented code here for debug purposes
// while(!source->entities_iterator_.completed()) {
// source->entities_iterator_.advance();
//}
});
}
@@ -935,8 +935,8 @@ void AsyncEventSourceResponse::process_buffer_() {
void AsyncEventSourceResponse::loop() {
process_buffer_();
process_deferred_queue_();
if (!this->entities_iterator_.completed())
this->entities_iterator_.advance();
// One step per loop; refusals retry next pass
this->entities_iterator_.try_advance(1);
}
bool AsyncEventSourceResponse::try_send_nodefer(const char *message, size_t message_len, const char *event, uint32_t id,
+14 -11
View File
@@ -22,23 +22,23 @@ void ComponentIterator::advance_platform_() {
this->at_ = 0;
}
void ComponentIterator::advance() {
bool ComponentIterator::advance_step_() {
switch (this->state_) {
case IteratorState::NONE:
// not started
return;
return false;
case IteratorState::BEGIN:
if (this->on_begin()) {
advance_platform_();
return true;
}
break;
return false;
// Entity iterator cases (generated from entity_types.h)
// NOLINTBEGIN(bugprone-macro-parentheses)
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
case IteratorState::upper: \
this->process_platform_item_(App.get_##plural(), &ComponentIterator::on_##singular); \
break;
return this->process_platform_item_(App.get_##plural(), &ComponentIterator::on_##singular);
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
ENTITY_TYPE_(type, singular, plural, count, upper)
#include "esphome/core/entity_types.h"
@@ -48,26 +48,29 @@ void ComponentIterator::advance() {
#ifdef USE_API_USER_DEFINED_ACTIONS
case IteratorState::SERVICE:
this->process_platform_item_(api::global_api_server->get_user_services(), &ComponentIterator::on_service);
break;
return this->process_platform_item_(api::global_api_server->get_user_services(), &ComponentIterator::on_service);
#endif
#ifdef USE_CAMERA
case IteratorState::CAMERA: {
camera::Camera *camera_instance = camera::Camera::instance();
if (camera_instance != nullptr && (!camera_instance->is_internal() || this->include_internal_)) {
this->on_camera(camera_instance);
if (camera_instance != nullptr && (!camera_instance->is_internal() || this->include_internal_) &&
!this->on_camera(camera_instance)) {
return false;
}
advance_platform_();
} break;
return true;
}
#endif
case IteratorState::MAX:
if (this->on_end()) {
this->state_ = IteratorState::NONE;
return true;
}
return;
return false;
}
return false;
}
bool ComponentIterator::on_end() { return true; }
+35 -8
View File
@@ -30,7 +30,23 @@ class RadioFrequency;
class ComponentIterator {
public:
void begin(bool include_internal = false);
void advance();
/// Run up to max_steps iteration steps; stops early when iteration
/// completes or a callback refuses (that step is retried on the next
/// call). Inline so an idle (completed) iterator costs one compare, no call.
ESPHOME_ALWAYS_INLINE void try_advance(size_t max_steps) {
size_t steps = 0;
while (steps < max_steps && !this->completed()) {
this->yield_requested_ = false;
if (!this->advance_step_())
break;
steps++;
if (this->yield_requested_)
break;
}
}
// Remove before 2027.3.0
ESPDEPRECATED("Use try_advance() instead. Removed in 2027.3.0", "2026.8.1")
void advance() { this->try_advance(1); }
bool completed() const { return this->state_ == IteratorState::NONE; }
virtual bool on_begin();
// Pure virtual entity callbacks (generated from entity_types.h)
@@ -73,23 +89,34 @@ class ComponentIterator {
#endif
MAX,
};
/// End the current try_advance() pass after this step; lets callbacks
/// that write directly to the socket cap direct writes per pass.
void yield_after_step_() { this->yield_requested_ = true; }
uint16_t at_{0}; // Supports up to 65,535 entities per type
IteratorState state_{IteratorState::NONE};
bool include_internal_{false};
bool yield_requested_ : 1 {false};
bool include_internal_ : 1 {false};
template<typename Container>
void process_platform_item_(const Container &items,
bool process_platform_item_(const Container &items,
bool (ComponentIterator::*on_item)(typename Container::value_type)) {
if (this->at_ >= items.size()) {
this->advance_platform_();
} else {
typename Container::value_type item = items[this->at_];
if ((item->is_internal() && !this->include_internal_) || (this->*on_item)(item)) {
this->at_++;
}
return true;
}
typename Container::value_type item = items[this->at_];
if ((item->is_internal() && !this->include_internal_) || (this->*on_item)(item)) {
this->at_++;
return true;
}
return false;
}
/// One iteration step; false if no progress was made (callback refused
/// or iterator not running).
bool advance_step_();
void advance_platform_();
};
+9 -4
View File
@@ -23,6 +23,12 @@ from dataclasses import dataclass
import os
from pathlib import Path
from esphome.build_helpers.idedata import (
get_toolchain_includes,
parse_entry,
reject_launcher_compiler,
)
TIDY_PROJECT_NAME = "esphome_tidy"
# A do-nothing C++ app: just enough for IDF to configure a valid project. It's
@@ -415,13 +421,12 @@ def _idedata_from_tidy_project(compile_commands: Path) -> dict:
"""
import json
from esphome.espidf.idedata import _get_toolchain_includes, _parse_entry
entries = json.loads(Path(compile_commands).read_text(encoding="utf-8"))
entry = next((e for e in entries if e["file"].endswith("tidy.cpp")), None)
if entry is None:
raise RuntimeError(f"tidy.cpp not found in {compile_commands}")
cxx_path, defines, includes, cxx_flags = _parse_entry(entry)
cxx_path, defines, includes, cxx_flags = parse_entry(entry)
reject_launcher_compiler(cxx_path)
return {
"cxx_path": cxx_path,
@@ -429,7 +434,7 @@ def _idedata_from_tidy_project(compile_commands: Path) -> dict:
"defines": defines,
"includes": {
"build": includes,
"toolchain": _get_toolchain_includes(cxx_path),
"toolchain": get_toolchain_includes(cxx_path),
},
}
+25 -55
View File
@@ -20,6 +20,7 @@ from esphome.platformio.library import (
DEFAULT_BUILD_SRC_FILTER,
ESPHOME_DATA_EXTRA_CMAKE_KEY,
ESPHOME_DATA_KEY,
ESPHOME_DATA_LINK_FLAGS_KEY,
SRC_FILE_EXTENSIONS,
ConvertedLibrary as IDFComponent,
LibraryBackend,
@@ -27,6 +28,7 @@ from esphome.platformio.library import (
collect_filtered_files,
convert_libraries,
ensure_list,
lex_build_flags,
split_list_by_condition,
)
@@ -40,37 +42,6 @@ def _idf_framework() -> str:
return "arduino" if CORE.using_arduino else "espidf"
def _apply_extra_script(component: IDFComponent) -> None:
"""Run a PIO ``extraScript`` and fold its captured env vars into
``component.data["build"]["flags"]`` so the existing -L/-l/-D
extraction in ``generate_cmakelists_txt`` picks them up."""
extra_script = component.data.get("build", {}).get("extraScript")
if not extra_script:
return
# Resolve and confine to the library's source dir so a malicious
# library.json can't escape (e.g. ``"extraScript": "../../etc/passwd"``).
source_path = component.source_dir
library_root = source_path.resolve()
script_path = (source_path / extra_script).resolve()
if not script_path.is_relative_to(library_root) or not script_path.is_file():
return
from esphome.components.esp32 import get_esp32_variant
from esphome.espidf.extra_script import captured_as_build_flags, run_extra_script
idf_target = variant_to_idf_target(get_esp32_variant())
result = run_extra_script(
script_path, library_dir=source_path, idf_target=idf_target
)
extra_flags = captured_as_build_flags(result, library_dir=source_path)
if not extra_flags:
return
flags = component.data.setdefault("build", {}).setdefault("flags", [])
if isinstance(flags, str):
flags = [flags]
flags.extend(extra_flags)
component.data["build"]["flags"] = flags
def generate_cmakelists_txt(component: IDFComponent) -> str:
"""
Generate a CMakeLists.txt file for an ESP-IDF component.
@@ -85,10 +56,6 @@ def generate_cmakelists_txt(component: IDFComponent) -> str:
Returns:
str: The complete CMakeLists.txt content as a string
"""
# Late import: this module loads with the esp32 platform on every
# validate/compile, but shlex is only needed when generating component
# CMakeLists.
import shlex
def escape_entry(p: PathType) -> str:
# In CMakeLists.txt, backslashes need to be escaped
@@ -122,26 +89,12 @@ def generate_cmakelists_txt(component: IDFComponent) -> str:
build_src_filter = ensure_list(
component.data.get("build", {}).get("srcFilter", DEFAULT_BUILD_SRC_FILTER)
)
build_flags = ensure_list(
component.data.get("build", {}).get("flags", DEFAULT_BUILD_FLAGS)
# PlatformIO shell-lexes each build.flags entry; bare -I/-L/-l/-D tokens
# re-glue to their argument so the prefix classifiers below route them.
build_flags = lex_build_flags(
component.data.get("build", {}).get("flags", DEFAULT_BUILD_FLAGS),
f"library {component.name}",
)
# PlatformIO shell-lexes each build.flags entry, so one entry can carry a
# flag and its argument (e.g. "-include cp_custom_alloc.h"). Split the
# same way; emitting such an entry as a single quoted compile option
# hands the compiler one argv with an embedded space.
build_flags = [token for entry in build_flags for token in shlex.split(entry)]
# Re-glue bare -I/-L/-l tokens to their argument ("-I foo" -> "-Ifoo") so
# the prefix classifiers below still route them to INCLUDE_DIRS and the
# link handling.
tokens, build_flags = build_flags, []
i = 0
while i < len(tokens):
if tokens[i] in ("-I", "-L", "-l") and i + 1 < len(tokens):
build_flags.append(tokens[i] + tokens[i + 1])
i += 2
else:
build_flags.append(tokens[i])
i += 1
# List all sources files
build_src_files = collect_filtered_files(
@@ -253,6 +206,16 @@ def generate_cmakelists_txt(component: IDFComponent) -> str:
content += f" {str_build_flag}\n"
content += ")\n"
# Extra-script LINKFLAGS: routed to the link line; in
# target_compile_options they would be silently ineffective
if link_flags := component.data.get(ESPHOME_DATA_KEY, {}).get(
ESPHOME_DATA_LINK_FLAGS_KEY, []
):
content += "target_link_options(${COMPONENT_LIB} INTERFACE\n"
for link_flag in link_flags:
content += f" {escape_entry(link_flag)}\n"
content += ")\n"
# Add custom CMake scripts
content += "\n".join(
component.data.get(ESPHOME_DATA_KEY, {}).get(ESPHOME_DATA_EXTRA_CMAKE_KEY, [])
@@ -299,7 +262,14 @@ def generate_idf_component_yml(component: IDFComponent) -> str:
def _emit_idf_component(component: IDFComponent) -> None:
"""Write the ESP-IDF build files for a resolved library into its cache dir."""
_apply_extra_script(component)
from esphome.components.esp32 import get_esp32_variant
from esphome.platformio.extra_script import apply_extra_script
apply_extra_script(
component,
board_mcu=lambda: variant_to_idf_target(get_esp32_variant()),
pio_platform="espressif32",
)
write_file_if_changed(
component.path / "CMakeLists.txt",
generate_cmakelists_txt(component),
-161
View File
@@ -1,161 +0,0 @@
"""Run a PlatformIO ``extraScript`` against a captured SCons-env stand-in.
PlatformIO libraries occasionally configure per-target link/build state
via a Python ``extraScript`` declared in ``library.json``'s ``build``
section instead of static fields. The script runs under SCons during
PIO's build and mutates the active ``Environment`` (``env.Append``,
``env.Replace``, ) chiefly to set ``LIBPATH``/``LIBS`` per chip MCU.
ESPHome's PIO→IDF converter doesn't run SCons, so these scripts were
previously ignored and any library
relying on them failed to link under ``toolchain: esp-idf``. This
module provides a small shim that ``exec``s an extra-script with a
fake ``env`` object, captures the common ``env.Append(...)`` calls,
and returns the captured vars so the caller can fold them back into
the library's generated CMakeLists.
Caveats
-------
* Only the ``env.Append`` API is captured. ``env.Replace``,
``env.Prepend``, ``env.AddPreAction``, SCons file generators, and any
arbitrary I/O are silently no-ops. Scripts that depend on those will
produce incomplete output.
* Running arbitrary Python from third-party libraries is a non-trivial
trust decision. The shim does no sandboxing anything in the
script's process can run. Use only with libraries whose source you
trust.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import logging
import os
from pathlib import Path
_LOGGER = logging.getLogger(__name__)
# Keys we know how to translate back into ESPHome's build-flag pipeline.
# Other env.Append kwargs are recorded but ignored downstream.
_CAPTURED_KEYS = frozenset({"LIBPATH", "LIBS", "CPPDEFINES", "LINKFLAGS", "CPPFLAGS"})
@dataclass
class ExtraScriptResult:
"""Build-var deltas captured from a PIO extra-script ``env.Append`` call."""
libpath: list[str] = field(default_factory=list)
libs: list[str] = field(default_factory=list)
cppdefines: list[str | tuple[str, str]] = field(default_factory=list)
linkflags: list[str] = field(default_factory=list)
cppflags: list[str] = field(default_factory=list)
class _FakeSConsEnv:
"""Minimal stand-in for SCons ``Environment`` exposed to extra-scripts.
Implements just enough surface area to let scripts query ``BOARD_MCU``
/ ``PIOENV`` and call ``env.Append(LIBPATH=, LIBS=, )``. Every
other env method swallows silently so unrelated calls don't raise
``AttributeError`` and abort the script.
"""
def __init__(self, *, board_mcu: str, pio_env: str) -> None:
self._vars: dict[str, str] = {
"BOARD_MCU": board_mcu,
"PIOPLATFORM": "espressif32",
"PIOENV": pio_env,
}
self.result = ExtraScriptResult()
# ----- SCons env API the common scripts use -----
def get(self, key: str, default: str | None = None) -> str | None:
return self._vars.get(key, default)
def Append(self, **kwargs) -> None: # noqa: N802 (SCons API name)
for key, value in kwargs.items():
if key not in _CAPTURED_KEYS:
continue
items = list(value) if isinstance(value, (list, tuple)) else [value]
bucket = getattr(self.result, key.lower())
bucket.extend(items)
# ----- Everything else is a no-op so unsupported scripts don't crash -----
def __getattr__(self, name: str):
def _noop(*args, **kwargs):
return None
return _noop
def run_extra_script(
script_path: Path, *, library_dir: Path, idf_target: str
) -> ExtraScriptResult:
"""Execute ``script_path`` with a fake SCons env and return captured vars.
``idf_target`` is the active ESP-IDF target name (e.g. ``esp32``,
``esp32s3``); it's exposed to the script as PlatformIO's
``BOARD_MCU`` so chip-conditional logic resolves the same way it
would under PIO. The script runs with ``library_dir`` as the
process CWD so relative-path lookups (``join``, ``realpath``,
``open``) resolve against the library tree.
On any exception inside the script we log at debug level and return
an empty result extra-scripts are best-effort, and an unsupported
script shouldn't block the build.
"""
env = _FakeSConsEnv(board_mcu=idf_target, pio_env=f"esphome_{idf_target}")
code = compile(script_path.read_text(encoding="utf-8"), str(script_path), "exec")
old_cwd = Path.cwd()
try:
os.chdir(library_dir)
exec( # noqa: S102 pylint: disable=exec-used
code,
{
"Import": lambda *_args: None, # SCons-side import; harmless here
"env": env,
"__file__": str(script_path),
"__name__": "__pio_extra_script__",
},
)
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
_LOGGER.warning("PIO extra-script %s raised %s; skipping", script_path, e)
return ExtraScriptResult()
finally:
os.chdir(old_cwd)
return env.result
def captured_as_build_flags(
result: ExtraScriptResult, *, library_dir: Path
) -> list[str]:
"""Translate captured env vars into the ``-L`` / ``-l`` / ``-D`` /
raw-flag form ``_generate_cmakelists_txt`` already knows how to consume.
``LIBPATH`` entries are made relative to ``library_dir`` so the
generated CMakeLists is portable; absolute paths outside the library
tree are kept as-is (CMake handles absolute paths in
``target_link_directories`` fine).
"""
flags: list[str] = []
library_root = library_dir.resolve()
for path in result.libpath:
# Anchor relative paths to library_dir (not the current CWD, which
# has been restored by the time we get here). Joining an absolute
# path against library_dir returns the absolute path unchanged.
resolved = (library_dir / path).resolve()
try:
flags.append(f"-L{resolved.relative_to(library_root)}")
except ValueError:
flags.append(f"-L{resolved}")
flags.extend(f"-l{lib}" for lib in result.libs)
for define in result.cppdefines:
if isinstance(define, tuple) and len(define) == 2:
flags.append(f"-D{define[0]}={define[1]}")
else:
flags.append(f"-D{define}")
flags.extend(result.linkflags)
flags.extend(result.cppflags)
return flags
+68 -23
View File
@@ -2,6 +2,7 @@
from collections.abc import Callable
from ctypes.util import find_library
from functools import partial
import json
import logging
import os
@@ -20,9 +21,11 @@ from esphome.framework_helpers import (
create_venv,
download_from_mirrors,
download_with_resume,
failure_reason,
get_python_env_executable_path,
get_system_python_path,
rmdir,
run_batch_downloads,
run_command,
run_command_ok,
str_to_lst_of_str,
@@ -690,6 +693,18 @@ def _patch_tools_json_demote_unused_tools(framework_path: Path) -> None:
)
def _download_tool(
dist_path: Path, entry: dict, tracker: Callable[[int], None]
) -> None:
download_with_resume(
entry["url"],
dist_path / entry["dest"],
sha256=entry["sha256"],
size=entry["size"],
progress=tracker,
)
def _prefetch_idf_tool_archives(
framework_path: Path,
targets_str: str,
@@ -702,10 +717,10 @@ def _prefetch_idf_tool_archives(
which makes large archives effectively impossible to fetch on unstable
connections (#17703). This asks the framework's idf_tools (via
``get_tool_downloads.py``) which archives the coming install needs, then
downloads each into ``<IDF_TOOLS_PATH>/dist`` with
``download_with_resume``. The installer then finds the verified archives
already in place ("file ... is already downloaded") and never touches the
network.
downloads them into ``<IDF_TOOLS_PATH>/dist`` with
``download_with_resume``, a few at a time under one combined progress
bar. The installer then finds the verified archives already in place
("file ... is already downloaded") and never touches the network.
Strictly best-effort: any failure here just logs and returns, leaving
``idf_tools.py install`` to download whatever is missing exactly as
@@ -727,30 +742,60 @@ def _prefetch_idf_tool_archives(
)
return
dist_path = get_idf_tools_path() / "dist"
entries = [
entry
for entry in json.loads(stdout)
if not (dist_path / entry["dest"]).is_file()
]
for index, entry in enumerate(entries, start=1):
_LOGGER.info(
"Downloading %s (%d/%d) ...", entry["name"], index, len(entries)
)
try:
download_with_resume(
entry["url"],
dist_path / entry["dest"],
sha256=entry["sha256"],
size=entry["size"],
entries = []
seen_dests: set[str] = set()
for entry in json.loads(stdout):
if (dist_path / entry["dest"]).is_file():
continue
# Never download unverified: an entry without sha256/size is
# left to the installer, which fails loudly on a bad archive.
# Checked before the dedupe so it cannot shadow a verifiable
# duplicate of the same dest.
if not (entry.get("sha256") and entry.get("size")):
_LOGGER.warning(
"Tool %s has no sha256/size in the download list; "
"leaving it to the installer",
entry["name"],
)
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
# Keep prefetching the remaining archives; the installer
# will retry this one itself (without resume).
_LOGGER.warning("Could not prefetch %s: %s", entry["name"], e)
continue
if entry["dest"] in seen_dests:
# Two workers on one .part file would interleave
# seek/truncate writes; mirror the library prefetch's dedupe
continue
seen_dests.add(entry["dest"])
entries.append(entry)
if not entries:
return
_LOGGER.info(
"Downloading %d ESP-IDF tool archive(s): %s",
len(entries),
", ".join(entry["name"] for entry in entries),
)
# No sequential fallback here: skipping the prefetch would lose the
# resume workaround for #17703, and every entry has a size (above).
# A failed archive is retried by the installer itself (without
# resume); keep prefetching the rest.
failures = run_batch_downloads(
"Downloading ESP-IDF tools",
[
(
entry["name"],
entry["size"],
partial(_download_tool, dist_path, entry),
)
for entry in entries
],
)
for name, e in failures:
# failure_reason: a message-less exception must not log blank
_LOGGER.warning("Could not prefetch %s: %s", name, failure_reason(e))
_LOGGER.debug("Prefetch failure detail", exc_info=e)
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
# The installer downloads anything missing itself; never let the
# prefetch become a new way for the install to fail.
_LOGGER.warning("ESP-IDF tool prefetch failed: %s", e)
_LOGGER.debug("Prefetch failure detail", exc_info=True)
def _check_esphome_idf_framework_install(
+4 -14
View File
@@ -28,6 +28,8 @@ import json
import logging
from pathlib import Path
from esphome.build_helpers.size_summary import print_size_line
_LOGGER = logging.getLogger(__name__)
_SIZE_SUFFIXES = {"K": 1024, "M": 1024 * 1024}
@@ -67,18 +69,6 @@ def _find_app_partition_size(partitions_csv: Path) -> int:
raise ValueError(f"No app+factory or app+ota_0 partition in {partitions_csv}")
def _format_bar(used: int, total: int) -> str:
"""Match PlatformIO's ``_format_availale_bytes`` (pioupload.py) exactly."""
pct_raw = used / total if total else 0
blocks = 10
filled = min(int(round(blocks * pct_raw)), blocks)
progress = "=" * filled
return (
f"[{progress:<{blocks}}] {pct_raw: 6.1%} "
f"(used {used:d} bytes from {total:d} bytes)"
)
def print_summary(size_json: Path, partitions_csv: Path | None) -> None:
"""Print PlatformIO-shaped RAM and Flash one-liners.
@@ -99,7 +89,7 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None:
ram_used = ram_region.get("used")
ram_total = ram_region.get("size")
if ram_total and ram_used is not None:
print(f"RAM: {_format_bar(ram_used, ram_total)}")
print_size_line("RAM", ram_used, ram_total)
image_size = data.get("image_size")
if image_size is None or partitions_csv is None:
@@ -109,4 +99,4 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None:
except ValueError as e:
_LOGGER.debug("Skipping Flash summary: %s", e)
return
print(f"Flash: {_format_bar(image_size, app_size)}")
print_size_line("Flash", image_size, app_size)
+8 -25
View File
@@ -526,32 +526,15 @@ def get_idedata() -> dict | None:
idedata fields IDE integrations and clang-tidy expect, cached alongside the
PlatformIO idedata path. Returns None if the compile DB doesn't exist yet.
"""
from esphome.espidf.idedata import idedata_from_build
from esphome.build_helpers.idedata import load_or_build_idedata
compile_commands = CORE.relative_build_path("build", "compile_commands.json")
if not compile_commands.is_file():
_LOGGER.debug("No %s yet; skipping idedata generation", compile_commands)
return None
cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json")
if cache.is_file() and cache.stat().st_mtime >= compile_commands.stat().st_mtime:
try:
cached = json.loads(cache.read_text(encoding="utf-8"))
except ValueError:
pass
else:
# Caches written before cc_path was emitted stay newer than
# compile_commands.json forever, so rebuild them on the field rather
# than on the timestamp. Check the type too: a corrupted cache can
# still be valid JSON, and "in" would match a substring of a string.
if isinstance(cached, dict) and "cc_path" in cached:
return cached
data = idedata_from_build(compile_commands)
data["prog_path"] = str(get_elf_path())
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
return data
# No launcher: CMake excludes CMAKE_<LANG>_COMPILER_LAUNCHER (ccache)
# from the exported compile database, unlike ninja's compdb dump.
return load_or_build_idedata(
CORE.relative_build_path("build", "compile_commands.json"),
get_elf_path(),
CORE.relative_internal_path("idedata", f"{CORE.name}.json"),
)
def create_factory_bin() -> bool:
+17 -4
View File
@@ -16,6 +16,7 @@ from esphome.const import CONF_FILE, CONF_TYPE, CONF_URL, __version__
from esphome.core import CORE, EsphomeError, TimePeriodSeconds
from esphome.happy_eyeballs import ensure_happy_eyeballs
from esphome.helpers import write_file
from esphome.net_retry import fetch_with_retry
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
@@ -157,8 +158,17 @@ def has_remote_file_changed(
}
if etag := _read_etag(local_file_path):
headers[IF_NONE_MATCH] = etag
response = requests.head(
url, headers=headers, timeout=timeout, allow_redirects=True
# Retried so allow_stale=False consumers don't hard-fail on a
# healed flake. Only connection-level failures retry: HEAD
# never raises on HTTP status (servers rejecting HEAD with
# 405/501 must fall through to the GET), so 5xx is handled by
# the GET's own retry.
response = fetch_with_retry(
url,
lambda: requests.head(
url, headers=headers, timeout=timeout, allow_redirects=True
),
what="Revalidation",
)
_LOGGER.debug(
@@ -293,7 +303,7 @@ def download_content(
_LOGGER.info("Downloading %s", url)
_LOGGER.debug("Saving to %s", path)
try:
def _fetch() -> tuple[requests.Response, bytes]:
req = requests.get(
url,
timeout=timeout,
@@ -304,7 +314,10 @@ def download_content(
# and mid-stream connection errors all surface here as
# RequestException subclasses, so this needs the same fall-back
# treatment as the request itself.
data = req.content
return req, req.content
try:
req, data = fetch_with_retry(url, _fetch)
except requests.exceptions.RequestException as e:
if path.exists():
# Memoized so a flaky host warns once per run, not per consumer.
+216 -46
View File
@@ -1,7 +1,8 @@
"""Generic toolchain installation helpers shared across framework implementations."""
from collections.abc import Iterable
from contextlib import ExitStack
from collections.abc import Callable, Iterable, Iterator
from concurrent.futures import ThreadPoolExecutor
from contextlib import ExitStack, contextmanager, suppress
import hashlib
import io
import json
@@ -10,11 +11,13 @@ import os
from pathlib import Path
import subprocess
import sys
import threading
import time
from typing import IO, TYPE_CHECKING
from esphome.happy_eyeballs import ensure_happy_eyeballs
from esphome.helpers import ProgressBar, rmtree
from esphome.net_retry import NETWORK_MAX_ATTEMPTS, is_transient_download_error
if TYPE_CHECKING:
import requests
@@ -23,14 +26,16 @@ PathType = str | os.PathLike
_LOGGER = logging.getLogger(__name__)
# Attempts per mirror URL before falling through to the next mirror; only
# mid-stream drops retry (resuming when the server gave a validator),
# connect errors move on to the next mirror immediately.
_MIRROR_ATTEMPTS = 3
# Passes over the whole mirror list when a transient network error is in
# the mix; matches git.py's _NETWORK_MAX_ATTEMPTS (3 tries, 2s/4s backoff).
_MIRROR_SWEEP_ATTEMPTS = 3
# the mix; shares net_retry's policy (3 tries, 2s/4s backoff), which in
# turn matches git.py's _NETWORK_MAX_ATTEMPTS.
_MIRROR_SWEEP_ATTEMPTS = NETWORK_MAX_ATTEMPTS
def get_project_link_flags() -> list[str]:
@@ -697,7 +702,11 @@ def _response_validator(resp: "requests.Response") -> str | None:
def _stream_response_to_file(
resp: "requests.Response", f: IO[bytes], offset: int, size: int | None = None
resp: "requests.Response",
f: IO[bytes],
offset: int,
size: int | None = None,
progress: Callable[[int], None] | None = None,
) -> None:
"""Stream an open ``_open_ranged`` response body into ``f`` at ``offset``.
@@ -705,21 +714,182 @@ def _stream_response_to_file(
(effective offset 0) discards the stale bytes. ``offset`` also seeds the
progress bar so a resumed download shows overall progress. ``size`` is
the known full file size; when None it is derived from the response's
content-length, and without either there is no progress bar.
content-length, and without either there is no bar. With ``progress``
set no bar is drawn here; the callback gets the absolute byte count.
"""
f.seek(offset)
f.truncate(offset)
total_size = size or offset + _content_length(resp)
downloaded = offset
progress = ProgressBar("Downloading") if total_size > 0 else None
own_bar: ProgressBar | None = None
if progress is None:
own_bar = ProgressBar("Downloading") if total_size > 0 else None
progress = (
(lambda done: own_bar.update(done / total_size))
if own_bar
else (lambda _: None)
)
progress(downloaded)
for chunk in resp.iter_content(chunk_size=256 * 1024):
if chunk:
f.write(chunk)
downloaded += len(chunk)
if progress is not None:
progress.update(downloaded / total_size)
if progress is not None:
progress.update(1)
progress(downloaded)
if own_bar is not None:
own_bar.update(1)
# Concurrent downloads per batch; enough to hide latency without
# hammering the host or the mirrors.
BATCH_DOWNLOAD_WORKERS = 4
def run_batch_downloads(
header: str,
jobs: list[tuple[str, int, Callable[[Callable[[int], None]], None]]],
max_workers: int = BATCH_DOWNLOAD_WORKERS,
) -> list[tuple[str, BaseException]]:
"""Run ``(name, size, fetch)`` download jobs concurrently under one bar.
Each ``fetch(tracker)`` reports absolute byte counts; the bar total is
the sum of the sizes. Failures are returned after the bar is done so
warnings never land on its row. Ctrl-C drops queued jobs and aborts
in-flight ones at their next progress tick or backoff boundary (a
parked socket read defers that by its timeout, and an in-progress
archive extraction runs to completion); resumable destinations
(``download_with_resume``) keep their fetched ``.part`` bytes.
``jobs`` must be non-empty.
"""
progress = _BatchDownloadProgress(header, sum(size for _, size, _ in jobs))
cancelled = threading.Event()
def _run(
name: str, fetch: Callable[[Callable[[int], None]], None]
) -> tuple[str, BaseException] | None:
tracker = progress.tracker()
def checked(done: int) -> None:
if cancelled.is_set():
raise _BatchDownloadCancelled
tracker(done)
try:
fetch(checked)
except (_BatchDownloadCancelled, Exception) as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
# A cancelled job reports like a failure: an abandoned download
# must never read as completed if a caller sees the list after
# Ctrl-C
failure = (name, err)
else:
return None
# A bar-frame write failure must not displace the download error
with suppress(Exception):
tracker(0)
return failure
ex = ThreadPoolExecutor(max_workers=max_workers)
try:
with progress.logging_guard():
futures = [ex.submit(_run, name, fetch) for name, _, fetch in jobs]
return [failure for f in futures if (failure := f.result()) is not None]
except BaseException:
# Without this the non-daemon workers download to completion before
# the interpreter can exit, making Ctrl-C ineffective for minutes
cancelled.set()
raise
finally:
ex.shutdown(wait=True, cancel_futures=True)
progress.done()
class _BatchDownloadCancelled(BaseException):
"""Raised inside a download job to abandon it after Ctrl-C.
BaseException, like KeyboardInterrupt: a broad ``except Exception`` in
the download layers must not convert an abort into a retry.
"""
class _BatchDownloadProgress:
"""One bar across several concurrent downloads, summing tracker bytes.
The lock also serialises stderr writes so workers never interleave
frames; a ``total`` of 0 draws nothing. Call ``done()`` at the end so a
bar short of 100% still ends its line.
"""
def __init__(self, header: str, total: int) -> None:
self._bar = ProgressBar(header) if total > 0 else None
self._total = total
self._sum = 0
self._lock = threading.Lock()
def tracker(self) -> Callable[[int], None]:
if self._bar is None:
return lambda _: None
last = 0
def update(done: int) -> None:
nonlocal last
with self._lock:
self._sum += done - last
last = done
self._bar.update(min(self._sum / self._total, 1))
return update
def done(self) -> None:
if self._bar is not None:
self._bar.done()
@contextmanager
def logging_guard(self) -> Iterator[None]:
r"""End a partial bar row before any log record while active.
Worker warnings (mirror retries) share stderr with the bar's \r
frames; without this the record lands mid-row and the next frame
overwrites it. A handler-level filter runs just before emit, so
only a tiny window remains for a concurrent frame.
"""
the_bar = self._bar
if the_bar is None:
yield
return
lock = self._lock
class _EndRow(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
with lock:
the_bar.interrupt()
return True
end_row = _EndRow()
handlers = logging.getLogger().handlers
for handler in handlers:
handler.addFilter(end_row)
try:
yield
finally:
for handler in handlers:
handler.removeFilter(end_row)
def _part_path(dest: Path) -> Path:
"""The in-progress sidecar ``download_with_resume`` streams into."""
return dest.with_name(dest.name + ".part")
def _cancellable_sleep(
delay: float, progress: Callable[[int], None] | None, done: int
) -> None:
"""Backoff sleep that still observes a batch cancellation tick."""
if progress is None:
time.sleep(delay)
return
end = time.monotonic() + delay
while (remaining := end - time.monotonic()) > 0:
progress(done) # raises when the batch was cancelled
time.sleep(min(0.5, remaining))
def download_with_resume(
@@ -732,6 +902,7 @@ def download_with_resume(
attempts: int = 5,
timeout: int = 30,
retry_connect_errors: bool = True,
progress: Callable[[int], None] | None = None,
) -> None:
"""Download ``url`` to ``dest``, resuming partial downloads.
@@ -754,6 +925,9 @@ def download_with_resume(
of consuming attempts for callers with their own fallback, like
``download_from_mirrors``.
``progress`` replaces the built-in bar: it receives the absolute bytes of
``dest`` obtained so far (see ``BatchDownloadProgress``).
Raises EsphomeError when all attempts are exhausted.
"""
# Imported lazily: requests is a heavy import (~85ms) and is only needed
@@ -765,7 +939,7 @@ def download_with_resume(
ensure_happy_eyeballs()
dest = Path(dest)
part = dest.with_name(dest.name + ".part")
part = _part_path(dest)
meta = part.with_name(part.name + ".meta")
dest.parent.mkdir(parents=True, exist_ok=True)
last_error: Exception | None = None
@@ -777,6 +951,8 @@ def download_with_resume(
if dest.is_file() and (sha256 is not None or size is not None):
try:
_verify_file(dest, sha256, size)
if progress is not None:
progress(size if size is not None else dest.stat().st_size)
return
except EsphomeError:
dest.unlink()
@@ -822,7 +998,7 @@ def download_with_resume(
# Recorded so a later run can prove an If-Range
# resume of this part file safe.
_write_download_meta(meta, url, validator, expected_total)
_stream_response_to_file(resp, f, offset, size)
_stream_response_to_file(resp, f, offset, size, progress)
# else: a previous run already wrote every byte (or more) but
# was killed before the rename below. Skip the network entirely
# — a Range request past EOF would draw HTTP 416 — and let
@@ -831,6 +1007,10 @@ def download_with_resume(
expected_size = size if size is not None else expected_total
_verify_file(part, sha256, expected_size or None)
if progress is not None:
# Also credits a part file an earlier run completed without
# streaming anything this time.
progress(expected_size or part.stat().st_size)
if not expected_size and sha256 is None:
# No sha, no size, and the server sent no usable
# content-length: nothing can prove the download complete
@@ -878,11 +1058,11 @@ def download_with_resume(
raise EsphomeError(
f"Failed to download {url} after {attempts} attempts: "
f"{_failure_reason(last_error)}"
f"{failure_reason(last_error)}"
) from last_error
def _failure_reason(e: Exception) -> str:
def failure_reason(e: BaseException) -> str:
"""Format a download exception for the aggregated error message.
``requests`` appends " for url: <url>" to HTTP errors; the URL is already
@@ -898,41 +1078,18 @@ def _spent_attempts_error(e: Exception, attempts: int) -> Exception:
the sweep classifies it as permanent."""
from esphome.core import EsphomeError
err = EsphomeError(f"failed after {attempts} attempts: {_failure_reason(e)}")
err = EsphomeError(f"failed after {attempts} attempts: {failure_reason(e)}")
err.__cause__ = e
return err
def _is_transient_download_error(e: Exception) -> bool:
"""Return True when a download failure is worth retrying.
Connection-level failures and HTTP 429/5xx are transient. Other HTTP
errors, local errors, and exhausted-attempts EsphomeError wrappers
(their per-mirror retries are already spent) are permanent.
"""
# Imported lazily: requests is a heavy import (~85ms) and is only
# needed when actually downloading, never during config validation.
import requests
if isinstance(e, requests.exceptions.HTTPError):
resp = e.response
return resp is not None and (resp.status_code == 429 or resp.status_code >= 500)
return isinstance(
e,
(
requests.exceptions.ConnectionError,
requests.exceptions.Timeout,
requests.exceptions.ChunkedEncodingError,
),
)
def _try_mirrors_once(
urls: list[str],
path_target: Path | None,
f: IO[bytes] | None,
timeout: int,
failures: list[tuple[str, Exception]],
progress: Callable[[int], None] | None = None,
) -> str | None:
"""Single pass over the resolved mirror ``urls``, one try per URL.
@@ -961,6 +1118,7 @@ def _try_mirrors_once(
# next mirror immediately; only mid-stream drops
# retry-with-resume on the same URL.
retry_connect_errors=False,
progress=progress,
)
return url
except (requests.RequestException, OSError, EsphomeError) as e:
@@ -1002,7 +1160,7 @@ def _try_mirrors_once(
if offset == 0:
validator = _response_validator(resp)
expected_total = _content_length(resp)
_stream_response_to_file(resp, f, offset)
_stream_response_to_file(resp, f, offset, progress=progress)
if expected_total and f.tell() != expected_total:
raise EsphomeError(
@@ -1051,6 +1209,7 @@ def download_from_mirrors(
substitutions: dict[str, str],
target: io.RawIOBase | IO[bytes] | PathType,
timeout: int = 30,
progress: Callable[[int], None] | None = None,
) -> str:
"""
Download file from multiple mirrors with substitution support.
@@ -1060,6 +1219,8 @@ def download_from_mirrors(
substitutions: Dictionary of substitutions to apply to URLs
target: Target file path or file-like object
timeout: Download timeout in seconds
progress: Passed through to the download (see ``download_with_resume``);
replaces the built-in per-file bar
Returns:
The source URL.
@@ -1124,14 +1285,16 @@ def download_from_mirrors(
for sweep in range(1, _MIRROR_SWEEP_ATTEMPTS + 1):
sweep_failures: list[tuple[str, Exception]] = []
if (
url := _try_mirrors_once(urls, path_target, f, timeout, sweep_failures)
url := _try_mirrors_once(
urls, path_target, f, timeout, sweep_failures, progress
)
) is not None:
return url
failures.extend(sweep_failures)
# Permanent failures (404, verification mismatch) won't heal;
# only retry when a transient error is in the mix (as git.py does).
transient = next(
((u, e) for u, e in sweep_failures if _is_transient_download_error(e)),
((u, e) for u, e in sweep_failures if is_transient_download_error(e)),
None,
)
if transient is None:
@@ -1141,12 +1304,19 @@ def download_from_mirrors(
_LOGGER.warning(
"Download of %s failed (%s); retrying in %d seconds (attempt %d/%d)",
transient[0],
_failure_reason(transient[1]),
failure_reason(transient[1]),
delay,
sweep + 1,
_MIRROR_SWEEP_ATTEMPTS,
)
time.sleep(delay)
# Tick with the bytes already on disk so a combined bar holds
# steady during the backoff instead of rewinding to zero
if f is not None:
done = f.tell()
else:
part = _part_path(path_target)
done = part.stat().st_size if part.is_file() else 0
_cancellable_sleep(delay, progress, done)
# 4. Report every attempted URL if all mirrors failed. failures spans
# all sweeps (deduplicated by URL and reason), so neither an early
@@ -1155,7 +1325,7 @@ def download_from_mirrors(
seen: set[tuple[str, str]] = set()
attempts = ""
for url, e in failures:
reason = _failure_reason(e)
reason = failure_reason(e)
if (url, reason) not in seen:
seen.add((url, reason))
attempts += f"\n {url}\n {reason}"
+12 -1
View File
@@ -729,11 +729,22 @@ class ProgressBar:
sys.stderr.flush()
def done(self) -> None:
if not self.enabled:
# No frame drawn, or the 100% frame already ended its own line
if not self.enabled or self.last_progress is None or self.last_progress == 100:
return
sys.stderr.write("\n")
sys.stderr.flush()
def interrupt(self) -> None:
"""End a mid-row frame so the next write starts on its own row.
The next ``update()`` redraws the bar; a finished bar stays done.
"""
if self.last_progress == 100:
return
self.done()
self.last_progress = None
def docs_url(path: str) -> str:
"""Return the URL to the documentation for a given path."""
+114
View File
@@ -0,0 +1,114 @@
"""Retry policy for HTTP downloads.
Kept import-light on purpose: this module is imported at config time, so it
must not pull in requests (a heavy import, ~85ms) at module scope.
"""
from __future__ import annotations
from collections.abc import Callable
import logging
import time
_LOGGER = logging.getLogger(__name__)
# 3 tries with 2s/4s backoff, matching git.py's _NETWORK_MAX_ATTEMPTS.
# Callers memoize failures so a flaky host pays this once per file per run.
NETWORK_MAX_ATTEMPTS = 3
def _is_permanent_dns_failure(e: BaseException) -> bool:
"""Whether a hard socket.gaierror hides in ``e``'s exception chain.
EAI_AGAIN (flaky resolver) stays retryable; anything else is permanent
so offline builds fall back to their cache without sleeping first.
Narrower than git.py, which retries NXDOMAIN too.
Walks ``__cause__``, ``args`` (requests wraps MaxRetryError without
``from``) and MaxRetryError's ``reason``, but not implicit
``__context__``: an unrelated earlier attempt's resolution failure
must not reclassify an error it did not cause.
"""
import socket
seen: set[int] = set()
stack: list[BaseException] = [e]
while stack:
exc = stack.pop()
if id(exc) in seen:
continue
if (
isinstance(exc, socket.gaierror)
and exc.errno is not None
and exc.errno != socket.EAI_AGAIN
):
return True
seen.add(id(exc))
stack.extend(
nxt
for nxt in (
exc.__cause__,
getattr(exc, "reason", None), # urllib3 MaxRetryError
*exc.args,
)
if isinstance(nxt, BaseException)
)
return False
def is_transient_download_error(e: Exception) -> bool:
"""Return True when a download failure is worth retrying.
Connection-level failures and HTTP 429/5xx are transient; hard DNS
failures, other HTTP errors, and local errors are permanent.
"""
# Imported lazily: requests is a heavy import (~85ms) and is only
# needed when actually downloading, never during config validation.
import requests
if isinstance(e, requests.exceptions.HTTPError):
resp = e.response
return resp is not None and (resp.status_code == 429 or resp.status_code >= 500)
if isinstance(e, requests.exceptions.ConnectionError) and _is_permanent_dns_failure(
e
):
return False
# SSLError (a ConnectionError subclass) stays transient on purpose: it
# also covers mid-handshake connection drops, not just bad certificates.
return isinstance(
e,
(
requests.exceptions.ConnectionError,
requests.exceptions.Timeout,
requests.exceptions.ChunkedEncodingError,
requests.exceptions.ContentDecodingError,
),
)
def fetch_with_retry[T](url: str, fetch: Callable[[], T], what: str = "Download") -> T:
"""Run ``fetch``, retrying transient failures with 2s/4s backoff.
Permanent failures and the final attempt propagate to the caller;
``what`` names the operation in the retry warning.
"""
import requests
for attempt in range(1, NETWORK_MAX_ATTEMPTS):
try:
return fetch()
except requests.exceptions.RequestException as e:
if not is_transient_download_error(e):
raise
delay = 2**attempt
_LOGGER.warning(
"%s of %s failed: %s. Retrying in %d seconds... (attempt %d/%d)",
what,
url,
e,
delay,
attempt + 1,
NETWORK_MAX_ATTEMPTS,
)
time.sleep(delay)
return fetch()
+356
View File
@@ -0,0 +1,356 @@
"""Run a PlatformIO library ``extraScript`` against a fake SCons env.
The shim execs the script with a stand-in ``env``, captures ``env.Append``
calls (everything else is a logged no-op), and folds the result into the
library's build flags. No sandboxing: the script runs with full process
access, so it carries the same trust as the library's own source.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
import logging
import os
from pathlib import Path
import shlex
from typing import TYPE_CHECKING, Any, NamedTuple
from esphome.core import EsphomeError
from esphome.platformio.library import ESPHOME_DATA_KEY, ESPHOME_DATA_LINK_FLAGS_KEY
if TYPE_CHECKING:
from esphome.platformio.library import ConvertedLibrary
_LOGGER = logging.getLogger(__name__)
def apply_extra_script(
component: ConvertedLibrary,
board_mcu: Callable[[], str],
pio_platform: str,
) -> None:
"""Run a library's ``extraScript`` and fold its captured env vars into
``build.flags``; ``board_mcu`` is a callable so it resolves lazily."""
extra_script = component.data.get("build", {}).get("extraScript")
if not extra_script:
return
if not isinstance(extra_script, str):
# A list/dict value would raise an opaque TypeError on the join below
raise EsphomeError(
f"extraScript of library {component.name} must be a string, "
f"got {type(extra_script).__name__}"
)
# Resolve and confine to the library's source dir so a malicious
# library.json can't escape (e.g. ``"extraScript": "../../etc/passwd"``).
source_path = component.source_dir
library_root = source_path.resolve()
script_path = (source_path / extra_script).resolve()
if not script_path.is_relative_to(library_root):
# More hostile than a missing script; must not be quieter than it
raise EsphomeError(
f"extraScript {extra_script} of library {component.name} escapes "
"the library directory"
)
if not script_path.is_file():
# A declared-but-absent script is a broken or half-downloaded
# package, not an unsupported script; PlatformIO fails on it too
raise EsphomeError(
f"extraScript {extra_script} of library {component.name} not found"
)
result = run_extra_script(
script_path,
library_dir=source_path,
board_mcu=board_mcu(),
pio_platform=pio_platform,
)
if link_flags := _str_entries(result.linkflags, "LINKFLAGS"):
# Kept apart from build.flags: the CMake emitters route those to
# target_compile_options, where a link flag is silently ineffective
esphome_data = component.data.setdefault(ESPHOME_DATA_KEY, {})
esphome_data.setdefault(ESPHOME_DATA_LINK_FLAGS_KEY, []).extend(link_flags)
extra_flags = captured_as_build_flags(result, library_dir=source_path)
if not extra_flags:
return
flags = component.data.setdefault("build", {}).setdefault("flags", [])
if isinstance(flags, str):
flags = [flags]
elif not isinstance(flags, list):
# A null/dict value coerced through a list wrapper would inject a
# non-string into the compiler command line; fail naming the library
raise EsphomeError(
f"Library {component.name} has a malformed build.flags "
f"({type(flags).__name__}); expected a string or list"
)
component.data["build"]["flags"] = [*flags, *extra_flags]
# Keys we know how to translate back into ESPHome's build-flag pipeline.
# Other env.Append kwargs are recorded but ignored downstream.
_CAPTURED_KEYS = frozenset(
{"CPPPATH", "LIBPATH", "LIBS", "CPPDEFINES", "LINKFLAGS", "CPPFLAGS"}
)
@dataclass
class ExtraScriptResult:
"""Build-var deltas captured from a PIO extra-script ``env.Append`` call."""
cpppath: list[str] = field(default_factory=list)
libpath: list[str] = field(default_factory=list)
libs: list[str] = field(default_factory=list)
cppdefines: list[CppDefine] = field(default_factory=list)
linkflags: list[str] = field(default_factory=list)
cppflags: list[str] = field(default_factory=list)
class CppDefine(NamedTuple):
"""One normalized CPPDEFINES entry; a ``value`` of None is a bare -DNAME."""
name: str
value: str | None = None
def _cppdefine(entry: Any) -> CppDefine | None:
"""Normalize one CPPDEFINES element, or warn and drop an unsupported
shape; formatting those blind would hand the compiler garbage like
``-D{'FOO': '1'}``."""
if isinstance(entry, str):
return CppDefine(entry)
if (
isinstance(entry, (tuple, list))
and len(entry) == 2
and isinstance(entry[0], (str, int))
and isinstance(entry[1], (str, int, type(None)))
):
value = entry[1]
return CppDefine(str(entry[0]), None if value is None else str(value))
_LOGGER.warning("Ignoring unsupported CPPDEFINES entry %r", entry)
return None
def _cppdefines_items(value: Any) -> list[CppDefine]:
"""Normalize SCons ``processDefines`` spellings into ``CppDefine``s: a
bare 2-tuple is one ``name=value`` pair, a dict maps names to values, a
list is element-wise."""
if isinstance(value, tuple) and len(value) == 2:
elements: list[Any] = [value]
elif isinstance(value, dict):
elements = list(value.items())
else:
elements = list(value) if isinstance(value, (list, tuple)) else [value]
return [d for e in elements if (d := _cppdefine(e)) is not None]
class _FakeSConsEnv:
"""Minimal SCons ``Environment`` stand-in: ``get`` and ``Append`` work;
every other method is a swallowed no-op so scripts don't abort."""
def __init__(self, *, board_mcu: str, pio_env: str, pio_platform: str) -> None:
self._vars: dict[str, str] = {
"BOARD_MCU": board_mcu,
"PIOPLATFORM": pio_platform,
"PIOENV": pio_env,
}
self.result = ExtraScriptResult()
self._warned_methods: set[str] = set()
self._warned_keys: set[str] = set()
self._warned_gets: set[str] = set()
# ----- SCons env API the common scripts use -----
def get(self, key: str, default: str | None = None) -> str | None:
if key not in self._vars and key not in self._warned_gets:
# A script branching on an unmodelled var silently takes the
# default branch; make that diagnosable from a normal build log
self._warned_gets.add(key)
_LOGGER.warning(
"PIO extra-script env.get(%r) is not modelled; returning the default",
key,
)
return self._vars.get(key, default)
def __getitem__(self, key: str) -> str:
# Scripts also read env["BOARD_MCU"]; an unmodelled subscript
# degrades one branch instead of discarding the whole capture
if key not in self._vars and key not in self._warned_gets:
self._warned_gets.add(key)
_LOGGER.warning(
"PIO extra-script env[%r] is not modelled; returning ''", key
)
return self._vars.get(key, "")
def Append(self, **kwargs) -> None: # noqa: N802 (SCons API name)
self._add(kwargs, prepend=False)
def Prepend(self, **kwargs) -> None: # noqa: N802 (SCons API name)
self._add(kwargs, prepend=True)
def _add(self, kwargs: dict[str, Any], *, prepend: bool) -> None:
for key, value in kwargs.items():
if key not in _CAPTURED_KEYS:
# Warn once per key so a loop of Appends cannot spam
if key not in self._warned_keys:
self._warned_keys.add(key)
_LOGGER.warning(
"PIO extra-script env.Append(%s=...) is not captured; ignoring",
key,
)
continue
if key == "CPPDEFINES":
items = _cppdefines_items(value)
else:
items = list(value) if isinstance(value, (list, tuple)) else [value]
bucket = getattr(self.result, key.lower())
if prepend:
# SCons order: new values ahead of what is already there
# (scripts prepend LIBS for static-link symbol resolution)
bucket[:0] = items
else:
bucket.extend(items)
# Dedup is not modelled; a repeated flag is harmless on the command line
AppendUnique = Append
PrependUnique = Prepend
# ----- Everything else is a no-op so unsupported scripts don't crash -----
def __getattr__(self, name: str):
if name.startswith("__") and name.endswith("__"):
# Protocol probes (copy, pickle, iteration) are not script calls
raise AttributeError(name)
if name not in self._warned_methods:
# Warn on access, not call: hasattr()/truthiness branches would
# otherwise silently take the wrong path; a script whose whole
# effect is env.Replace() stays diagnosable either way
self._warned_methods.add(name)
_LOGGER.warning("PIO extra-script env.%s is not supported; ignoring", name)
def _noop(*args, **kwargs):
return None
return _noop
def run_extra_script(
script_path: Path,
*,
library_dir: Path,
board_mcu: str,
pio_platform: str,
) -> ExtraScriptResult:
"""Execute ``script_path`` with a fake SCons env, ``library_dir`` as CWD.
A crashed script warns and returns an empty result, never a partial
capture."""
env = _FakeSConsEnv(
board_mcu=board_mcu,
pio_env=f"esphome_{board_mcu}",
pio_platform=pio_platform,
)
try:
source = script_path.read_text(encoding="utf-8")
except OSError as err:
# An unreadable declared script is a broken package, exactly like a
# missing one; must not be quieter than that case
raise EsphomeError(f"extraScript {script_path} is unreadable: {err}") from err
except UnicodeDecodeError as e:
# A content problem, best-effort like a SyntaxError below
_LOGGER.warning(
"PIO extra-script %s (in %s) is not UTF-8 (%r); ignoring its output",
script_path,
library_dir.name,
e,
)
return ExtraScriptResult()
old_cwd = Path.cwd()
try:
# Inside the try: a SyntaxError in a vendored script is just as
# best-effort as a runtime failure
code = compile(source, str(script_path), "exec")
os.chdir(library_dir)
exec( # noqa: S102 pylint: disable=exec-used
code,
{
"Import": lambda *_args: None, # SCons-side import; harmless here
"env": env,
"__file__": str(script_path),
"__name__": "__pio_extra_script__",
},
)
except SystemExit as e:
if not e.code:
# sys.exit() / sys.exit(0) is a normal PlatformIO script ending;
# the capture is complete
return env.result
_LOGGER.warning(
"PIO extra-script %s (in %s) exited with status %r; ignoring its output",
script_path,
library_dir.name,
e.code,
)
return ExtraScriptResult()
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
# Discard any partial capture: half-applied flags could build wrong
# firmware that links cleanly.
_LOGGER.warning(
"PIO extra-script %s (in %s) raised %r; ignoring its output",
script_path,
library_dir.name,
e,
)
return ExtraScriptResult()
finally:
os.chdir(old_cwd)
return env.result
def _str_entries(bucket: list, kind: str) -> list[str]:
# Third-party scripts legally append SCons nodes, ints, or dicts;
# stringifying those into flags would hand the compiler garbage
good = [entry for entry in bucket if isinstance(entry, str)]
for entry in bucket:
if not isinstance(entry, str):
_LOGGER.warning("Ignoring unsupported %s entry %r", kind, entry)
return good
def captured_as_build_flags(
result: ExtraScriptResult, *, library_dir: Path
) -> list[str]:
"""Translate captured env vars into -L/-l/-D/raw build flags; path
entries anchor to ``library_dir`` so the build files stay portable."""
flags: list[str] = []
library_root = library_dir.resolve()
def _anchored(path: str) -> str:
# Anchor relative paths to library_dir; the script's CWD has been
# restored by now
resolved = (library_dir / path).resolve()
try:
return str(resolved.relative_to(library_root))
except ValueError:
return str(resolved)
# shlex.quote so a spaced path survives lex_build_flags as one token
flags.extend(
f"-I{shlex.quote(_anchored(path))}"
for path in _str_entries(result.cpppath, "CPPPATH")
)
flags.extend(
f"-L{shlex.quote(_anchored(path))}"
for path in _str_entries(result.libpath, "LIBPATH")
)
flags.extend(f"-l{shlex.quote(lib)}" for lib in _str_entries(result.libs, "LIBS"))
for define in result.cppdefines:
if define.value is None:
# {"FOO": None} / ("FOO", None) is a bare -DFOO in SCons
flags.append(shlex.quote(f"-D{define.name}"))
else:
flags.append(shlex.quote(f"-D{define.name}={define.value}"))
# Each captured entry is one argv token in SCons; quote so the
# lex_build_flags round-trip cannot split a spaced value into two.
# LINKFLAGS are deliberately absent: they travel via
# ESPHOME_DATA_LINK_FLAGS_KEY straight to the link line.
flags.extend(shlex.quote(f) for f in _str_entries(result.cppflags, "CPPFLAGS"))
return flags
+429 -136
View File
@@ -13,8 +13,9 @@ regardless of which toolchain consumes the result.
"""
from collections import deque
from collections.abc import Callable
from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from functools import partial
import glob
import hashlib
import itertools
@@ -30,7 +31,13 @@ from urllib.request import url2pathname
from esphome import git
from esphome.core import CORE, EsphomeError, Library
from esphome.framework_helpers import archive_extract_all, download_from_mirrors, rmdir
from esphome.framework_helpers import (
archive_extract_all,
download_from_mirrors,
failure_reason,
rmdir,
run_batch_downloads,
)
_LOGGER = logging.getLogger(__name__)
@@ -47,25 +54,38 @@ DEFAULT_BUILD_SRC_FILTER = (
DEFAULT_BUILD_SRC_DIRS = "src"
DEFAULT_BUILD_INCLUDE_DIR = "include"
DEFAULT_BUILD_FLAGS = []
SRC_FILE_EXTENSIONS = [
".c",
".cpp",
".cc",
".cxx",
".c++",
".S",
".spp",
".SPP",
".sx",
".s",
".asm",
".ASM",
]
# Suffix -> compiler kind (PlatformIO's CSUFFIXES/CXXSUFFIXES/ASSUFFIXES);
# "asm" merges SCons's AS and ASPP sets. Per CXXSUFFIXES .C/.C++ are C++
# here, even where SCons demotes .C on case-insensitive filesystems.
SOURCE_KIND_FOR_SUFFIX: dict[str, str] = {
".c": "c",
".cpp": "cxx",
".cc": "cxx",
".cxx": "cxx",
".c++": "cxx",
".C": "cxx",
".C++": "cxx",
".S": "asm",
".spp": "asm",
".SPP": "asm",
".sx": "asm",
".s": "asm",
".asm": "asm",
".ASM": "asm",
}
SRC_FILE_EXTENSIONS = list(SOURCE_KIND_FOR_SUFFIX)
DOMAIN = "pio_components"
# Marks a cache dir whose archive finished extracting; a missing marker
# means a torn extraction that must be redone
_EXTRACTED_MARKER = ".esphome_extracted"
ESPHOME_DATA_KEY = "ESPHOME"
ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE"
# Captured extra-script LINKFLAGS; kept apart from build.flags so they reach
# the link line (target_link_options), not target_compile_options
ESPHOME_DATA_LINK_FLAGS_KEY = "LINK_FLAGS"
class Source:
@@ -84,12 +104,13 @@ class Source:
class URLSource(Source):
def __init__(self, url: str):
def __init__(self, url: str, size: int | None = None):
self.url = url
# Archive size as reported by the registry, when known; sizes the
# combined prefetch bar without any extra network probe
self.size = size
def download(
self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = ""
) -> Path:
def _cache_dir(self, dir_suffix: str, salt: str, namespace: str) -> Path:
# Namespace the cache per backend (e.g. pio_components/idf, .../zephyr) so
# the build files each backend writes into the library dir can't collide.
base_dir = Path(CORE.data_dir) / DOMAIN
@@ -99,22 +120,40 @@ class URLSource(Source):
h.update(self.url.encode())
if salt:
h.update(salt.encode())
path = base_dir / h.hexdigest()[:8] / dir_suffix
return base_dir / h.hexdigest()[:8] / dir_suffix
def is_cached(self, dir_suffix: str, salt: str = "", namespace: str = "") -> bool:
"""Whether a completed extraction already exists for this source."""
return (
self._cache_dir(dir_suffix, salt, namespace) / _EXTRACTED_MARKER
).is_file()
def download(
self,
dir_suffix: str,
force: bool = False,
salt: str = "",
namespace: str = "",
progress: Callable[[int], None] | None = None,
) -> Path:
path = self._cache_dir(dir_suffix, salt, namespace)
# Marker file written last to signal a complete extraction. Using a
# marker (instead of just `path.is_dir()`) means an interrupted
# extraction is correctly detected and re-run on the next invocation,
# and lets us extract directly into ``path`` — avoiding a
# post-extraction rename that races with antivirus on Windows.
extracted_marker = path / ".esphome_extracted"
extracted_marker = path / _EXTRACTED_MARKER
if not extracted_marker.is_file() or force:
rmdir(path, msg=f"Clean up library directory {path}")
# Download in temporary file
with tempfile.NamedTemporaryFile() as tmp:
_LOGGER.info("Downloading %s ...", self.url)
if progress is None:
# A batch caller draws one combined bar and logs the list
_LOGGER.info("Downloading %s ...", self.url)
_LOGGER.debug("Location: %s", path)
download_from_mirrors([self.url], {}, tmp.file)
download_from_mirrors([self.url], {}, tmp.file, progress=progress)
_LOGGER.debug("Extracting archive to %s ...", path)
archive_extract_all(tmp.file, path)
@@ -203,6 +242,11 @@ class InvalidLibrary(Exception):
pass
class IncompatiblePlatform(InvalidLibrary):
"""The routine cross-platform skip, typed so callers need not match
message text."""
class ConvertedLibrary:
"""A resolved PlatformIO library plus its parsed manifest and on-disk path.
@@ -427,17 +471,22 @@ def check_library_data(data: dict, platform: str | None, framework: str):
if isinstance(platforms, str):
platforms = [a.strip() for a in platforms.split(",")]
platforms = ensure_list(platforms)
if not all(isinstance(pf, str) for pf in platforms):
# A real (non-platform) manifest problem; callers warn, not skip
raise InvalidLibrary(f"Malformed platforms value: {platforms!r}")
# Check if library supports the target platform
valid_platforms = platform is None or "*" in platforms or platform in platforms
if not valid_platforms:
raise InvalidLibrary(f"Unsupported library platforms: {platforms}")
raise IncompatiblePlatform(f"Unsupported library platforms: {platforms}")
frameworks = data.get("frameworks", "*")
if isinstance(frameworks, str):
frameworks = [a.strip() for a in frameworks.split(",")]
frameworks = ensure_list(frameworks)
if not all(isinstance(fw, str) for fw in frameworks):
raise InvalidLibrary(f"Malformed frameworks value: {frameworks!r}")
# Check if library declares the active framework. PIO library manifests
# often list only "arduino" even when the library actually compiles fine
@@ -455,7 +504,7 @@ def check_library_data(data: dict, platform: str | None, framework: str):
)
def _parse_library_json(library_json_path: PathType):
def parse_library_json(library_json_path: PathType):
"""
Load and parse a JSON file describing a library.
@@ -469,7 +518,7 @@ def _parse_library_json(library_json_path: PathType):
return json.load(fp)
def _parse_library_properties(library_properties_path: PathType):
def parse_library_properties(library_properties_path: PathType):
"""
Parse a key-value platformio .properties style file into a dictionary.
@@ -518,9 +567,10 @@ def _make_registry_client() -> Any:
def _resolve_registry_version(
owner: str | None, pkgname: str, requirements: set[str]
) -> tuple[str, str, str, str]:
) -> tuple[str, str, str, str, int | None]:
"""Resolve a registry package to the single highest version satisfying ALL
the given requirements; return ``(owner, name, version, download_url)``.
the given requirements; return ``(owner, name, version, download_url,
size)`` (``size`` is None when the registry omits it).
Intersecting every requirement (rather than resolving each consumer in
isolation) makes the result independent of processing order and guarantees
@@ -550,22 +600,130 @@ def _resolve_registry_version(
pkgfile = registry.pick_compatible_pkg_file(best["files"])
if not pkgfile:
raise RuntimeError(f"No package file for {owner}/{name}@{best['name']}")
return owner, name, best["name"], pkgfile["download_url"]
return owner, name, best["name"], pkgfile["download_url"], pkgfile.get("size")
def _normalize_dependencies(dependencies: Any) -> list[dict]:
def split_flag_entry(entry: Any, owner: str) -> list[str]:
"""``shlex.split`` with a clean error naming the offending flags entry."""
# Late import: shlex is only needed when actually lexing flags
import shlex
try:
return shlex.split(entry)
except (ValueError, AttributeError, TypeError) as err:
# AttributeError/TypeError: a dict or number from a third-party
# manifest; name the entry instead of an opaque shlex traceback
raise EsphomeError(f"Malformed build flag {entry!r} in {owner}: {err}") from err
def lex_build_flags(entries: str | list[str], owner: str) -> list[str]:
"""Shell-lex ``build.flags`` entries the way PlatformIO's ParseFlags
does; bare -I/-L/-l/-D tokens re-glue to their argument."""
# Lex per entry as ParseFlags does: a dangling -I must warn, not absorb
# the next entry's first token
return [
token
for entry in ensure_list(entries)
for token in join_flag_args(split_flag_entry(entry, owner), owner)
]
# Flags whose argument may follow as a separate token; ParseFlags glues them
BARE_ARG_FLAGS = frozenset({"-I", "-L", "-l", "-D"})
def join_flag_args(tokens: Iterable[str], owner: str) -> list[str]:
"""Join a bare ``-I``/``-L``/``-l``/``-D`` with its following token, as
PlatformIO's ParseFlags does. A trailing or empty argument is warned and
dropped: the bare flag would make gcc eat the next flag."""
out: list[str] = []
it = iter(tokens)
for tok in it:
if tok in BARE_ARG_FLAGS:
arg = next(it, None)
if arg is None:
_LOGGER.warning("Ignoring trailing '%s' in %s build flags", tok, owner)
break
if not arg:
_LOGGER.warning(
"Ignoring '%s' with empty argument in %s build flags", tok, owner
)
continue
tok += arg
out.append(tok)
return out
def warn_properties_depends(name: str, data: object) -> None:
"""Warn for ``depends=``-only manifests; the walk reads only the JSON
``dependencies`` key, so they would otherwise drop silently."""
if isinstance(data, dict) and not data.get("dependencies") and data.get("depends"):
# INFO: common and unactionable for transitive libraries; a WARNING
# on every build would train users to ignore the stream
_LOGGER.info(
"Library %s declares dependencies via library.properties "
"depends=, which are not resolved automatically; add them with "
"add_library() if needed",
name,
)
def dependency_is_usable(
dep: dict, platform: str | None, framework: str, requester: str
) -> bool:
"""Compatibility filter for a manifest dependency: platform mismatches
skip at debug, any other ``InvalidLibrary`` warns naming the requester."""
try:
check_library_data(dep, platform, framework)
except IncompatiblePlatform as e:
_LOGGER.debug("Skip dependency %s of %s: %s", dep.get("name"), requester, e)
return False
except InvalidLibrary as e:
_LOGGER.warning(
"Skipping dependency %s of %s: %s", dep.get("name"), requester, e
)
return False
return True
def _valid_dependency_entry(entry: dict, manifest_name: str) -> bool:
"""Whether a normalized entry carries a usable name (non-empty string)
and version (string, if present); invalid entries warn naming the
manifest."""
name = entry.get("name")
if (
isinstance(name, str)
and name
and ("version" not in entry or isinstance(entry["version"], str))
):
return True
_LOGGER.warning(
"Ignoring unrecognized dependency entry %r of %s", entry, manifest_name
)
return False
def normalize_dependencies(
dependencies: Any, manifest_name: str = "manifest"
) -> list[dict]:
"""Normalize a library manifest's ``dependencies`` to a list of dicts.
PIO's library.json accepts both the list-of-dicts form and the shorthand
dict form (``{"owner/Name": "version_spec"}``); normalize the latter so
callers see a uniform list.
PIO's library.json accepts the list-of-dicts form, the shorthand dict
form (``{"owner/Name": "version_spec"}``), bare name strings inside the
list, and a plain (possibly comma-separated) string; normalize them all
so callers see a uniform list. ``manifest_name`` names the manifest in the
warning for entries that cannot be normalized.
"""
if not dependencies:
return []
if isinstance(dependencies, str):
# A plain string is one or more comma-separated names; iterating it
# as a list would shred it into one-character "libraries"
return [{"name": n.strip()} for n in dependencies.split(",") if n.strip()]
if isinstance(dependencies, dict):
normalized = []
for raw_name, spec in dependencies.items():
if "/" in raw_name:
if isinstance(raw_name, str) and "/" in raw_name:
owner, pkgname = raw_name.split("/", 1)
else:
owner, pkgname = None, raw_name
@@ -574,9 +732,31 @@ def _normalize_dependencies(dependencies: Any) -> list[dict]:
entry.update(spec)
else:
entry["version"] = spec
normalized.append(entry)
if _valid_dependency_entry(entry, manifest_name):
normalized.append(entry)
return normalized
return [d for d in dependencies if isinstance(d, dict)]
if not isinstance(dependencies, (list, tuple)):
_LOGGER.warning(
"Ignoring unrecognized dependencies %r of %s",
dependencies,
manifest_name,
)
return []
normalized = []
for entry in dependencies:
if isinstance(entry, dict):
if _valid_dependency_entry(entry, manifest_name):
normalized.append(entry)
elif isinstance(entry, str) and entry:
# PIO also accepts a bare list of names ("dependencies": ["Wire"])
normalized.append({"name": entry})
else:
_LOGGER.warning(
"Ignoring unrecognized dependency entry %r of %s",
entry,
manifest_name,
)
return normalized
@dataclass
@@ -688,6 +868,102 @@ def _node_key(
return name, "registry", (owner, pkgname)
def lib_ignore_set() -> set[str]:
"""The ``lib_ignore`` names from ``esphome->platformio_options``,
normalized to lowercase short names (the part after the ``/``)."""
return {
name.split("/")[-1].lower()
for name in CORE.platformio_options.get("lib_ignore", [])
}
def is_lib_ignored(name: str | None, lib_ignore: set[str]) -> bool:
"""Whether ``name`` matches the normalized ``lib_ignore`` set."""
return (
bool(lib_ignore)
and name is not None
and (name.split("/")[-1].lower() in lib_ignore)
)
def _fetch_source(
component: ConvertedLibrary,
salt: str,
namespace: str,
tracker: Callable[[int], None],
) -> None:
# Straight to URLSource: only it takes progress, and mutating the
# shared component from a worker is the authoritative loop's job
component.source.download(
component.get_sanitized_name(), salt=salt, namespace=namespace, progress=tracker
)
def _prefetch_wave(
wave: list[tuple[str, ConvertedLibrary]], salt: str, namespace: str
) -> None:
"""Best-effort parallel download of a wave's registry archives.
The walk's own ``download()`` stays authoritative; duplicate URLs
prefetch once so two threads never share a cache directory. Archives
whose size the registry did not report are left to the sequential
loop, whose per-file bars don't interleave. A node a sibling in the
same wave supersedes has its archive fetched in vain (knowing better
would need the manifests being downloaded).
"""
try:
components: list[ConvertedLibrary] = []
seen: set[str] = set()
for _key, component in wave:
source = component.source
if not isinstance(source, URLSource) or not source.size:
continue
if source.url in seen:
continue
seen.add(source.url)
try:
cached = source.is_cached(
component.get_sanitized_name(), salt=salt, namespace=namespace
)
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
# Best-effort: a failing probe prefetches (and re-downloads)
_LOGGER.debug("Cache probe for %s failed: %s", component.name, err)
cached = False
if cached:
# A warm build must stay silent
continue
components.append(component)
if not components:
return
# Single-item waves (a dependency chain discovers one archive per
# wave) go through the same runner: one download method, one bar
_LOGGER.info(
"Downloading %d library archive(s): %s",
len(components),
", ".join(c.name for c in components),
)
failures = run_batch_downloads(
"Downloading libraries",
[
(c.name, c.source.size, partial(_fetch_source, c, salt, namespace))
for c in components
],
)
for name, err in failures:
# The sequential call below retries and raises the real error
_LOGGER.warning(
"Prefetch of %s failed (retrying sequentially): %s",
name,
failure_reason(err),
)
_LOGGER.debug("Prefetch failure detail", exc_info=err)
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
# Same policy as the ESP-IDF twin: the prefetch must never become a
# new way for the build to fail
_LOGGER.warning("Library prefetch failed: %s", err)
_LOGGER.debug("Prefetch failure detail", exc_info=True)
def convert_libraries(
libraries: list[Library], backend: LibraryBackend
) -> list[ConvertedLibrary]:
@@ -713,10 +989,7 @@ def convert_libraries(
"""
nodes: dict[str, _LibNode] = {}
lib_ignore = {
name.split("/")[-1].lower()
for name in CORE.platformio_options.get("lib_ignore", [])
}
lib_ignore = lib_ignore_set()
# The generated build files inside the shared cache bake in the dependency
# wiring, which lib_ignore changes; salt the cache path so configs with
@@ -728,11 +1001,6 @@ def convert_libraries(
else ""
)
def is_ignored(name: str | None) -> bool:
if not lib_ignore or name is None:
return False
return name.split("/")[-1].lower() in lib_ignore
def add_spec(name: str | None, version: str | None, repository: str | None) -> str:
key, kind, locator = _node_key(name, version, repository)
node = nodes.get(key) or _LibNode(key=key, is_git=kind == "git")
@@ -781,7 +1049,7 @@ def convert_libraries(
top_level = [
add_spec(library.name, library.version, library.repository)
for library in libraries
if not is_ignored(library.name)
if not is_lib_ignored(library.name, lib_ignore)
]
# Collect + resolve to a fixpoint: a node is (re)resolved whenever its
@@ -792,105 +1060,130 @@ def convert_libraries(
top_level_keys = set(top_level)
worklist = deque(dict.fromkeys(top_level))
while worklist:
key = worklist.popleft()
node = nodes[key]
# Drain the frontier sequentially (spec resolution mutates shared
# state), then prefetch the wave in parallel
wave: list[tuple[str, ConvertedLibrary]] = []
while worklist:
key = worklist.popleft()
node = nodes[key]
# A node is queued once per referring edge; skip the (uncached) registry
# lookup + download + dependency walk unless its requirement set grew
# since the last resolve. Requirements only ever grow, so this still
# converges the fixpoint and terminates dependency cycles.
requirements = frozenset(node.requirements)
if resolved_requirements.get(key) == requirements:
continue
resolved_requirements[key] = requirements
# Re-resolve only when the requirement set grew; requirements
# only ever grow, so the fixpoint converges and cycles terminate
requirements = frozenset(node.requirements)
if resolved_requirements.get(key) == requirements:
continue
resolved_requirements[key] = requirements
if node.is_git:
component = ConvertedLibrary(key, "*", GitSource(node.url, node.ref))
elif node.is_local:
component = ConvertedLibrary(key, "*", LocalSource(node.local_path))
else:
owner, name, version, url = _resolve_registry_version(
node.owner, node.pkgname, node.requirements
)
component = ConvertedLibrary(
_owner_pkgname_to_name(owner, name), version, URLSource(url)
)
component.download(salt=salt, namespace=backend.cache_key)
if node.is_git:
component = ConvertedLibrary(key, "*", GitSource(node.url, node.ref))
elif node.is_local:
component = ConvertedLibrary(key, "*", LocalSource(node.local_path))
else:
owner, name, version, url, size = _resolve_registry_version(
node.owner, node.pkgname, node.requirements
)
component = ConvertedLibrary(
_owner_pkgname_to_name(owner, name), version, URLSource(url, size)
)
wave.append((key, component))
_prefetch_wave(wave, salt, backend.cache_key)
for key, component in wave:
node = nodes[key]
if frozenset(node.requirements) != resolved_requirements[key]:
# Requirements grew mid-wave: skip parsing a manifest the
# next wave will re-resolve and replace
worklist.append(key)
continue
component.download(salt=salt, namespace=backend.cache_key)
source_dir = component.source_dir
library_json_path = source_dir / "library.json"
library_properties_path = source_dir / "library.properties"
has_json = library_json_path.is_file()
has_properties = library_properties_path.is_file()
if not has_json and not has_properties and not node.is_local:
# The shared cache can hold a broken copy (e.g. a clone or an
# extraction interrupted by a killed process). Force one
# re-download so a bad cache entry self-heals instead of failing
# every build until the user runs a full clean. A local source is
# read in place, so there is nothing to re-download.
_LOGGER.warning(
"Library %s at %s is missing library.json and library.properties; "
"re-downloading",
key,
source_dir,
)
component.download(force=True, salt=salt, namespace=backend.cache_key)
source_dir = component.source_dir
library_json_path = source_dir / "library.json"
library_properties_path = source_dir / "library.properties"
has_json = library_json_path.is_file()
has_properties = library_properties_path.is_file()
if has_json:
component.data = _parse_library_json(library_json_path)
elif has_properties:
component.data = _parse_library_properties(library_properties_path)
else:
# For a local library a missing manifest is user input, so raise
# EsphomeError (clean CLI message) like the missing-directory case;
# for registry/git a missing manifest means a corrupt cache, which
# is not user error, so keep RuntimeError.
error_cls = EsphomeError if node.is_local else RuntimeError
raise error_cls(
f"Invalid PIO library {key}: missing library.json and "
f"library.properties in {source_dir}"
)
if not has_json and not has_properties and not node.is_local:
# An interrupted clone/extraction self-heals with one forced
# re-download; a local source has nothing to re-download
_LOGGER.warning(
"Library %s at %s is missing library.json and library.properties; "
"re-downloading",
key,
source_dir,
)
component.download(force=True, salt=salt, namespace=backend.cache_key)
has_json = library_json_path.is_file()
has_properties = library_properties_path.is_file()
if has_json:
component.data = parse_library_json(library_json_path)
elif has_properties:
component.data = parse_library_properties(library_properties_path)
else:
# Local sources are user input (EsphomeError); a registry/git
# miss means a corrupt cache (RuntimeError)
error_cls = EsphomeError if node.is_local else RuntimeError
raise error_cls(
f"Invalid PIO library {key}: missing library.json and "
f"library.properties in {source_dir}"
)
try:
check_library_data(component.data, backend.platform, backend.framework)
except InvalidLibrary as e:
# Skip an incompatible transitive dependency, but fail fast if a
# top-level library the build explicitly requested is incompatible.
if key in top_level_keys:
raise RuntimeError(
f"Requested library {key} is not compatible with "
f"{backend.framework}: {e}"
) from e
_LOGGER.debug("Skip incompatible dependency %s: %s", key, str(e))
continue
components[key] = component
if not isinstance(component.data, dict) or not isinstance(
component.data.get("build", {}), dict
):
# A bare json.load imposes no shape; every backend dereferences
# data/build, so validate once here and name the library
raise EsphomeError(f"Library {key} has a malformed manifest")
warn_properties_depends(component.name, component.data)
# Requirements changed (we got past the short-circuit above), so
# (re)walk this component's dependencies.
node.edges = set()
for dependency in _normalize_dependencies(component.data.get("dependencies")):
if "name" not in dependency or "version" not in dependency:
continue
try:
check_library_data(dependency, backend.platform, backend.framework)
check_library_data(component.data, backend.platform, backend.framework)
except InvalidLibrary as e:
_LOGGER.debug("Skip dependency %s: %s", dependency.get("name"), str(e))
# An explicitly requested library fails fast; the routine
# cross-platform skip stays at debug, other causes warn
if key in top_level_keys:
raise RuntimeError(
f"Requested library {key} is not compatible with "
f"{backend.framework}: {e}"
) from e
if isinstance(e, IncompatiblePlatform):
_LOGGER.debug("Skip incompatible dependency %s: %s", key, str(e))
else:
_LOGGER.warning("Skipping dependency %s: %s", key, str(e))
continue
dep_name = _owner_pkgname_to_name(
dependency.get("owner"), dependency.get("name")
)
if is_ignored(dep_name):
_LOGGER.debug("Skip ignored dependency %s", dep_name)
continue
# The version field may actually be a URL (git/archive dependency).
dep_version = dependency["version"]
dep_url = _url_or_none(dep_version)
if dep_url is not None:
dep_version = None
dep_key = add_spec(dep_name, dep_version, dep_url)
node.edges.add(dep_key)
worklist.append(dep_key)
components[key] = component
# Requirements changed (we got past the short-circuit above), so
# (re)walk this component's dependencies.
node.edges = set()
for dependency in normalize_dependencies(
component.data.get("dependencies"), component.name
):
if "version" not in dependency:
# Cannot resolve from the registry; common for bundled
# names (Wire, SPI) -- unactionable noise above debug
_LOGGER.debug(
"Skip version-less dependency %r of %s",
dependency.get("name"),
component.name,
)
continue
if not dependency_is_usable(
dependency, backend.platform, backend.framework, component.name
):
continue
dep_name = _owner_pkgname_to_name(
dependency.get("owner"), dependency.get("name")
)
if is_lib_ignored(dep_name, lib_ignore):
_LOGGER.debug("Skip ignored dependency %s", dep_name)
continue
# The version field may actually be a URL (git/archive dependency).
dep_version = dependency["version"]
dep_url = _url_or_none(dep_version)
if dep_url is not None:
dep_version = None
dep_key = add_spec(dep_name, dep_version, dep_url)
node.edges.add(dep_key)
worklist.append(dep_key)
# A git or local source wins over the same component requested from the
# registry. That's intentional, but warn so the dropped registry spec isn't
+3
View File
@@ -254,6 +254,9 @@ def _ccache_runs(ccache: str) -> bool:
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=15,
# Repo-wide convention (posix_spawn fast path); see the
# close_fds=False call sites across esphome/ and script/helpers.py
close_fds=False,
)
except (OSError, subprocess.SubprocessError):
_LOGGER.warning(
+44 -11
View File
@@ -288,11 +288,13 @@ def copy_src_tree():
# Source file removed, delete target
p.unlink()
if target not in generated_files:
_LOGGER.debug("Source removed: %s", target)
sources_changed = True
else:
src_file = source_files_copy.pop(target)
with src_file.path() as src_path:
if copy_file_if_changed(src_path, p) and target not in generated_files:
_LOGGER.debug("Source changed: %s", target)
sources_changed = True
# Now copy new files
@@ -303,21 +305,25 @@ def copy_src_tree():
copy_file_if_changed(src_path, dst_path)
and target not in generated_files
):
_LOGGER.debug("Source added: %s", target)
sources_changed = True
# Finally copy defines
if write_file_if_changed(
CORE.relative_src_path("esphome", "core", "defines.h"), generate_defines_h()
):
_LOGGER.debug("Source changed: esphome/core/defines.h")
sources_changed = True
write_file_if_changed(CORE.relative_build_path("README.txt"), ESPHOME_README_TXT)
if write_file_if_changed(
CORE.relative_src_path("esphome.h"), ESPHOME_H_FORMAT.format(include_s)
):
_LOGGER.debug("Source changed: esphome.h")
sources_changed = True
if write_file_if_changed(
CORE.relative_src_path("esphome", "core", "version.h"), generate_version_h()
):
_LOGGER.debug("Source changed: esphome/core/version.h")
sources_changed = True
# Generate new build_info files if needed
@@ -332,18 +338,13 @@ def copy_src_tree():
# Defensively force a rebuild if the build_info files don't exist, or if
# there was a config change which didn't actually cause a source change
if not build_info_data_h_path.exists() or not build_info_data_cpp_path.exists():
if _build_info_stale(
build_info_data_h_path,
build_info_data_cpp_path,
build_info_json_path,
config_hash,
):
sources_changed = True
else:
try:
existing = json.loads(build_info_json_path.read_text(encoding="utf-8"))
if (
existing.get("config_hash") != config_hash
or existing.get("esphome_version") != __version__
):
sources_changed = True
except (json.JSONDecodeError, KeyError, OSError):
sources_changed = True
# Write build_info header and JSON metadata
if sources_changed:
@@ -397,6 +398,38 @@ def generate_version_h():
)
def _build_info_stale(
h_path: Path, cpp_path: Path, json_path: Path, config_hash: int
) -> bool:
"""Whether the build-info sources must regenerate (missing or stale)."""
if not h_path.exists() or not cpp_path.exists():
_LOGGER.debug("Build info files missing; regenerating")
return True
try:
existing = json.loads(json_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
_LOGGER.debug("Build info JSON unreadable; regenerating")
return True
if not isinstance(existing, dict):
# Valid JSON that is not an object (truncated or hand-edited) is
# stale, not a traceback
_LOGGER.debug("Build info JSON malformed; regenerating")
return True
if (
existing.get("config_hash") != config_hash
or existing.get("esphome_version") != __version__
):
_LOGGER.debug(
"Build info stale (config_hash %s -> %s, version %s -> %s)",
existing.get("config_hash"),
config_hash,
existing.get("esphome_version"),
__version__,
)
return True
return False
def get_build_info() -> tuple[int, int, str, str]:
"""Calculate build_info values from current config.
+1 -1
View File
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.1.19
esptool==5.3.1
click==8.3.3
aioesphomeapi==45.13.1
aioesphomeapi==46.0.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.150.0
puremagic==2.2.0
+12 -4
View File
@@ -525,13 +525,21 @@ def _esp32_platformio_path_or_file_trigger(files: list[str]) -> bool:
return False
# ESP-IDF infra: changes under esphome/espidf/ or to the IDF build generator
# affect every esp32 IDF build (now the default toolchain) but aren't
# Native-build infra: changes under esphome/espidf/, the shared
# esphome/build_helpers/ package, or the modules the native ESP-IDF build
# imports affect every esp32 IDF build (now the default toolchain) but aren't
# components, so the component matrix wouldn't otherwise force any esp32
# compile. When they change we fold the `esp32` component into the matrix so
# the default native-IDF build path is still compiled on an infra-only PR.
ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES = ("esphome/espidf/",)
ESP_IDF_INFRA_TRIGGER_FILES = frozenset({"esphome/build_gen/espidf.py"})
ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES = ("esphome/espidf/", "esphome/build_helpers/")
ESP_IDF_INFRA_TRIGGER_FILES = frozenset(
{
"esphome/build_gen/espidf.py",
"esphome/framework_helpers.py",
"esphome/platformio/library.py",
"esphome/platformio/extra_script.py",
}
)
def _esp_idf_infra_changed(files: list[str]) -> bool:
@@ -0,0 +1,19 @@
esphome:
name: scan-window-explicit
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: MySSID
esp32_ble_tracker:
scan_parameters:
window: 30ms
bluetooth_proxy:
active: true
api:
@@ -0,0 +1,17 @@
esphome:
name: scan-window-raised
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: MySSID
esp32_ble_tracker:
bluetooth_proxy:
active: true
api:
@@ -0,0 +1,12 @@
esphome:
name: scan-window-scan-only
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: MySSID
esp32_ble_tracker:
@@ -0,0 +1,14 @@
esphome:
name: scan-window-user-scan-only
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: MySSID
esp32_ble_tracker:
scan_parameters:
connection_scan_window: 20ms
@@ -12,11 +12,12 @@ arbiter a full-duty scan would starve wifi, so the 30 ms default is kept.
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome import config_validation as cv
from esphome.components.ble_device_base import to_ble_units
from esphome.components.ble_device_base import CONF_CONNECTION_SCAN_WINDOW, to_ble_units
from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW
from esphome.components.esp32 import KEY_IDF_VERSION
from esphome.components.esp32_ble_tracker import (
@@ -120,3 +121,103 @@ def test_short_interval_without_window_still_rejected(
stage_esp32("5.5.5", wifi=True)
with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"):
_scan_params({"scan_parameters": {"interval": "20ms"}})
# The connection-time fallback window: while a GATT connection is active the
# scanner drops from a raised full-duty window back to this value so the
# connection gets guaranteed airtime.
def test_raise_arms_connection_scan_window_default(
stage_esp32: Callable[..., None],
) -> None:
stage_esp32("5.5.5", wifi=True)
params = _scan_params({})
assert params[CONF_WINDOW] == params[CONF_INTERVAL]
assert to_ble_units(params[CONF_CONNECTION_SCAN_WINDOW]) == 48
def test_user_connection_scan_window_survives_raise(
stage_esp32: Callable[..., None],
) -> None:
stage_esp32("5.5.5", wifi=True)
params = _scan_params({"scan_parameters": {"connection_scan_window": "60ms"}})
assert params[CONF_WINDOW] == params[CONF_INTERVAL]
assert to_ble_units(params[CONF_CONNECTION_SCAN_WINDOW]) == 96
def test_unraised_window_gets_no_connection_scan_window_default(
stage_esp32: Callable[..., None],
) -> None:
stage_esp32("5.5.4", wifi=True)
assert CONF_CONNECTION_SCAN_WINDOW not in _scan_params({})
def test_connection_scan_window_above_interval_rejected(
stage_esp32: Callable[..., None],
) -> None:
stage_esp32("5.5.5", wifi=True)
with pytest.raises(
cv.Invalid, match="connection_scan_window .* needs to be smaller"
):
_scan_params({"scan_parameters": {"connection_scan_window": "400ms"}})
def test_connection_scan_window_above_window_rejected(
stage_esp32: Callable[..., None],
) -> None:
"""A connection window above the (post-raise) window would widen the scan
during connections; the reject runs after the raise so a fallback below a
raised window still validates (covered by the survives-raise test)."""
stage_esp32("5.5.5", wifi=True)
with pytest.raises(
cv.Invalid, match="connection_scan_window .* needs to be smaller"
):
_scan_params(
{"scan_parameters": {"window": "30ms", "connection_scan_window": "300ms"}}
)
def test_connection_scan_window_truncation_collapse_rejected(
stage_esp32: Callable[..., None],
) -> None:
"""A connection window that truncates into the interval's 0.625 ms unit
would silently program a full-duty scan during connections."""
stage_esp32("5.5.5", wifi=True)
with pytest.raises(cv.Invalid, match="connection_scan_window .* both truncate"):
_scan_params(
{
"scan_parameters": {
"interval": "320.5ms",
"connection_scan_window": "320.2ms",
}
}
)
@pytest.mark.parametrize(
("config_file", "window_call", "connection_call", "warns"),
[
# Raised window with GATT clients: the injected fallback is emitted.
("scan_window_raised.yaml", "set_scan_window(512)", True, False),
# Explicit window: nothing injected.
("scan_window_explicit.yaml", "set_scan_window(48)", False, False),
# Scan-only build compiles the path out: the injected default is
# dropped silently, a user-set value warns.
("scan_window_scan_only.yaml", "set_scan_window(512)", False, False),
("scan_window_user_set_scan_only.yaml", "set_scan_window(512)", False, True),
],
)
def test_connection_scan_window_codegen(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
caplog: pytest.LogCaptureFixture,
config_file: str,
window_call: str,
connection_call: bool,
warns: bool,
) -> None:
main_cpp = generate_main(component_config_path(config_file))
assert window_call in main_cpp
assert ("set_connection_scan_window(48)" in main_cpp) == connection_call
assert ("'connection_scan_window' has no effect" in caplog.text) == warns
@@ -16,7 +16,13 @@ from esphome.components.modbus_client import (
CONFIG_SCHEMA,
MODBUS_CLIENT_SEND_SCHEMA,
)
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_ON_ERROR, CONF_ON_RESPONSE
from esphome.const import (
CONF_ADDRESS,
CONF_CONTINUOUS,
CONF_ID,
CONF_ON_ERROR,
CONF_ON_RESPONSE,
)
from esphome.core import Lambda
from esphome.types import ConfigType
@@ -118,6 +124,29 @@ def test_on_no_response_retry_lambda_accepted() -> None:
)
def test_continuous_on_write_pdu_rejected() -> None:
"""A literal write-code PDU with continuous: true is rejected at config time (reads only)."""
with pytest.raises(cv.Invalid, match="does not apply to a write PDU"):
MODBUS_CLIENT_SEND_SCHEMA(
{
CONF_ADDRESS: 0x01,
CONF_PDU: [0x06, 0x00, 0x01, 0x00, 0x0A],
CONF_CONTINUOUS: True,
}
)
def test_continuous_on_read_pdu_accepted() -> None:
"""A literal read-code PDU with continuous: true is fine - continuous polling applies to reads."""
MODBUS_CLIENT_SEND_SCHEMA(
{
CONF_ADDRESS: 0x01,
CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01],
CONF_CONTINUOUS: True,
}
)
# The standalone component block. The compile fixtures cover the accepted shapes end to end; these pin
# the parts a fixture cannot express - a rejection, and a module flag whose absence breaks other
# components rather than this one.
+11
View File
@@ -0,0 +1,11 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# No host camera platform exists to emit USE_CAMERA; define it here so
# the iterator CAMERA state compiles into the test binary.
async def to_code_testing(config):
cg.add_define("USE_CAMERA")
manifest.to_code = to_code_testing
@@ -0,0 +1,79 @@
#include <gtest/gtest.h>
#include "esphome/core/component_iterator.h"
#ifdef USE_CAMERA
#include "esphome/components/camera/camera.h"
namespace esphome::testing {
class StubCamera : public camera::Camera {
public:
void add_listener(camera::CameraListener *listener) override {}
camera::CameraImageReader *create_image_reader() override { return nullptr; }
void request_image(camera::CameraRequester requester) override {}
void start_stream(camera::CameraRequester requester) override {}
void stop_stream(camera::CameraRequester requester) override {}
};
// Iterator that accepts everything except the camera, which can refuse a
// configurable number of times. The CAMERA state is a singleton path
// distinct from process_platform_item_; this pins the same contract:
// a refused camera is re-offered, never skipped.
class CameraRefusingIterator : public ComponentIterator {
public:
// NOLINTBEGIN(bugprone-macro-parentheses)
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
bool on_##singular(type *obj) override { return true; }
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
ENTITY_TYPE_(type, singular, plural, count, upper)
#include "esphome/core/entity_types.h"
#undef ENTITY_TYPE_
#undef ENTITY_CONTROLLER_TYPE_
// NOLINTEND(bugprone-macro-parentheses)
bool on_camera(camera::Camera *obj) override {
this->camera_calls++;
if (this->camera_refusals > 0) {
this->camera_refusals--;
return false;
}
return true;
}
int camera_calls{0};
int camera_refusals{0};
};
// Far above the fixed number of iterator states
static constexpr size_t BIG_BUDGET = 1000;
class ComponentIteratorCameraTest : public ::testing::Test {
protected:
void SetUp() override {
// Constructing a Camera installs the process-wide singleton
static StubCamera stub_camera;
ASSERT_EQ(camera::Camera::instance(), &stub_camera);
}
};
TEST_F(ComponentIteratorCameraTest, RefusedCameraIsReofferedNotSkipped) {
CameraRefusingIterator it;
it.camera_refusals = 2;
it.begin();
// Runs until the camera refuses, which stops the pass
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.camera_calls, 1);
EXPECT_FALSE(it.completed());
// The camera is re-offered once per call, not skipped
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.camera_calls, 2);
EXPECT_FALSE(it.completed());
// Once accepted, the iteration completes
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_EQ(it.camera_calls, 3);
}
} // namespace esphome::testing
#endif // USE_CAMERA
+11
View File
@@ -0,0 +1,11 @@
# Pulls in sensor so entity iteration paths compile (USE_SENSOR);
# tests register their own instances. Plain yaml.safe_load, no ESPHome tags.
# An alphabetically-earlier component's sensor: block shadows this one in
# combined builds; the tests' sensor-count ASSERT catches a capacity drop.
sensor:
- platform: template
id: bench_sensor_a
name: "Bench A"
- platform: template
id: bench_sensor_b
name: "Bench B"
@@ -0,0 +1,195 @@
#include <gtest/gtest.h>
#include "esphome/core/component_iterator.h"
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/application.h"
#endif
namespace esphome::testing {
// Iterator whose begin/end callbacks can refuse a configurable number of
// times; all entity callbacks accept (any registered entities are accepted).
class RefusingIterator : public ComponentIterator {
public:
// NOLINTBEGIN(bugprone-macro-parentheses)
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
bool on_##singular(type *obj) override { return true; }
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
ENTITY_TYPE_(type, singular, plural, count, upper)
#include "esphome/core/entity_types.h"
#undef ENTITY_TYPE_
#undef ENTITY_CONTROLLER_TYPE_
// NOLINTEND(bugprone-macro-parentheses)
bool on_begin() override { return step(this->begin_calls, this->begin_refusals); }
bool on_end() override { return step(this->end_calls, this->end_refusals); }
int begin_calls{0};
int end_calls{0};
int begin_refusals{0};
int end_refusals{0};
protected:
static bool step(int &calls, int &refusals) {
calls++;
if (refusals > 0) {
refusals--;
return false;
}
return true;
}
};
// Far above the fixed number of iterator states
static constexpr size_t BIG_BUDGET = 1000;
TEST(ComponentIterator, NotRunningMakesNoProgress) {
RefusingIterator it;
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_EQ(it.begin_calls, 0);
EXPECT_EQ(it.end_calls, 0);
}
TEST(ComponentIterator, CompletesInOneCallWithoutRefusals) {
RefusingIterator it;
it.begin();
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_EQ(it.begin_calls, 1);
EXPECT_EQ(it.end_calls, 1);
}
TEST(ComponentIterator, StepBudgetIsHonored) {
RefusingIterator it;
it.begin();
it.try_advance(1);
EXPECT_EQ(it.begin_calls, 1);
EXPECT_EQ(it.end_calls, 0);
EXPECT_FALSE(it.completed());
}
TEST(ComponentIterator, RefusedStepStopsBatchAndRetriesSameStep) {
RefusingIterator it;
it.end_refusals = 3;
it.begin();
// First call runs until the refused end step, which stops the pass
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.end_calls, 1);
EXPECT_FALSE(it.completed());
// The refused step is retried once per call, not skipped
it.try_advance(BIG_BUDGET);
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.end_calls, 3);
EXPECT_FALSE(it.completed());
// Once accepted, the iteration completes
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_EQ(it.end_calls, 4);
}
TEST(ComponentIterator, RefusedBeginStopsBatchAndRetries) {
RefusingIterator it;
it.begin_refusals = 2;
it.begin();
it.try_advance(BIG_BUDGET);
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.begin_calls, 2);
EXPECT_FALSE(it.completed());
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_EQ(it.begin_calls, 3);
}
// The deprecated advance() wrapper must keep the legacy once-per-loop
// pattern working during the deprecation window.
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
TEST(ComponentIterator, DeprecatedAdvanceKeepsLegacyPatternWorking) {
RefusingIterator it;
it.end_refusals = 2;
it.begin();
size_t guard = 0;
while (!it.completed() && guard++ < BIG_BUDGET) {
it.advance();
}
EXPECT_TRUE(it.completed());
// Two refused end steps were retried, then accepted
EXPECT_EQ(it.end_calls, 3);
}
#pragma GCC diagnostic pop
#ifdef USE_SENSOR
// Iterator whose sensor callback can refuse or yield; pins the per-item
// contract: a refused item is re-offered with at_ unchanged, never skipped.
class ItemRefusingIterator : public RefusingIterator {
public:
bool on_sensor(sensor::Sensor *obj) override {
this->last_sensor = obj;
if (!step(this->sensor_calls, this->sensor_refusals))
return false;
if (this->yield_on_sensor)
this->yield_after_step_();
return true;
}
sensor::Sensor *last_sensor{nullptr};
int sensor_calls{0};
int sensor_refusals{0};
bool yield_on_sensor{false};
};
class ComponentIteratorSensorTest : public ::testing::Test {
protected:
void SetUp() override {
static sensor::Sensor sensor_a;
static sensor::Sensor sensor_b;
static bool registered = false;
if (!registered) {
App.register_sensor(&sensor_a);
App.register_sensor(&sensor_b);
registered = true;
}
// StaticVector drops silently when full; fail the fixture, not the contract
ASSERT_EQ(App.get_sensors().size(), 2u) << "benchmark.yaml sensor count too small";
}
};
TEST_F(ComponentIteratorSensorTest, RefusedItemIsReofferedNotSkipped) {
ItemRefusingIterator it;
it.sensor_refusals = 2;
it.begin();
// Runs until the first sensor refuses
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.sensor_calls, 1);
EXPECT_FALSE(it.completed());
// The refused item is re-offered, not skipped
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.sensor_calls, 2);
sensor::Sensor *refused = it.last_sensor;
// Once accepted, iteration continues through the second sensor to the end
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_NE(it.last_sensor, refused);
EXPECT_EQ(it.sensor_calls, 4);
}
TEST_F(ComponentIteratorSensorTest, YieldAfterStepEndsPassAndResumes) {
ItemRefusingIterator it;
it.yield_on_sensor = true;
it.begin();
// The pass ends right after the first sensor despite a big budget
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.sensor_calls, 1);
EXPECT_FALSE(it.completed());
// The next pass ends after the second sensor
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.sensor_calls, 2);
// Remaining states then run to completion in one pass
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
}
#endif // USE_SENSOR
} // namespace esphome::testing
@@ -2,10 +2,19 @@
#include <utility>
#include "../common.h"
#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h"
namespace esphome::mitsubishi_cn105::testing {
struct MitsubishiCN105ClimateTestContext {
MitsubishiCN105Component component;
MitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext() { this->sut.set_parent(&this->component); }
};
TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpectedValues) {
TestableMitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext context;
const auto mapping = TemperatureMapping();
for (int temperature = 16; temperature <= 31; ++temperature) {
@@ -13,7 +22,7 @@ TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpecte
EXPECT_EQ(mapping.from_mitsubishi(temperature), temperature);
}
const auto traits = sut.traits();
const auto traits = context.sut.traits();
EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::CELSIUS);
EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 16.0f);
EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 31.0f);
@@ -22,10 +31,10 @@ TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpecte
}
TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingAndTraitsMatchExpectedValues) {
TestableMitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext context;
auto mapping = TemperatureMapping();
mapping.set_use_fahrenheit(true);
sut.set_use_fahrenheit(true);
context.component.set_use_fahrenheit(true);
const std::array cases{
std::pair{61, 16.0f}, std::pair{62, 16.5f}, std::pair{63, 17.0f}, std::pair{64, 17.5f}, std::pair{65, 18.0f},
@@ -40,7 +49,7 @@ TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingAndTraitsMatchExpe
EXPECT_FLOAT_EQ(mapping.to_mitsubishi(fahrenheit), mitsubishi_celsius);
EXPECT_FLOAT_EQ(mapping.from_mitsubishi(mitsubishi_celsius), fahrenheit);
}
const auto traits = sut.traits();
const auto traits = context.sut.traits();
EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::FAHRENHEIT);
EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 61.0f);
EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 88.0f);
@@ -63,163 +72,44 @@ TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingUsesLinearConversi
}
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeOffLeavesTraitsEmpty) {
TestableMitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext context;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_OFF);
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_OFF);
EXPECT_FALSE(sut.traits().get_supports_swing_modes());
EXPECT_FALSE(context.sut.traits().get_supports_swing_modes());
}
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeVerticalExposesOffAndVertical) {
TestableMitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext context;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
}
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeHorizontalExposesOffAndHorizontal) {
TestableMitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext context;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
}
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeBothExposesAllExpectedModes) {
TestableMitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext context;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsVerticalSwingWhenSupported) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_VERTICAL);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsHorizontalSwingWhenSupported) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
sut.status().vane_mode = MitsubishiCN105::VaneMode::AUTO;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_HORIZONTAL);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsBothSwingWhenSupported) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_BOTH);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsSwingOffWhenNoSwingActive) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
sut.status().vane_mode = MitsubishiCN105::VaneMode::POSITION_3;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesRemembersLastNonSwingPositions) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
sut.status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::RIGHT;
sut.apply_values_();
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_4);
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::RIGHT);
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
sut.apply_values_();
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_4);
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::RIGHT);
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_BOTH);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesDoesNotOverwriteRememberedPositionWithUnknownValues) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
sut.last_non_swing_vane_mode_ = MitsubishiCN105::VaneMode::POSITION_2;
sut.last_non_swing_wide_vane_mode_ = MitsubishiCN105::WideVaneMode::LEFT;
sut.status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::UNKNOWN;
sut.apply_values_();
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_2);
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::LEFT);
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesIgnoresUnsupportedVerticalSwingState) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesIgnoresUnsupportedHorizontalSwingState) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
sut.status().vane_mode = MitsubishiCN105::VaneMode::AUTO;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
}
} // namespace esphome::mitsubishi_cn105::testing
@@ -0,0 +1,99 @@
#include "../common.h"
#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_swing_mode_manager.h"
namespace esphome::mitsubishi_cn105::testing {
static SwingModeManager make_swing_mode_manager(std::initializer_list<climate::ClimateSwingMode> supported_modes) {
SwingModeManager manager;
climate::ClimateSwingModeMask supported_swing_modes;
for (const auto mode : supported_modes)
supported_swing_modes.insert(mode);
manager.set_supported_swing_modes(supported_swing_modes);
return manager;
}
TEST(SwingModeManagerTests, StatusMapsVerticalSwingWhenSupported) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL});
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::CENTER),
std::optional{climate::CLIMATE_SWING_VERTICAL});
}
TEST(SwingModeManagerTests, StatusMapsHorizontalSwingWhenSupported) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL});
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::WideVaneMode::SWING),
std::optional{climate::CLIMATE_SWING_HORIZONTAL});
}
TEST(SwingModeManagerTests, StatusMapsBothSwingWhenSupported) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::SWING),
std::optional{climate::CLIMATE_SWING_BOTH});
}
TEST(SwingModeManagerTests, StatusMapsSwingOffWhenNoSwingActive) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
EXPECT_EQ(
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::POSITION_3, MitsubishiCN105::WideVaneMode::CENTER),
std::optional{climate::CLIMATE_SWING_OFF});
}
TEST(SwingModeManagerTests, RemembersLastNonSwingPositions) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::WideVaneMode::RIGHT);
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::SWING);
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::VaneMode::POSITION_4});
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::WideVaneMode::RIGHT});
}
TEST(SwingModeManagerTests, UnknownValuesDoNotOverwriteRememberedPositions) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::POSITION_2, MitsubishiCN105::WideVaneMode::LEFT);
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::UNKNOWN, MitsubishiCN105::WideVaneMode::UNKNOWN);
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::VaneMode::POSITION_2});
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::WideVaneMode::LEFT});
}
TEST(SwingModeManagerTests, UnsupportedVerticalSwingStateIsIgnored) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL});
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::CENTER),
std::optional{climate::CLIMATE_SWING_OFF});
}
TEST(SwingModeManagerTests, UnsupportedHorizontalSwingStateIsIgnored) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL});
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::WideVaneMode::SWING),
std::optional{climate::CLIMATE_SWING_OFF});
}
TEST(SwingModeManagerTests, SwingModeFromReturnsNulloptWhenNoSwingModesSupported) {
auto manager = make_swing_mode_manager({});
EXPECT_FALSE(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::SWING)
.has_value());
}
TEST(SwingModeManagerTests, VaneFromSwingModeReturnsNulloptWhenVerticalUnsupported) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL});
EXPECT_FALSE(manager.vane_from(climate::CLIMATE_SWING_VERTICAL).has_value());
}
TEST(SwingModeManagerTests, WideVaneFromSwingModeReturnsNulloptWhenHorizontalUnsupported) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL});
EXPECT_FALSE(manager.wide_vane_from(climate::CLIMATE_SWING_HORIZONTAL).has_value());
}
TEST(SwingModeManagerTests, VaneAndWideVaneFromSwingModeMapSwingModes) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_VERTICAL), std::optional{MitsubishiCN105::VaneMode::SWING});
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_BOTH), std::optional{MitsubishiCN105::VaneMode::SWING});
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_HORIZONTAL),
std::optional{MitsubishiCN105::WideVaneMode::SWING});
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_BOTH), std::optional{MitsubishiCN105::WideVaneMode::SWING});
}
} // namespace esphome::mitsubishi_cn105::testing
@@ -64,26 +64,4 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 {
void set_current_time(uint32_t ms) { test_loop_time_ms = ms; }
};
class TestableMitsubishiCN105Climate : public MitsubishiCN105Climate {
public:
TestableMitsubishiCN105Climate() { this->set_parent(&this->component_); }
using MitsubishiCN105Climate::apply_values_;
using MitsubishiCN105Climate::last_non_swing_vane_mode_;
using MitsubishiCN105Climate::last_non_swing_wide_vane_mode_;
MitsubishiCN105::Status &status() { return const_cast<MitsubishiCN105::Status &>(this->component_.status()); }
void set_use_fahrenheit(bool value) { this->component_.set_use_fahrenheit(value); }
protected:
MitsubishiCN105Component component_;
};
class TestableMitsubishiCN105Component : public MitsubishiCN105Component {
public:
MitsubishiCN105::Status &mutable_status() { return const_cast<MitsubishiCN105::Status &>(this->status()); }
void notify_status() { this->status_callback_.call(); }
};
} // namespace esphome::mitsubishi_cn105::testing
@@ -3,7 +3,7 @@
namespace esphome::mitsubishi_cn105::testing {
TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) {
TestableMitsubishiCN105Component hub;
MitsubishiCN105Component hub;
size_t callback_count = 0;
std::optional<VerticalVaneMode> callback_direction;
hub.add_on_vane_state_callback([&](const VaneState &state) {
@@ -11,8 +11,9 @@ TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) {
callback_direction = state.vertical.direction;
});
hub.mutable_status().room_temperature = 20.0f;
hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4;
hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
hub.set_target_temperature(20.0f);
hub.set_vane_mode(MitsubishiCN105::VaneMode::POSITION_4);
hub.publish_status();
EXPECT_EQ(callback_count, 1);
@@ -25,7 +26,7 @@ TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) {
}
TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) {
TestableMitsubishiCN105Component hub;
MitsubishiCN105Component hub;
size_t status_callback_count = 0;
size_t vane_callback_count = 0;
std::optional<VerticalVaneMode> callback_direction;
@@ -35,15 +36,16 @@ TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) {
callback_direction = state.vertical.direction;
});
hub.mutable_status().room_temperature = 20.0f;
hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN;
hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
hub.set_target_temperature(20.0f);
ASSERT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::UNKNOWN);
hub.publish_status();
EXPECT_EQ(status_callback_count, 1);
EXPECT_EQ(vane_callback_count, 1);
EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_UNKNOWN});
hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4;
hub.set_vane_mode(MitsubishiCN105::VaneMode::POSITION_4);
hub.publish_status();
EXPECT_EQ(status_callback_count, 2);
@@ -52,7 +54,7 @@ TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) {
}
TEST(MitsubishiCN105ComponentTests, VaneCallAppliesVerticalDirection) {
TestableMitsubishiCN105Component hub;
MitsubishiCN105Component hub;
auto call = hub.make_vane_call();
call.vertical.set_direction(VERTICAL_VANE_MODE_POSITION_5);
@@ -62,12 +64,11 @@ TEST(MitsubishiCN105ComponentTests, VaneCallAppliesVerticalDirection) {
}
TEST(MitsubishiCN105ComponentTests, VaneControlActionAppliesConfiguredFields) {
TestableMitsubishiCN105Component hub;
MitsubishiCN105Component hub;
VaneControlAction<> action(&hub, [](VaneCall &call) { call.vertical.set_direction(VERTICAL_VANE_MODE_SWING); });
action.play();
EXPECT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::SWING);
}
} // namespace esphome::mitsubishi_cn105::testing
@@ -3,14 +3,9 @@
namespace esphome::mitsubishi_cn105::testing {
class TestableMitsubishiCN105VerticalVaneDirectionSelect : public MitsubishiCN105VerticalVaneDirectionSelect {
public:
using MitsubishiCN105VerticalVaneDirectionSelect::control;
};
struct VerticalVaneDirectionSelectTestContext {
TestableMitsubishiCN105Component hub;
TestableMitsubishiCN105VerticalVaneDirectionSelect select;
MitsubishiCN105Component hub;
MitsubishiCN105VerticalVaneDirectionSelect select;
VerticalVaneDirectionSelectTestContext() {
this->select.traits.set_options({"Auto", "1", "2", "3", "4", "5", "Swing"});
@@ -31,13 +26,15 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, MapsIndexesToVaneModes) {
for (size_t i = 0; i < expected_modes.size(); ++i) {
SCOPED_TRACE(i);
ctx.select.control(i);
ctx.select.make_call().set_index(i).perform();
EXPECT_EQ(ctx.hub.status().vane_mode, expected_modes[i]);
}
}
TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, PublishesIncomingVaneModes) {
VerticalVaneDirectionSelectTestContext ctx;
ctx.hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
ctx.hub.set_target_temperature(20.0f);
constexpr std::array modes{
MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1,
@@ -48,13 +45,12 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, PublishesIncomingVaneModes
for (size_t i = 0; i < modes.size(); ++i) {
SCOPED_TRACE(i);
ctx.hub.mutable_status().vane_mode = modes[i];
ctx.hub.notify_status();
ctx.hub.set_vane_mode(modes[i]);
ctx.hub.publish_status();
EXPECT_EQ(ctx.select.active_index(), std::optional{i});
}
ctx.hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN;
ctx.hub.notify_status();
ctx.select.publish_vane_state(MitsubishiCN105::VaneMode::UNKNOWN);
EXPECT_EQ(ctx.select.active_index(), std::optional{modes.size() - 1});
}
@@ -64,14 +60,15 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ControlPublishesSelectAndC
climate_entity.set_parent(&ctx.hub);
climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
ctx.hub.mutable_status().room_temperature = 20.0f;
ctx.hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
ctx.hub.set_target_temperature(20.0f);
climate_entity.setup();
ctx.select.control(6);
ctx.select.make_call().set_index(6).perform();
EXPECT_EQ(ctx.select.active_index(), std::optional<size_t>{6});
EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_VERTICAL);
ctx.select.control(3);
ctx.select.make_call().set_index(3).perform();
EXPECT_EQ(ctx.select.active_index(), std::optional<size_t>{3});
EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_OFF);
}
@@ -82,7 +79,8 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ClimateControlPublishesSel
climate_entity.set_parent(&ctx.hub);
climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
ctx.hub.mutable_status().room_temperature = 20.0f;
ctx.hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
ctx.hub.set_target_temperature(20.0f);
climate_entity.setup();
climate_entity.make_call().set_swing_mode(climate::CLIMATE_SWING_VERTICAL).perform();
@@ -95,10 +93,9 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ClimateControlPublishesSel
TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, BeforeInitializationDoesNotPublishSelectState) {
VerticalVaneDirectionSelectTestContext ctx;
ctx.select.control(3);
ctx.select.make_call().set_index(3).perform();
EXPECT_EQ(ctx.hub.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_3);
EXPECT_FALSE(ctx.select.has_state());
}
} // namespace esphome::mitsubishi_cn105::testing
@@ -322,14 +322,14 @@ TEST(ModbusClientHubPriority, ContinuousReadRequeuesOnSuccessOnly) {
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
EXPECT_TRUE(hub.queued(0).options.continuous);
hub.force_send_next();
// A matching successful response cycles the continuous entry back to READY.
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
EXPECT_TRUE(hub.queued(0).options.continuous);
// An exception response ends the poll.
hub.force_send_next();
@@ -346,13 +346,13 @@ TEST(ModbusClientHubPriority, RetriedContinuousReadStaysContinuous) {
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_TRUE(hub.queued(0).continuous);
ASSERT_TRUE(hub.queued(0).options.continuous);
hub.force_send_next();
hub.timeout_waiting(); // no response -> device requests retry
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous); // the retried poll stays continuous
EXPECT_TRUE(hub.queued(0).options.continuous); // the retried poll stays continuous
}
// A one-shot duplicate downgrades a continuous poll to a one-shot (the mirror of a continuous
@@ -363,16 +363,16 @@ TEST(ModbusClientHubPriority, DuplicateSendDowngradesContinuous) {
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_TRUE(hub.queued(0).continuous);
ASSERT_TRUE(hub.queued(0).options.continuous);
device.read_holding_registers(0x100, 2); // one-shot duplicate downgrades the poll
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_FALSE(hub.queued(0).continuous);
EXPECT_FALSE(hub.queued(0).options.continuous);
EXPECT_EQ(hub.queued(0).pending, 1u);
// It runs one more cycle to serve the request, then stops - not re-queued as a poll.
hub.force_send_next();
EXPECT_FALSE(hub.waiting_command().continuous);
EXPECT_FALSE(hub.waiting_command().options.continuous);
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response);
EXPECT_EQ(hub.queued_frames(), 0u);
@@ -407,16 +407,16 @@ TEST(ModbusClientHubPriority, DowngradeAfterTerminalKeepsRequestAlive) {
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_TRUE(hub.queued(0).continuous);
ASSERT_TRUE(hub.queued(0).options.continuous);
hub.force_send_next();
const uint8_t exception_response[] = {0x83, 0x02};
hub.receive_frame_for_test(0x02, exception_response); // exception ends the poll; on_error re-sends
EXPECT_EQ(device.error_count_, 1); // one terminal delivered so far
ASSERT_EQ(hub.queued_frames(), 1u); // the re-send survived the sweep instead of being erased
EXPECT_FALSE(hub.queued(0).continuous); // downgraded to a one-shot
EXPECT_EQ(hub.queued(0).pending, 1u); // debt restored so the request runs
EXPECT_EQ(device.error_count_, 1); // one terminal delivered so far
ASSERT_EQ(hub.queued_frames(), 1u); // the re-send survived the sweep instead of being erased
EXPECT_FALSE(hub.queued(0).options.continuous); // downgraded to a one-shot
EXPECT_EQ(hub.queued(0).pending, 1u); // debt restored so the request runs
// And it runs to its own terminal - a good response this time - then the entry is gone.
hub.force_send_next();
@@ -434,18 +434,18 @@ TEST(ModbusClientHubPriority, ContinuousRequestUpgradesQueuedDuplicate) {
device.read_holding_registers(0x100, 2);
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_FALSE(hub.queued(0).continuous);
ASSERT_FALSE(hub.queued(0).options.continuous);
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
EXPECT_TRUE(hub.queued(0).options.continuous);
// And it behaves as a poll from here: success cycles it back to READY.
hub.force_send_next();
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
EXPECT_TRUE(hub.queued(0).options.continuous);
}
// The transmit order is one key with three levels: writes, then one-shot reads, then continuous
@@ -473,7 +473,7 @@ TEST(ModbusClientHubPriority, WritesThenOneShotReadsThenContinuousPolls) {
EXPECT_EQ(hub.waiting_command().frame.pdu()[1], 0x02); // then the one-shot read
hub.timeout_waiting();
hub.force_send_next();
EXPECT_TRUE(hub.waiting_command().continuous); // and the poll takes what is left
EXPECT_TRUE(hub.waiting_command().options.continuous); // and the poll takes what is left
}
// continuous is ignored for writes: the frame still sends at WRITE priority, once.
@@ -485,7 +485,7 @@ TEST(ModbusClientHubPriority, ContinuousIgnoredForWrites) {
device.queue_pdu(write_pdu, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE);
EXPECT_FALSE(hub.queued(0).continuous);
EXPECT_FALSE(hub.queued(0).options.continuous);
}
// A queued continuous poll does not count against immediate-send readiness: it ranks below every
@@ -496,7 +496,7 @@ TEST(ModbusClientHubPriority, ContinuousPollDoesNotBlockImmediateSend) {
EXPECT_TRUE(hub.tx_buffer_empty()); // nothing queued
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_TRUE(hub.queued(0).continuous);
ASSERT_TRUE(hub.queued(0).options.continuous);
EXPECT_TRUE(hub.tx_buffer_empty()); // a READY continuous poll still leaves room to send now
device.read_holding_registers(0x200, 2); // a one-shot does count
@@ -1878,8 +1878,8 @@ TEST(ModbusClientHubPriority, ResendFromOnResponseAbsorbsIntoCompletingCommand)
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response); // handler re-sends the identical frame mid-completion
ASSERT_EQ(hub.queued_frames(), 1u); // absorbed into the same entry, not a fresh twin
EXPECT_FALSE(hub.queued(0).continuous); // the one-shot re-send downgraded the poll
ASSERT_EQ(hub.queued_frames(), 1u); // absorbed into the same entry, not a fresh twin
EXPECT_FALSE(hub.queued(0).options.continuous); // the one-shot re-send downgraded the poll
}
// An exception-flagged function code is never silently re-sendable, even though the read check
@@ -51,6 +51,7 @@ button:
# A pdu lambda can hand-assemble bytes or return a modbus::helpers::create_*_pdu() builder result.
- modbus_client.send:
address: 0x01
continuous: true
pdu: !lambda "return modbus::helpers::create_read_pdu(modbus::FunctionCode::READ_HOLDING_REGISTERS, 0x0010, 1);"
- modbus_client.send:
address: !lambda "return 1;"
@@ -91,6 +92,7 @@ button:
address: !lambda "return 1;"
start_address: 0x10
count: 2
continuous: true
on_response:
then:
- lambda: 'ESP_LOGI("modbus_client.test", "first=%u n=%u", values[0], (unsigned) values.size());'
@@ -98,6 +100,7 @@ button:
then:
- logger.log: "typed read timeout"
- modbus_client.read_input_registers:
continuous: !lambda "return false;"
address: 0x01
start_address: 0x20
on_custom_response:
@@ -113,12 +116,14 @@ button:
address: 0x01
start_address: 0x03
count: 16
continuous: true
on_response:
then:
- lambda: 'ESP_LOGI("modbus_client.test", "coil0=%d n=%u", bits[0], (unsigned) bits.size());'
- modbus_client.read_discrete_inputs:
address: 0x01
start_address: 0x00
continuous: true
on_error:
then:
- lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);'
@@ -57,6 +57,11 @@ image:
url: http://www.faqs.org/images/library.jpg
format: JPG
type: RGB565
- platform: online_image
id: online_auto_image
url: http://www.faqs.org/images/library.jpg
format: AUTO
type: RGB565
# Check the set_url action
esphome:
@@ -77,18 +77,12 @@ class TestableRuntimeImage : public RuntimeImage {
: RuntimeImage(format, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE, nullptr, false, 0, 0) {}
ImageDecoder *decoder() { return this->decoder_.get(); }
/// Simulates the state a dynamic-format producer (PR #16337) would leave behind:
/// a cached decoder whose format no longer matches the image's format.
/// TODO: once #16337 adds a public way to change the format, drive the mismatch
/// through it and delete this seam.
void plant_decoder(ImageFormat format) { this->decoder_ = this->create_decoder_(format); }
};
/// Runs one full decode session. Returns true when every stage succeeded.
static bool decode_all(TestableRuntimeImage &img, const uint8_t *data, size_t len) {
static bool decode_all(TestableRuntimeImage &img, const uint8_t *data, size_t len, ImageFormat format = AUTO) {
std::vector<uint8_t> buffer(data, data + len); // feed_data needs mutable bytes
if (!img.begin_decode(len)) {
if (!img.begin_decode(len, format)) {
return false;
}
size_t offset = 0;
@@ -203,25 +197,51 @@ TEST(RuntimeImageDecoder, ChunkedFeedDecodesLikeDownloadLoop) {
}
TEST(RuntimeImageDecoder, FormatSwitchEvictsMismatchedDecoder) {
// PNG image holding a stale BMP decoder: begin_decode must evict and recreate.
TestableRuntimeImage png_img(PNG);
png_img.plant_decoder(BMP);
ASSERT_NE(png_img.decoder(), nullptr);
ASSERT_EQ(png_img.decoder()->get_format(), BMP);
// Drive the format switch through begin_decode()'s format parameter, the way
// a dynamic-format producer (online_image MIME detection) does.
TestableRuntimeImage img(AUTO);
ASSERT_TRUE(decode_all(png_img, PNG_RGB, sizeof(PNG_RGB)));
EXPECT_EQ(png_img.decoder()->get_format(), PNG);
expect_pixels(png_img, PNG_RGB_EXPECTED);
ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP), BMP));
ASSERT_NE(img.decoder(), nullptr);
ASSERT_EQ(img.decoder()->get_format(), BMP);
expect_pixels(img, BMP_24BPP_EXPECTED);
// And the other direction: BMP image holding a stale PNG decoder.
TestableRuntimeImage bmp_img(BMP);
bmp_img.plant_decoder(PNG);
ASSERT_NE(bmp_img.decoder(), nullptr);
ASSERT_EQ(bmp_img.decoder()->get_format(), PNG);
// Same explicit format again: the decoder must stay warm.
ImageDecoder *bmp_decoder = img.decoder();
ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP), BMP));
expect_pixels(img, BMP_8BPP_EXPECTED);
EXPECT_EQ(img.decoder(), bmp_decoder);
ASSERT_TRUE(decode_all(bmp_img, BMP_24BPP, sizeof(BMP_24BPP)));
EXPECT_EQ(bmp_img.decoder()->get_format(), BMP);
expect_pixels(bmp_img, BMP_24BPP_EXPECTED);
// Different format: the stale decoder must be evicted and recreated.
ASSERT_TRUE(decode_all(img, PNG_RGB, sizeof(PNG_RGB), PNG));
EXPECT_EQ(img.decoder()->get_format(), PNG);
expect_pixels(img, PNG_RGB_EXPECTED);
// And back again.
ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP), BMP));
EXPECT_EQ(img.decoder()->get_format(), BMP);
expect_pixels(img, BMP_24BPP_EXPECTED);
}
TEST(RuntimeImageDecoder, AutoFormatFallsBackToConfiguredAndKeepsDecoderWarm) {
// With a configured format, an AUTO begin_decode() must resolve to the
// configured format before the reuse check instead of evicting the decoder.
TestableRuntimeImage img(BMP);
ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP), AUTO));
ImageDecoder *first = img.decoder();
ASSERT_NE(first, nullptr);
EXPECT_EQ(first->get_format(), BMP);
ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP), AUTO));
expect_pixels(img, BMP_24BPP_EXPECTED);
EXPECT_EQ(img.decoder(), first) << "AUTO must not evict the configured-format decoder";
}
TEST(RuntimeImageDecoder, AutoWithoutConfiguredFormatFails) {
// Neither a configured format nor an explicit one: there is nothing to decode with.
TestableRuntimeImage img(AUTO);
EXPECT_FALSE(img.begin_decode(64));
}
TEST(RuntimeImageDecoder, ReleaseKeepsDecoderWarm) {
+2
View File
@@ -7,6 +7,7 @@ This directory contains end-to-end integration tests for ESPHome, focusing on te
- `conftest.py` - Common fixtures and utilities
- `const.py` - Constants used throughout the integration tests
- `types.py` - Type definitions for fixtures and functions
- `raw_api_client.py` - Minimal plaintext api client whose reads happen only on request (for backpressure tests)
- `state_utils.py` - State handling utilities (e.g., `InitialStateHelper`, `find_entity`, `require_entity`)
- `fixtures/` - YAML configuration files for tests
- `test_*.py` - Individual test files
@@ -347,6 +348,7 @@ Create C++ components in `fixtures/external_components/` for:
- Custom entity behaviors
- Scheduler testing
- Memory management tests
- Deterministic network backpressure (`sndbuf_pin_component` pins socket send buffers; assert on its log line to prove the pin took effect)
##### Log Line Monitoring
```python
@@ -0,0 +1,23 @@
esphome:
name: api-backpressure-test
host:
api:
# Smallest queue so a non-draining client blocks the send path quickly
max_send_queue: 1
actions:
# GENERATED_ACTIONS
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
components: [sndbuf_pin_component]
# Pins the device's socket send buffers for deterministic TCP backpressure
sndbuf_pin_component:
buffer_size: SERVER_SNDBUF
logger:
level: DEBUG
@@ -0,0 +1,20 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_BUFFER_SIZE, CONF_ID
DEPENDENCIES = ["api"]
sndbuf_pin_ns = cg.esphome_ns.namespace("sndbuf_pin")
SndbufPinComponent = sndbuf_pin_ns.class_("SndbufPinComponent", cg.Component)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(SndbufPinComponent),
cv.Required(CONF_BUFFER_SIZE): cv.int_range(min=1),
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID], config[CONF_BUFFER_SIZE])
await cg.register_component(var, config)
@@ -0,0 +1,55 @@
#include "sndbuf_pin_component.h"
#include <netinet/in.h>
#include <sys/socket.h>
#include <cerrno>
#include "esphome/components/api/api_server.h"
#include "esphome/core/log.h"
namespace esphome::sndbuf_pin {
static const char *const TAG = "sndbuf_pin";
// Skip stdio; scan the low fd range where the listeners land
static constexpr int FIRST_USER_FD = 3;
static constexpr int MAX_FD_SCAN = 128;
void SndbufPinComponent::setup() {
int pinned = 0;
for (int fd = FIRST_USER_FD; fd < MAX_FD_SCAN; fd++) {
int type = 0;
socklen_t len = sizeof(type);
if (::getsockopt(fd, SOL_SOCKET, SO_TYPE, &type, &len) != 0 || type != SOCK_STREAM)
continue;
struct sockaddr_in addr {};
socklen_t addr_len = sizeof(addr);
if (::getsockname(fd, reinterpret_cast<struct sockaddr *>(&addr), &addr_len) != 0) {
ESP_LOGW(TAG, "fd %d: getsockname failed, errno %d", fd, errno);
continue;
}
if (ntohs(addr.sin_port) != api::global_api_server->get_port())
continue;
if (::setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &this->buffer_size_, sizeof(this->buffer_size_)) != 0) {
ESP_LOGW(TAG, "fd %d: SO_SNDBUF pin failed, errno %d", fd, errno);
continue;
}
int applied = 0;
len = sizeof(applied);
if (::getsockopt(fd, SOL_SOCKET, SO_SNDBUF, &applied, &len) != 0 || applied < this->buffer_size_) {
// Linux doubles the requested value; anything below it means clamped
ESP_LOGW(TAG, "fd %d: SO_SNDBUF readback %d below requested %d", fd, applied, this->buffer_size_);
continue;
}
// Tests assert on this line; accepted sockets inherit the pinned size
ESP_LOGD(TAG, "fd %d port %d: SO_SNDBUF pinned to %d (effective %d)", fd, ntohs(addr.sin_port), this->buffer_size_,
applied);
pinned++;
}
if (pinned == 0) {
ESP_LOGE(TAG, "api listener socket was not pinned");
this->mark_failed();
}
}
} // namespace esphome::sndbuf_pin
@@ -0,0 +1,21 @@
#pragma once
#include "esphome/core/component.h"
namespace esphome::sndbuf_pin {
// Test-only (host): pins SO_SNDBUF on every open TCP socket so integration
// tests get deterministic backpressure; an explicit SO_SNDBUF also disables
// kernel autotuning, and accepted sockets inherit it from the listener.
class SndbufPinComponent : public Component {
public:
explicit SndbufPinComponent(int buffer_size) : buffer_size_(buffer_size) {}
void setup() override;
// After the api server so its listening socket exists
float get_setup_priority() const override { return setup_priority::LATE; }
protected:
int buffer_size_;
};
} // namespace esphome::sndbuf_pin
@@ -0,0 +1,28 @@
esphome:
name: online-image-bmp
host:
http_request:
display:
image:
- platform: online_image
url: http://127.0.0.1:HTTP_PORT/foo.bmp
format: AUTO
id: myimg
type: RGB
on_download_finished:
logger.log:
format: "download finished. cache hit: %u"
args: [cached]
api:
actions:
- action: fetch_image
then:
- component.update: myimg
logger:
level: DEBUG
@@ -0,0 +1,28 @@
esphome:
name: online-image-bmp
host:
http_request:
display:
image:
- platform: online_image
url: http://127.0.0.1:HTTP_PORT/foo.bmp
id: myimg
format: AUTO
type: RGB
on_download_finished:
logger.log:
format: "download finished. cache hit: %u"
args: [cached]
api:
actions:
- action: fetch_image
then:
- component.update: myimg
logger:
level: DEBUG
@@ -7,8 +7,9 @@ http_request:
display:
online_image:
- url: http://127.0.0.1:HTTP_PORT/foo.bmp
image:
- platform: online_image
url: http://127.0.0.1:HTTP_PORT/foo.bmp
id: myimg
format: BMP
type: RGB
+158
View File
@@ -0,0 +1,158 @@
"""Shared fixture server and log helpers for the online_image integration tests."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
import re
# black 8x8 RGB BMP, generated with
# from PIL import Image
# from io import BytesIO
# b = BytesIO()
# img = Image.new("RGB", (8, 8))
# img.save(b, format="BMP")
# b.getvalue()
BMP_IMAGE = b"BM\xf6\x00\x00\x00\x00\x00\x00\x006\x00\x00\x00(\x00\x00\x00\x08\x00\x00\x00\x08\x00\x00\x00\x01\x00\x18\x00\x00\x00\x00\x00\xc0\x00\x00\x00\xc4\x0e\x00\x00\xc4\x0e\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
LEN_BMP_IMAGE = len(BMP_IMAGE)
async def wait_for_download(
downloaded_bytes_future: asyncio.Future,
server_error_future: asyncio.Future,
) -> int:
"""Await the downloaded byte count, raising a server handler error first."""
await asyncio.wait(
{downloaded_bytes_future, server_error_future},
return_when=asyncio.FIRST_COMPLETED,
)
if server_error_future.done() and (exc := server_error_future.exception()):
raise exc
# Retrieve a late teardown error so asyncio does not log it at GC
server_error_future.add_done_callback(lambda f: f.exception())
return downloaded_bytes_future.result()
def make_download_watcher(
downloaded_bytes_future: asyncio.Future,
download_finished_future: asyncio.Future,
) -> Callable[[str], None]:
"""Build a line callback resolving the futures from the device log."""
def check_output(line: str) -> None:
if (
match := re.search(r"Image fully downloaded, (\d+) bytes", line)
) and not downloaded_bytes_future.done():
downloaded_bytes_future.set_result(int(match.group(1)))
if "download finished" in line and not download_finished_future.done():
download_finished_future.set_result(True)
return check_output
def handle_http(
http_request_future,
content_type: str = "text/plain",
*,
request_path: str = "/foo.bmp",
request_line_consumed: bool = False,
server_error_future: asyncio.Future | None = None,
):
async def handler(reader, writer):
try:
# Only read the request line if it hasn't been consumed by a caller
if not request_line_consumed:
async with asyncio.timeout(1.0):
data = await reader.readuntil(b"\r\n")
expected_request = f"GET {request_path} HTTP/1.1\r\n".encode()
assert data[: len(expected_request)] == expected_request
async with asyncio.timeout(1.0):
await reader.readuntil(b"\r\n\r\n")
if not http_request_future.done():
http_request_future.set_result(True)
http_response = [
b"HTTP/1.1 200 OK",
b"Content-Length: %d" % LEN_BMP_IMAGE,
f"Content-Type: {content_type}".encode(),
b"Connection: close",
b"",
b"",
]
writer.write(b"\r\n".join(http_response))
await writer.drain()
writer.write(BMP_IMAGE)
await writer.drain()
except Exception as exc:
if server_error_future is not None and not server_error_future.done():
server_error_future.set_exception(exc)
if not http_request_future.done():
http_request_future.set_exception(exc)
raise
finally:
writer.close()
return handler
def handle_http_redirect(
http_request_future, final_request_future, server_error_future, port_holder
):
async def handler(reader, writer):
try:
async with asyncio.timeout(1.0):
request = await reader.readuntil(b"\r\n")
if (
request[: len(b"GET /foo.bmp HTTP/1.1\r\n")]
== b"GET /foo.bmp HTTP/1.1\r\n"
):
if not http_request_future.done():
http_request_future.set_result(True)
async with asyncio.timeout(1.0):
await reader.readuntil(b"\r\n\r\n")
http_response = [
b"HTTP/1.1 302 Found",
f"Location: http://127.0.0.1:{port_holder['port']}/final.bmp".encode(),
b"Content-Type: text/html",
b"Content-Length: 0",
b"Connection: close",
b"",
b"",
]
writer.write(b"\r\n".join(http_response))
await writer.drain()
return
assert (
request[: len(b"GET /final.bmp HTTP/1.1\r\n")]
== b"GET /final.bmp HTTP/1.1\r\n"
)
if not final_request_future.done():
final_request_future.set_result(True)
await handle_http(
final_request_future,
"image/bmp",
request_path="/final.bmp",
request_line_consumed=True,
server_error_future=server_error_future,
)(reader, writer)
except Exception as exc:
# Route handler failures to the dedicated error future so they're not silently lost
if not server_error_future.done():
server_error_future.set_exception(exc)
if not http_request_future.done():
http_request_future.set_exception(exc)
if not final_request_future.done():
final_request_future.set_exception(exc)
raise
finally:
writer.close()
return handler
+148
View File
@@ -0,0 +1,148 @@
"""Minimal plaintext native-api client over a raw socket.
Reads only when told to, so tests control when the TCP pipe backs up toward
the device; payloads are skipped and only message types are counted.
"""
from __future__ import annotations
import asyncio
from collections import Counter
import socket
from typing import Self
from aioesphomeapi import api_pb2
import aioesphomeapi.core as api_core
from google.protobuf import message
from .const import LOCALHOST
# Message type ids are protocol constants; derive them from aioesphomeapi so
# they cannot drift from the client library in use.
MESSAGE_TYPE_OF = {cls: num for num, cls in api_core.MESSAGE_TYPE_TO_PROTO.items()}
_READ_CHUNK = 4096
def encode_varint(value: int) -> bytes:
out = bytearray()
while True:
byte = value & 0x7F
value >>= 7
if value:
out.append(byte | 0x80)
else:
out.append(byte)
return bytes(out)
def decode_varint(buf: bytearray, pos: int) -> tuple[int, int] | None:
"""Decode one varint at pos; return (value, new_pos) or None if short."""
value = shift = 0
while pos < len(buf):
byte = buf[pos]
pos += 1
value |= (byte & 0x7F) << shift
if not byte & 0x80:
return value, pos
shift += 7
return None
def encode_frame(msg_type: int, payload: bytes) -> bytes:
"""Encode one plaintext api frame: 0x00, payload length, message type."""
return b"\x00" + encode_varint(len(payload)) + encode_varint(msg_type) + payload
class FrameParser:
"""Incremental parser for the plaintext api frame stream."""
def __init__(self) -> None:
self._buf = bytearray()
def feed(self, data: bytes) -> list[int]:
self._buf.extend(data)
types: list[int] = []
while (msg_type := self._try_parse()) is not None:
types.append(msg_type)
return types
def _try_parse(self) -> int | None:
buf = self._buf
if not buf:
return None
assert buf[0] == 0, f"expected plaintext frame, got indicator {buf[0]}"
if (size_decoded := decode_varint(buf, 1)) is None:
return None
size, pos = size_decoded
if (type_decoded := decode_varint(buf, pos)) is None:
return None
msg_type, pos = type_decoded
if len(buf) - pos < size:
return None
del buf[: pos + size]
return msg_type
class RawApiClient:
"""Plaintext api client whose reads happen only on request."""
def __init__(self, port: int, recv_buffer_size: int | None = None) -> None:
self._port = port
self._parser = FrameParser()
self.bytes_received = 0
self.frame_counts: Counter[int] = Counter()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
if recv_buffer_size is not None:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, recv_buffer_size)
# Kernels may round up (Linux doubles) but must not clamp below
applied = sock.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF)
assert applied >= recv_buffer_size, (
f"SO_RCVBUF clamped to {applied}, requested {recv_buffer_size}"
)
sock.setblocking(False)
except Exception:
sock.close()
raise
self._sock = sock
async def __aenter__(self) -> Self:
return self
async def __aexit__(self, *exc_info: object) -> None:
self.close()
async def connect(self, client_info: str = "raw-api-client") -> None:
"""Connect and complete the Hello handshake (no auth step since 2026.1.0)."""
loop = asyncio.get_running_loop()
await loop.sock_connect(self._sock, (LOCALHOST, self._port))
hello = api_pb2.HelloRequest()
hello.client_info = client_info
hello.api_version_major = 1
hello.api_version_minor = 10
await self.send_message(hello)
await self.read_until_frame(MESSAGE_TYPE_OF[api_pb2.HelloResponse])
async def send_message(self, msg: message.Message) -> None:
loop = asyncio.get_running_loop()
await loop.sock_sendall(
self._sock,
encode_frame(MESSAGE_TYPE_OF[type(msg)], msg.SerializeToString()),
)
async def read_until_frame(self, msg_type: int, timeout: float = 10.0) -> None:
"""Read until at least one frame of msg_type has been received."""
loop = asyncio.get_running_loop()
async def _read_loop() -> None:
while not self.frame_counts[msg_type]:
data = await loop.sock_recv(self._sock, _READ_CHUNK)
assert data, "server closed the connection unexpectedly"
self.bytes_received += len(data)
self.frame_counts.update(self._parser.feed(data))
await asyncio.wait_for(_read_loop(), timeout)
def close(self) -> None:
self._sock.close()
@@ -0,0 +1,110 @@
"""A client that stops reading the entity listing must not starve other clients.
Service responses are sent directly (not via the deferred batch), so a full
TCP pipe makes the send path refuse; the drive loop now lives in
try_advance(), which stops on refusal instead of retrying forever. Not a
before/after regression test: pre-fix builds survive here because the
refusal path yields and pumps the socket each retry.
The sndbuf_pin_component fixture pins the device's send buffers so the pipe
fills deterministically regardless of kernel autotuning; the test waits for
its log line before proceeding.
"""
from __future__ import annotations
import asyncio
from aioesphomeapi import api_pb2
import pytest
from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient
from .types import APIClientConnectedFactory, RunCompiledFunction
SERVICES_RESPONSE = MESSAGE_TYPE_OF[api_pb2.ListEntitiesServicesResponse]
LIST_DONE_RESPONSE = MESSAGE_TYPE_OF[api_pb2.ListEntitiesDoneResponse]
# Both ends of the pipe are pinned small; only tens of KB fit in the kernel
RECV_BUFFER_SIZE = 4096
SERVER_SNDBUF = 8192 # substituted into the fixture yaml
# Logged by the sndbuf_pin_component fixture when it pins a socket
SNDBUF_PIN_LOG = "SO_SNDBUF pinned to"
# One response (~6.4 KB) must stay smaller than the pinned send buffer; an
# oversized message parks in the overflow buffer and reports as sent.
ARGS_PER_SERVICE = 8
ARG_NAME_LEN = 800
# ~160 KB listing versus a tens-of-KB pipe guarantees a mid-services block
NUM_SERVICES = 25
assert ARGS_PER_SERVICE * ARG_NAME_LEN < SERVER_SNDBUF
# The pipe fills in well under a second
STALL_SECONDS = 0.5
# Well above pipe capacity, well below the listing size
MIN_DRAINED_BYTES = 60_000
def _generated_actions() -> str:
"""Build the api actions block: services with long argument names."""
lines: list[str] = []
for i in range(NUM_SERVICES):
lines.append(f" - action: backpressure_service_{i:04d}")
lines.append(" variables:")
for j in range(ARGS_PER_SERVICE):
prefix = f"arg_{i:04d}_{j:02d}_"
lines.append(
f" {prefix}{'x' * (ARG_NAME_LEN - len(prefix))}: string"
)
lines.append(" then:")
lines.append(" - logger.log: service called")
return "\n".join(lines)
@pytest.mark.asyncio
async def test_api_list_entities_backpressure(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
unused_tcp_port: int,
) -> None:
"""A stalled reader mid-services must not block other api clients."""
assert "# GENERATED_ACTIONS" in yaml_config
config = yaml_config.replace("# GENERATED_ACTIONS", _generated_actions())
config = config.replace("SERVER_SNDBUF", str(SERVER_SNDBUF))
pin_applied = asyncio.Event()
def _on_log_line(line: str) -> None:
if SNDBUF_PIN_LOG in line:
pin_applied.set()
async with run_compiled(config, line_callback=_on_log_line):
# Fails loudly if the pin never applied
await asyncio.wait_for(pin_applied.wait(), 10)
async with RawApiClient(
unused_tcp_port, recv_buffer_size=RECV_BUFFER_SIZE
) as stalled:
await stalled.connect(client_info="backpressure-stall-client")
await stalled.send_message(api_pb2.ListEntitiesRequest())
# The client now stops reading entirely.
# Let the server run against the full pipe
await asyncio.sleep(STALL_SECONDS)
# Other clients must still be served while the first is blocked
async with api_client_connected(timeout=20) as client:
device_info = await asyncio.wait_for(client.device_info(), 20)
assert device_info.name == "api-backpressure-test"
_, services = await asyncio.wait_for(
client.list_entities_services(), 30
)
assert len(services) == NUM_SERVICES
# Fixture-size guard: the listing must dwarf the pinned pipe
before = stalled.bytes_received
await stalled.read_until_frame(LIST_DONE_RESPONSE, timeout=60)
drained = stalled.bytes_received - before
assert drained > MIN_DRAINED_BYTES, (
f"only {drained} bytes drained; the listing never backed up"
)
assert stalled.frame_counts[SERVICES_RESPONSE] == NUM_SERVICES
assert stalled.frame_counts[LIST_DONE_RESPONSE] == 1
@@ -0,0 +1,71 @@
"""Test that online_image AUTO format detection reads the Content-Type header."""
from __future__ import annotations
import asyncio
import pytest
from .online_image_utils import (
LEN_BMP_IMAGE,
handle_http,
make_download_watcher,
wait_for_download,
)
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_online_image_auto_detects_image_bmp_mime(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""AUTO format detection should honor the final response MIME type without explicit format."""
loop = asyncio.get_running_loop()
http_request_future = loop.create_future()
server_error_future = loop.create_future()
download_finished_future = loop.create_future()
downloaded_bytes_future = loop.create_future()
check_output = make_download_watcher(
downloaded_bytes_future, download_finished_future
)
server = await asyncio.start_server(
handle_http(
http_request_future,
"image/bmp",
server_error_future=server_error_future,
),
"127.0.0.1",
0,
)
http_server_port = server.sockets[0].getsockname()[1]
config = yaml_config.replace("HTTP_PORT", str(http_server_port))
async with (
server,
run_compiled(config, line_callback=check_output),
api_client_connected() as client,
):
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "online-image-bmp"
_, services = await client.list_entities_services()
request_service = next((s for s in services if s.name == "fetch_image"), None)
assert request_service is not None
await client.execute_service(request_service, {})
async with asyncio.timeout(0.1):
await http_request_future
async with asyncio.timeout(0.5):
numbytes = await wait_for_download(
downloaded_bytes_future, server_error_future
)
assert numbytes == LEN_BMP_IMAGE
await download_finished_future
@@ -0,0 +1,71 @@
"""Test that AUTO format detection uses the final Content-Type after redirects."""
from __future__ import annotations
import asyncio
import pytest
from .online_image_utils import (
LEN_BMP_IMAGE,
handle_http_redirect,
make_download_watcher,
wait_for_download,
)
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_online_image_auto_detects_redirected_image_bmp_mime(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Redirect hops should not leave the 302 HTML Content-Type in place for the final image."""
loop = asyncio.get_running_loop()
http_request_future = loop.create_future()
final_request_future = loop.create_future()
server_error_future = loop.create_future()
download_finished_future = loop.create_future()
downloaded_bytes_future = loop.create_future()
check_output = make_download_watcher(
downloaded_bytes_future, download_finished_future
)
port_holder = {}
server = await asyncio.start_server(
handle_http_redirect(
http_request_future, final_request_future, server_error_future, port_holder
),
"127.0.0.1",
0,
)
port_holder["port"] = server.sockets[0].getsockname()[1]
config = yaml_config.replace("HTTP_PORT", str(port_holder["port"]))
async with (
server,
run_compiled(config, line_callback=check_output),
api_client_connected() as client,
):
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "online-image-bmp"
_, services = await client.list_entities_services()
request_service = next((s for s in services if s.name == "fetch_image"), None)
assert request_service is not None
await client.execute_service(request_service, {})
async with asyncio.timeout(0.1):
await http_request_future
async with asyncio.timeout(0.5):
await final_request_future
numbytes = await wait_for_download(
downloaded_bytes_future, server_error_future
)
assert numbytes == LEN_BMP_IMAGE
await download_finished_future
+4 -59
View File
@@ -1,62 +1,12 @@
from __future__ import annotations
import asyncio
import re
import pytest
from .online_image_utils import LEN_BMP_IMAGE, handle_http, make_download_watcher
from .types import APIClientConnectedFactory, RunCompiledFunction
# black 8x8 RGB BMP, generated with
# from PIL import Image
# from io import BytesIO
# b = BytesIO()
# img = Image.new("RGB", (8, 8))
# img.save(b, format="BMP")
# b.getvalue()
BMP_IMAGE = b"BM\xf6\x00\x00\x00\x00\x00\x00\x006\x00\x00\x00(\x00\x00\x00\x08\x00\x00\x00\x08\x00\x00\x00\x01\x00\x18\x00\x00\x00\x00\x00\xc0\x00\x00\x00\xc4\x0e\x00\x00\xc4\x0e\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
LEN_BMP_IMAGE = len(BMP_IMAGE)
def handle_http(http_request_future):
async def handler(reader, writer):
try:
async with asyncio.timeout(1.0):
data = await reader.readuntil(b"\r\n")
# ensure our request matches the expectation
expected_request = b"GET /foo.bmp HTTP/1.1\r\n"
assert data[: len(expected_request)] == expected_request
# consume rest of request
async with asyncio.timeout(1.0):
data = await reader.readuntil(b"\r\n\r\n")
http_request_future.set_result(True)
http_response = [
b"HTTP/1.1 200 OK",
b"Content-Length: %d" % LEN_BMP_IMAGE,
b"Content-Type: text/plain",
b"Connection: close",
b"",
b"",
]
writer.write(b"\r\n".join(http_response))
await writer.drain()
writer.write(BMP_IMAGE)
await writer.drain()
except Exception as exc:
if not http_request_future.done():
http_request_future.set_exception(exc)
raise
finally:
writer.close()
return handler
@pytest.mark.asyncio
async def test_online_image_bmp(
@@ -72,14 +22,9 @@ async def test_online_image_bmp(
download_finished_future = loop.create_future()
downloaded_bytes_future = loop.create_future()
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if match := re.search(r"Image fully downloaded, (\d+) bytes", line):
downloaded_bytes_future.set_result(int(match.group(1)))
if "download finished" in line:
download_finished_future.set_result(True)
check_output = make_download_watcher(
downloaded_bytes_future, download_finished_future
)
server = await asyncio.start_server(
handle_http(http_request_future), "127.0.0.1", 0
+18 -1
View File
@@ -1120,7 +1120,14 @@ def test_should_run_esp32_platformio_with_branch() -> None:
(["esphome/espidf/runner.py"], True),
(["esphome/espidf/framework.py"], True),
(["esphome/build_gen/espidf.py"], True),
# PlatformIO build gen and esp32 component are NOT IDF-infra triggers
# Shared native-build modules the IDF build imports -> trigger
(["esphome/build_helpers/idedata.py"], True),
(["esphome/platformio/library.py"], True),
(["esphome/framework_helpers.py"], True),
(["esphome/platformio/extra_script.py"], True),
# PlatformIO build gen, its toolchain, and the esp32 component are
# NOT IDF-infra triggers
(["esphome/platformio/toolchain.py"], False),
(["esphome/build_gen/platformio.py"], False),
(["esphome/components/esp32/__init__.py"], False),
(["README.md"], False),
@@ -1132,6 +1139,16 @@ def test_esp_idf_infra_changed(changed_files: list[str], expected: bool) -> None
assert determine_jobs._esp_idf_infra_changed(changed_files) is expected
def test_esp_idf_infra_trigger_paths_exist() -> None:
"""A renamed or moved trigger module must fail here, not silently stop
forcing the esp32 IDF compile."""
repo_root = Path(__file__).resolve().parents[2]
for file in determine_jobs.ESP_IDF_INFRA_TRIGGER_FILES:
assert (repo_root / file).is_file(), f"trigger file {file} moved or renamed"
for prefix in determine_jobs.ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES:
assert (repo_root / prefix).is_dir(), f"trigger dir {prefix} moved or renamed"
@pytest.mark.parametrize(
("changed_files", "expected_result"),
[
View File
@@ -9,7 +9,7 @@ from esphome.analyze_memory.toolchain import (
find_idedata_path,
idedata_candidates,
)
from esphome.espidf.idedata import _cc_path_from_cxx
from esphome.build_helpers.idedata import _cc_path_from_cxx
from esphome.platformio.toolchain import IDEData
@@ -0,0 +1,678 @@
"""Tests for esphome.build_helpers.idedata (compile_commands.json -> idedata)."""
# pylint: disable=protected-access
import json
import logging
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from esphome.build_helpers import idedata
from esphome.core import EsphomeError
# An absolute, forward-slash (shlex-safe) path prefix valid on the host OS, so
# tests exercise the same is-absolute / normalize behavior as a real compile DB
# (a drive-qualified path on Windows, a leading slash elsewhere).
ABS = "C:/" if os.name == "nt" else "/"
def _entry(directory: str, file: str, command: str) -> dict:
return {"directory": directory, "file": file, "command": command}
def test_parse_entry_extracts_fields() -> None:
"""cxx_path, defines, includes and remaining flags are split apart."""
entry = _entry(
f"{ABS}build",
f"{ABS}build/src/esphome/core/application.cpp",
f"/tools/xtensa-esp32-elf-g++ -DUSE_ESP32 -DESPHOME_LOG_LEVEL=5 "
f"-I{ABS}inc/a -isystem {ABS}sys/b -std=gnu++20 -c app.cpp -o app.cpp.o",
)
cxx_path, defines, includes, cxx_flags = idedata.parse_entry(entry)
assert cxx_path == "/tools/xtensa-esp32-elf-g++"
assert "USE_ESP32" in defines
assert "ESPHOME_LOG_LEVEL=5" in defines
assert f"{ABS}inc/a" in includes
assert f"{ABS}sys/b" in includes
assert "-std=gnu++20" in cxx_flags
# input/output files and their flags are not treated as flags
assert "-c" not in cxx_flags
assert "-o" not in cxx_flags
assert "app.cpp" not in cxx_flags
assert "app.cpp.o" not in cxx_flags
def test_parse_entry_space_separated_args() -> None:
"""``-D X`` / ``-I path`` (separate arg) and ``-isystem<path>`` (joined)."""
entry = _entry(
f"{ABS}build",
f"{ABS}build/src/esphome/x.cpp",
f"g++ -D FOO=1 -I {ABS}inc/sep -isystem{ABS}sys/joined -c x.cpp",
)
_, defines, includes, _ = idedata.parse_entry(entry)
assert "FOO=1" in defines
assert f"{ABS}inc/sep" in includes
assert f"{ABS}sys/joined" in includes
def test_parse_entry_resolves_relative_includes() -> None:
"""Relative includes are resolved against the entry's ``directory``."""
directory = f"{ABS}build/proj"
entry = _entry(
directory,
f"{directory}/src/esphome/x.cpp",
"g++ -Iconfig -I../shared -isystem rel/sys -c x.cpp",
)
_, _, includes, _ = idedata.parse_entry(entry)
def resolved(rel: str) -> str:
# parse_entry emits forward slashes for consistency (normpath would
# yield backslashes on Windows).
return os.path.normpath(Path(directory) / rel).replace("\\", "/")
assert resolved("config") in includes
assert resolved("../shared") in includes # ../ normalized away
assert resolved("rel/sys") in includes
# nothing is left relative
assert all(Path(inc).is_absolute() for inc in includes)
def test_parse_entry_skips_dependency_flags() -> None:
"""Dependency-generation flags (and their args) are dropped."""
entry = _entry(
"/build",
"/build/src/esphome/x.cpp",
"g++ -MD -MT x.cpp.o -MF x.cpp.o.d -c x.cpp -o x.cpp.o",
)
_, _, _, cxx_flags = idedata.parse_entry(entry)
for tok in ("-MD", "-MT", "x.cpp.o", "-MF", "x.cpp.o.d", "-c", "-o", "x.cpp"):
assert tok not in cxx_flags
def test_expand_response_files(tmp_path: Path) -> None:
"""``@file`` arguments are inlined relative to the directory."""
rsp = tmp_path / "flags.rsp"
rsp.write_text("-DFROM_RSP -I/rsp/inc")
tokens = idedata._expand_response_files(
["g++", f"@{rsp.name}", "-c", "x.cpp"], tmp_path
)
assert "-DFROM_RSP" in tokens
assert "-I/rsp/inc" in tokens
assert not any(t.startswith("@") for t in tokens)
def test_expand_response_files_keeps_literal_when_missing(tmp_path: Path) -> None:
"""An unreadable ``@file`` token is kept verbatim rather than dropped."""
tokens = idedata._expand_response_files(["g++", "@nope.rsp"], tmp_path)
assert "@nope.rsp" in tokens
def test_pick_entry_prefers_esphome_tu() -> None:
"""A ``/src/esphome/`` C++ TU is picked over other compile entries."""
entries = [
_entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"),
_entry("/b", "/b/src/esphome/core/app.cpp", "g++ -c app.cpp"),
]
assert idedata._pick_entry(entries)["file"].endswith("app.cpp")
def test_pick_entry_falls_back_to_any_cxx_tu() -> None:
"""With no ``/src/esphome/`` TU present, the first C++ entry is the fallback."""
entries = [
_entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"),
_entry("/b", "/b/components/x/x.cpp", "g++ -c x.cpp"),
]
assert idedata._pick_entry(entries)["file"].endswith("x.cpp")
def test_is_esphome_src_handles_backslash_paths() -> None:
r"""The src marker must match Windows ``\src\esphome\`` paths too.
compile_commands ``file`` entries use the OS-native separator; if the
marker only matched forward slashes no source would match on Windows and
the build-include union would be silently empty.
"""
assert idedata._is_esphome_src(r"C:\b\src\esphome\core\app.cpp")
assert idedata._is_esphome_src("/b/src/esphome/core/app.cpp")
# non-esphome and non-C++ still rejected regardless of separator
assert not idedata._is_esphome_src(r"C:\b\managed_components\x\x.cpp")
assert not idedata._is_esphome_src(r"C:\b\src\esphome\core\app.h")
@pytest.mark.parametrize(
("command", "launcher"),
[
("", None),
# A command that is only the launcher strips to nothing
("/usr/bin/ccache", "/usr/bin/ccache"),
],
)
def test_parse_entry_empty_command_raises(command: str, launcher: str | None) -> None:
"""A blank (or launcher-only) command fails with a named ValueError,
not an IndexError."""
entry = {"directory": "/b", "file": "/b/src/x.cpp", "command": command}
with pytest.raises(ValueError, match="empty compile command"):
idedata.parse_entry(entry, launcher)
def test_idedata_from_build_empty_includes_raises(tmp_path: Path) -> None:
"""A compile DB with no ESPHome TU is never usable idedata and must
not be cached (call sites downgrade the raise to a build warning)."""
compile_commands = tmp_path / "compile_commands.json"
compile_commands.write_text(
json.dumps(
[
_entry(
f"{ABS}build",
f"{ABS}build/other/lib.cpp",
"/tools/g++ -c other/lib.cpp -o lib.o",
)
]
)
)
with (
patch.object(idedata, "get_toolchain_includes", return_value=[]),
pytest.raises(EsphomeError, match="No ESPHome translation unit found"),
):
idedata.idedata_from_build(compile_commands)
def test_idedata_from_build_rsp_commands_never_dedupe(tmp_path: Path) -> None:
"""Per-object response files strip to one shape while holding different
include sets; @-commands must tokenize per TU."""
entries = []
for name in ("a", "b"):
rsp = tmp_path / f"{name}.cpp.o.rsp"
rsp.write_text(f"-I{ABS}inc/{name}")
file = f"{ABS}build/src/esphome/core/{name}.cpp"
entries.append(
{
"directory": str(tmp_path),
"file": file,
"command": f"/tools/g++ @{rsp.name} -c {file} -o {name}.o",
"output": f"{name}.o",
}
)
compile_commands = tmp_path / "compile_commands.json"
compile_commands.write_text(json.dumps(entries))
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
data = idedata.idedata_from_build(compile_commands)
joined = " ".join(data["includes"]["build"])
assert "inc/a" in joined and "inc/b" in joined
def test_idedata_from_build_dedupes_identical_command_shapes(
tmp_path: Path,
) -> None:
"""Translation units sharing one ninja rule (same command modulo
file/output) carry
identical includes, so only one per shape is tokenized; a differing
shape still contributes its includes."""
def _tu(name: str, inc: str) -> dict:
# ninja's compdb embeds the file and output strings verbatim
file = f"{ABS}build/src/esphome/core/{name}.cpp"
return _entry(
f"{ABS}build", file, f"/tools/g++ -I{ABS}inc/{inc} -c {file} -o {name}.o"
) | {"output": f"{name}.o"}
entries = [_tu(name, "shared") for name in ("application", "component", "helpers")]
entries.append(_tu("extra", "extra"))
compile_commands = tmp_path / "compile_commands.json"
compile_commands.write_text(json.dumps(entries))
with (
patch.object(idedata, "get_toolchain_includes", return_value=[]),
patch.object(idedata, "parse_entry", wraps=idedata.parse_entry) as spy,
):
data = idedata.idedata_from_build(compile_commands)
includes = set(data["includes"]["build"])
assert f"{ABS}inc/shared".replace("\\", "/") in {
i.replace("\\", "/") for i in includes
}
assert any("inc/extra" in i for i in includes)
# Representative + one distinct shape; the two same-shape duplicates
# are never tokenized
assert spy.call_count == 2
def test_idedata_from_build(tmp_path: Path) -> None:
"""Full transform: representative entry + include union + toolchain dirs."""
compile_commands = tmp_path / "compile_commands.json"
entries = [
_entry(
f"{ABS}b",
f"{ABS}b/src/esphome/core/app.cpp",
f"g++ -DUSE_ESP32 -I{ABS}inc/core -std=gnu++20 -c app.cpp -o app.cpp.o",
),
_entry(
f"{ABS}b",
f"{ABS}b/src/esphome/sensor/s.cpp",
f"g++ -DUSE_ESP32 -I{ABS}inc/sensor -c s.cpp -o s.cpp.o",
),
# non-esphome TU: its includes must not leak into the union
_entry(
f"{ABS}b",
f"{ABS}b/managed_components/x/x.c",
f"gcc -I{ABS}inc/managed -c x.c",
),
]
compile_commands.write_text(json.dumps(entries))
fake_proc = MagicMock(
returncode=0,
stderr=(
"ignored\n"
"#include <...> search starts here:\n"
" /tc/inc/c++\n"
" /tc/inc\n"
"End of search list.\n"
"more ignored\n"
),
)
with patch.object(idedata.subprocess, "run", return_value=fake_proc):
data = idedata.idedata_from_build(compile_commands)
assert data["cxx_path"] == "g++"
assert "USE_ESP32" in data["defines"]
assert "-std=gnu++20" in data["cxx_flags"]
# include dirs unioned across all esphome TUs
assert f"{ABS}inc/core" in data["includes"]["build"]
assert f"{ABS}inc/sensor" in data["includes"]["build"]
# the non-esphome TU is excluded from the union
assert f"{ABS}inc/managed" not in data["includes"]["build"]
# toolchain search dirs parsed from the compiler's -v output
assert data["includes"]["toolchain"] == ["/tc/inc/c++", "/tc/inc"]
def test_get_toolchain_includes_raises_on_probe_failure() -> None:
"""A failed compiler probe is a hard error, not a silent empty list."""
fake_proc = MagicMock(returncode=1, stderr="xtensa-esp32-elf-g++: not found")
with (
patch.object(idedata.subprocess, "run", return_value=fake_proc),
pytest.raises(RuntimeError, match="builtin include dirs"),
):
idedata.get_toolchain_includes("/bad/compiler")
def test_get_toolchain_includes_raises_when_no_dirs_found() -> None:
"""Markers present but no dirs (anomalous output) also raises."""
fake_proc = MagicMock(
returncode=0,
stderr="#include <...> search starts here:\nEnd of search list.\n",
)
with (
patch.object(idedata.subprocess, "run", return_value=fake_proc),
pytest.raises(RuntimeError, match="builtin include dirs"),
):
idedata.get_toolchain_includes("/some/compiler")
# ESP-IDF's compile_commands.json on Windows mixes literal backslash path
# separators in the compiler path with shell ``\"`` quote-escaping in defines,
# which only the real Windows argv parser handles. These exercise that path.
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
def test_split_command_preserves_paths_and_unescapes_quotes() -> None:
r"""Backslash paths survive while ``\"`` define-quoting is unescaped."""
command = r"C:\esp\bin\riscv32-esp-elf-g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp"
tokens = idedata._split_command(command)
assert tokens[0] == r"C:\esp\bin\riscv32-esp-elf-g++.exe"
assert '-DVER="1.2.3"' in tokens
assert "-IC:/inc/a" in tokens
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
def test_split_command_empty_returns_empty() -> None:
"""An empty or blank command tokenizes to ``[]`` (e.g. an empty response file).
Guards against ``CommandLineToArgvW("")`` returning the current process name
instead of an empty list.
"""
assert idedata._split_command("") == []
assert idedata._split_command(" ") == []
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
def test_parse_entry_normalizes_windows_cxx_path() -> None:
"""A backslash compiler path is emitted forward-slashed; define unescaped."""
entry = _entry(
r"C:\b",
r"C:\b\src\esphome\x.cpp",
r"C:\esp\bin\g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp",
)
cxx_path, defines, includes, _ = idedata.parse_entry(entry)
assert cxx_path == "C:/esp/bin/g++.exe"
assert "\\" not in cxx_path
assert 'VER="1.2.3"' in defines
assert "C:/inc/a" in includes
def test_parse_entry_strips_launcher_prefix() -> None:
"""A launcher-wrapped compile names the compiler second; the exact
configured launcher is stripped, not anything ccache-shaped."""
entry = _entry(
f"{ABS}build",
f"{ABS}build/src/esphome/core/application.cpp",
"/opt/homebrew/bin/ccache /tools/xtensa-lx106-elf-g++ -DUSE_ESP8266 "
"-c app.cpp -o app.cpp.o",
)
cxx_path, defines, _, _ = idedata.parse_entry(
entry, launcher="/opt/homebrew/bin/ccache"
)
assert cxx_path == "/tools/xtensa-lx106-elf-g++"
assert defines == ["USE_ESP8266"]
def test_parse_entry_recovers_from_unconfigured_launcher(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A stale compile DB built with a launcher this run no longer configures
still yields the real compiler (the next token), not the launcher."""
entry = _entry(
f"{ABS}build",
f"{ABS}build/src/esphome/core/application.cpp",
"/opt/homebrew/bin/ccache /tools/xtensa-lx106-elf-g++ -c a.cpp -o a.o",
)
caplog.set_level(logging.DEBUG)
cxx_path, _, _, _ = idedata.parse_entry(entry)
assert cxx_path == "/tools/xtensa-lx106-elf-g++"
assert "Stripping unconfigured launcher" in caplog.text
def test_parse_entry_rejects_launcher_without_program() -> None:
"""A launcher followed only by flags is rejected in the parser itself,
so no caller can record ccache as the compiler."""
entry = _entry(
f"{ABS}build",
f"{ABS}build/src/esphome/core/application.cpp",
"/opt/homebrew/bin/ccache -c a.cpp -o a.o",
)
with pytest.raises(EsphomeError, match="compile database is unusable"):
idedata.parse_entry(entry)
def _write_compile_commands(tmp_path: Path) -> Path:
compile_commands = tmp_path / "compile_commands.json"
compile_commands.write_text(
json.dumps(
[
_entry(
f"{ABS}build",
f"{ABS}build/src/esphome/core/application.cpp",
"/tools/g++ -DUSE_ESP8266 -c app.cpp -o app.cpp.o",
)
]
)
)
return compile_commands
def test_load_or_build_idedata_missing_compile_db(tmp_path: Path) -> None:
assert (
idedata.load_or_build_idedata(
tmp_path / "compile_commands.json", tmp_path / "f.elf", tmp_path / "c.json"
)
is None
)
def test_load_or_build_idedata_builds_and_caches(tmp_path: Path) -> None:
compile_commands = _write_compile_commands(tmp_path)
cache = tmp_path / "cache" / "test.json"
with patch.object(
idedata, "get_toolchain_includes", return_value=["/toolchain/include"]
):
data = idedata.load_or_build_idedata(
compile_commands, tmp_path / "firmware.elf", cache
)
assert data["cc_path"] == "/tools/gcc"
assert data["prog_path"] == str(tmp_path / "firmware.elf")
assert json.loads(cache.read_text()) == data
# A fresh cache is served without re-parsing the compile DB
os.utime(cache, (compile_commands.stat().st_mtime + 10,) * 2)
with patch.object(idedata, "idedata_from_build") as mock_build:
assert (
idedata.load_or_build_idedata(
compile_commands, tmp_path / "firmware.elf", cache
)
== data
)
mock_build.assert_not_called()
def test_load_or_build_idedata_rebuilds_bad_cache(tmp_path: Path) -> None:
compile_commands = _write_compile_commands(tmp_path)
cache = tmp_path / "cache.json"
for bad in ("not json", json.dumps({"no_cc_path": True})):
cache.write_text(bad)
os.utime(cache, (compile_commands.stat().st_mtime + 10,) * 2)
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
data = idedata.load_or_build_idedata(
compile_commands, tmp_path / "f.elf", cache
)
assert "cc_path" in data
def test_load_or_build_idedata_rebuilds_when_compile_db_newer(tmp_path: Path) -> None:
"""A compile DB newer than the cache forces regeneration."""
compile_commands = _write_compile_commands(tmp_path)
cache = tmp_path / "cache.json"
cache.write_text(json.dumps({"cc_path": "stale"}))
os.utime(compile_commands, (cache.stat().st_mtime + 10,) * 2)
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
data = idedata.load_or_build_idedata(
compile_commands, tmp_path / "f.elf", cache
)
assert data["cc_path"] != "stale"
def test_load_or_build_idedata_rebuilds_non_dict_cache(tmp_path: Path) -> None:
"""Valid JSON that is not an object is regenerated, never handed out.
A bare string would otherwise pass the cc_path check by substring.
"""
compile_commands = _write_compile_commands(tmp_path)
cache = tmp_path / "cache.json"
for bad in ('"cc_path is a string"', "[]", "42"):
cache.write_text(bad)
os.utime(cache, (compile_commands.stat().st_mtime + 10,) * 2)
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
data = idedata.load_or_build_idedata(
compile_commands, tmp_path / "f.elf", cache
)
assert isinstance(data, dict)
assert "cc_path" in data
def test_is_launcher_matches_only_known_launchers() -> None:
"""Compilers of any shape pass; only the closed launcher set matches."""
for token in ("/t/g++-13", "gcc-8.4.0", "clang++-17", "armcc", "icx", "cc"):
assert not idedata._is_launcher(token)
for token in ("/opt/homebrew/bin/ccache", "CCACHE.EXE", "distcc", "sccache"):
assert idedata._is_launcher(token)
def test_load_or_build_idedata_corrupted_cache_is_logged(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A truncated cache is diagnosable, not a silent slow-build cause."""
compile_commands = _write_compile_commands(tmp_path)
cache = tmp_path / "c.json"
cache.write_text('{"cc_path": trunc')
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
data = idedata.load_or_build_idedata(
compile_commands, tmp_path / "f.elf", cache
)
assert data["cxx_path"] == "/tools/g++"
assert "Discarding unreadable idedata cache" in caplog.text
def test_load_or_build_idedata_discards_unreadable_cache_file(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An OSError on the cache read (permissions, I/O) regenerates like a
parse failure instead of aborting the consumer."""
compile_commands = _write_compile_commands(tmp_path)
cache = tmp_path / "c.json"
cache.write_text("{}")
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
real_read_text = Path.read_text
def fail_cache_read(self: Path, *args: object, **kwargs: object) -> str:
# chmod(0) cannot revoke read access on Windows, so fault the read
# itself for a platform-independent OSError
if self == cache:
raise OSError("permission denied")
return real_read_text(self, *args, **kwargs)
with (
patch.object(idedata, "get_toolchain_includes", return_value=[]),
patch.object(Path, "read_text", fail_cache_read),
):
data = idedata.load_or_build_idedata(
compile_commands, tmp_path / "f.elf", cache
)
assert data["cxx_path"] == "/tools/g++"
assert "Discarding unreadable idedata cache" in caplog.text
def test_load_or_build_idedata_never_caches_a_launcher(tmp_path: Path) -> None:
"""A compile DB naming a launcher as the compiler is rejected, never cached."""
compile_commands = tmp_path / "compile_commands.json"
compile_commands.write_text(
json.dumps(
[
_entry(
f"{ABS}build",
f"{ABS}build/src/esphome/core/application.cpp",
"/opt/homebrew/bin/ccache -c app.cpp -o app.cpp.o",
)
]
)
)
cache = tmp_path / "c.json"
# No probe patch needed: the launcher is rejected before the probe runs
with pytest.raises(EsphomeError, match="compile database is unusable"):
idedata.load_or_build_idedata(compile_commands, tmp_path / "f.elf", cache)
assert not cache.exists()
@pytest.mark.parametrize(
"cached",
(
{"cc_path": "/x/gcc", "cxx_path": "/opt/homebrew/bin/ccache"},
{"cc_path": "/x/gcc", "cxx_path": "/tools/g++"},
{"cc_path": "/x/gcc", "cxx_path": "/tools/g++", "includes": {}},
),
ids=("launcher-cxx", "no-includes", "no-build-list"),
)
def test_load_or_build_idedata_regenerates_invalid_cache(
tmp_path: Path, cached: dict
) -> None:
"""A cache written by an older version fails validation and regenerates."""
compile_commands = _write_compile_commands(tmp_path)
cache = tmp_path / "c.json"
cache.write_text(json.dumps(cached))
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
data = idedata.load_or_build_idedata(
compile_commands, tmp_path / "f.elf", cache
)
assert data["cxx_path"] == "/tools/g++"
assert "includes" in data
def test_load_or_build_idedata_cache_hit_restamps_prog_path(tmp_path: Path) -> None:
"""A served cache carries the current ELF path, not the one it was written with."""
compile_commands = _write_compile_commands(tmp_path)
cache = tmp_path / "c.json"
cache.write_text(
json.dumps(
{
"cc_path": "/tools/gcc",
"cxx_path": "/tools/g++",
"includes": {"build": [], "toolchain": []},
"prog_path": "/old/location/firmware.elf",
}
)
)
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
data = idedata.load_or_build_idedata(
compile_commands, tmp_path / "firmware.elf", cache
)
assert data["prog_path"] == str(tmp_path / "firmware.elf")
def test_idedata_from_build_non_list_compile_db_raises(tmp_path: Path) -> None:
"""Valid JSON that is not a list raises by name, inside the best-effort tuple."""
compile_commands = tmp_path / "compile_commands.json"
for bad in ("{}", "null", '"text"', '["a", "b"]', "[1, 2]"):
compile_commands.write_text(bad)
with pytest.raises(EsphomeError, match="not a compile-command list"):
idedata.idedata_from_build(compile_commands)
def test_idedata_from_build_same_file_rsp_commands_never_dedupe(
tmp_path: Path,
) -> None:
"""Two objects built from one source with different .rsp files keep both
include sets; the rsp sentinel keys on the output, not the source."""
file = f"{ABS}build/src/esphome/core/shared.cpp"
entries = []
for name in ("a", "b"):
rsp = tmp_path / f"{name}.o.rsp"
rsp.write_text(f"-I{ABS}inc/{name}")
entries.append(
{
"directory": str(tmp_path),
"file": file,
"command": f"/tools/g++ @{rsp.name} -c {file} -o {name}.o",
"output": f"{name}.o",
}
)
compile_commands = tmp_path / "compile_commands.json"
compile_commands.write_text(json.dumps(entries))
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
data = idedata.idedata_from_build(compile_commands)
joined = " ".join(data["includes"]["build"])
assert "inc/a" in joined and "inc/b" in joined
def test_load_or_build_idedata_cache_hit_skips_rebuild(tmp_path: Path) -> None:
"""A valid cache newer than the compile DB is served without re-parsing."""
compile_commands = _write_compile_commands(tmp_path)
cache = tmp_path / "c.json"
cache.write_text(
json.dumps(
{
"cc_path": "/tools/gcc",
"cxx_path": "/tools/g++",
"includes": {"build": ["/inc"], "toolchain": []},
"cached": True,
}
)
)
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
with patch.object(idedata, "idedata_from_build") as mock_build:
data = idedata.load_or_build_idedata(
compile_commands, tmp_path / "f.elf", cache
)
mock_build.assert_not_called()
assert data["cached"] is True
@@ -0,0 +1,22 @@
"""Tests for the shared PlatformIO-format size bar."""
from __future__ import annotations
import pytest
from esphome.build_helpers.size_summary import format_bar, print_size_line
def test_format_bar_zero_total() -> None:
"""A zero total must not divide by zero."""
assert format_bar(0, 0) == "[ ] 0.0% (used 0 bytes from 0 bytes)"
def test_print_size_line_label_padding(capsys: pytest.CaptureFixture[str]) -> None:
"""The label column is exactly what ci_memory_impact_extract.py greps."""
print_size_line("RAM", 47932, 180736)
print_size_line("Flash", 888511, 1835008)
out = capsys.readouterr().out.splitlines()
assert out[0].startswith("RAM: [")
assert out[1].startswith("Flash: [")
assert "26.5% (used 47932 bytes from 180736 bytes)" in out[0]
+1 -1
View File
@@ -6,9 +6,9 @@ from unittest.mock import patch
from hypothesis import given
import pytest
from strategies import mac_addr_strings
from esphome import const, core
from tests.unit_tests.strategies import mac_addr_strings
class TestHexInt:
@@ -1,10 +1,13 @@
"""Tests for esphome.espidf.clang_tidy tidy-project generation."""
import json
import os
from pathlib import Path
from unittest.mock import patch
import pytest
from esphome.espidf import clang_tidy
from esphome.espidf.clang_tidy import _Settings, _setup_core, _write_tidy_project
REPO_ROOT = Path(__file__).resolve().parents[2]
@@ -64,3 +67,35 @@ def test_setup_core_sets_arduino_env(
_setup_core(tmp_path / "proj", _settings(target_framework=target_framework))
assert os.environ["ESPHOME_ARDUINO_COMPONENT"] == expected
def test_idedata_from_tidy_project(tmp_path) -> None:
"""The tidy TU's compile entry is assembled into consumer-shaped idedata."""
compile_commands = tmp_path / "compile_commands.json"
compile_commands.write_text(
json.dumps(
[
{
"directory": str(tmp_path),
"file": str(tmp_path / "main" / "tidy.cpp"),
"command": "/tc/xtensa-esp32-elf-g++ -DUSE_ESP32 "
f"-I{tmp_path}/inc -c main/tidy.cpp -o tidy.o",
}
]
)
)
with patch(
"esphome.espidf.clang_tidy.get_toolchain_includes", return_value=["/tc/inc"]
):
data = clang_tidy._idedata_from_tidy_project(compile_commands)
assert data["cxx_path"] == "/tc/xtensa-esp32-elf-g++"
assert data["defines"] == ["USE_ESP32"]
assert data["includes"]["toolchain"] == ["/tc/inc"]
assert any(inc.endswith("/inc") for inc in data["includes"]["build"])
def test_idedata_from_tidy_project_missing_tu_raises(tmp_path) -> None:
compile_commands = tmp_path / "compile_commands.json"
compile_commands.write_text(json.dumps([]))
with pytest.raises(RuntimeError, match="tidy.cpp not found"):
clang_tidy._idedata_from_tidy_project(compile_commands)
+74 -136
View File
@@ -1,12 +1,12 @@
import glob
import hashlib
import json
import os
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from esphome.components import esp32 as esp32_module
from esphome.const import (
KEY_CORE,
KEY_TARGET_FRAMEWORK,
@@ -16,21 +16,24 @@ from esphome.const import (
)
from esphome.core import CORE, Library
from esphome.espidf.component import (
_emit_idf_component,
generate_cmakelists_txt,
generate_idf_component_yml,
generate_idf_components,
)
import esphome.platformio.library
from esphome.platformio.library import (
ESPHOME_DATA_KEY,
ESPHOME_DATA_LINK_FLAGS_KEY,
ConvertedLibrary as IDFComponent,
GitSource,
URLSource,
_node_key,
_normalize_dependencies,
_parse_library_json,
_parse_library_properties,
_resolve_registry_version,
collect_filtered_files,
normalize_dependencies,
parse_library_json,
parse_library_properties,
split_list_by_condition,
)
@@ -291,6 +294,25 @@ def test_generate_cmakelists_txt_multi_token_flag(tmp_component):
assert ' "-include"\n "cp_custom_alloc.h"\n' in content
def test_generate_cmakelists_txt_extra_script_link_flags(tmp_component):
"""Captured extra-script LINKFLAGS come out as target_link_options, not
compile options where they would be silently ineffective."""
src_dir = tmp_component.path / "src"
src_dir.mkdir()
(src_dir / "main.c").write_text("int main() {}")
tmp_component.data = {
ESPHOME_DATA_KEY: {ESPHOME_DATA_LINK_FLAGS_KEY: ["-Wl,--gc-sections"]}
}
content = generate_cmakelists_txt(tmp_component)
assert (
'target_link_options(${COMPONENT_LIB} INTERFACE\n "-Wl,--gc-sections"\n)'
in content
)
assert "target_compile_options" not in content
def test_generate_cmakelists_txt_space_separated_classified_flags(tmp_component):
# Space-separated -I/-L/-l entries routed to INCLUDE_DIRS and the link
# handling before the shlex split was added; splitting must not leak
@@ -369,133 +391,11 @@ def test_generate_idf_component_yml_missing_path_raises(tmp_component):
generate_idf_component_yml(tmp_component)
def test_extra_script_captures_libpath_libs_and_defines(tmp_path):
from esphome.espidf.extra_script import captured_as_build_flags, run_extra_script
(tmp_path / "src" / "esp32").mkdir(parents=True)
script = tmp_path / "extra_script.py"
script.write_text(
"Import('env')\n"
"mcu = env.get('BOARD_MCU')\n"
"env.Append(\n"
" LIBPATH=[join('src', mcu)],\n"
" LIBS=['algobsec'],\n"
" CPPDEFINES=['FOO', ('BAR', '1')],\n"
" LINKFLAGS=['-Wl,--gc-sections'],\n"
")\n"
)
# The script uses bare ``join`` (PIO's extra-scripts run inside SCons
# where this is in scope). Inject it via the script header so the
# shim's exec namespace can resolve it.
script.write_text("from os.path import join\n" + script.read_text())
result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32")
assert result.libpath == [str(Path("src") / "esp32")]
assert result.libs == ["algobsec"]
assert ("BAR", "1") in result.cppdefines
assert "FOO" in result.cppdefines
assert result.linkflags == ["-Wl,--gc-sections"]
flags = captured_as_build_flags(result, library_dir=tmp_path)
sep = os.sep
assert f"-Lsrc{sep}esp32" in flags
assert "-lalgobsec" in flags
assert "-DFOO" in flags
assert "-DBAR=1" in flags
assert "-Wl,--gc-sections" in flags
def test_extra_script_libpath_relative_resolves_against_library_dir(
tmp_path, monkeypatch
):
"""Relative LIBPATH entries must resolve against ``library_dir``, not the
caller's CWD (the shim restores CWD before ``captured_as_build_flags``
runs)."""
from esphome.espidf.extra_script import ExtraScriptResult, captured_as_build_flags
(tmp_path / "lib" / "esp32").mkdir(parents=True)
elsewhere = tmp_path.parent / "not_the_library_dir"
elsewhere.mkdir(exist_ok=True)
monkeypatch.chdir(elsewhere)
result = ExtraScriptResult(libpath=["lib/esp32"])
flags = captured_as_build_flags(result, library_dir=tmp_path)
sep = os.sep
assert flags == [f"-Llib{sep}esp32"]
def test_extra_script_libpath_absolute_outside_library_dir(tmp_path):
from esphome.espidf.extra_script import ExtraScriptResult, captured_as_build_flags
outside = tmp_path.parent / "system_lib"
outside.mkdir(exist_ok=True)
result = ExtraScriptResult(libpath=[str(outside)])
flags = captured_as_build_flags(result, library_dir=tmp_path)
assert flags == [f"-L{outside.resolve()}"]
def test_extra_script_failure_returns_empty_result(tmp_path, caplog):
from esphome.espidf.extra_script import run_extra_script
script = tmp_path / "broken.py"
script.write_text("raise RuntimeError('boom')\n")
with caplog.at_level("WARNING"):
result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32")
assert result.libpath == []
assert result.libs == []
assert "broken.py" in caplog.text
def test_apply_extra_script_path_traversal_is_rejected(tmp_path):
from esphome.espidf.component import _apply_extra_script
library_dir = tmp_path / "lib"
library_dir.mkdir()
outside = tmp_path / "evil.py"
outside.write_text("env.Append(LIBS=['pwned'])\n")
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
c.path = library_dir
c.data = {"build": {"extraScript": "../evil.py"}}
_apply_extra_script(c)
# Nothing was folded into flags: the traversal was rejected before
# the script could run.
assert "flags" not in c.data["build"]
def test_apply_extra_script_merges_into_existing_flags(tmp_path, monkeypatch):
from esphome.components import esp32 as esp32_module
monkeypatch.setattr(esp32_module, "get_esp32_variant", lambda: "ESP32")
from esphome.espidf.component import _apply_extra_script
(tmp_path / "src").mkdir()
script = tmp_path / "extra.py"
script.write_text("env.Append(LIBS=['algobsec'])\n")
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
c.path = tmp_path
c.data = {"build": {"extraScript": "extra.py", "flags": ["-DEXISTING"]}}
_apply_extra_script(c)
assert "-DEXISTING" in c.data["build"]["flags"]
assert "-lalgobsec" in c.data["build"]["flags"]
def test_parse_library_json(tmp_path):
f = tmp_path / "library.json"
f.write_text(json.dumps({"name": "test"}))
result = _parse_library_json(f)
result = parse_library_json(f)
assert result["name"] == "test"
@@ -510,7 +410,7 @@ empty=
"""
)
result = _parse_library_properties(f)
result = parse_library_properties(f)
assert result["name"] == "Test"
assert result["version"] == "1.0"
@@ -680,22 +580,22 @@ def test_node_key_registry_bare_name():
def test_normalize_dependencies_none():
assert _normalize_dependencies(None) == []
assert normalize_dependencies(None) == []
def test_normalize_dependencies_list_form():
deps = [{"name": "foo", "version": "1.0"}]
assert _normalize_dependencies(deps) == [{"name": "foo", "version": "1.0"}]
assert normalize_dependencies(deps) == [{"name": "foo", "version": "1.0"}]
def test_normalize_dependencies_dict_form():
out = _normalize_dependencies({"nanopb/Nanopb": "^0.4.91", "BareName": "1.2.3"})
out = normalize_dependencies({"nanopb/Nanopb": "^0.4.91", "BareName": "1.2.3"})
assert {"name": "Nanopb", "owner": "nanopb", "version": "^0.4.91"} in out
assert {"name": "BareName", "owner": None, "version": "1.2.3"} in out
def test_normalize_dependencies_dict_form_nested_spec():
out = _normalize_dependencies(
out = normalize_dependencies(
{"nanopb/Nanopb": {"version": "^0.4.91", "platforms": "espidf"}}
)
assert out == [
@@ -735,7 +635,7 @@ def _patch_registry(monkeypatch, versions):
def test_resolve_registry_version_intersects_constraints(monkeypatch):
_patch_registry(monkeypatch, ["1.10018.1", "1.10021.0", "1.10021.1"])
owner, name, version, url = _resolve_registry_version(
owner, name, version, url, _size = _resolve_registry_version(
"esphome", "libsodium", {"==1.10021.0", "^1.10018.1"}
)
assert (owner, name, version) == ("esphome", "libsodium", "1.10021.0")
@@ -744,7 +644,9 @@ def test_resolve_registry_version_intersects_constraints(monkeypatch):
def test_resolve_registry_version_picks_highest_satisfying(monkeypatch):
_patch_registry(monkeypatch, ["1.0.0", "1.5.0", "2.0.0"])
_owner, _name, version, _url = _resolve_registry_version("o", "p", {"^1.0.0"})
_owner, _name, version, _url, _size = _resolve_registry_version(
"o", "p", {"^1.0.0"}
)
assert version == "1.5.0"
@@ -794,7 +696,7 @@ def test_generate_idf_components_dedupes_shared_dependency(
resolve_calls.append(pkgname)
captured[f"{owner}/{pkgname}"] = set(requirements)
version = "1.10021.0" if pkgname == "C" else "1.0.0"
return owner, pkgname, version, f"http://x/{pkgname}.tar.gz"
return owner, pkgname, version, f"http://x/{pkgname}.tar.gz", None
monkeypatch.setattr(
esphome.platformio.library, "_resolve_registry_version", fake_resolve
@@ -853,7 +755,7 @@ def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies(
def fake_resolve(owner, pkgname, requirements):
resolve_calls.append(pkgname)
return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz"
return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz", None
monkeypatch.setattr(
esphome.platformio.library, "_resolve_registry_version", fake_resolve
@@ -909,6 +811,7 @@ def test_generate_idf_components_handles_dependency_cycle(
pkgname,
"1.0.0",
f"http://x/{pkgname}.tar.gz",
None,
),
)
@@ -966,6 +869,7 @@ def test_generate_idf_components_git_overrides_registry_warns(
pkgname,
"1.0.0",
f"http://x/{pkgname}.tar.gz",
None,
),
)
@@ -1002,6 +906,7 @@ def test_generate_idf_components_missing_manifest_raises(
pkgname,
"1.0.0",
f"http://x/{pkgname}.tar.gz",
None,
),
)
@@ -1046,6 +951,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate(
pkgname,
"1.0.0",
f"http://x/{pkgname}.tar.gz",
None,
),
)
@@ -1079,6 +985,7 @@ def test_generate_idf_components_incompatible_top_level_raises(
pkgname,
"1.0.0",
f"http://x/{pkgname}.tar.gz",
None,
),
)
@@ -1115,6 +1022,7 @@ def test_generate_idf_components_incompatible_dependency_skipped(
pkgname,
"1.0.0",
f"http://x/{pkgname}.tar.gz",
None,
),
)
@@ -1190,3 +1098,33 @@ def test_idf_component_download_passes_salt() -> None:
"owner/name", force=True, salt="abcd1234", namespace="idf"
)
assert c.path == Path("/converted/owner/name")
def test_emit_idf_component_wires_esp32_target(tmp_path, monkeypatch):
"""Emitting a component resolves the esp32 variant into the shared
extraScript helper."""
monkeypatch.setattr(esp32_module, "get_esp32_variant", lambda: "ESP32")
(tmp_path / "src").mkdir()
script = tmp_path / "extra.py"
script.write_text("env.Append(LIBS=[env.get('BOARD_MCU')])\n")
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
c.path = tmp_path
c.data = {"build": {"extraScript": "extra.py"}}
_emit_idf_component(c)
assert c.data["build"]["flags"] == ["-lesp32"]
def test_build_flags_dangling_flag_does_not_cross_entries(
tmp_path, caplog: pytest.LogCaptureFixture
) -> None:
"""Each entry is lexed independently, as ParseFlags does: a dangling -I ending one
entry warns instead of absorbing the next entry's first token."""
(tmp_path / "src").mkdir()
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
c.path = tmp_path
c.data = {"build": {"flags": ["-Wall -I", "-DFOO=1"]}}
content = generate_cmakelists_txt(c)
assert "FOO=1" in content
assert "-I-DFOO" not in content
assert "Ignoring trailing '-I'" in caplog.text
+149 -8
View File
@@ -2,6 +2,7 @@
# pylint: disable=protected-access
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
import importlib.util
import io
@@ -14,7 +15,7 @@ import subprocess
import sys
import tarfile
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
@@ -887,6 +888,78 @@ _PREFETCH_JSON = json.dumps(
)
def test_prefetch_leaves_unverifiable_entries_to_the_installer(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An entry missing sha256 or size must not download unverified; the
installer handles it and fails loudly on a bad archive."""
entries = json.loads(_PREFETCH_JSON)
del entries[0]["sha256"]
del entries[1]["size"]
entries.append(
{
"name": "gcc@14.2.0",
"url": "https://example.com/gcc.tar.gz",
"size": 67,
"sha256": "ef" * 32,
"dest": "gcc.tar.gz",
}
)
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, json.dumps(entries), ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.framework_helpers._BatchDownloadProgress") as progress_cls,
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
assert [call[0][0] for call in download.call_args_list] == [
"https://example.com/gcc.tar.gz"
]
assert download.call_args[1]["sha256"] == "ef" * 32
progress_cls.assert_called_once_with("Downloading ESP-IDF tools", 67)
assert "cmake@3.30.2 has no sha256/size" in caplog.text
assert "ninja@1.12.1 has no sha256/size" in caplog.text
def test_prefetch_all_entries_unverifiable_is_a_noop(tmp_path: Path) -> None:
entries = json.loads(_PREFETCH_JSON)
for entry in entries:
del entry["sha256"]
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, json.dumps(entries), ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
download.assert_not_called()
def test_prefetch_dedupes_entries_by_dest(tmp_path: Path) -> None:
"""Two entries resolving to one dest would interleave writes into the
same .part file; only the first downloads."""
entries = json.loads(_PREFETCH_JSON)
dup = dict(entries[0]) | {"name": "cmake-alias@3.30.2"}
entries.append(dup)
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, json.dumps(entries), ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.framework_helpers._BatchDownloadProgress"),
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
dests = [call[0][1].name for call in download.call_args_list]
assert dests.count("cmake-3.30.2.tar.gz") == 1
def test_prefetch_downloads_each_archive_with_resume(tmp_path: Path) -> None:
with (
patch(
@@ -895,16 +968,58 @@ def test_prefetch_downloads_each_archive_with_resume(tmp_path: Path) -> None:
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.framework_helpers._BatchDownloadProgress") as progress_cls,
):
# Materialize the lazy mock before threads race its first creation
tracker = progress_cls.return_value.tracker.return_value
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
dist = get_idf_tools_path() / "dist"
assert download.call_count == 2
assert download.call_args_list[0][0] == (
"https://example.com/cmake.tar.gz",
dist / "cmake-3.30.2.tar.gz",
)
assert download.call_args_list[0][1] == {"sha256": "ab" * 32, "size": 123}
# Archives download concurrently, so the call order is not fixed.
calls = {call[0]: call[1] for call in download.call_args_list}
assert set(calls) == {
("https://example.com/cmake.tar.gz", dist / "cmake-3.30.2.tar.gz"),
("https://example.com/ninja.zip", dist / "ninja.zip"),
}
kwargs = calls[("https://example.com/cmake.tar.gz", dist / "cmake-3.30.2.tar.gz")]
assert kwargs["sha256"] == "ab" * 32
assert kwargs["size"] == 123
# every archive reports into the one combined progress bar via the
# cancellation-checked wrapper; verify it delegates to the tracker
progress_cls.assert_called_once_with("Downloading ESP-IDF tools", 123 + 45)
before = tracker.call_count
for kw in calls.values():
kw["progress"](7)
assert tracker.call_count == before + len(calls)
def test_prefetch_downloads_archives_concurrently(tmp_path: Path) -> None:
"""More than one archive fans out over a bounded thread pool."""
entries = [
{
"name": f"tool{i}@1",
"url": f"https://example.com/tool{i}.tar.gz",
"size": 10,
"sha256": "ab" * 32,
"dest": f"tool{i}.tar.gz",
}
for i in range(6)
]
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, json.dumps(entries), ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch(
"esphome.framework_helpers.ThreadPoolExecutor", wraps=ThreadPoolExecutor
) as pool,
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
pool.assert_called_once_with(max_workers=4)
assert download.call_count == 6
def test_prefetch_skips_already_downloaded_archives(tmp_path: Path) -> None:
@@ -964,6 +1079,11 @@ def test_prefetch_one_failed_archive_does_not_stop_the_rest(
) -> None:
"""A single archive failing its download must not abort the prefetch of
the remaining archives."""
def _fail_cmake_download(url: str, *args, **kwargs) -> None:
if "cmake" in url:
raise OSError("network down")
with (
patch(
"esphome.espidf.framework.run_command",
@@ -971,7 +1091,7 @@ def test_prefetch_one_failed_archive_does_not_stop_the_rest(
),
patch(
"esphome.espidf.framework.download_with_resume",
side_effect=[OSError("network down"), None],
side_effect=_fail_cmake_download,
) as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
@@ -981,6 +1101,27 @@ def test_prefetch_one_failed_archive_does_not_stop_the_rest(
assert "Could not prefetch cmake@3.30.2" in caplog.text
def test_prefetch_finishes_progress_bar_and_cancels_queue(tmp_path: Path) -> None:
"""The batch bar is closed out after the pool, and the pool is shut down
with cancel_futures so Ctrl-C does not drain every queued archive."""
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
),
patch("esphome.espidf.framework.download_with_resume"),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.framework_helpers._BatchDownloadProgress") as progress_cls,
patch("esphome.framework_helpers.ThreadPoolExecutor") as pool_cls,
):
pool = MagicMock(wraps=ThreadPoolExecutor(max_workers=2))
pool_cls.return_value = pool
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
pool.shutdown.assert_called_once_with(wait=True, cancel_futures=True)
progress_cls.return_value.done.assert_called_once_with()
def test_prefetch_passes_targets_and_tools_to_script(tmp_path: Path) -> None:
with (
patch(
-264
View File
@@ -1,264 +0,0 @@
"""Tests for esphome.espidf.idedata (compile_commands.json -> idedata)."""
# pylint: disable=protected-access
import json
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from esphome.espidf import idedata
# An absolute, forward-slash (shlex-safe) path prefix valid on the host OS, so
# tests exercise the same is-absolute / normalize behavior as a real compile DB
# (a drive-qualified path on Windows, a leading slash elsewhere).
ABS = "C:/" if os.name == "nt" else "/"
def _entry(directory: str, file: str, command: str) -> dict:
return {"directory": directory, "file": file, "command": command}
def test_parse_entry_extracts_fields() -> None:
"""cxx_path, defines, includes and remaining flags are split apart."""
entry = _entry(
f"{ABS}build",
f"{ABS}build/src/esphome/core/application.cpp",
f"/tools/xtensa-esp32-elf-g++ -DUSE_ESP32 -DESPHOME_LOG_LEVEL=5 "
f"-I{ABS}inc/a -isystem {ABS}sys/b -std=gnu++20 -c app.cpp -o app.cpp.o",
)
cxx_path, defines, includes, cxx_flags = idedata._parse_entry(entry)
assert cxx_path == "/tools/xtensa-esp32-elf-g++"
assert "USE_ESP32" in defines
assert "ESPHOME_LOG_LEVEL=5" in defines
assert f"{ABS}inc/a" in includes
assert f"{ABS}sys/b" in includes
assert "-std=gnu++20" in cxx_flags
# input/output files and their flags are not treated as flags
assert "-c" not in cxx_flags
assert "-o" not in cxx_flags
assert "app.cpp" not in cxx_flags
assert "app.cpp.o" not in cxx_flags
def test_parse_entry_space_separated_args() -> None:
"""``-D X`` / ``-I path`` (separate arg) and ``-isystem<path>`` (joined)."""
entry = _entry(
f"{ABS}build",
f"{ABS}build/src/esphome/x.cpp",
f"g++ -D FOO=1 -I {ABS}inc/sep -isystem{ABS}sys/joined -c x.cpp",
)
_, defines, includes, _ = idedata._parse_entry(entry)
assert "FOO=1" in defines
assert f"{ABS}inc/sep" in includes
assert f"{ABS}sys/joined" in includes
def test_parse_entry_resolves_relative_includes() -> None:
"""Relative includes are resolved against the entry's ``directory``."""
directory = f"{ABS}build/proj"
entry = _entry(
directory,
f"{directory}/src/esphome/x.cpp",
"g++ -Iconfig -I../shared -isystem rel/sys -c x.cpp",
)
_, _, includes, _ = idedata._parse_entry(entry)
def resolved(rel: str) -> str:
# _parse_entry emits forward slashes for consistency (normpath would
# yield backslashes on Windows).
return os.path.normpath(Path(directory) / rel).replace("\\", "/")
assert resolved("config") in includes
assert resolved("../shared") in includes # ../ normalized away
assert resolved("rel/sys") in includes
# nothing is left relative
assert all(Path(inc).is_absolute() for inc in includes)
def test_parse_entry_skips_dependency_flags() -> None:
"""Dependency-generation flags (and their args) are dropped."""
entry = _entry(
"/build",
"/build/src/esphome/x.cpp",
"g++ -MD -MT x.cpp.o -MF x.cpp.o.d -c x.cpp -o x.cpp.o",
)
_, _, _, cxx_flags = idedata._parse_entry(entry)
for tok in ("-MD", "-MT", "x.cpp.o", "-MF", "x.cpp.o.d", "-c", "-o", "x.cpp"):
assert tok not in cxx_flags
def test_expand_response_files(tmp_path: Path) -> None:
"""``@file`` arguments are inlined relative to the directory."""
rsp = tmp_path / "flags.rsp"
rsp.write_text("-DFROM_RSP -I/rsp/inc")
tokens = idedata._expand_response_files(
["g++", f"@{rsp.name}", "-c", "x.cpp"], tmp_path
)
assert "-DFROM_RSP" in tokens
assert "-I/rsp/inc" in tokens
assert not any(t.startswith("@") for t in tokens)
def test_expand_response_files_keeps_literal_when_missing(tmp_path: Path) -> None:
"""An unreadable ``@file`` token is kept verbatim rather than dropped."""
tokens = idedata._expand_response_files(["g++", "@nope.rsp"], tmp_path)
assert "@nope.rsp" in tokens
def test_pick_entry_prefers_esphome_tu() -> None:
"""A ``/src/esphome/`` C++ TU is picked over other compile entries."""
entries = [
_entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"),
_entry("/b", "/b/src/esphome/core/app.cpp", "g++ -c app.cpp"),
]
assert idedata._pick_entry(entries)["file"].endswith("app.cpp")
def test_pick_entry_falls_back_to_any_cxx_tu() -> None:
"""With no ``/src/esphome/`` TU present, the first C++ entry is the fallback."""
entries = [
_entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"),
_entry("/b", "/b/components/x/x.cpp", "g++ -c x.cpp"),
]
assert idedata._pick_entry(entries)["file"].endswith("x.cpp")
def test_is_esphome_src_handles_backslash_paths() -> None:
r"""The src marker must match Windows ``\src\esphome\`` paths too.
compile_commands ``file`` entries use the OS-native separator; if the
marker only matched forward slashes no source would match on Windows and
the build-include union would be silently empty.
"""
assert idedata._is_esphome_src(r"C:\b\src\esphome\core\app.cpp")
assert idedata._is_esphome_src("/b/src/esphome/core/app.cpp")
# non-esphome and non-C++ still rejected regardless of separator
assert not idedata._is_esphome_src(r"C:\b\managed_components\x\x.cpp")
assert not idedata._is_esphome_src(r"C:\b\src\esphome\core\app.h")
def test_idedata_from_build(tmp_path: Path) -> None:
"""Full transform: representative entry + include union + toolchain dirs."""
compile_commands = tmp_path / "compile_commands.json"
entries = [
_entry(
f"{ABS}b",
f"{ABS}b/src/esphome/core/app.cpp",
f"g++ -DUSE_ESP32 -I{ABS}inc/core -std=gnu++20 -c app.cpp -o app.cpp.o",
),
_entry(
f"{ABS}b",
f"{ABS}b/src/esphome/sensor/s.cpp",
f"g++ -DUSE_ESP32 -I{ABS}inc/sensor -c s.cpp -o s.cpp.o",
),
# non-esphome TU: its includes must not leak into the union
_entry(
f"{ABS}b",
f"{ABS}b/managed_components/x/x.c",
f"gcc -I{ABS}inc/managed -c x.c",
),
]
compile_commands.write_text(json.dumps(entries))
fake_proc = MagicMock(
returncode=0,
stderr=(
"ignored\n"
"#include <...> search starts here:\n"
" /tc/inc/c++\n"
" /tc/inc\n"
"End of search list.\n"
"more ignored\n"
),
)
with patch.object(idedata.subprocess, "run", return_value=fake_proc):
data = idedata.idedata_from_build(compile_commands)
assert data["cxx_path"] == "g++"
assert "USE_ESP32" in data["defines"]
assert "-std=gnu++20" in data["cxx_flags"]
# include dirs unioned across all esphome TUs
assert f"{ABS}inc/core" in data["includes"]["build"]
assert f"{ABS}inc/sensor" in data["includes"]["build"]
# the non-esphome TU is excluded from the union
assert f"{ABS}inc/managed" not in data["includes"]["build"]
# toolchain search dirs parsed from the compiler's -v output
assert data["includes"]["toolchain"] == ["/tc/inc/c++", "/tc/inc"]
def test_get_toolchain_includes_raises_on_probe_failure() -> None:
"""A failed compiler probe is a hard error, not a silent empty list."""
fake_proc = MagicMock(returncode=1, stderr="xtensa-esp32-elf-g++: not found")
with (
patch.object(idedata.subprocess, "run", return_value=fake_proc),
pytest.raises(RuntimeError, match="builtin include dirs"),
):
idedata._get_toolchain_includes("/bad/compiler")
def test_get_toolchain_includes_raises_when_no_dirs_found() -> None:
"""Markers present but no dirs (anomalous output) also raises."""
fake_proc = MagicMock(
returncode=0,
stderr="#include <...> search starts here:\nEnd of search list.\n",
)
with (
patch.object(idedata.subprocess, "run", return_value=fake_proc),
pytest.raises(RuntimeError, match="builtin include dirs"),
):
idedata._get_toolchain_includes("/some/compiler")
# ESP-IDF's compile_commands.json on Windows mixes literal backslash path
# separators in the compiler path with shell ``\"`` quote-escaping in defines,
# which only the real Windows argv parser handles. These exercise that path.
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
def test_split_command_preserves_paths_and_unescapes_quotes() -> None:
r"""Backslash paths survive while ``\"`` define-quoting is unescaped."""
command = r"C:\esp\bin\riscv32-esp-elf-g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp"
tokens = idedata._split_command(command)
assert tokens[0] == r"C:\esp\bin\riscv32-esp-elf-g++.exe"
assert '-DVER="1.2.3"' in tokens
assert "-IC:/inc/a" in tokens
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
def test_split_command_empty_returns_empty() -> None:
"""An empty or blank command tokenizes to ``[]`` (e.g. an empty response file).
Guards against ``CommandLineToArgvW("")`` returning the current process name
instead of an empty list.
"""
assert idedata._split_command("") == []
assert idedata._split_command(" ") == []
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
def test_parse_entry_normalizes_windows_cxx_path() -> None:
"""A backslash compiler path is emitted forward-slashed; define unescaped."""
entry = _entry(
r"C:\b",
r"C:\b\src\esphome\x.cpp",
r"C:\esp\bin\g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp",
)
cxx_path, defines, includes, _ = idedata._parse_entry(entry)
assert cxx_path == "C:/esp/bin/g++.exe"
assert "\\" not in cxx_path
assert 'VER="1.2.3"' in defines
assert "C:/inc/a" in includes
+2 -110
View File
@@ -140,7 +140,7 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None:
compile_commands.write_text("[]")
with patch(
"esphome.espidf.idedata.idedata_from_build",
"esphome.build_helpers.idedata.idedata_from_build",
return_value={"cxx_path": "g++"},
) as mock_transform:
result = toolchain.get_idedata()
@@ -151,114 +151,6 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None:
assert json.loads(cache.read_text()) == {"cxx_path": "g++", "prog_path": prog_path}
def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None:
"""A cache at least as new as the compile DB is reused without regenerating."""
compile_commands, cache = _setup_build(setup_core)
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text('{"cc_path": "cached-gcc", "cxx_path": "cached"}')
cc_mtime = compile_commands.stat().st_mtime
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
with patch("esphome.espidf.idedata.idedata_from_build") as mock_transform:
result = toolchain.get_idedata()
mock_transform.assert_not_called()
assert result == {"cc_path": "cached-gcc", "cxx_path": "cached"}
def test_get_idedata_regenerates_cache_without_cc_path(setup_core: Path) -> None:
"""A cache predating cc_path is rebuilt even though it is newer.
Such a cache stays newer than the compile DB forever, so consumers that
derive the binutils paths from cc_path would keep failing on it.
"""
compile_commands, cache = _setup_build(setup_core)
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text('{"cxx_path": "cached"}')
cc_mtime = compile_commands.stat().st_mtime
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
with patch(
"esphome.espidf.idedata.idedata_from_build",
return_value={"cc_path": "gcc", "cxx_path": "g++"},
) as mock_transform:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert result["cc_path"] == "gcc"
def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -> None:
"""A compile DB newer than the cache forces regeneration."""
compile_commands, cache = _setup_build(setup_core)
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text('{"cxx_path": "stale"}')
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
cache_mtime = cache.stat().st_mtime
os.utime(compile_commands, (cache_mtime + 1, cache_mtime + 1))
with patch(
"esphome.espidf.idedata.idedata_from_build",
return_value={"cxx_path": "fresh"},
) as mock_transform:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())}
@pytest.mark.parametrize("cached", ['"cc_path is a string"', "[]", "42"])
def test_get_idedata_regenerates_on_non_dict_cache(
setup_core: Path, cached: str
) -> None:
"""A newer cache holding valid JSON that is not an object is regenerated.
A bare string would otherwise pass the cc_path check by substring and be
handed to consumers expecting a dict.
"""
compile_commands, cache = _setup_build(setup_core)
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(cached)
cc_mtime = compile_commands.stat().st_mtime
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
with patch(
"esphome.espidf.idedata.idedata_from_build",
return_value={"cc_path": "gcc", "cxx_path": "g++"},
) as mock_transform:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert isinstance(result, dict)
def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
"""An unparseable (but newer) cache falls back to regeneration."""
compile_commands, cache = _setup_build(setup_core)
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text("{not json")
cc_mtime = compile_commands.stat().st_mtime
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
with patch(
"esphome.espidf.idedata.idedata_from_build",
return_value={"cxx_path": "regen"},
) as mock_transform:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert result == {"cxx_path": "regen", "prog_path": str(toolchain.get_elf_path())}
def test_get_idedata_prog_path_points_at_firmware_elf(setup_core: Path) -> None:
"""The idedata exposes prog_path (the ELF) so consumers like build-action
can locate firmware.factory.bin / firmware.ota.bin as its siblings."""
@@ -267,7 +159,7 @@ def test_get_idedata_prog_path_points_at_firmware_elf(setup_core: Path) -> None:
compile_commands.write_text("[]")
with patch(
"esphome.espidf.idedata.idedata_from_build",
"esphome.build_helpers.idedata.idedata_from_build",
return_value={"cxx_path": "g++"},
):
result = toolchain.get_idedata()
+6 -2
View File
@@ -998,10 +998,10 @@ def test_progress_bar(capsys: CaptureFixture[str]) -> None:
assert "100%" in captured.err
assert "Done" in captured.err
# Test done method
# done() after the 100% frame adds nothing; that frame ended its line
progress.done()
captured = capsys.readouterr()
assert captured.err == "\n"
assert captured.err == ""
# Test same progress doesn't update
progress.update(0.5)
@@ -1010,6 +1010,10 @@ def test_progress_bar(capsys: CaptureFixture[str]) -> None:
# Should only see one update (second call shouldn't write)
assert captured.err.count("50%") == 1
# done() after a mid-way frame ends the line
progress.done()
assert capsys.readouterr().err == "\n"
# Tests for SHA256 authentication
@pytest.mark.usefixtures("mock_time")
+137 -1
View File
@@ -4,7 +4,7 @@ import os
from pathlib import Path
import time
from typing import Any
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, call, patch
import pytest
import requests
@@ -81,6 +81,15 @@ def mock_download_content_many() -> MagicMock:
yield m
@pytest.fixture
def mock_retry_sleep() -> MagicMock:
"""Patch the retry backoff sleep (process-wide; net_retry.time is the
global module) so transient-error tests don't really wait 2s/4s.
"""
with patch("esphome.net_retry.time.sleep") as m:
yield m
def test_compute_local_file_dir(setup_core: Path) -> None:
"""Test compute_local_file_dir creates and returns correct path."""
domain = "font"
@@ -495,6 +504,7 @@ class _BodyReadErrorResponse:
def test_download_content_with_body_read_error_uses_cache(
mock_has_remote_file_changed: MagicMock,
mock_requests_get: MagicMock,
mock_retry_sleep: MagicMock,
setup_core: Path,
) -> None:
"""Body-read errors (chunked-decode/gzip-decode/mid-stream connection
@@ -519,6 +529,7 @@ def test_download_content_with_body_read_error_uses_cache(
def test_download_content_with_body_read_error_no_cache_fails(
mock_has_remote_file_changed: MagicMock,
mock_requests_get: MagicMock,
mock_retry_sleep: MagicMock,
setup_core: Path,
) -> None:
"""A body-read failure with no cache available must surface as a
@@ -535,6 +546,131 @@ def test_download_content_with_body_read_error_no_cache_fails(
external_files.download_content("https://example.com/file.txt", test_file)
def test_download_content_retries_transient_error_then_succeeds(
mock_has_remote_file_changed: MagicMock,
mock_requests_get: MagicMock,
mock_retry_sleep: MagicMock,
setup_core: Path,
) -> None:
"""Transient failures (connection reset, timeout) are retried with 2s/4s
backoff before giving up; a late success downloads normally."""
test_file = setup_core / "downloads" / "file.txt"
mock_has_remote_file_changed.return_value = True
ok = MagicMock()
ok.content = b"downloaded"
ok.headers = {}
mock_requests_get.side_effect = [
requests.exceptions.ConnectionError("reset by peer"),
requests.exceptions.Timeout("timed out"),
ok,
]
result = external_files.download_content("https://example.com/file.txt", test_file)
assert result == b"downloaded"
assert test_file.read_bytes() == b"downloaded"
assert mock_retry_sleep.call_args_list == [call(2), call(4)]
def test_download_content_transient_error_exhausts_attempts(
mock_has_remote_file_changed: MagicMock,
mock_requests_get: MagicMock,
mock_retry_sleep: MagicMock,
setup_core: Path,
) -> None:
"""A persistent transient failure gives up after three attempts and then
follows the normal no-cache error path."""
test_file = setup_core / "nonexistent.txt"
mock_has_remote_file_changed.return_value = True
mock_requests_get.side_effect = requests.exceptions.ConnectionError("reset by peer")
with pytest.raises(Invalid, match="Could not download from.*reset by peer"):
external_files.download_content("https://example.com/file.txt", test_file)
assert mock_retry_sleep.call_args_list == [call(2), call(4)]
def test_download_content_non_transient_error_not_retried(
mock_has_remote_file_changed: MagicMock,
mock_requests_get: MagicMock,
mock_retry_sleep: MagicMock,
setup_core: Path,
) -> None:
"""Permanent failures like a 404 fail on the first attempt."""
test_file = setup_core / "nonexistent.txt"
mock_has_remote_file_changed.return_value = True
response = MagicMock()
response.status_code = 404
mock_requests_get.side_effect = requests.exceptions.HTTPError(
"404 Client Error", response=response
)
with pytest.raises(Invalid, match="Could not download from.*404"):
external_files.download_content("https://example.com/file.txt", test_file)
assert mock_requests_get.call_count == 1
mock_retry_sleep.assert_not_called()
def test_download_content_retries_body_read_error(
mock_has_remote_file_changed: MagicMock,
mock_requests_get: MagicMock,
mock_retry_sleep: MagicMock,
setup_core: Path,
) -> None:
"""Mid-stream failures surfacing from `.content` are retried too."""
test_file = setup_core / "downloads" / "file.txt"
mock_has_remote_file_changed.return_value = True
ok = MagicMock()
ok.content = b"downloaded"
ok.headers = {}
mock_requests_get.side_effect = [
_BodyReadErrorResponse(
requests.exceptions.ChunkedEncodingError("body truncated")
),
ok,
]
result = external_files.download_content("https://example.com/file.txt", test_file)
assert result == b"downloaded"
assert mock_requests_get.call_count == 2
assert mock_retry_sleep.call_args_list == [call(2)]
def test_has_remote_file_changed_retries_transient_error(
mock_requests_head: MagicMock,
mock_retry_sleep: MagicMock,
setup_core: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A HEAD revalidation that fails transiently then returns 304 does not
mark the cached copy stale, and the retry warning names the operation."""
test_file = setup_core / "cached.txt"
test_file.write_bytes(b"cached content")
ok = MagicMock()
ok.status_code = 304
ok.headers = {}
mock_requests_head.side_effect = [
requests.exceptions.ConnectionError("reset by peer"),
ok,
]
changed = external_files.has_remote_file_changed(
"https://example.com/file.txt", test_file
)
assert changed is False
assert test_file not in external_files._run_data().stale_paths
assert mock_requests_head.call_count == 2
assert mock_retry_sleep.call_args_list == [call(2)]
assert "Revalidation of" in caplog.text
def test_download_content_skip_external_update_uses_cache(
mock_has_remote_file_changed: MagicMock,
mock_requests_get: MagicMock,
+255 -38
View File
@@ -12,6 +12,8 @@ from pathlib import Path
import subprocess
import sys
import tarfile
import threading
import time
from unittest.mock import MagicMock, Mock, call, patch
import zipfile
@@ -22,8 +24,8 @@ from esphome import framework_helpers
from esphome.core import EsphomeError
from esphome.framework_helpers import (
_7z_extract_all,
_BatchDownloadProgress,
_detect_archive_root,
_is_transient_download_error,
_rename_with_retry,
_tar_extract_all,
_zip_extract_all,
@@ -37,6 +39,7 @@ from esphome.framework_helpers import (
get_python_env_executable_path,
get_system_python_path,
rmdir,
run_batch_downloads,
run_command,
run_command_ok,
str_to_lst_of_str,
@@ -1112,6 +1115,220 @@ class TestDownloadWithResume:
assert mock_get.call_args[1]["headers"] == {}
assert dest.read_bytes() == b"data"
def test_progress_callback_reports_absolute_bytes(self, tmp_path: Path) -> None:
"""With a callback no bar is drawn; the callback sees the running
byte count of this file, then its final verified size."""
dest = tmp_path / "tool.tar.gz"
resp = _mock_response(b"")
resp.headers = {"content-length": "7"}
resp.iter_content.return_value = [b"1234", b"567"]
seen: list[int] = []
with (
patch("requests.get", return_value=resp),
patch("esphome.framework_helpers.ProgressBar") as bar_cls,
):
download_with_resume(
"https://example.com/t", dest, size=7, progress=seen.append
)
assert seen == [0, 4, 7, 7]
bar_cls.assert_not_called()
def test_progress_callback_seeds_with_resume_offset(self, tmp_path: Path) -> None:
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"12345")
good = hashlib.sha256(b"12345678").hexdigest()
seen: list[int] = []
with patch("requests.get", return_value=_resumed_response(b"678")):
download_with_resume(
"https://example.com/t", dest, sha256=good, size=8, progress=seen.append
)
assert seen[0] == 5
assert seen[-1] == 8
def test_progress_callback_credits_already_complete_download(
self, tmp_path: Path
) -> None:
"""A verified dest from an earlier run still counts toward the batch."""
dest = tmp_path / "tool.tar.gz"
dest.write_bytes(b"12345678")
seen: list[int] = []
with patch("requests.get") as mock_get:
download_with_resume(
"https://example.com/t", dest, size=8, progress=seen.append
)
mock_get.assert_not_called()
assert seen == [8]
def test_run_batch_downloads_ctrl_c_aborts_in_flight_jobs() -> None:
"""Ctrl-C cancels in-flight downloads at their next tick instead of
letting non-daemon workers download to completion."""
started = threading.Event()
ticks: list[int] = []
def interrupter(tracker) -> None:
started.wait(5)
raise KeyboardInterrupt
def slow_download(tracker) -> None:
started.set()
for i in range(500):
tracker(i)
ticks.append(i)
time.sleep(0.01)
t0 = time.monotonic()
with pytest.raises(KeyboardInterrupt):
run_batch_downloads(
"Downloading",
[("boom", 0, interrupter), ("slow", 0, slow_download)],
max_workers=2,
)
# Uncancelled, slow_download alone takes ~5s
assert time.monotonic() - t0 < 3
assert len(ticks) < 500
def test_cancellation_escapes_broad_except_in_fetch() -> None:
"""A fetch that wraps its work in except Exception cannot swallow the
Ctrl-C sentinel (it is a BaseException)."""
from esphome.framework_helpers import _BatchDownloadCancelled
started = threading.Event()
swallowed = []
def interrupter(tracker) -> None:
started.wait(5)
raise KeyboardInterrupt
def greedy_fetch(tracker) -> None:
started.set()
try:
for i in range(500):
tracker(i)
time.sleep(0.01)
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
swallowed.append(err)
t0 = time.monotonic()
with pytest.raises(KeyboardInterrupt):
run_batch_downloads(
"Downloading",
[("boom", 0, interrupter), ("greedy", 0, greedy_fetch)],
max_workers=2,
)
assert time.monotonic() - t0 < 3
assert not swallowed
assert issubclass(_BatchDownloadCancelled, BaseException)
assert not issubclass(_BatchDownloadCancelled, Exception)
def test_logging_guard_ends_the_bar_row_before_a_record() -> None:
r"""A worker warning gets its own line instead of the bar's \r row."""
stream = io.StringIO()
stream.isatty = lambda: True # type: ignore[method-assign]
with patch("esphome.helpers.sys.stderr", stream):
progress = _BatchDownloadProgress("Downloading", 10)
progress.tracker()(5)
with progress.logging_guard():
logging.getLogger("esphome.test").warning("mirror retry")
# The partial 50% frame ended its line before the record was emitted
assert stream.getvalue().endswith("50% \n")
# And the next tick redraws the frame on a fresh row
progress.tracker()(2)
assert stream.getvalue().endswith("70% ")
def test_logging_guard_without_a_bar_is_a_no_op() -> None:
"""An unknown total draws no bar; the guard passes records through."""
from esphome.framework_helpers import _BatchDownloadProgress
progress = _BatchDownloadProgress("Downloading", 0)
with progress.logging_guard():
logging.getLogger("esphome.test").warning("plain record")
def test_cancellable_sleep_sleeps_between_ticks() -> None:
"""An uncancelled backoff actually waits out its delay in slices."""
from esphome.framework_helpers import _cancellable_sleep
ticks: list[int] = []
t0 = time.monotonic()
_cancellable_sleep(0.05, ticks.append, 3)
assert time.monotonic() - t0 >= 0.05
assert ticks and all(t == 3 for t in ticks)
def test_cancellable_sleep_aborts_at_the_tick() -> None:
"""A backoff sleep observes the cancellation raise promptly."""
from esphome.framework_helpers import _BatchDownloadCancelled, _cancellable_sleep
def cancelled_tick(done: int) -> None:
raise _BatchDownloadCancelled
t0 = time.monotonic()
with pytest.raises(_BatchDownloadCancelled):
_cancellable_sleep(30, cancelled_tick, 0)
assert time.monotonic() - t0 < 1
class Test_BatchDownloadProgress:
def test_sums_trackers_into_one_bar(self) -> None:
with patch("esphome.framework_helpers.ProgressBar") as bar_cls:
progress = _BatchDownloadProgress("Downloading", 100)
a = progress.tracker()
b = progress.tracker()
a(10)
b(20)
a(30)
a(0) # a restart from zero takes that file's bytes back out
bar_cls.assert_called_once_with("Downloading")
updates = [c[0][0] for c in bar_cls.return_value.update.call_args_list]
assert updates == [0.1, 0.3, 0.5, 0.2]
def test_clamps_at_one(self) -> None:
"""Sizes are advisory; an over-delivering server never pushes past 100%."""
with patch("esphome.framework_helpers.ProgressBar") as bar_cls:
progress = _BatchDownloadProgress("Downloading", 10)
progress.tracker()(25)
assert bar_cls.return_value.update.call_args[0][0] == 1
def test_unknown_total_draws_nothing(self) -> None:
with patch("esphome.framework_helpers.ProgressBar") as bar_cls:
progress = _BatchDownloadProgress("Downloading", 0)
progress.tracker()(5)
progress.done()
bar_cls.assert_not_called()
def test_done_ends_an_unfinished_bar(self) -> None:
"""A batch that stops short of 100% (a failed archive) still ends its
line so the next log message starts on a fresh row."""
stream = io.StringIO()
stream.isatty = lambda: True # type: ignore[method-assign]
with patch("esphome.helpers.sys.stderr", stream):
progress = _BatchDownloadProgress("Downloading", 10)
progress.tracker()(5)
progress.done()
assert stream.getvalue().endswith("50% \n")
def test_done_before_any_frame_writes_nothing(self) -> None:
"""A batch aborted before any tracker fired must not emit a stray
newline for a bar that was never drawn."""
stream = io.StringIO()
stream.isatty = lambda: True # type: ignore[method-assign]
with patch("esphome.helpers.sys.stderr", stream):
_BatchDownloadProgress("Downloading", 10).done()
assert stream.getvalue() == ""
def test_done_after_full_bar_adds_nothing(self) -> None:
stream = io.StringIO()
stream.isatty = lambda: True # type: ignore[method-assign]
with patch("esphome.helpers.sys.stderr", stream):
progress = _BatchDownloadProgress("Downloading", 10)
progress.tracker()(10)
progress.done()
assert stream.getvalue().endswith("100% Done...\r\n")
class TestDownloadFromMirrors:
def test_success_returns_url_and_writes_content(self, tmp_path: Path) -> None:
@@ -1124,6 +1341,22 @@ class TestDownloadFromMirrors:
assert url == "https://example.com/f"
assert target.read_bytes() == b"filedata"
def test_file_object_target_reports_progress(self) -> None:
"""The library prefetch's production path: a file-object target
streams through the mirror fallback and ticks the tracker."""
buf = io.BytesIO()
ticks: list[int] = []
with patch(
"requests.get",
return_value=_mock_response(b"filedata"),
):
url = download_from_mirrors(
["https://example.com/f"], {}, buf, progress=ticks.append
)
assert url == "https://example.com/f"
assert buf.getvalue() == b"filedata"
assert ticks and ticks[-1] == len(b"filedata")
def test_substitutions_applied_to_url(self, tmp_path: Path) -> None:
with patch(
"requests.get",
@@ -1469,6 +1702,27 @@ class TestDownloadFromMirrors:
assert mock_get.call_count == 2
mock_sleep.assert_called_once_with(2)
def test_backoff_tick_reports_partial_bytes(self, tmp_path: Path) -> None:
"""The backoff tick carries the bytes already in the part file, so a
combined bar holds steady instead of rewinding to zero."""
dest = tmp_path / "out.bin"
(tmp_path / "out.bin.part").write_bytes(b"12345")
ticks: list[int] = []
with (
patch(
"requests.get",
side_effect=[
req.ConnectionError("down"),
_mock_response(b"data"),
],
),
patch("esphome.framework_helpers._cancellable_sleep") as mock_sleep,
):
download_from_mirrors(
["https://mirror1.com/f"], {}, dest, progress=ticks.append
)
assert mock_sleep.call_args == call(2, ticks.append, 5)
def test_permanent_failure_does_not_retry_sweep(self, tmp_path: Path) -> None:
"""An HTTP 404 will not heal on its own; fail after a single pass."""
with (
@@ -1594,43 +1848,6 @@ class TestDownloadFromMirrors:
mock_sleep.assert_not_called()
def _http_error(status: int) -> req.HTTPError:
"""An HTTPError carrying a response with the given status, as raised by
``raise_for_status`` on a real response."""
resp = MagicMock()
resp.status_code = status
return req.HTTPError(str(status), response=resp)
class TestIsTransientDownloadError:
def test_connection_errors_are_transient(self) -> None:
assert _is_transient_download_error(req.ConnectionError("reset"))
assert _is_transient_download_error(req.Timeout("timed out"))
assert _is_transient_download_error(
req.exceptions.ChunkedEncodingError("dropped")
)
def test_http_statuses(self) -> None:
assert not _is_transient_download_error(_http_error(404))
assert not _is_transient_download_error(_http_error(403))
assert _is_transient_download_error(_http_error(429))
assert _is_transient_download_error(_http_error(503))
def test_http_error_without_response_is_permanent(self) -> None:
assert not _is_transient_download_error(req.HTTPError("boom"))
def test_exhausted_resume_attempts_are_permanent(self) -> None:
"""download_with_resume already spent its own resume attempts; its
EsphomeError wrapper is not retried again at the sweep level."""
wrapped = EsphomeError("Failed to download after 3 attempts")
wrapped.__cause__ = req.ConnectionError("down")
assert not _is_transient_download_error(wrapped)
def test_unrelated_errors_are_permanent(self) -> None:
assert not _is_transient_download_error(OSError("disk full"))
assert not _is_transient_download_error(EsphomeError("size mismatch"))
def test_importing_framework_helpers_does_not_import_requests() -> None:
"""Importing framework_helpers must not drag in requests.

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