Compare commits

...
Author SHA1 Message Date
J. Nick Koston 1e9499d87d Merge remote-tracking branch 'origin/dev' into esp8266-native-build-surgery 2026-08-25 13:20:50 -05:00
J. Nick Koston 145373ed13 [api] Widen message type storage to uint16_t (#18526) 2026-08-25 18:04:41 +00:00
J. Nick Koston 8749c3d18d [esp32_ble_tracker] Warn when the scan window is above 600ms with wifi (#18725) 2026-08-25 12:40:57 -05:00
J. Nick Koston 3426eb2733 Merge remote-tracking branch 'origin/dev' into esp8266-native-build-surgery 2026-08-25 12:38:49 -05:00
J. Nick Koston 688e833413 [core] Consolidate HTTP download paths (#18742) 2026-08-25 12:38:20 -05:00
J. Nick Koston 94b47496c8 Merge remote-tracking branch 'origin/dev' into esp8266-native-build-surgery 2026-08-25 12:37:36 -05:00
J. Nick Kostonandpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> 19b434f99c [espidf] Cache the discovered component list to skip the discovery configure (#18752)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-08-25 12:36:57 -05:00
Frédéric MetrichClaudepre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
0b78800e3d [emontx] Add apparent power (AP) and frequency (F) sensor support (#18586)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-08-25 11:07:18 -05:00
J. Nick Koston 515aa96844 Merge remote-tracking branch 'origin/dev' into esp8266-native-build-surgery 2026-08-25 10:47:44 -05:00
Jesse Hills 6a86ef0ee0 [ethernet] Add spi_id option to attach to an existing SPI bus (#18530) 2026-08-25 10:45:38 -05:00
Frédéric Metrich 4eba9ee41a [core] Fix false-positive readability-non-const-parameter in string_ref.h (#18758) 2026-08-25 10:36:00 -05:00
NoQuarrelandpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> b579751bdf [sfa40] Add SFA40 sensor support (#17815)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-08-25 07:55:19 -04:00
J. Nick Koston bc8d0840eb [time] Skip automation.cpp when no on_time or on_time_sync automation is configured (#18750) 2026-08-25 19:50:43 +12:00
J. Nick KostonandJesse Hills 3f615f87cf [esp32] Exclude esp_http_server and nvs_sec_provider from IDF builds by default (#18748)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-08-25 06:16:32 +00:00
J. Nick Koston c6d423159c [esp32] Only build the mbedTLS certificate bundle when a component needs it (#18747) 2026-08-25 17:26:18 +12:00
J. Nick Koston 791590c8a3 [uptime] Skip the timestamp sensor source unless a timestamp sensor is configured (#18746) 2026-08-25 17:24:20 +12:00
J. Nick Koston 036e5cda7e [core] Remove empty automation.cpp files from sensor, switch and output (#18745) 2026-08-25 17:24:14 +12:00
J. Nick Koston 44860ff512 [time] Skip posix_tz.cpp when no timezone is configured (#18680) 2026-08-25 17:22:41 +12:00
Edvard Filistovič 90927ba788 [rp2_ble_tracker] Automation triggers and scan actions (#18717) 2026-08-24 22:57:01 -05:00
Edvard Filistovič 169c055011 [wifi] libretiny: reset the STA state on synchronous connect failure (#18719) 2026-08-25 01:25:44 +00:00
esphome[bot] 8623b6836d Bump bundled esphome-device-builder to 1.13.0 (#18743) 2026-08-25 00:44:28 +00:00
J. Nick Koston 802a14f90a Hoist the test imports to module level 2026-08-24 18:53:36 -05:00
J. Nick Koston 69a3ab6fb1 Error instead of substituting a state-destroying layout on legacy cores, extract the ld choice, regroup KEY_LDSCRIPT, document segment_length's None 2026-08-24 18:51:10 -05:00
J. Nick Koston 2cd5db4854 Merge remote-tracking branch 'origin/esp8266-native-build-surgery' into esp8266-native-build-surgery 2026-08-24 18:47:58 -05:00
J. Nick Koston 767b60a914 Merge remote-tracking branch 'origin/dev' into esp8266-native-build-surgery 2026-08-24 18:46:55 -05:00
J. Nick Koston 7ca1b79544 Name the ldscript key, warn when a pre-2.4.2 core cannot honor it, pin the rule
KEY_LDSCRIPT replaces the bare literal at both the table entry and the
lookup, so a typo cannot silently fall back to the layout-moving
default; the regeneration recipe notes that overrides must survive a
refresh; a board pinning an override on an Arduino core <= 2.4.2 warns
that the layout cannot be honored; and board_ld_script gains the test
pinning d1_wroom_02's shipped layout and the size defaults.
2026-08-24 18:46:47 -05:00
J. Nick Koston a25509bda0 Share the per-board ldscript rule via boards.board_ld_script
One source of truth for the PlatformIO pinning and the native
generator's fallback, so the per-board rule cannot drift.
2026-08-24 18:46:47 -05:00
J. Nick Koston 04825d3a58 Reconcile BOARDS with the generated table: d1_wroom_02 joins with its shipped flash layout 2026-08-24 18:46:47 -05:00
J. Nick Koston e714848fb9 Trim comment essays and hoist function-local test imports 2026-08-24 18:46:47 -05:00
J. Nick Koston d7ee824228 Phrase the board-table regen comment imperatively 2026-08-24 18:46:47 -05:00
J. Nick Koston 862b7f7c3e Pin fingerprint stability and sensitivity behaviorally, note the platform pairing 2026-08-24 18:46:47 -05:00
J. Nick Koston 77c41d2579 State what the fingerprint test actually pins 2026-08-24 18:46:47 -05:00
J. Nick Koston 4d92741e69 Pin the decoy anchor, guard unselected segments, fingerprint the module source 2026-08-24 18:46:47 -05:00
J. Nick Koston b2ac49d8a8 Anchor the segment regex, reject unknown segments, and pin real-script fixtures 2026-08-24 18:46:47 -05:00
J. Nick Koston 8fb6496583 Export a surgery fingerprint for linker-script cache stamps 2026-08-24 18:46:47 -05:00
J. Nick Koston 5f3d73720e Patch exactly the named segments and share the segment-line pattern 2026-08-24 18:46:47 -05:00
J. Nick Koston cdd8a16cb9 Add the ESP8266 linker-script surgery and board build metadata 2026-08-24 18:46:47 -05:00
J. Nick Koston b3d9aa2d84 [core] Log which source change triggered a rebuild (#18552) 2026-08-24 18:46:43 -05:00
acd3c4f156 [lvgl] Implement list widget (#18018)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-24 23:20:20 +00:00
luar123andJ. Nick Koston 37b78ad46a [zigbee][time] Add zigbee time component on esp32 (#18656)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-24 17:24:57 -05:00
esphome[bot] a15fae6a1e Bump aioesphomeapi from 46.1.0 to 46.2.0 (#18740) 2026-08-24 22:15:09 +00:00
guillempagesandJ. Nick Koston c866add320 [runtime_image] Fix include (#18738)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-24 16:23:07 -05:00
J. Nick Koston 2a05372a51 [gpio] Fix one_wire reset busy-waiting with interrupts off when delay wraps (#18733) 2026-08-24 16:20:25 -05:00
J. Nick Koston 4efc3ecb70 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-24 16:00:08 -05:00
J. Nick Koston 4457158e3b Merge remote-tracking branch 'origin/dev' into esp8266-native-shared-helpers 2026-08-24 15:59:59 -05:00
J. Nick Koston 19866c60ee [core] Parallelize registry and tool downloads (#18662) 2026-08-24 15:59:00 -05:00
J. Nick Koston ad437cbd15 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-24 15:58:30 -05:00
J. Nick Koston 7e236bc762 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-24 15:58:30 -05:00
J. Nick Koston 6eed2e5d2b Merge remote-tracking branch 'origin/dev' into esp8266-native-library-converter 2026-08-24 15:58:21 -05:00
J. Nick Koston 3f0ec0b157 [espidf] Always reconfigure after component discovery (#18730) 2026-08-25 08:56:59 +12:00
Clyde Stubbs 7c5105b16e [key_collector][mipi_rgb] Suppress compilation warnings (#18721) 2026-08-25 06:36:06 +10:00
Keith BurzinskiandJ. Nick Koston b97c877992 [api] Acknowledge proxy subscribe and configuration requests (#18312)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-24 20:27:24 +00:00
guillempagesJ. Nick Kostonpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>J. Nick Koston
ca96365143 [runtime_image] Add MIME types to image formats (#16361)
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-24 15:07:01 -05:00
esphome[bot] 3b6c6e3dad Bump aioesphomeapi from 46.0.0 to 46.1.0 (#18737) 2026-08-24 14:50:35 -05:00
J. Nick Koston 454e380057 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-24 14:38:58 -05:00
J. Nick Koston cd1e5aad67 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-24 14:38:58 -05:00
J. Nick Koston a8c4018536 Merge remote-tracking branch 'origin/dev' into esp8266-native-library-converter 2026-08-24 14:38:49 -05:00
dependabot[bot] ad508d79af Bump github/codeql-action/init from 4.37.7 to 4.37.8 (#18736)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-24 14:33:52 -05:00
dependabot[bot] 32a0a5c55c Bump github/codeql-action/analyze from 4.37.7 to 4.37.8 (#18735)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-24 14:33:39 -05:00
dependabot[bot] b103df1bb1 Bump ruff from 0.16.3 to 0.16.4 (#18734)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-24 14:33:24 -05:00
Bonne Eggleston c2a153a946 [modbus_controller] Add continuous polling option (#18080) 2026-08-24 13:26:26 -05:00
J. Nick Koston 200ee0c9c3 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-24 12:32:54 -05:00
J. Nick Koston 81c375af0b Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-24 12:32:53 -05:00
J. Nick Koston 3ca65ffb0d Cover the file-like backoff tick, settle the batch worker return shape 2026-08-24 12:32:38 -05:00
J. Nick Koston 68b474fd00 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-24 12:20:18 -05:00
J. Nick Koston efa7fae845 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-24 12:20:18 -05:00
J. Nick Koston 29c290166b Apply simplify pass: flatten the batch worker, drop a dead guard, skip the unread backoff probe 2026-08-24 12:20:14 -05:00
J. Nick Koston 49fc4be861 [ci] Run C++ unit tests when a component's Python or test override changes (#18706) 2026-08-24 12:18:17 -05:00
J. Nick Koston d348284d9e Hoist the manifest shape gate into a helper 2026-08-24 12:17:05 -05:00
GafieltandJ. Nick Koston b8408e14b9 [uart] Delete ESP32 UART driver on shutdown to keep ROM output off the bus (#18684)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-08-24 11:36:23 -05:00
Boris Krivonog b11a34af5c [mitsubishi_cn105] Defer status requests after responses (#18227) 2026-08-24 10:51:09 -05:00
n-IA-haneandn-IA-hane 5ed20805ce [audio_http] Add persistent ring buffer option (#18708)
Co-authored-by: n-IA-hane <n-IA-hane@users.noreply.github.com>
2026-08-24 11:35:59 -04:00
Bonne Eggleston 7674477dd0 [modbus_controller] Brace poll-refused log to fix -Wempty-body warning (#18724) 2026-08-24 09:05:00 -05:00
2b11fbe164 [preferences] modify IntervalSyncer to be a PollingComponent (#14370)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-23 22:50:19 -05:00
J. Nick Koston 3a248d526d Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 22:25:37 -05:00
J. Nick Koston 2f49405740 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 22:25:36 -05:00
J. Nick Koston d418472a8f Merge remote-tracking branch 'origin/dev' into esp8266-native-library-converter
# Conflicts:
#	esphome/platformio/library.py
#	tests/unit_tests/test_platformio_library.py
2026-08-23 22:25:22 -05:00
J. Nick Koston 5f6a910e2d [core] Harden the shared library converter (#18685) 2026-08-23 22:24:12 -05:00
J. Nick Koston 7a715ee583 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 22:02:12 -05:00
J. Nick Koston d8dc1dc70e Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 22:02:12 -05:00
J. Nick Koston 5479752da6 Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter 2026-08-23 22:02:12 -05:00
J. Nick Koston b0e4711cc3 Merge remote-tracking branch 'origin/esp8266-native-converter-hardening' into esp8266-native-converter-hardening 2026-08-23 22:01:59 -05:00
J. Nick Koston d744a98f5b Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 22:00:34 -05:00
J. Nick Koston a03945bbfb Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 22:00:34 -05:00
J. Nick Koston bc2b4bd381 Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter 2026-08-23 22:00:34 -05:00
J. Nick Koston f8acaab6c1 Merge remote-tracking branch 'origin/dev' into esp8266-native-converter-hardening
# Conflicts:
#	script/determine-jobs.py
#	tests/script/test_determine_jobs.py
2026-08-23 22:00:22 -05:00
J. Nick Koston 5f2b8f7251 Type LINK_FLAGS in the gate, skip malformed transitive deps, escape quotes in zephyr CMake entries 2026-08-23 21:59:22 -05:00
J. Nick Koston d80d2c7034 Tolerate malformed repository/description metadata instead of crashing 2026-08-23 21:59:22 -05:00
J. Nick Koston 1f5152892c Give the fake env real membership, widen the manifest gate, route zephyr through the shared lexer 2026-08-23 21:59:22 -05:00
J. Nick Koston 89671f9e0d Normalize captured CPPDEFINES to a CppDefine named tuple at capture time 2026-08-23 21:59:22 -05:00
J. Nick Koston c6f9153e63 Route captured LINKFLAGS to the link line and make Prepend a real front-insert 2026-08-23 21:59:22 -05:00
J. Nick Koston aa3268a6d8 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 21:59:22 -05:00
J. Nick Koston 879cbf6264 Cover env access warnings and the unmodelled-subscript degrade 2026-08-23 21:59:22 -05:00
J. Nick Koston 4da8868510 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 21:59:22 -05:00
J. Nick Koston df76fc6fba Trim comments and docstrings 2026-08-23 21:59:22 -05:00
J. Nick Koston 0d75f597b3 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 21:59:22 -05:00
J. Nick Koston 9974ad97fa [core] Extract the shared idedata and size-summary helpers into build_helpers (#18661) 2026-08-23 21:59:19 -05:00
Jesse Hills c8ebe461d1 [ci] Skip CodSpeed benchmarks on beta and release pull requests (#18703) 2026-08-24 14:11:35 +12:00
J. Nick Koston 936bc708af Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 21:10:46 -05:00
J. Nick Koston 13dcf54a43 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 21:10:46 -05:00
J. Nick Koston 5bccc41ca3 Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter 2026-08-23 21:10:46 -05:00
J. Nick Koston 5ba31dcccb Merge branch 'esp8266-native-idedata-helpers' into esp8266-native-converter-hardening 2026-08-23 21:10:45 -05:00
J. Nick Koston 3fe4e255d8 Merge remote-tracking branch 'origin/dev' into esp8266-native-idedata-helpers 2026-08-23 21:10:35 -05:00
J. Nick Koston 67608f9386 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 19:38:32 -05:00
J. Nick Koston 5aaf5e8150 Parametrize the build-info staleness test 2026-08-23 19:33:08 -05:00
J. Nick Koston 5ce8c68df5 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 19:30:46 -05:00
J. Nick Koston 412b7a8b99 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 19:30:46 -05:00
J. Nick Koston e594888176 Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter
# Conflicts:
#	esphome/platformio/library.py
2026-08-23 19:30:35 -05:00
J. Nick Koston 6bdd8b1163 Type LINK_FLAGS in the gate, skip malformed transitive deps, escape quotes in zephyr CMake entries 2026-08-23 19:30:02 -05:00
J. Nick Koston 06e3525f82 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 18:58:59 -05:00
J. Nick Koston 476ec16a3d Split the AS and ASPP source kinds to match SCons 2026-08-23 18:58:48 -05:00
J. Nick Koston d9b3536e9f Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 18:45:14 -05:00
J. Nick Koston 0ed990e311 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 18:45:14 -05:00
J. Nick Koston 69d997521f Guard the bar writes, narrow the cache probe, make a total prefetch failure loud 2026-08-23 18:45:02 -05:00
J. Nick Koston 4affd574ac Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 18:31:08 -05:00
J. Nick Koston 4840740032 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers
# Conflicts:
#	esphome/writer.py
2026-08-23 18:31:05 -05:00
J. Nick Koston ae5e5b3011 Merge remote-tracking branch 'origin/dev' into esp8266-native-idedata-helpers 2026-08-23 18:30:06 -05:00
J. Nick Koston 3ab464c345 Merge branch 'esp8266-native-idedata-helpers' into esp8266-native-converter-hardening 2026-08-23 18:30:06 -05:00
J. Nick Koston b74de2dfa2 Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter 2026-08-23 18:30:06 -05:00
J. Nick Koston 7ea8892e0a Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 18:20:45 -05:00
J. Nick Koston dce979245d Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 18:20:45 -05:00
J. Nick Koston 6e65634408 Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter 2026-08-23 18:20:45 -05:00
J. Nick Koston 6300e9b72e Tolerate malformed repository/description metadata instead of crashing 2026-08-23 18:20:43 -05:00
J. Nick Koston cb4dd32e19 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 18:15:04 -05:00
J. Nick Koston 6381ed97b9 Merge branch 'esp8266-native-library-converter' into esp8266-native-shared-helpers 2026-08-23 18:15:04 -05:00
J. Nick Koston 786e9ef278 Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter
# Conflicts:
#	esphome/platformio/library.py
2026-08-23 18:14:53 -05:00
J. Nick Koston 24ce2195da Merge remote-tracking branch 'origin/dev' into esp8266-native-idedata-helpers 2026-08-23 18:12:27 -05:00
J. Nick Koston d5fe45db66 Merge branch 'esp8266-native-idedata-helpers' into esp8266-native-converter-hardening 2026-08-23 18:12:27 -05:00
J. Nick Koston a027d06231 Give the fake env real membership, widen the manifest gate, route zephyr through the shared lexer 2026-08-23 18:12:14 -05:00
J. Nick Koston 8f7534f39a Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 17:27:06 -05:00
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 76ddbe5ebd Merge branch 'esp8266-native-idedata-helpers' into esp8266-native-converter-hardening 2026-08-23 17:17:59 -05:00
J. Nick Koston 81d9b943aa Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 17:17:59 -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 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 b738b36a39 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 17:08:16 -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 5f20ddb630 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 16:54:56 -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 89e5ba00de Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 16:35:58 -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 330f070921 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 16:31:12 -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 d7d236c6bc Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 16:25:22 -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 03c7d3b343 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 16:17:47 -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 Koston 4f9941decb Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 16:12:01 -05: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 72d6864938 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 16:09:48 -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 7061bd7e70 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 16:06:48 -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 438a4ddc14 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 15:59:01 -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 393dabadf8 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 15:52:30 -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 6dbef0d0bb Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 15:45:46 -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 Koston 54429bcdfb Name the ldscript key, warn when a pre-2.4.2 core cannot honor it, pin the rule
KEY_LDSCRIPT replaces the bare literal at both the table entry and the
lookup, so a typo cannot silently fall back to the layout-moving
default; the regeneration recipe notes that overrides must survive a
refresh; a board pinning an override on an Arduino core <= 2.4.2 warns
that the layout cannot be honored; and board_ld_script gains the test
pinning d1_wroom_02's shipped layout and the size defaults.
2026-08-23 15:27:36 -05: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 20920e0971 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 15:09:05 -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 8170cd3d96 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 15:06:44 -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
J. Nick Koston 6497ca1496 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 14:42:19 -05: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
J. Nick Koston d23e9a205a Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 14:31:26 -05: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 4803a92554 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 13:57:11 -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 abcfaa1810 Share the per-board ldscript rule via boards.board_ld_script
One source of truth for the PlatformIO pinning and the native
generator's fallback, so the per-board rule cannot drift.
2026-08-23 13:54:22 -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 ae8aa3fee9 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 13:36:08 -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 554e19ccdb Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 13:35:09 -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 2da0319eaa Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 13:17:14 -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 a272ca9e17 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 13:09:53 -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
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 bd69eab7fb Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 12:33:55 -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 f595a32434 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 12:15:19 -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 f8de6fe0d1 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 12:05:07 -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 1157189b36 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 11:36:56 -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 Koston 4e172a5774 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 10:48:53 -05: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 8cbb83a06d Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 10:37:16 -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 b8dac2dc89 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 10:33:02 -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 692bb2a810 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-23 10:16:07 -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 ec6123e0ef Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 23:41:57 -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 327630523c Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 23:23:40 -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 1b109518f7 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 23:21:06 -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 905c806009 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 22:58:47 -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 6fda5b9a07 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 22:39:48 -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 e714ef4dfc Reconcile BOARDS with the generated table: d1_wroom_02 joins with its shipped flash layout 2026-08-22 22:32:26 -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 4d6300b400 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 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 a22c2a9768 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 21:41:27 -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 791e2b8204 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 21:27:39 -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 d26d3fdc33 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 21:24:06 -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 044d593f09 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 21:12:36 -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 149ca8c954 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 20:01:31 -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 36b0b62a8f Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 19:55:33 -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 cbbc0a8fa9 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 19:46:45 -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 27aef88077 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 17:10:23 -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 bc98ba3b46 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 16:41:52 -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 2acde317d2 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 16:10:08 -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 4e6a8e297e Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 15:51:41 -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 689861ffc9 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 15:46:47 -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 b3f058613c Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 15:33:40 -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 520bf3f63b Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 15:20:42 -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 91836a8c69 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 15:03:20 -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 f599e37375 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 14:54:57 -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 910147139a Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 14:52:39 -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 35a1136bea Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 14:49:41 -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 d7af5f37b6 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 14:44:17 -05:00
J. Nick Koston 121795fa8d Trim comment and docstring essays 2026-08-22 14:44:12 -05:00
J. Nick Koston a2267eb686 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 14:41:33 -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 3a9a6edc0e Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 14:37:46 -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 a97b836012 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 14:31:38 -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 803eef7de6 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 14:11:02 -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 a6ca775a48 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 12:46:29 -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 60cf44de3b Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 12:15:38 -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 f08e06f574 Trim comment essays and hoist function-local test imports 2026-08-22 11:35:35 -05:00
J. Nick Koston 27f4db8458 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-22 11:34:57 -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 4d63564cdd Phrase the board-table regen comment imperatively 2026-08-21 20:56:27 -05:00
J. Nick Koston d47dc0d992 Pin fingerprint stability and sensitivity behaviorally, note the platform pairing 2026-08-20 22:08:41 -05:00
J. Nick Koston 9624abcd57 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 21:09:42 -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 6a0ce046fd Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 20:53:31 -05:00
J. Nick Koston 6b6344b454 Cover the healthy post-compile idedata path 2026-08-20 20:53:30 -05:00
J. Nick Koston 09fd3fb53b Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 20:48:32 -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 b8bb4453e0 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 20:46: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 3063b9067a State what the fingerprint test actually pins 2026-08-20 20:34:24 -05:00
J. Nick Koston 62c27826ee Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 19:51:27 -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 84a3c39562 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 19:29:28 -05:00
J. Nick Koston 5cf2135038 Cover the warn-once dedup branch 2026-08-20 19:29:27 -05:00
J. Nick Koston 7ad97697b2 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 19:25:45 -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 5cec72020f Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 19:02:46 -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 12f8b14418 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 18:40:46 -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 77fa411f04 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 18:18:07 -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 e20c43520b Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 17:52:57 -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 f93d46e684 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 17:29:59 -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 8008eda57f Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 17:12:31 -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 57b7f03e42 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 16:54:39 -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 65f5c302c5 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 16:30:26 -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 51af18cdfb Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 16:25:48 -05:00
J. Nick Koston 57a9c71da8 Cover the malformed build.flags rejection 2026-08-20 16:25:47 -05:00
J. Nick Koston 3d8a3c6527 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 16:24:32 -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 37de984df5 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 16:13:20 -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 8f5f349e1b Pin the decoy anchor, guard unselected segments, fingerprint the module source 2026-08-20 16:01:06 -05:00
J. Nick Koston 83b4da1777 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 15:59:10 -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 6a84d67786 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 15:55:01 -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 7d9c18a330 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 15:26:38 -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 375cf95240 Anchor the segment regex, reject unknown segments, and pin real-script fixtures 2026-08-20 14:38:25 -05:00
J. Nick Koston 18e3eac419 Export a surgery fingerprint for linker-script cache stamps 2026-08-20 14:14:32 -05:00
J. Nick Koston 0620ce81c3 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 13:57:51 -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 e3936f500f Patch exactly the named segments and share the segment-line pattern 2026-08-20 13:44:12 -05:00
J. Nick Koston cea86989c9 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 13:42:37 -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 46fa42fa33 Merge branch 'esp8266-native-shared-helpers' into esp8266-native-build-surgery 2026-08-20 13:35:42 -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 72da2b1ce2 Add the ESP8266 linker-script surgery and board build metadata 2026-08-20 12:53:50 -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 Koston 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 Koston 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
226 changed files with 10729 additions and 2259 deletions
+21 -4
View File
@@ -112,6 +112,12 @@ jobs:
component-test-batches: ${{ steps.determine.outputs.component-test-batches }}
validate-only-components: ${{ steps.determine.outputs.validate-only-components }}
benchmarks: ${{ steps.determine.outputs.benchmarks }}
# "true" when this run is a pull request into one of the release
# branches. Those pull requests are batches of changes already tested on
# their original dev pull requests, so several jobs below trade coverage
# for turnaround time on them. Matched exactly, not by prefix, so an
# ordinary branch named e.g. "release-notes" is not caught by it.
release-pr: ${{ github.base_ref == 'beta' || github.base_ref == 'release' }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -230,7 +236,7 @@ jobs:
runs-on: ubuntu-latest
needs:
- determine-jobs
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.release-pr == 'false' && needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -449,10 +455,21 @@ jobs:
if: >-
github.repository == 'esphome/esphome' && (
(github.event_name == 'push' && github.ref_name == 'dev') ||
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
(
github.event_name == 'pull_request' &&
needs.determine-jobs.outputs.release-pr == 'false' &&
needs.determine-jobs.outputs.benchmarks == 'true'
)
)
# CodSpeed benchmarks require a CodSpeed account linked to the repository to run
# (https://codspeed.io) -- disabled on forks that aren't esphome/esphome itself.
#
# Pull requests into beta and release are skipped as well. CodSpeed compares a
# pull request against the newest commit of its base branch that has a benchmark
# run of its own, and only dev is benchmarked. A release pull request therefore
# falls back to dev's latest run, so every speed-up merged into dev since the
# release branched is reported as a regression in the release. The changes there
# have already been benchmarked on their original dev pull requests.
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -929,7 +946,7 @@ jobs:
ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf
strategy:
fail-fast: false
max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 32 || 16 }}
max-parallel: ${{ needs.determine-jobs.outputs.release-pr == 'true' && 32 || 16 }}
matrix:
batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }}
steps:
@@ -1016,7 +1033,7 @@ jobs:
# - This catches pin conflicts and other issues in directly changed code
# - Grouped tests use --testing-mode to allow config merging (disables some checks)
# - Dependencies are safe to group since they weren't modified in this PR
if [[ "${{ github.base_ref }}" == beta* ]] || [[ "${{ github.base_ref }}" == release* ]]; then
if [[ "${{ needs.determine-jobs.outputs.release-pr }}" == "true" ]]; then
directly_changed_csv=""
echo "Testing components: $components_csv"
echo "Target branch: ${{ github.base_ref }} - grouping all components"
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with:
category: "/language:${{matrix.language}}"
+1
View File
@@ -476,6 +476,7 @@ esphome/components/sensirion_common/* @martgras
esphome/components/sensor/* @esphome/core
esphome/components/serial_proxy/* @kbx81
esphome/components/sfa30/* @ghsensdev
esphome/components/sfa40/* @NoQuarrel
esphome/components/sgp40/* @SenexCrenshaw
esphome/components/sgp4x/* @martgras @SenexCrenshaw
esphome/components/sha256/* @esphome/core
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.12.4
RUN uv pip install --no-cache-dir esphome-device-builder==1.13.0
RUN \
platformio settings set enable_telemetry No \
+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
+38 -33
View File
@@ -1,6 +1,7 @@
"""ESP-IDF direct build generator for ESPHome."""
import json
import logging
from pathlib import Path
from esphome.components.esp32 import (
@@ -11,6 +12,7 @@ from esphome.components.esp32 import (
)
import esphome.config_validation as cv
from esphome.core import CORE
from esphome.espidf import variant_to_idf_target
from esphome.framework_helpers import (
get_project_compile_flags,
get_project_cxx_compile_flags,
@@ -18,6 +20,8 @@ from esphome.framework_helpers import (
)
from esphome.helpers import mkdir_p, write_file_if_changed
_LOGGER = logging.getLogger(__name__)
# Replaces the IDF default C++ standard (-std=gnu++2b appended to
# CXX_COMPILE_OPTIONS by project.cmake's __build_init) with the one set via
# cg.set_cpp_standard(). Emitted between include(project.cmake) and project(),
@@ -31,11 +35,12 @@ idf_build_set_property(CXX_COMPILE_OPTIONS "${{esphome_cxx_compile_options}}")""
def get_available_components() -> list[str] | None:
"""Get list of built-in ESP-IDF components from project_description.json.
"""List the built-in ESP-IDF components from ``project_description.json``.
Excludes ``src``, IDF-managed components (``managed_components/``), and
converted PIO libs (``pio_components/``). Returns ``None`` if the build
dir or ``project_description.json`` isn't ready yet.
Only components below its ``idf_path/components`` count, which leaves out
``src``, IDF-managed components, converted PIO libs and project local
ones such as the Arduino ``component_stubs``. Returns ``None`` if the
build dir or ``project_description.json`` isn't ready yet.
"""
if CORE.build_path is None:
return None
@@ -46,30 +51,24 @@ def get_available_components() -> list[str] | None:
try:
with project_desc.open(encoding="utf-8") as f:
data = json.load(f)
component_info = data.get("build_component_info", {})
result = []
for name, info in component_info.items():
# Exclude our own src component
if name == "src":
continue
# Exclude IDF-managed and converted-PIO components (external).
comp_dir = info.get("dir", "")
if "managed_components" in comp_dir or "pio_components" in comp_dir:
continue
result.append(name)
return result
except (json.JSONDecodeError, OSError):
root = (Path(data["idf_path"]) / "components").resolve()
result = [
name
for name, info in data.get("build_component_info", {}).items()
if (comp_dir := info.get("dir"))
and Path(comp_dir).resolve().is_relative_to(root)
]
except (json.JSONDecodeError, KeyError, OSError) as err:
_LOGGER.debug("Could not read %s: %s", project_desc, err)
return None
if not result:
_LOGGER.warning("No ESP-IDF components found under %s", root)
return result
def has_discovered_components() -> bool:
"""Check if we have discovered components from a previous configure."""
return get_available_components() is not None
"""Check if a previous configure discovered any built-in components."""
return bool(get_available_components())
def _cmake_quote(value: str) -> str:
@@ -79,15 +78,17 @@ def _cmake_quote(value: str) -> str:
return f'"{escaped}"'
def get_project_cmakelists(minimal: bool = False) -> str:
def get_project_cmakelists(
minimal: bool = False, builtin_components: list[str] | None = None
) -> str:
"""Generate the top-level CMakeLists.txt for ESP-IDF project.
When ``minimal`` is true, omit ``ESPHOME_PROJECT_BUILTIN_COMPONENTS``
since ``project_description.json`` may be stale on the first write.
``builtin_components`` supplies the discovered list (from the cache)
instead of reading it from ``project_description.json``.
"""
# Get IDF target from ESP32 variant (e.g., ESP32S3 -> esp32s3)
variant = get_esp32_variant()
idf_target = variant.lower().replace("-", "")
idf_target = variant_to_idf_target(get_esp32_variant())
# esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and
# removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get
@@ -162,9 +163,11 @@ def get_project_cmakelists(minimal: bool = False) -> str:
else "\n".join(
f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)"
for name in sorted(
set(get_available_components() or []).difference(
CORE.cmake_args.get("EXCLUDE_COMPONENTS", "").split(";")
)
set(
builtin_components
if builtin_components is not None
else get_available_components() or []
).difference(CORE.cmake_args.get("EXCLUDE_COMPONENTS", "").split(";"))
)
)
)
@@ -279,7 +282,9 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC
"""
def write_project(minimal: bool = False) -> None:
def write_project(
minimal: bool = False, builtin_components: list[str] | None = None
) -> None:
"""Write ESP-IDF project files."""
mkdir_p(CORE.build_path)
mkdir_p(CORE.relative_src_path())
@@ -287,7 +292,7 @@ def write_project(minimal: bool = False) -> None:
# Write top-level CMakeLists.txt
write_file_if_changed(
CORE.relative_build_path("CMakeLists.txt"),
get_project_cmakelists(minimal=minimal),
get_project_cmakelists(minimal=minimal, builtin_components=builtin_components),
)
# Write component CMakeLists.txt in src/
+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)}")
+28 -1
View File
@@ -232,6 +232,7 @@ enum SerialProxyPortType {
message SerialProxyInfo {
string name = 1; // Human-readable port name
SerialProxyPortType port_type = 2; // Port type (RS232, RS485)
uint32 configured_line_states = 3; // Bitmask of SerialProxyLineStateFlags this instance can drive
}
// DeviceInfoResponse max_data_length values:
@@ -2626,6 +2627,22 @@ message ZWaveProxyRequest {
bytes data = 2;
}
enum ZWaveProxyStatus {
ZWAVE_PROXY_STATUS_OK = 0; // Request completed successfully
ZWAVE_PROXY_STATUS_IN_USE = 1; // Denied: another client is already subscribed
ZWAVE_PROXY_STATUS_NOT_SUPPORTED = 2; // Request type not supported
}
// Acknowledges a ZWaveProxyRequest (subscribe/unsubscribe). Sent since API 1.16.
message ZWaveProxyRequestResponse {
option (id) = 151;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_ZWAVE_PROXY";
ZWaveProxyRequestType type = 1; // Which request type this responds to
ZWaveProxyStatus status = 2; // Result status
}
// ==================== INFRARED ====================
// Note: Feature and capability flag enums are defined in
// esphome/components/infrared/infrared.h
@@ -2769,12 +2786,18 @@ message SerialProxyGetModemPinsResponse {
uint32 instance = 1; // Instance index (0-based)
uint32 line_states = 2; // Bitmask of SerialProxyLineStateFlags
SerialProxyStatus status = 3; // INVALID_ARGUMENT if the instance index is out of range (since API 1.16)
}
enum SerialProxyRequestType {
SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE = 0; // Subscribe to receive data from this serial proxy instance
SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1; // Unsubscribe from this serial proxy instance
SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2; // Flush the serial port (block until all TX data is sent)
// Values below are only valid in SerialProxyRequestResponse.type, identifying which
// operation is being acknowledged. Sending them in SerialProxyRequest.type is an
// error the device answers with INVALID_ARGUMENT.
SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3; // Acknowledges a SerialProxyConfigureRequest
SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4; // Acknowledges a SerialProxySetModemPinsRequest
}
enum SerialProxyStatus {
@@ -2783,6 +2806,8 @@ enum SerialProxyStatus {
SERIAL_PROXY_STATUS_ERROR = 2; // Driver or hardware error
SERIAL_PROXY_STATUS_TIMEOUT = 3; // Timed out before TX completed
SERIAL_PROXY_STATUS_NOT_SUPPORTED = 4; // Request type not supported by this instance
SERIAL_PROXY_STATUS_PORT_IN_USE = 5; // Denied: another client holds the port
SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6; // Invalid instance index or parameter value
}
// Generic request message for simple serial proxy operations
@@ -2795,7 +2820,9 @@ message SerialProxyRequest {
SerialProxyRequestType type = 2; // Request type
}
// Response to a SerialProxyRequest (e.g. flush completion or failure)
// Acknowledges a serial proxy operation; the type field identifies which
// operation is being acknowledged. Flush has been acknowledged since the
// message was introduced; all other acknowledgements are sent since API 1.16.
message SerialProxyRequestResponse {
option (id) = 147;
option (source) = SOURCE_SERVER;
+85 -40
View File
@@ -1381,7 +1381,12 @@ void APIConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) {
}
void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) {
zwave_proxy::global_zwave_proxy->zwave_proxy_request(this, msg.type);
ZWaveProxyRequestResponse resp{};
resp.type = msg.type;
resp.status = zwave_proxy::global_zwave_proxy->zwave_proxy_request(this, msg.type);
if (!this->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Z-Wave proxy response");
}
}
#endif
@@ -1550,15 +1555,50 @@ void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent
#endif
#ifdef USE_SERIAL_PROXY
static enums::SerialProxyStatus serial_proxy_result_to_status(serial_proxy::SerialProxyResult result) {
switch (result) {
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_OK:
return enums::SERIAL_PROXY_STATUS_OK;
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_ASSUMED_SUCCESS:
return enums::SERIAL_PROXY_STATUS_ASSUMED_SUCCESS;
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE:
return enums::SERIAL_PROXY_STATUS_PORT_IN_USE;
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT:
return enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_TIMEOUT:
return enums::SERIAL_PROXY_STATUS_TIMEOUT;
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED:
return enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED;
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_ERROR:
return enums::SERIAL_PROXY_STATUS_ERROR;
}
return enums::SERIAL_PROXY_STATUS_ERROR; // Unreachable; all enum values handled above
}
static void send_serial_proxy_ack(APIConnection *conn, uint32_t instance, enums::SerialProxyRequestType type,
enums::SerialProxyStatus status) {
SerialProxyRequestResponse resp{};
resp.instance = instance;
resp.type = type;
resp.status = status;
if (!conn->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
}
}
void APIConnection::on_serial_proxy_configure_request(const SerialProxyConfigureRequest &msg) {
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range (max %" PRIu32 ")", msg.instance,
static_cast<uint32_t>(proxies.size()));
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE,
enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT);
return;
}
proxies[msg.instance]->configure(this, msg.baudrate, msg.flow_control, static_cast<uint8_t>(msg.parity),
msg.stop_bits, msg.data_size);
serial_proxy::SerialProxyResult result = proxies[msg.instance]->configure(
this, msg.baudrate, msg.flow_control, static_cast<uint8_t>(msg.parity), msg.stop_bits, msg.data_size);
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE,
serial_proxy_result_to_status(result));
}
void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) {
@@ -1574,20 +1614,30 @@ void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetM
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS,
enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT);
return;
}
proxies[msg.instance]->set_modem_pins(this, msg.line_states);
serial_proxy::SerialProxyResult result = proxies[msg.instance]->set_modem_pins(this, msg.line_states);
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS,
serial_proxy_result_to_status(result));
}
void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg) {
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
return;
}
SerialProxyGetModemPinsResponse resp{};
resp.instance = msg.instance;
resp.line_states = proxies[msg.instance]->get_modem_pins();
if (msg.instance >= proxies.size()) {
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
// Pre-1.16 clients do not read the status field and would take this error
// for a successful "both pins deasserted" answer; let them time out as before
if (!this->client_supports_api_version(1, 16)) {
return;
}
resp.status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
} else {
resp.line_states = proxies[msg.instance]->get_modem_pins();
}
if (!this->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
}
@@ -1597,40 +1647,31 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
send_serial_proxy_ack(this, msg.instance, msg.type, enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT);
return;
}
auto *proxy = proxies[msg.instance];
enums::SerialProxyStatus status;
switch (msg.type) {
case enums::SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE:
case enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE:
proxies[msg.instance]->serial_proxy_request(this, msg.type);
status = serial_proxy_result_to_status(proxy->serial_proxy_request(this, msg.type));
break;
case enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH: {
SerialProxyRequestResponse resp{};
resp.instance = msg.instance;
resp.type = enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH;
switch (proxies[msg.instance]->flush_port()) {
case uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS:
resp.status = enums::SERIAL_PROXY_STATUS_OK;
break;
case uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS:
resp.status = enums::SERIAL_PROXY_STATUS_ASSUMED_SUCCESS;
break;
case uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT:
resp.status = enums::SERIAL_PROXY_STATUS_TIMEOUT;
break;
case uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED:
resp.status = enums::SERIAL_PROXY_STATUS_ERROR;
break;
}
if (!this->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
}
case enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH:
status = serial_proxy_result_to_status(proxy->flush_port(this));
break;
case enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE:
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS:
// Response-only discriminators; never valid in a request
ESP_LOGW(TAG, "Response-only serial proxy request type: %" PRIu32, static_cast<uint32_t>(msg.type));
status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
break;
}
default:
ESP_LOGW(TAG, "Unknown serial proxy request type: %" PRIu32, static_cast<uint32_t>(msg.type));
status = enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED;
break;
}
send_serial_proxy_ack(this, msg.instance, msg.type, status);
}
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) {
@@ -1749,15 +1790,17 @@ void APIConnection::complete_authentication_() {
bool APIConnection::send_hello_response_(const HelloRequest &msg) {
// Copy client name with truncation if needed (set_client_name handles truncation)
this->helper_->set_client_name(msg.client_info.c_str(), msg.client_info.size());
this->client_api_version_major_ = msg.api_version_major;
this->client_api_version_minor_ = msg.api_version_minor;
this->client_api_version_major_ =
static_cast<uint8_t>(std::min<uint32_t>(msg.api_version_major, std::numeric_limits<uint8_t>::max()));
this->client_api_version_minor_ =
static_cast<uint8_t>(std::min<uint32_t>(msg.api_version_minor, std::numeric_limits<uint8_t>::max()));
char peername[socket::SOCKADDR_STR_LEN];
ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %" PRIu16 ".%" PRIu16, this->helper_->get_client_name(),
ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %u.%u", this->helper_->get_client_name(),
this->helper_->get_peername_to(peername), this->client_api_version_major_, this->client_api_version_minor_);
HelloResponse resp;
resp.api_version_major = 1;
resp.api_version_minor = 15;
resp.api_version_minor = 16;
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise
resp.server_info = ESPHOME_VERSION_REF;
resp.name = StringRef(App.get_name());
@@ -1891,6 +1934,7 @@ bool APIConnection::send_device_info_response_() {
auto &info = resp.serial_proxies[serial_proxy_index++];
info.name = StringRef(proxy->get_name());
info.port_type = proxy->get_port_type();
info.configured_line_states = proxy->get_configured_modem_pins();
}
#endif
#ifdef USE_API_NOISE
@@ -1951,6 +1995,7 @@ bool APIConnection::send_device_capabilities_response_() {
auto &info = resp.serial_proxies[serial_proxy_index++];
info.name = StringRef(proxy->get_name());
info.port_type = proxy->get_port_type();
info.configured_line_states = proxy->get_configured_modem_pins();
}
#endif
return this->send_message(resp);
@@ -2181,7 +2226,7 @@ bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) {
}
return false;
}
bool APIConnection::send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn,
bool APIConnection::send_message_(uint32_t payload_size, uint16_t message_type, MessageEncodeFn encode_fn,
const void *msg) {
#ifdef HAS_PROTO_MESSAGE_DUMP
// Skip dump for log messages (recursive logging risk) and camera frames (high-frequency noise)
@@ -2210,7 +2255,7 @@ uint16_t APIConnection::encode_to_buffer_slow(uint32_t calculated_size, MessageE
APIConnection *conn, uint32_t remaining_size) {
return encode_to_buffer(calculated_size, encode_fn, msg, conn, remaining_size);
}
bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) {
bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint16_t message_type) {
const bool is_log_message = (message_type == SubscribeLogsResponse::MESSAGE_TYPE);
if (!this->try_to_clear_buffer(!is_log_message)) {
@@ -2240,12 +2285,12 @@ void APIConnection::on_fatal_error() {
this->flags_.remove = true;
}
bool APIConnection::schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) {
bool APIConnection::schedule_message_front_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) {
this->deferred_batch_.add_item_front(entity, message_type, estimated_size);
return this->schedule_batch_();
}
bool APIConnection::send_message_smart_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size,
bool APIConnection::send_message_smart_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
uint8_t aux_data_index) {
if (this->should_send_immediately_(message_type) && this->helper_->can_write_without_blocking()) {
auto &shared_buf = this->parent_->get_shared_buffer_ref();
+20 -17
View File
@@ -326,8 +326,10 @@ class APIConnection final : public APIServerConnectionBase {
bool is_marked_for_removal() const { return this->flags_.remove; }
uint8_t get_log_subscription_level() const { return this->flags_.log_subscription; }
// Get client API version for feature detection
bool client_supports_api_version(uint16_t major, uint16_t minor) const {
// Get client API version for feature detection.
// Stored versions saturate at 255 (see send_hello_response_), so requesting
// a minimum above that can never match.
bool client_supports_api_version(uint8_t major, uint8_t minor) const {
return this->client_api_version_major_ > major ||
(this->client_api_version_major_ == major && this->client_api_version_minor_ >= minor);
}
@@ -374,7 +376,7 @@ class APIConnection final : public APIServerConnectionBase {
return true;
return this->try_to_clear_buffer_slow_(log_out_of_space);
}
bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type);
bool send_buffer(ProtoWriteBuffer buffer, uint16_t message_type);
const char *get_name() const { return this->helper_->get_client_name(); }
/// Get peer name (IP address) into caller-provided buffer, returns buf for convenience
@@ -423,7 +425,7 @@ class APIConnection final : public APIServerConnectionBase {
}
// Non-template buffer management for send_message
bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg);
bool send_message_(uint32_t payload_size, uint16_t message_type, MessageEncodeFn encode_fn, const void *msg);
// Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites.
// Defined in api_connection_buffer.h (needs APIServer complete).
@@ -664,10 +666,9 @@ class APIConnection final : public APIServerConnectionBase {
struct BatchItem {
EntityBase *entity; // 4 bytes - Entity pointer
uint8_t message_type; // 1 byte - Message type for protocol and dispatch
uint16_t message_type; // 2 bytes - Message type for protocol and dispatch
uint8_t estimated_size; // 1 byte - Estimated message size (max 255 bytes)
uint8_t aux_data_index{AUX_DATA_UNUSED}; // 1 byte - For events: index into entity's event_types
// 1 byte padding
};
std::vector<BatchItem> items;
@@ -677,7 +678,7 @@ class APIConnection final : public APIServerConnectionBase {
// connections that do, buffers are released after initial sync anyway
// Add item to the batch (with deduplication)
void add_item(EntityBase *entity, uint8_t message_type, uint8_t estimated_size,
void add_item(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
uint8_t aux_data_index = AUX_DATA_UNUSED) {
// Dedup: O(n) scan but optimized for RAM over performance
// Skip deduplication for events - they are edge-triggered, every occurrence matters
@@ -693,7 +694,7 @@ class APIConnection final : public APIServerConnectionBase {
this->items.push_back({entity, message_type, estimated_size, aux_data_index});
}
// Add item to the front of the batch (for high priority messages like ping)
void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) {
void add_item_front(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) {
// Swap to front avoids expensive vector::insert which shifts all elements
this->items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED});
if (this->items.size() > 1) {
@@ -758,13 +759,15 @@ class APIConnection final : public APIServerConnectionBase {
#endif
} flags_{}; // 2 bytes total
// 2-byte types immediately after flags_ (no padding between them)
uint16_t client_api_version_major_{0};
uint16_t client_api_version_minor_{0};
// 2-byte type immediately after flags_ (no padding between them)
uint16_t batch_message_type_{0}; // Current message type during batch encoding
// 1-byte types to fill remaining space before next 4-byte boundary
// Client API versions are clamped to 255 on receive (see send_hello_response_)
uint8_t client_api_version_major_{0};
uint8_t client_api_version_minor_{0};
ActiveIterator active_iterator_{ActiveIterator::NONE};
uint8_t batch_message_type_{0}; // Current message type during batch encoding
// Total: 2 (flags) + 2 + 2 + 1 + 1 = 8 bytes, aligned to 4-byte boundary
// Total: 2 (flags) + 2 + 1 + 1 + 1 + 1 (batch_header_size_ below) = 8 bytes,
// aligned to 4-byte boundary
// Actual header size used by encode_to_buffer for the current message.
// Read by process_batch_multi_ to pass into MessageInfo.
@@ -813,7 +816,7 @@ class APIConnection final : public APIServerConnectionBase {
// 2. It's an EventResponse (events are edge-triggered - every occurrence matters)
// 3. OR: User has opted into immediate sending (should_try_send_immediately = true
// AND batch_delay = 0)
inline bool should_send_immediately_(uint8_t message_type) const {
inline bool should_send_immediately_(uint16_t message_type) const {
return (
#ifdef USE_UPDATE
message_type == UpdateStateResponse::MESSAGE_TYPE ||
@@ -827,11 +830,11 @@ class APIConnection final : public APIServerConnectionBase {
// Helper method to send a message either immediately or via batching
// Tries immediate send if should_send_immediately_() returns true and buffer has space
// Falls back to batching if immediate send fails or isn't applicable
bool send_message_smart_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size,
bool send_message_smart_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
uint8_t aux_data_index = DeferredBatch::AUX_DATA_UNUSED);
// Helper function to schedule a deferred message with known message type
bool schedule_message_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size,
bool schedule_message_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
uint8_t aux_data_index = DeferredBatch::AUX_DATA_UNUSED) {
this->deferred_batch_.add_item(entity, message_type, estimated_size, aux_data_index);
return this->schedule_batch_();
@@ -839,7 +842,7 @@ class APIConnection final : public APIServerConnectionBase {
// Helper function to schedule a high priority message at the front of the batch
// Out-of-line: callers (on_shutdown, check_keepalive_) are cold paths
bool schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size);
bool schedule_message_front_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size);
// Helper function to log client messages with name and peername
void log_client_(int level, const LogString *message);
+9 -9
View File
@@ -49,16 +49,16 @@ struct ReadPacketBuffer {
};
// Packed message info structure to minimize memory usage
// Note: message_type is uint8_t — all current protobuf message types fit in 8 bits.
// The noise wire format encodes types as 16-bit, but the high byte is always 0.
// If message types ever exceed 255, this and encrypt_noise_message_ must be updated.
// message_type matches the wire formats: noise carries a fixed 16-bit type
// field, plaintext a type varint. The proto codegen caps message IDs at 16383
// so the plaintext type varint fits the 2 bytes budgeted in HEADER_PADDING.
struct MessageInfo {
uint16_t offset; // Offset in buffer where message starts
uint16_t payload_size; // Size of the message payload
uint8_t message_type; // Message type (0-255)
uint16_t message_type; // Message type (0-16383)
uint8_t header_size; // Actual header size used (avoids recomputation in write path)
MessageInfo(uint8_t type, uint16_t off, uint16_t size, uint8_t hdr)
MessageInfo(uint16_t type, uint16_t off, uint16_t size, uint8_t hdr)
: offset(off), payload_size(size), message_type(type), header_size(hdr) {}
};
@@ -173,7 +173,7 @@ class APIFrameHelper {
}
// Write a single protobuf message - the hot path (87-100% of all writes).
// Caller must ensure state is DATA before calling.
virtual APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) = 0;
virtual APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) = 0;
// Write multiple protobuf messages in a single batched operation.
// Caller must ensure state is DATA and messages is not empty.
// messages contains (message_type, offset, length) for each message in the buffer.
@@ -187,15 +187,15 @@ class APIFrameHelper {
// Distinguishes protocols via frame_footer_size_ (noise always has a non-zero MAC
// footer, plaintext has footer=0). If a protocol with a plaintext footer is ever
// added, this should become a virtual method.
uint8_t frame_header_size(uint16_t payload_size, uint8_t message_type) const {
uint8_t frame_header_size(uint16_t payload_size, uint16_t message_type) const {
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
return this->frame_footer_size_
? this->frame_header_padding_
: static_cast<uint8_t>(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint8(message_type));
: static_cast<uint8_t>(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint16(message_type));
#elif defined(USE_API_NOISE)
return this->frame_header_padding_;
#else // USE_API_PLAINTEXT only
return static_cast<uint8_t>(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint8(message_type));
return static_cast<uint8_t>(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint16(message_type));
#endif
}
// Get the frame footer size required by this protocol
@@ -442,7 +442,7 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) {
}
// Encrypt a single noise message in place and return the encrypted frame length.
// Returns APIError::OK on success.
APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type,
APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint16_t message_type,
uint16_t &encrypted_len_out) {
// The noise frame header is written after encryption, when the size is known
@@ -472,7 +472,7 @@ APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_
return APIError::OK;
}
APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) {
APIError APINoiseFrameHelper::write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) {
#ifdef ESPHOME_DEBUG_API
assert(this->state_ == State::DATA);
#endif
@@ -31,7 +31,7 @@ class APINoiseFrameHelper final : public APIFrameHelper {
#endif
APIError loop() override;
APIError read_packet(ReadPacketBuffer *buffer) override;
APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override;
APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) override;
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) override;
protected:
@@ -44,7 +44,7 @@ class APINoiseFrameHelper final : public APIFrameHelper {
APIError state_action_handshake_write_();
APIError try_read_frame_();
APIError write_frame_(const uint8_t *data, uint16_t len);
APIError encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type,
APIError encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint16_t message_type,
uint16_t &encrypted_len_out);
APIError init_handshake_();
APIError check_handshake_finished_();
@@ -5,6 +5,7 @@
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "api_pb2.h"
#include "proto.h"
#include <cstring>
#include <cinttypes>
@@ -252,24 +253,21 @@ ESPHOME_ALWAYS_INLINE static inline void encode_varint_16(uint16_t value, uint8_
*p = static_cast<uint8_t>(value);
}
// Encode an 8-bit varint (1-2 bytes) using pre-computed length.
ESPHOME_ALWAYS_INLINE static inline void encode_varint_8(uint8_t value, uint8_t varint_len, uint8_t *p) {
if (varint_len == 2) {
*p++ = static_cast<uint8_t>(value | 0x80);
*p = static_cast<uint8_t>(value >> 7);
} else {
*p = value;
}
}
// The generator rejects message IDs above MAX_MESSAGE_TYPE, so the type varint
// can never outgrow the 2 bytes HEADER_PADDING budgets for it. Without this
// bound, write_plaintext_header's header_offset would underflow for the first
// message in a batch and the header write would land outside the buffer.
static_assert(1 + 3 + ProtoSize::varint16(MAX_MESSAGE_TYPE) <= APIPlaintextFrameHelper::HEADER_PADDING,
"HEADER_PADDING cannot fit the type varint of the largest message ID");
// Write plaintext header into pre-allocated padding before payload.
// padding_size: bytes reserved before payload (HEADER_PADDING for first/single msg,
// actual header size for contiguous batch messages).
// Returns the total header length (indicator + varints).
ESPHOME_ALWAYS_INLINE static inline uint8_t write_plaintext_header(uint8_t *buf_start, uint16_t payload_size,
uint8_t message_type, uint8_t padding_size) {
uint16_t message_type, uint8_t padding_size) {
uint8_t size_varint_len = ProtoSize::varint16(payload_size);
uint8_t type_varint_len = ProtoSize::varint8(message_type);
uint8_t type_varint_len = ProtoSize::varint16(message_type);
uint8_t total_header_len = 1 + size_varint_len + type_varint_len;
// The header is right-justified within the padding so it sits immediately before payload.
@@ -292,12 +290,12 @@ ESPHOME_ALWAYS_INLINE static inline uint8_t write_plaintext_header(uint8_t *buf_
// Encode varints directly into buffer using pre-computed lengths
encode_varint_16(payload_size, size_varint_len, buf_start + header_offset + 1);
encode_varint_8(message_type, type_varint_len, buf_start + header_offset + 1 + size_varint_len);
encode_varint_16(message_type, type_varint_len, buf_start + header_offset + 1 + size_varint_len);
return total_header_len;
}
APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) {
APIError APIPlaintextFrameHelper::write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) {
#ifdef ESPHOME_DEBUG_API
assert(this->state_ == State::DATA);
#endif
@@ -10,7 +10,8 @@ class APIPlaintextFrameHelper final : public APIFrameHelper {
// Plaintext header structure (worst case):
// Pos 0: indicator (0x00)
// Pos 1-3: payload size varint (up to 3 bytes)
// Pos 4-5: message type varint (up to 2 bytes)
// Pos 4-5: message type varint (up to 2 bytes; covers message IDs up to
// 16383, enforced by the proto codegen)
// Pos 6+: actual payload data
static constexpr uint8_t HEADER_PADDING = 1 + 3 + 2; // indicator + size varint + type varint
@@ -21,7 +22,7 @@ class APIPlaintextFrameHelper final : public APIFrameHelper {
APIError init() override;
APIError loop() override;
APIError read_packet(ReadPacketBuffer *buffer) override;
APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override;
APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) override;
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) override;
#ifdef USE_API_NOISE
// After try_read_frame_ returned PROTOCOL_SWITCH_TO_NOISE: copy out the
+16
View File
@@ -102,12 +102,14 @@ uint8_t *SerialProxyInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PAR
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast<uint32_t>(this->port_type));
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->configured_line_states);
return pos;
}
uint32_t SerialProxyInfo::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_length(1, this->name.size());
size += this->port_type ? 2 : 0;
size += ProtoSize::calc_uint32(1, this->configured_line_states);
return size;
}
#endif
@@ -3942,6 +3944,18 @@ uint32_t ZWaveProxyRequest::calculate_size() const {
size += ProtoSize::calc_length(1, this->data_len);
return size;
}
uint8_t *ZWaveProxyRequestResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->type));
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast<uint32_t>(this->status));
return pos;
}
uint32_t ZWaveProxyRequestResponse::calculate_size() const {
uint32_t size = 0;
size += this->type ? 2 : 0;
size += this->status ? 2 : 0;
return size;
}
#endif
#ifdef USE_INFRARED
uint8_t *ListEntitiesInfraredResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
@@ -4184,12 +4198,14 @@ uint8_t *SerialProxyGetModemPinsResponse::encode(ProtoWriteBuffer &buffer PROTO_
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->line_states);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast<uint32_t>(this->status));
return pos;
}
uint32_t SerialProxyGetModemPinsResponse::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, this->instance);
size += ProtoSize::calc_uint32(1, this->line_states);
size += this->status ? 2 : 0;
return size;
}
bool SerialProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
File diff suppressed because it is too large Load Diff
+28
View File
@@ -816,6 +816,18 @@ template<> const char *proto_enum_to_string<enums::ZWaveProxyRequestType>(enums:
return ESPHOME_PSTR("UNKNOWN");
}
}
template<> const char *proto_enum_to_string<enums::ZWaveProxyStatus>(enums::ZWaveProxyStatus value) {
switch (value) {
case enums::ZWAVE_PROXY_STATUS_OK:
return ESPHOME_PSTR("ZWAVE_PROXY_STATUS_OK");
case enums::ZWAVE_PROXY_STATUS_IN_USE:
return ESPHOME_PSTR("ZWAVE_PROXY_STATUS_IN_USE");
case enums::ZWAVE_PROXY_STATUS_NOT_SUPPORTED:
return ESPHOME_PSTR("ZWAVE_PROXY_STATUS_NOT_SUPPORTED");
default:
return ESPHOME_PSTR("UNKNOWN");
}
}
#endif
#ifdef USE_SERIAL_PROXY
template<> const char *proto_enum_to_string<enums::SerialProxyParity>(enums::SerialProxyParity value) {
@@ -838,6 +850,10 @@ template<> const char *proto_enum_to_string<enums::SerialProxyRequestType>(enums
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE");
case enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH:
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_FLUSH");
case enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE:
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_CONFIGURE");
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS:
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS");
default:
return ESPHOME_PSTR("UNKNOWN");
}
@@ -854,6 +870,10 @@ template<> const char *proto_enum_to_string<enums::SerialProxyStatus>(enums::Ser
return ESPHOME_PSTR("SERIAL_PROXY_STATUS_TIMEOUT");
case enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED:
return ESPHOME_PSTR("SERIAL_PROXY_STATUS_NOT_SUPPORTED");
case enums::SERIAL_PROXY_STATUS_PORT_IN_USE:
return ESPHOME_PSTR("SERIAL_PROXY_STATUS_PORT_IN_USE");
case enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT:
return ESPHOME_PSTR("SERIAL_PROXY_STATUS_INVALID_ARGUMENT");
default:
return ESPHOME_PSTR("UNKNOWN");
}
@@ -914,6 +934,7 @@ const char *SerialProxyInfo::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyInfo"));
dump_field(out, ESPHOME_PSTR("name"), this->name);
dump_field(out, ESPHOME_PSTR("port_type"), static_cast<enums::SerialProxyPortType>(this->port_type));
dump_field(out, ESPHOME_PSTR("configured_line_states"), this->configured_line_states);
return out.c_str();
}
#endif
@@ -2644,6 +2665,12 @@ const char *ZWaveProxyRequest::dump_to(DumpBuffer &out) const {
dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len);
return out.c_str();
}
const char *ZWaveProxyRequestResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("ZWaveProxyRequestResponse"));
dump_field(out, ESPHOME_PSTR("type"), static_cast<enums::ZWaveProxyRequestType>(this->type));
dump_field(out, ESPHOME_PSTR("status"), static_cast<enums::ZWaveProxyStatus>(this->status));
return out.c_str();
}
#endif
#ifdef USE_INFRARED
const char *ListEntitiesInfraredResponse::dump_to(DumpBuffer &out) const {
@@ -2753,6 +2780,7 @@ const char *SerialProxyGetModemPinsResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyGetModemPinsResponse"));
dump_field(out, ESPHOME_PSTR("instance"), this->instance);
dump_field(out, ESPHOME_PSTR("line_states"), this->line_states);
dump_field(out, ESPHOME_PSTR("status"), static_cast<enums::SerialProxyStatus>(this->status));
return out.c_str();
}
const char *SerialProxyRequest::dump_to(DumpBuffer &out) const {
-5
View File
@@ -684,11 +684,6 @@ class ProtoSize {
return value < VARINT_THRESHOLD_1_BYTE ? 1 : (value < VARINT_THRESHOLD_2_BYTE ? 2 : 3);
}
// Varint encoded length for an 8-bit value (1 or 2 bytes).
static constexpr inline uint8_t ESPHOME_ALWAYS_INLINE varint8(uint8_t value) {
return value < VARINT_THRESHOLD_1_BYTE ? 1 : 2;
}
/**
* @brief Calculates the size in bytes needed to encode a uint32_t value as a varint
*
+3
View File
@@ -7,6 +7,7 @@ from esphome.components.esp32 import (
add_idf_component,
add_idf_sdkconfig_option,
include_builtin_idf_component,
require_certificate_bundle,
)
import esphome.config_validation as cv
from esphome.const import (
@@ -335,6 +336,8 @@ def _emit_memory_pair(value: str | None, psram_key: str, internal_key: str) -> N
async def to_code(config: ConfigType) -> None:
# Re-enable ESP-IDF's HTTP client (excluded by default to save compile time)
include_builtin_idf_component("esp_http_client")
# HTTPS streams verify the server against the root certificate bundle
require_certificate_bundle()
add_idf_component(
name="esphome/esp-audio-libs",
@@ -30,8 +30,9 @@ void AudioHTTPMediaSource::dump_config() {
ESP_LOGCONFIG(TAG,
"Audio HTTP Media Source:\n"
" Buffer Size: %zu bytes\n"
" Persistent Ring Buffer: %s\n"
" Decoder Task Stack in PSRAM: %s",
this->buffer_size_, YESNO(this->decoder_task_stack_in_psram_));
this->buffer_size_, YESNO(this->persistent_ring_buffer_), YESNO(this->decoder_task_stack_in_psram_));
}
void AudioHTTPMediaSource::setup() {
@@ -39,6 +40,7 @@ void AudioHTTPMediaSource::setup() {
micro_decoder::DecoderConfig config;
config.ring_buffer_size = this->buffer_size_;
config.persistent_ring_buffer = this->persistent_ring_buffer_;
// Keep the transfer buffer smaller than the ring buffer so the reader can top up the ring
// while the decoder is still draining it, instead of oscillating between empty and full.
config.transfer_buffer_size = std::min(DEFAULT_TRANSFER_BUFFER_SIZE, this->buffer_size_ / 2);
@@ -33,6 +33,7 @@ class AudioHTTPMediaSource final : public Component,
void set_buffer_size(size_t buffer_size) { this->buffer_size_ = buffer_size; }
void set_task_stack_in_psram(bool task_stack_in_psram) { this->decoder_task_stack_in_psram_ = task_stack_in_psram; }
void set_persistent_ring_buffer(bool persistent) { this->persistent_ring_buffer_ = persistent; }
// MediaSource interface implementation
bool play_uri(const std::string &uri) override;
@@ -54,6 +55,7 @@ class AudioHTTPMediaSource final : public Component,
// on_audio_write(). Must be atomic to avoid a data race.
std::atomic<bool> pause_{false};
bool decoder_task_stack_in_psram_{false};
bool persistent_ring_buffer_{false};
};
} // namespace esphome::audio_http
@@ -7,6 +7,8 @@ from esphome.types import ConfigType
CODEOWNERS = ["@kahrendt"]
AUTO_LOAD = ["audio"]
CONF_PERSISTENT_RING_BUFFER = "persistent_ring_buffer"
audio_http_ns = cg.esphome_ns.namespace("audio_http")
AudioHTTPMediaSource = audio_http_ns.class_(
"AudioHTTPMediaSource", cg.Component, media_source.MediaSource
@@ -28,6 +30,7 @@ CONFIG_SCHEMA = cv.All(
min=5000, max=1000000
),
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
cv.Optional(CONF_PERSISTENT_RING_BUFFER, default=False): cv.boolean,
}
)
.extend(cv.COMPONENT_SCHEMA),
@@ -45,3 +48,4 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_task_stack_in_psram(True))
psram.request_external_task_stack()
cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE]))
cg.add(var.set_persistent_ring_buffer(config[CONF_PERSISTENT_RING_BUFFER]))
@@ -4,7 +4,6 @@ import re
import secrets
from typing import Any
import requests
from ruamel.yaml import YAML
from esphome import git
@@ -13,7 +12,7 @@ from esphome.components.packages import validate_source_shorthand
import esphome.config_validation as cv
from esphome.const import CONF_ESPHOME, CONF_PROJECT, CONF_REF, CONF_WIFI
import esphome.final_validate as fv
from esphome.happy_eyeballs import ensure_happy_eyeballs
from esphome.net_retry import fetch_with_retry, http_request
from esphome.types import ConfigType
from esphome.yaml_util import dump
@@ -111,14 +110,20 @@ def import_config(
if git_file.query and "full_config" in git_file.query:
url = git_file.raw_url
try:
ensure_happy_eyeballs()
req = requests.get(url, timeout=30)
# Deferred so config-time imports of this component stay light;
# http_request does the lazy import for the request itself.
import requests
def _fetch() -> str:
req = http_request("GET", url, timeout=30)
req.raise_for_status()
return req.text
try:
contents = fetch_with_retry(url, _fetch, what="Import")
except requests.exceptions.RequestException as e:
raise ValueError(f"Error while fetching {url}: {e}") from e
contents = req.text
yaml = YAML()
loaded_yaml = yaml.load(contents)
if (
+83 -15
View File
@@ -7,8 +7,10 @@ from esphome.const import (
CONF_ID,
CONF_STATE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
DEVICE_CLASS_APPARENT_POWER,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_FREQUENCY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_POWER_FACTOR,
DEVICE_CLASS_TEMPERATURE,
@@ -18,8 +20,10 @@ from esphome.const import (
UNIT_AMPERE,
UNIT_CELSIUS,
UNIT_EMPTY,
UNIT_HERTZ,
UNIT_PULSES,
UNIT_VOLT,
UNIT_VOLT_AMPS,
UNIT_WATT,
UNIT_WATT_HOURS,
)
@@ -29,6 +33,32 @@ from .. import CONF_EMONTX_ID, CONF_TAG_NAME, EmonTx, emontx_ns
EmonTxSensor = emontx_ns.class_("EmonTxSensor", sensor.Sensor, cg.Component)
# Known emonTx/avrdb JSON tag conventions, gathered from real firmware
# (see https://github.com/openenergymonitor/avrdb_firmware), used to decide
# whether each tag below requires a numeric index or may also appear bare:
#
# Tag family Bare (no index) Numeric-indexed
# ----------- ----------------------- ----------------------------------
# P (power) no P1, P2, ... (multi-channel boards)
# E (energy) no E1, E2, ...
# V (voltage) Vrms (NOT matched here, V1, V2, V3 (per-phase boards)
# doesn't fit "V"+digits)
# I (current) no I1, I2, ...
# T (temp.) no T1, T2, ...
# F (frequency) F (single mains freq.) not seen indexed
# PULSE pulse (single-CT boards) PULSE1, PULSE2, ... (other variants)
# PF (power not seen bare PF1, PF2, ... (currently unused/
# factor) commented out in avrdb firmware)
# AP (apparent not seen bare AP1, AP2, ... (not an avrdb tag at
# power) all; avrdb uses "VA"+index instead,
# itself currently unused/commented
# out; "AP" is kept here for other
# firmware/integrations using it)
#
# This is why a bare "PULSE" resolves to proper defaults below, but bare
# "PF"/"AP" fall back to generic defaults instead: only PULSE has a
# confirmed bare-tag use in real, currently-shipping firmware.
# Define sensor type configurations by prefix
SENSOR_CONFIGS = {
"P": {
@@ -63,7 +93,25 @@ SENSOR_CONFIGS = {
},
}
# Pattern-based configurations
# Tags reported once, without a numeric index (e.g. "F"), matched exactly
# rather than by prefix.
EXACT_TAG_CONFIGS = {
"F": {
CONF_UNIT_OF_MEASUREMENT: UNIT_HERTZ,
CONF_DEVICE_CLASS: DEVICE_CLASS_FREQUENCY,
CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT,
CONF_ACCURACY_DECIMALS: 2,
},
}
# Pattern-based configurations. The remainder after the prefix must be a
# non-empty numeric index (like V1/I1/E1), so e.g. "APPLE" doesn't collide
# with the "AP" prefix and a bare "PF"/"AP" (no index) doesn't match.
# "PULSE" is the exception: some emonTx firmware (e.g. avrdb-based single-CT
# variants) reports a single pulse counter as a bare "pulse" tag with no
# numeric index at all, so that pattern also accepts an empty suffix.
PATTERNS_ALLOWING_BARE_TAG = {"PULSE"}
PATTERN_CONFIGS = {
"PULSE": {
CONF_UNIT_OF_MEASUREMENT: UNIT_PULSES,
@@ -77,14 +125,21 @@ PATTERN_CONFIGS = {
CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT,
CONF_ACCURACY_DECIMALS: 2,
},
"AP": {
CONF_UNIT_OF_MEASUREMENT: UNIT_VOLT_AMPS,
CONF_DEVICE_CLASS: DEVICE_CLASS_APPARENT_POWER,
CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT,
CONF_ACCURACY_DECIMALS: 2,
},
}
# BASE_SCHEMA intentionally omits state_class and accuracy_decimals defaults.
# Passing them to sensor_schema() would register them via cv.Optional(key, default=...),
# making them always present in the validated config dict and preventing
# apply_tag_defaults from overriding them with the correct per-prefix values.
# They are injected by apply_tag_defaults below, after running through
# sensor.validate_state_class() so the value is code-generation-ready.
# They are injected by apply_tag_defaults below, after running through the
# same validators sensor_schema() would use (see _DEFAULT_VALIDATORS) so the
# values are code-generation-ready.
BASE_SCHEMA = sensor.sensor_schema(EmonTxSensor).extend(
{
cv.GenerateID(CONF_EMONTX_ID): cv.use_id(EmonTx),
@@ -93,30 +148,43 @@ BASE_SCHEMA = sensor.sensor_schema(EmonTxSensor).extend(
)
_DEFAULT_VALIDATORS = {
CONF_STATE_CLASS: sensor.validate_state_class,
CONF_DEVICE_CLASS: sensor.validate_device_class,
CONF_UNIT_OF_MEASUREMENT: sensor.validate_unit_of_measurement,
}
def _apply_defaults(config: ConfigType, defaults: dict) -> None:
"""Inject defaults into config, skipping keys already set by the user.
state_class values are run through validate_state_class so they are
code-generation-ready, matching what sensor_schema() would normally do."""
Values are run through the same validators sensor_schema() would use, so
they are code-generation-ready and a typo'd constant fails validation
instead of shipping silently."""
for key, value in defaults.items():
if key not in config:
if key == CONF_STATE_CLASS:
value = sensor.validate_state_class(value)
if key in _DEFAULT_VALIDATORS:
value = _DEFAULT_VALIDATORS[key](value)
config[key] = value
def apply_tag_defaults(config: ConfigType) -> ConfigType:
"""Apply defaults based on tag prefix if applicable, but don't restrict any tags."""
tag = config[CONF_TAG_NAME]
tag_upper = tag.upper()
if (exact_config := EXACT_TAG_CONFIGS.get(tag_upper)) is not None:
_apply_defaults(config, exact_config)
return config
for pattern, pattern_config in PATTERN_CONFIGS.items():
suffix = tag_upper[len(pattern) :]
bare_ok = not suffix and pattern in PATTERNS_ALLOWING_BARE_TAG
if tag_upper.startswith(pattern) and (suffix.isdigit() or bare_ok):
_apply_defaults(config, pattern_config)
return config
# Only apply defaults for known prefixes with numeric indices (e.g. E1, V2, T3)
if len(tag) >= 2:
tag_upper = tag.upper()
for pattern, pattern_config in PATTERN_CONFIGS.items():
if tag_upper.startswith(pattern):
_apply_defaults(config, pattern_config)
return config
# Only apply defaults for known prefixes with numeric indices (e.g. E1, V2, T3)
prefix = tag_upper[0]
if prefix in SENSOR_CONFIGS and tag[1:].isdigit():
_apply_defaults(config, SENSOR_CONFIGS[prefix])
+101 -43
View File
@@ -65,6 +65,7 @@ from .boards import BOARDS, STANDARD_BOARDS
from .const import (
KEY_ARDUINO_LIBRARIES,
KEY_BOARD,
KEY_CERT_BUNDLE,
KEY_COMPONENTS,
KEY_ESP32,
KEY_EXCLUDE_COMPONENTS,
@@ -237,6 +238,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = (
"esp_gdbstub", # GDB stub panic handler - unused by ESPHome; bt pulls it back
"esp_hid", # HID host/device support - ESPHome doesn't implement HID functionality
"esp_http_client", # HTTP client - only needed by http_request component
"esp_http_server", # HTTP server - re-included by web_server_idf, esp32_camera_web_server
"esp_https_ota", # ESP-IDF HTTPS OTA - ESPHome has its own OTA implementation
"esp_https_server", # HTTPS server - ESPHome has its own web server
"esp_lcd", # LCD controller drivers - only needed by display component
@@ -245,6 +247,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = (
"fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage
"json", # cJSON library - ESPHome uses ArduinoJson instead
"mqtt", # ESP-IDF MQTT library - ESPHome has its own MQTT implementation
"nvs_sec_provider", # NVS encryption key provider - re-included when CONFIG_NVS_ENCRYPTION is set
"openthread", # Thread protocol - only needed by openthread component
"perfmon", # Xtensa performance monitor - ESPHome has its own debug component
"protobuf-c", # Protobuf runtime - only used by provisioning components (also excluded)
@@ -343,6 +346,10 @@ ARDUINO_LIBRARY_IDF_COMPONENTS: dict[str, tuple[str, ...]] = {
"Zigbee": ("espressif__esp-zigbee-lib", "espressif__esp-zboss-lib"),
}
# Arduino libraries whose sources reference esp_crt_bundle_attach without a
# CONFIG_MBEDTLS_CERTIFICATE_BUNDLE guard, so enabling them needs the bundle.
ARDUINO_LIBRARIES_NEEDING_CERT_BUNDLE = frozenset({"NetworkClientSecure"})
# Arduino library to Arduino library dependencies
# When enabling one library, also enable its dependencies
# Kconfig "select" statements don't work with CONFIG_ARDUINO_SELECTIVE_COMPILATION
@@ -644,6 +651,27 @@ class RawSdkconfigValue:
SdkconfigValueType = bool | int | HexInt | str | RawSdkconfigValue
def is_idf_sdkconfig_option_enabled(name: str) -> bool:
"""Return True when a bool sdkconfig option resolves to ``y``.
Handles both the ``True`` a component sets and the raw ``y`` a user sets
in ``sdkconfig_options``.
"""
value = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get(name)
return value is not None and _format_sdkconfig_val(value) == "y"
def set_idf_sdkconfig_default(name: str, value: SdkconfigValueType) -> None:
"""Set an sdkconfig option unless it is already set.
For the FINAL priority reconcile jobs: they run after every to_code,
including the user's sdkconfig_options, and must not override an
existing value.
"""
if name not in CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]:
add_idf_sdkconfig_option(name, value)
def add_idf_sdkconfig_option(name: str, value: SdkconfigValueType):
"""Set an esp-idf sdkconfig value."""
CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS][name] = value
@@ -788,6 +816,10 @@ def _enable_arduino_library(name: str) -> None:
# Also enable any required IDF components
for idf_component in ARDUINO_LIBRARY_IDF_COMPONENTS.get(name, ()):
include_builtin_idf_component(idf_component)
if not ARDUINO_LIBRARIES_NEEDING_CERT_BUNDLE.isdisjoint(
{name, *ARDUINO_LIBRARY_DEPENDENCIES.get(name, ())}
):
require_certificate_bundle()
def add_extra_script(stage: str, filename: str, path: Path):
@@ -1735,6 +1767,16 @@ def require_vfs_termios() -> None:
CORE.data[KEY_VFS_TERMIOS_REQUIRED] = True
def require_certificate_bundle() -> None:
"""Enable the mbedTLS root certificate bundle for this build.
The bundle is off by default; components that verify TLS server
certificates (http_request, audio streaming) call this so the bundle is
compiled and gen_crt_bundle runs only when something uses it.
"""
CORE.data[KEY_ESP32][KEY_CERT_BUNDLE] = True
def require_full_certificate_bundle() -> None:
"""Request the full certificate bundle instead of the common-CAs-only bundle.
@@ -1744,6 +1786,7 @@ def require_full_certificate_bundle() -> None:
Call this from components that need to connect to services using uncommon CAs.
"""
require_certificate_bundle()
CORE.data[KEY_ESP32][KEY_FULL_CERT_BUNDLE] = True
@@ -2160,6 +2203,10 @@ def register_exclude_components_cmake_arg() -> None:
@coroutine_with_priority(CoroPriority.FINAL)
async def _write_exclude_components() -> None:
"""Write EXCLUDE_COMPONENTS cmake arg after all components have registered exclusions."""
# NVS encryption needs nvs_sec_provider however it was enabled: the
# nvs_encryption option, raw sdkconfig_options or another component.
if is_idf_sdkconfig_option_enabled("CONFIG_NVS_ENCRYPTION"):
include_builtin_idf_component("nvs_sec_provider")
register_exclude_components_cmake_arg()
@@ -2218,6 +2265,31 @@ async def _set_libc_picolibc_newlib_compat() -> None:
)
@coroutine_with_priority(CoroPriority.FINAL)
async def _reconcile_certificate_bundle_sdkconfig() -> None:
"""Enable the mbedTLS certificate bundle only when something asked for it.
Runs at FINAL priority so every require_certificate_bundle() call has
happened. Without a request the bundle is disabled, which skips
esp_crt_bundle.c, the gen_crt_bundle step and the x509_crt_bundle.S embed.
A user-supplied sdkconfig_options value takes precedence.
"""
data = CORE.data[KEY_ESP32]
enabled = data.get(KEY_CERT_BUNDLE, False)
set_idf_sdkconfig_default("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", enabled)
if not enabled:
return
# Use CMN (common CAs) bundle by default to save ~51KB flash
# CMN covers CAs with >1% market share (~99% of websites)
# Components needing uncommon CAs can call require_full_certificate_bundle()
use_full_bundle = data.get(KEY_FULL_CERT_BUNDLE, False)
set_idf_sdkconfig_default(
"CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL", use_full_bundle
)
if not use_full_bundle:
set_idf_sdkconfig_default("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN", True)
@coroutine_with_priority(CoroPriority.FINAL)
async def _reconcile_network_sdkconfig() -> None:
"""Reconcile WiFi/Ethernet/Bluetooth/coexistence sdkconfig flags.
@@ -2229,37 +2301,31 @@ async def _reconcile_network_sdkconfig() -> None:
always takes precedence.
"""
net = CORE.data[KEY_ESP32].get(KEY_NETWORK_SDKCONFIG, NetworkSdkconfigData())
opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
is_arduino = CORE.using_arduino
def set_opt(name: str, value: SdkconfigValueType) -> None:
# User sdkconfig_options (applied during to_code) win.
if name not in opts:
add_idf_sdkconfig_option(name, value)
# Bluetooth: only ever enable when requested. The IDF default is off.
# According to the IDF docs, only one of 4.2 or 5.0 should be enabled.
if net.bluetooth:
set_opt("CONFIG_BT_ENABLED", True)
set_opt("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True)
set_opt("CONFIG_BT_BLE_50_FEATURES_SUPPORTED", False)
set_idf_sdkconfig_default("CONFIG_BT_ENABLED", True)
set_idf_sdkconfig_default("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True)
set_idf_sdkconfig_default("CONFIG_BT_BLE_50_FEATURES_SUPPORTED", False)
# WiFi stack: disable only when Ethernet is present and WiFi is not. WiFi
# relies on the IDF default (enabled), so it is never written True here.
wifi_disabled = net.ethernet and not net.wifi
if wifi_disabled:
set_opt("CONFIG_ESP_WIFI_ENABLED", False)
set_idf_sdkconfig_default("CONFIG_ESP_WIFI_ENABLED", False)
# Software coexistence: enable when requested (the schema only allows it
# alongside WiFi). Disable only in the Ethernet-without-WiFi case.
if net.software_coexistence:
set_opt("CONFIG_SW_COEXIST_ENABLE", True)
set_idf_sdkconfig_default("CONFIG_SW_COEXIST_ENABLE", True)
elif wifi_disabled:
set_opt("CONFIG_SW_COEXIST_ENABLE", False)
set_idf_sdkconfig_default("CONFIG_SW_COEXIST_ENABLE", False)
# SoftAP support: drop it when WiFi is used without AP mode (IDF only).
if not is_arduino and net.wifi and not net.wifi_ap:
set_opt("CONFIG_ESP_WIFI_SOFTAP_SUPPORT", False)
set_idf_sdkconfig_default("CONFIG_ESP_WIFI_SOFTAP_SUPPORT", False)
# LWIP DHCP server: a WiFi-AP-mode / enable_lwip_dhcp_server concern (not
# coexistence). Disable when WiFi has no AP (IDF) or the enable_lwip_dhcp_server
@@ -2270,7 +2336,7 @@ async def _reconcile_network_sdkconfig() -> None:
if (
wifi_wants_dhcps_off or dhcp_server_disabled_by_option
) and not arduino_eth_exclusion:
set_opt("CONFIG_LWIP_DHCPS", False)
set_idf_sdkconfig_default("CONFIG_LWIP_DHCPS", False)
@coroutine_with_priority(CoroPriority.FINAL)
@@ -2295,29 +2361,24 @@ async def _reconcile_vfs_fatfs_sdkconfig(
"""Reconcile VFS/FATFS sdkconfig flags after all require_*() calls; user sdkconfig_options win."""
opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
def set_opt(name: str, value: SdkconfigValueType) -> None:
# User sdkconfig_options (applied during to_code) win.
if name not in opts:
add_idf_sdkconfig_option(name, value)
# USB Serial JTAG VFS needs termios (require_vfs_termios(), e.g. logger). ~1.8KB flash when off.
if CORE.data.get(KEY_VFS_TERMIOS_REQUIRED, False):
set_opt("CONFIG_VFS_SUPPORT_TERMIOS", True)
set_idf_sdkconfig_default("CONFIG_VFS_SUPPORT_TERMIOS", True)
else:
set_opt("CONFIG_VFS_SUPPORT_TERMIOS", not disable_vfs_termios)
set_idf_sdkconfig_default("CONFIG_VFS_SUPPORT_TERMIOS", not disable_vfs_termios)
# VFS select is only needed for UART/eventfd fds (require_vfs_select(), e.g. openthread);
# sockets use lwip_select() either way. ~2.7KB flash when off.
if CORE.data.get(KEY_VFS_SELECT_REQUIRED, False):
set_opt("CONFIG_VFS_SUPPORT_SELECT", True)
set_idf_sdkconfig_default("CONFIG_VFS_SUPPORT_SELECT", True)
else:
set_opt("CONFIG_VFS_SUPPORT_SELECT", not disable_vfs_select)
set_idf_sdkconfig_default("CONFIG_VFS_SUPPORT_SELECT", not disable_vfs_select)
# Directory functions: opendir/readdir/mkdir etc. (require_vfs_dir()). ~0.5KB flash when off.
if CORE.data.get(KEY_VFS_DIR_REQUIRED, False):
set_opt("CONFIG_VFS_SUPPORT_DIR", True)
set_idf_sdkconfig_default("CONFIG_VFS_SUPPORT_DIR", True)
else:
set_opt("CONFIG_VFS_SUPPORT_DIR", not disable_vfs_dir)
set_idf_sdkconfig_default("CONFIG_VFS_SUPPORT_DIR", not disable_vfs_dir)
# FATFS (require_fatfs()): LFN + one volume per esp_vfs_fat mount. Defaults only;
# sdkconfig_options override. FATFS_LONG_FILENAMES is a Kconfig choice -- if the user set
@@ -2330,15 +2391,15 @@ async def _reconcile_vfs_fatfs_sdkconfig(
user_picked_lfn = any(k in opts for k in lfn_keys)
if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False):
if not user_picked_lfn:
set_opt("CONFIG_FATFS_LFN_NONE", False)
set_opt("CONFIG_FATFS_LFN_HEAP", True)
set_opt("CONFIG_FATFS_MAX_LFN", 255)
set_opt("CONFIG_FATFS_VOLUME_COUNT", 4)
set_idf_sdkconfig_default("CONFIG_FATFS_LFN_NONE", False)
set_idf_sdkconfig_default("CONFIG_FATFS_LFN_HEAP", True)
set_idf_sdkconfig_default("CONFIG_FATFS_MAX_LFN", 255)
set_idf_sdkconfig_default("CONFIG_FATFS_VOLUME_COUNT", 4)
elif disable_fatfs:
if not user_picked_lfn:
set_opt("CONFIG_FATFS_LFN_NONE", True)
set_idf_sdkconfig_default("CONFIG_FATFS_LFN_NONE", True)
# Kconfig range is [1,10]; 0 gets clamped to the default.
set_opt("CONFIG_FATFS_VOLUME_COUNT", 1)
set_idf_sdkconfig_default("CONFIG_FATFS_VOLUME_COUNT", 1)
@coroutine_with_priority(CoroPriority.FINAL - 1)
@@ -2525,21 +2586,11 @@ async def to_code(config):
)
add_idf_sdkconfig_option("CONFIG_MBEDTLS_PSK_MODES", True)
add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True)
cg.add_build_flag("-Wno-nonnull-compare")
# Use CMN (common CAs) bundle by default to save ~51KB flash
# CMN covers CAs with >1% market share (~99% of websites)
# Components needing uncommon CAs can call require_full_certificate_bundle()
use_full_bundle = conf[CONF_ADVANCED].get(
CONF_USE_FULL_CERTIFICATE_BUNDLE, False
) or CORE.data[KEY_ESP32].get(KEY_FULL_CERT_BUNDLE, False)
add_idf_sdkconfig_option(
"CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL", use_full_bundle
)
if not use_full_bundle:
add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN", True)
if conf[CONF_ADVANCED].get(CONF_USE_FULL_CERTIFICATE_BUNDLE, False):
require_full_certificate_bundle()
add_idf_sdkconfig_option(f"CONFIG_IDF_TARGET_{variant}", True)
add_idf_sdkconfig_option(
@@ -2929,6 +2980,9 @@ async def to_code(config):
# FINAL priority: runs after every network/coexistence request_*() call
CORE.add_job(_reconcile_network_sdkconfig)
# FINAL priority: runs after every require_certificate_bundle() call
CORE.add_job(_reconcile_certificate_bundle_sdkconfig)
# FINAL: require_*() calls can come from to_code at or below this priority, so an
# inline read would be iteration-order-dependent; reconcile once after every job ran.
CORE.add_job(
@@ -2956,6 +3010,10 @@ async def to_code(config):
for name, value in conf[CONF_SDKCONFIG_OPTIONS].items():
add_idf_sdkconfig_option(name, RawSdkconfigValue(value))
# A bundle forced on through sdkconfig_options is a request like any other,
# so it still gets the CMN variant pinned.
if conf[CONF_SDKCONFIG_OPTIONS].get("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE") == "y":
require_certificate_bundle()
# Components from YAML are added in a separate coroutine with FINAL priority
# Schedule it to run after all other components
+1
View File
@@ -27,6 +27,7 @@ KEY_REFRESH = "refresh"
KEY_PATH = "path"
KEY_SUBMODULES = "submodules"
KEY_EXTRA_BUILD_FILES = "extra_build_files"
KEY_CERT_BUNDLE = "cert_bundle"
KEY_FULL_CERT_BUNDLE = "full_cert_bundle"
KEY_NETWORK_SDKCONFIG = "network_sdkconfig"
@@ -143,6 +143,13 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType:
# BLE uses the airtime wifi does not claim.
IDF_SCAN_WINDOW_FIX_VERSION = cv.Version(5, 5, 5)
# Above this the scanner holds the shared radio long enough that wifi drops
# packets and connections on some access points (others cope fine, which is
# why this is a warning and not an error); old proxy configs with 1100 ms
# windows are a recurring cause of instability (esphome/esphome#18655). Only
# wifi shares the radio; long windows are fine on ethernet builds.
MAX_RECOMMENDED_WIFI_SCAN_WINDOW = TimePeriod(milliseconds=600)
@dataclass
class TrackerData:
@@ -209,6 +216,45 @@ def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType:
return config
def _warn_long_scan_window_with_wifi(config: ConfigType) -> ConfigType:
"""Warn when the scan window is long enough to starve wifi.
Runs after _raise_defaulted_scan_window so it sees the final window.
software_coexistence is only present when wifi is configured, so ethernet
builds never warn: BLE has the radio to itself there. Presence is what
matters, not the value; with the arbiter disabled a long window starves
wifi outright.
"""
params = config[CONF_SCAN_PARAMETERS]
window = params[CONF_WINDOW]
if CONF_SOFTWARE_COEXISTENCE not in config:
return config
if window <= MAX_RECOMMENDED_WIFI_SCAN_WINDOW:
return config
if _get_data().scan_window_defaulted:
# The window was raised to match the interval, so point at the key the
# user actually set.
_LOGGER.warning(
"BLE scan interval of %s sets the scan window to the same value, "
"which starves wifi on the same radio and can cause wifi disconnects "
"depending on the access point; keep the interval at or below %s "
"(for example interval: 320ms). Long windows are only a problem with "
"wifi, they are fine on ethernet",
params[CONF_INTERVAL],
MAX_RECOMMENDED_WIFI_SCAN_WINDOW,
)
return config
_LOGGER.warning(
"BLE scan window of %s with wifi on the same radio starves wifi and "
"can cause wifi disconnects depending on the access point; keep the "
"window at or below %s (for example interval: 320ms, window: 300ms). "
"Long windows are only a problem with wifi, they are fine on ethernet",
window,
MAX_RECOMMENDED_WIFI_SCAN_WINDOW,
)
return config
# 320 ms is the ESP-IDF reference scan interval; the shared schema also
# tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects
# window/interval pairs that collapse to the same 0.625 ms unit count.
@@ -271,6 +317,7 @@ CONFIG_SCHEMA = cv.All(
).extend(cv.COMPONENT_SCHEMA),
validate_max_connections_deprecated,
_raise_defaulted_scan_window,
_warn_long_scan_window_with_wifi,
)
@@ -1,4 +1,5 @@
import esphome.codegen as cg
from esphome.components.esp32 import include_builtin_idf_component
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_MODE, CONF_PORT
from esphome.types import ConfigType
@@ -35,6 +36,7 @@ CONFIG_SCHEMA = cv.All(
cv.Required(CONF_MODE): cv.enum(MODES, upper=True),
},
).extend(cv.COMPONENT_SCHEMA),
cv.only_on_esp32,
_consume_camera_web_server_sockets,
)
@@ -44,3 +46,5 @@ async def to_code(config: ConfigType) -> None:
cg.add(server.set_port(config[CONF_PORT]))
cg.add(server.set_mode(config[CONF_MODE]))
await cg.register_component(server, config)
# esp_http_server is excluded from IDF builds by default to save compile time
include_builtin_idf_component("esp_http_server")
+28 -12
View File
@@ -35,7 +35,7 @@ from esphome.platformio.toolchain import copy_ccache_script
from esphome.storage_json import StorageJSON
from esphome.types import ConfigType
from .boards import BOARDS, ESP8266_LD_SCRIPTS
from .boards import BOARDS, ESP8266_LD_SCRIPTS, board_ld_script
from .const import (
CONF_EARLY_PIN_INIT,
CONF_ENABLE_SERIAL,
@@ -44,6 +44,7 @@ from .const import (
KEY_BOARD,
KEY_ESP8266,
KEY_FLASH_SIZE,
KEY_LDSCRIPT,
KEY_PIN_INITIAL_STATES,
KEY_SERIAL1_REQUIRED,
KEY_SERIAL_REQUIRED,
@@ -276,6 +277,31 @@ def check_rosetta() -> None:
)
def _choose_ld_script(board: str, ver: cv.Version) -> str | None:
"""The flash ld to pin for this board and core, or None for cores
without ld-script support."""
board_data = BOARDS[board]
ld_scripts = ESP8266_LD_SCRIPTS[board_data[KEY_FLASH_SIZE]]
if ver <= cv.Version(2, 3, 0):
# No ld script support
return None
if ver <= cv.Version(2, 4, 2):
# Old ld script path; the modern per-board override names do not
# exist in this core's SDK, so the override cannot be honored.
# Substituting the size default would move _FS_end and the
# preferences sector, wiping flash-backed state on flash.
if KEY_LDSCRIPT in board_data:
raise EsphomeError(
f"Board {board} requires its {board_data[KEY_LDSCRIPT]} "
f"flash layout, which Arduino core {ver} cannot honor; "
"use a core newer than 2.4.2"
)
return ld_scripts[0]
# A per-board override preserves a layout the board shipped with
# (see d1_wroom_02 in boards.py)
return board_ld_script(board_data)
@coroutine_with_priority(CoroPriority.PLATFORM)
async def to_code(config: ConfigType) -> None:
cg.add(esp8266_ns.setup_preferences())
@@ -397,17 +423,7 @@ async def to_code(config: ConfigType) -> None:
)
if config[CONF_BOARD] in BOARDS:
flash_size = BOARDS[config[CONF_BOARD]][KEY_FLASH_SIZE]
ld_scripts = ESP8266_LD_SCRIPTS[flash_size]
if ver <= cv.Version(2, 3, 0):
# No ld script support
ld_script = None
elif ver <= cv.Version(2, 4, 2):
# Old ld script path
ld_script = ld_scripts[0]
else:
ld_script = ld_scripts[1]
ld_script = _choose_ld_script(config[CONF_BOARD], ver)
if ld_script is not None:
cg.add_platformio_option("board_build.ldscript", ld_script)
+135 -1
View File
@@ -1,3 +1,5 @@
from .const import KEY_FLASH_SIZE, KEY_LDSCRIPT
FLASH_SIZE_1_MB = 2**20
FLASH_SIZE_512_KB = FLASH_SIZE_1_MB // 2
FLASH_SIZE_2_MB = 2 * FLASH_SIZE_1_MB
@@ -164,7 +166,8 @@ ESP8266_BOARD_PINS = {
}
"""
BOARDS generate with:
BOARDS generate with (preserve per-board KEY_LDSCRIPT overrides such as
d1_wroom_02; the recipe emits only name/flash_size):
git clone https://github.com/platformio/platform-espressif8266
for x in platform-espressif8266/boards/*.json; do
@@ -182,6 +185,19 @@ for x in platform-espressif8266/boards/*.json; do
done | sort
"""
def board_ld_script(board_data: dict) -> str:
"""The modern (core > 2.4.2) flash linker script for a board: its
shipped-layout override, else the size default (the no-FS layout).
Single source of truth for the PlatformIO pinning in __init__ and the
native generator's fallback, so the per-board rule cannot drift.
"""
return board_data.get(
KEY_LDSCRIPT, ESP8266_LD_SCRIPTS[board_data[KEY_FLASH_SIZE]][1]
)
BOARDS = {
"agruminolemon": {
"name": "Lifely Agrumino Lemon v4",
@@ -199,6 +215,15 @@ BOARDS = {
"name": "WeMos D1 mini Pro",
"flash_size": FLASH_SIZE_16_MB,
},
"d1_wroom_02": {
"name": "WeMos D1 ESP-WROOM-02",
"flash_size": FLASH_SIZE_2_MB,
# This board joined BOARDS after shipping with the manifest default
# (64 KB filesystem region); the flash-size default (2m.ld) would
# move _FS_end and with it the preferences sector, wiping existing
# devices' flash-backed state on update.
KEY_LDSCRIPT: "eagle.flash.2m64.ld",
},
"d1": {
"name": "WEMOS D1 R1",
"flash_size": FLASH_SIZE_4_MB,
@@ -360,3 +385,112 @@ BOARDS = {
"flash_size": FLASH_SIZE_4_MB,
},
}
# Per-board variant dir + identity defines from platform-espressif8266 4.x
# build.extra_flags; the shared -DESP8266/-DARDUINO_ARCH_ESP8266 are added
# by the generator.
#
# Regenerate ESP8266_BOARD_BUILD with (v4.2.1 is the platform version the
# native toolchain mirrors; regenerate against the tag when bumping it):
#
# git clone -b v4.2.1 https://github.com/platformio/platform-espressif8266
# python3 - <<'EOF'
# import json, glob, os
# for f in sorted(glob.glob("platform-espressif8266/boards/*.json")):
# b = json.load(open(f))["build"]
# extra = b["extra_flags"]
# extra = extra.split() if isinstance(extra, str) else extra
# defines = [
# e[2:] for e in extra if e not in ("-DESP8266", "-DARDUINO_ARCH_ESP8266")
# ]
# entries = ", ".join(f'"{d}"' for d in defines) + ("," if len(defines) == 1 else "")
# board = os.path.splitext(os.path.basename(f))[0]
# print(f' "{board}": {{"variant": "{b["variant"]}", "defines": ({entries})}},')
# EOF
ESP8266_BOARD_BUILD = {
"agruminolemon": {
"variant": "agruminolemonv4",
"defines": ("ARDUINO_ESP8266_AGRUMINO_LEMON_V4",),
},
"d1": {"variant": "d1", "defines": ("ARDUINO_ESP8266_WEMOS_D1R1",)},
"d1_mini": {"variant": "d1_mini", "defines": ("ARDUINO_ESP8266_WEMOS_D1MINI",)},
"d1_mini_lite": {
"variant": "d1_mini",
"defines": ("ARDUINO_ESP8266_WEMOS_D1MINILITE",),
},
"d1_mini_pro": {
"variant": "d1_mini",
"defines": ("ARDUINO_ESP8266_WEMOS_D1MINIPRO",),
},
"d1_wroom_02": {
"variant": "d1_mini",
"defines": ("ARDUINO_ESP8266_WEMOS_D1WROOM02",),
},
"eduinowifi": {
"variant": "eduinowifi",
"defines": ("ARDUINO_ESP8266_SCHIRMILABS_EDUINO_WIFI",),
},
"esp01": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP01",)},
"esp01_1m": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP01",)},
"esp07": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP07",)},
"esp07s": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP07",)},
"esp12e": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP12",)},
"esp210": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP210",)},
"esp8285": {"variant": "esp8285", "defines": ("ARDUINO_ESP8266_ESP01",)},
"esp_wroom_02": {
"variant": "nodemcu",
"defines": ("ARDUINO_ESP8266_ESP_WROOM_02",),
},
"espduino": {"variant": "ESPDuino", "defines": ("ARDUINO_ESP8266_ESP13",)},
"espectro": {"variant": "espectro", "defines": ("ARDUINO_ESP8266_ESPECTRO_CORE",)},
"espino": {"variant": "espino", "defines": ("ARDUINO_ESP8266_ESP12",)},
"espinotee": {"variant": "espinotee", "defines": ("ARDUINO_ESP8266_ESP13",)},
"espmxdevkit": {
"variant": "esp8285",
"defines": ("ARDUINO_ESP8266_ESP01", "LED_BUILTIN=16"),
},
"espresso_lite_v1": {
"variant": "espresso_lite_v1",
"defines": ("ARDUINO_ESP8266_ESPRESSO_LITE_V1",),
},
"espresso_lite_v2": {
"variant": "espresso_lite_v2",
"defines": ("ARDUINO_ESP8266_ESPRESSO_LITE_V2",),
},
"gen4iod": {"variant": "generic", "defines": ("ARDUINO_GEN4_IOD",)},
"heltec_wifi_kit_8": {
"variant": "wifi_kit_8",
"defines": ("ARDUINO_wifi_kit_8",),
},
"huzzah": {"variant": "adafruit", "defines": ("ARDUINO_ESP8266_ADAFRUIT_HUZZAH",)},
"inventone": {"variant": "inventone", "defines": ("ARDUINO_ESP8266_INVENT_ONE",)},
"modwifi": {"variant": "generic", "defines": ("ARDUINO_MOD_WIFI_ESP8266",)},
"nodemcu": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_NODEMCU",)},
"nodemcuv2": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_NODEMCU_ESP12E",)},
"oak": {"variant": "oak", "defines": ("ARDUINO_ESP8266_OAK",)},
"phoenix_v1": {
"variant": "phoenix_v1",
"defines": ("ARDUINO_ESP8266_PHOENIX_V1",),
},
"phoenix_v2": {
"variant": "phoenix_v2",
"defines": ("ARDUINO_ESP8266_PHOENIX_V2",),
},
"sonoff_basic": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_BASIC",)},
"sonoff_s20": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_S20",)},
"sonoff_sv": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_SV",)},
"sonoff_th": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_TH",)},
"sparkfunBlynk": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING",)},
"thing": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING",)},
"thingdev": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING_DEV",)},
"wifi_slot": {"variant": "wifi_slot", "defines": ("ARDUINO_AMPERKA_WIFI_SLOT",)},
"wifiduino": {"variant": "wifiduino", "defines": ("ARDUINO_WIFIDUINO_ESP8266",)},
"wifinfo": {"variant": "wifinfo", "defines": ("ARDUINO_WIFINFO",)},
"wio_link": {"variant": "wiolink", "defines": ("ARDUINO_ESP8266_WIO_LINK",)},
"wio_node": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP_WROOM_02",)},
"xinabox_cw01": {
"variant": "xinabox",
"defines": ("ARDUINO_ESP8266_XINABOX_CW01",),
},
}
+123
View File
@@ -0,0 +1,123 @@
"""Linker-script surgery shared with the native (PlatformIO-free) toolchain.
These mirror the PlatformIO extra scripts in this directory
(``relocate_ratetable.py.script`` and ``testing_mode.py.script``), which run
inside SCons and must stay self-contained. The native build generator applies
the same patches to the linker scripts it generates, so the logic lives here
as plain functions. Keep both in sync when changing either.
``segment_length`` is native-toolchain-only and has no script twin.
"""
from __future__ import annotations
from collections.abc import Collection
import hashlib
import re
# Move the NONOS SDK wifi rate tables from flash to DRAM; see
# relocate_ratetable.py.script for the full background (NONOS SDK issue 320).
RATETABLE_RULE = "*libnet80211.a:ieee80211_phy.o(.irom.text .irom.text.*)"
_RATETABLE_COMMENT = (
"/* ESPHome: wifi rate tables must live in DRAM, see NONOS SDK issue 320 */"
)
# Match the whole line: "_data_start" is also a substring of the
# "_dport0_data_start" line in the earlier .dport0.data section
_RATETABLE_ANCHOR = re.compile(r"^\s*_data_start = ABSOLUTE\(\.\);", re.MULTILINE)
# Memory sizes for testing mode (allow larger builds for CI component grouping)
TESTING_IRAM_SIZE = "0x200000" # 2MB
TESTING_DRAM_SIZE = "0x200000" # 2MB
TESTING_FLASH_SIZE = "0x2000000" # 32MB
def relocate_ratetable(content: str) -> str:
"""Insert the rate-table DRAM rule into a generated common linker script."""
if RATETABLE_RULE in content:
return content
match = _RATETABLE_ANCHOR.search(content)
if match is None:
raise RuntimeError(
"'_data_start' anchor not found in the generated linker script; "
"cannot apply wifi rate table DRAM relocation "
"(has the Arduino core linker script changed?)"
)
insert_pos = match.end()
return (
content[:insert_pos]
+ f"\n {_RATETABLE_COMMENT}"
+ f"\n {RATETABLE_RULE}"
+ content[insert_pos:]
)
_TESTING_SEGMENT_SIZES = {
"iram1_0_seg": TESTING_IRAM_SIZE,
"dram0_0_seg": TESTING_DRAM_SIZE,
"irom0_0_seg": TESTING_FLASH_SIZE,
}
def _segment_line_re(segment_name: str) -> re.Pattern[str]:
"""The MEMORY line for one segment: ``<seg> : org = 0x..., len = 0x...``.
Anchored to the start of the line so a name never matches inside a
longer one (``ram0_0_seg`` must not read ``dram0_0_seg``). The size
group stops at the hex digits, leaving any ``ul`` suffix (from the
preprocessed ``MMU_IRAM_SIZE``) in place.
"""
return re.compile(
rf"(^[ \t]*{re.escape(segment_name)}"
r"\s*:\s*org\s*=\s*0x[0-9a-fA-F]+\s*,\s*len\s*=\s*)"
r"(0x[0-9a-fA-F]+)",
re.MULTILINE,
)
def apply_testing_memory_patches(content: str, segments: Collection[str]) -> str:
"""Enlarge the named memory segments so grouped CI test builds can link.
Each caller passes the segments its linker script defines: the
generated common ld carries ``iram1_0_seg``; the flash ld carries
``dram0_0_seg`` and ``irom0_0_seg``. A segment that fails to match
raises, since a silently kept real memory limit would fail grouped
builds far from the cause.
"""
for segment in _TESTING_SEGMENT_SIZES:
if segment not in segments and _segment_line_re(segment).search(content):
raise RuntimeError(
f"Testing-mode segment {segment} is present in the linker "
"script but was not selected for patching"
)
for segment in segments:
if segment not in _TESTING_SEGMENT_SIZES:
raise RuntimeError(f"Unknown testing-mode segment {segment!r}")
content, count = _segment_line_re(segment).subn(
rf"\g<1>{_TESTING_SEGMENT_SIZES[segment]}", content
)
if count == 0:
raise RuntimeError(
f"Testing-mode memory patch failed: segment {segment} "
"not found (has the Arduino core linker script changed?)"
)
return content
def segment_length(content: str, segment_name: str) -> int | None:
"""Read a memory segment's length from linker script content.
Returns None for an absent segment OR an unparsable line; callers must
treat None as "no usable budget" and warn (as the Flash summary does),
never as "no limit".
"""
match = _segment_line_re(segment_name).search(content)
return int(match.group(2), 16) if match else None
def surgery_fingerprint() -> str:
"""Hash of this module's source; linker-script caches include it so an
edit here invalidates them."""
import inspect
import sys
source = inspect.getsource(sys.modules[__name__])
return hashlib.sha256(source.encode()).hexdigest()
+5
View File
@@ -15,6 +15,11 @@ CONF_ENABLE_SERIAL1 = "enable_serial1"
KEY_WAVEFORM_REQUIRED = "waveform_required"
KEY_SERIAL_REQUIRED = "serial_required"
KEY_SERIAL1_REQUIRED = "serial1_required"
# Set for the native (non-PlatformIO) toolchain's build generator
KEY_FLASH_MODE = "flash_mode"
KEY_SCANF_FLOAT = "scanf_float"
# Per-board flash-layout override consumed by board_ld_script()
KEY_LDSCRIPT = "ldscript"
# esp8266 namespace is already defined by arduino, manually prefix esphome
esp8266_ns = cg.global_ns.namespace("esphome").namespace("esp8266")
+76 -7
View File
@@ -4,6 +4,7 @@ import logging
from esphome import automation, pins
from esphome.automation import Condition
import esphome.codegen as cg
from esphome.components import spi
from esphome.components.network import (
add_use_address,
get_network_priority,
@@ -39,6 +40,7 @@ from esphome.const import (
CONF_POLLING_INTERVAL,
CONF_RESET_PIN,
CONF_SPI,
CONF_SPI_ID,
CONF_STATIC_IP,
CONF_SUBNET,
CONF_TYPE,
@@ -263,10 +265,42 @@ def _is_framework_spi_polling_mode_supported() -> bool:
return False
# Options that come from the referenced spi bus when spi_id is set
_SPI_BUS_PROVIDED_OPTIONS = (
CONF_CLK_PIN,
CONF_MOSI_PIN,
CONF_MISO_PIN,
CONF_INTERFACE,
)
def _validate_spi_bus(config: ConfigType) -> ConfigType:
"""Cross-validate spi_id against the options the referenced bus provides."""
if CONF_SPI_ID in config:
for key in _SPI_BUS_PROVIDED_OPTIONS:
if key in config:
raise cv.Invalid(
f"'{key}' cannot be used together with '{CONF_SPI_ID}'; "
f"it comes from the referenced 'spi:' bus.",
path=[key],
)
else:
for key in (CONF_CLK_PIN, CONF_MOSI_PIN, CONF_MISO_PIN):
if key not in config:
raise cv.Invalid(
f"'{key}' is a required option when '{CONF_SPI_ID}' is not set.",
path=[key],
)
return config
def _validate_spi_interface(config: ConfigType) -> ConfigType:
"""Set default SPI interface or validate user choice against the variant."""
if not CORE.is_esp32:
return config
if CONF_SPI_ID in config:
# The interface comes from the referenced spi bus; don't set a default.
return config
from esphome.components.esp32 import VARIANT_ESP32, get_esp32_variant
from esphome.components.spi import get_hw_interface_list
@@ -451,9 +485,14 @@ def _spi_schema(default_clock: str = "26.67MHz", max_clock: int = int(80e6)) ->
BASE_SCHEMA.extend(
cv.Schema(
{
cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_number,
cv.Required(CONF_MISO_PIN): pins.internal_gpio_input_pin_number,
cv.Required(CONF_MOSI_PIN): pins.internal_gpio_output_pin_number,
# clk/mosi/miso are required unless spi_id is set; enforced
# by _validate_spi_bus below.
cv.Optional(CONF_CLK_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_MISO_PIN): pins.internal_gpio_input_pin_number,
cv.Optional(CONF_MOSI_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_SPI_ID): cv.All(
cv.only_on_esp32, cv.use_id(spi.SPIComponent)
),
cv.Required(CONF_CS_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(
CONF_INTERRUPT_PIN
@@ -478,6 +517,7 @@ def _spi_schema(default_clock: str = "26.67MHz", max_clock: int = int(80e6)) ->
),
),
cv.only_on([Platform.ESP32, Platform.RP2]),
_validate_spi_bus,
_validate_spi_interface,
)
@@ -529,6 +569,30 @@ def _final_validate_spi(config: ConfigType) -> None:
return
from esphome.components.spi import CONF_INTERFACE_INDEX, get_spi_interface
if CONF_SPI_ID in config:
# Sharing the bus: the standard spi device schema enforces that the
# referenced bus declares both data lines. The IDF ethernet drivers
# additionally need a hardware host, which shows as an interface index
# on the validated bus config.
spi.final_validate_device_schema(
"ethernet", require_mosi=True, require_miso=True
)(config)
cv.Schema(
{
cv.Required(CONF_SPI_ID): fv.id_declaration_match_schema(
{
cv.Required(
CONF_INTERFACE_INDEX,
msg="Component ethernet requires this spi bus to use "
"a hardware interface",
): cv.valid
}
)
},
extra=cv.ALLOW_EXTRA,
)(config)
return
if spi_configs := fv.full_config.get().get(CONF_SPI):
# get_spi_interface() returns strings like "SPI2_HOST"
spi_host = f"{config[CONF_INTERFACE].upper()}_HOST"
@@ -625,9 +689,15 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None:
)
if config[CONF_TYPE] in SPI_ETHERNET_TYPES:
cg.add(var.set_clk_pin(config[CONF_CLK_PIN]))
cg.add(var.set_miso_pin(config[CONF_MISO_PIN]))
cg.add(var.set_mosi_pin(config[CONF_MOSI_PIN]))
if (spi_id := config.get(CONF_SPI_ID)) is not None:
# Pins and host come from the shared spi bus.
spi_parent = await cg.get_variable(spi_id)
cg.add(var.set_spi_parent(spi_parent))
else:
cg.add(var.set_clk_pin(config[CONF_CLK_PIN]))
cg.add(var.set_miso_pin(config[CONF_MISO_PIN]))
cg.add(var.set_mosi_pin(config[CONF_MOSI_PIN]))
cg.add(var.set_interface(SPI_INTERFACE_MAP[config[CONF_INTERFACE]]))
cg.add(var.set_cs_pin(config[CONF_CS_PIN]))
if CONF_INTERRUPT_PIN in config:
cg.add(var.set_interrupt_pin(config[CONF_INTERRUPT_PIN]))
@@ -641,7 +711,6 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None:
cg.add_define("USE_ETHERNET_SPI")
cg.add(var.set_interface(SPI_INTERFACE_MAP[config[CONF_INTERFACE]]))
add_idf_sdkconfig_option("CONFIG_ETH_USE_SPI_ETHERNET", True)
# CONFIG_ETH_SPI_ETHERNET_{TYPE} Kconfig options were removed in IDF 6.0
# Types that are never built into IDF ship no Kconfig option at all
@@ -13,6 +13,9 @@
#include "esp_eth.h"
#ifdef USE_ETHERNET_SPI
#include "hal/spi_types.h"
#ifdef USE_SPI
#include "esphome/components/spi/spi.h"
#endif
#endif
#include "esp_eth_mac.h"
#include "esp_eth_mac_esp.h"
@@ -176,6 +179,9 @@ class EthernetComponent final : public Component {
void set_reset_pin(uint8_t reset_pin) { this->reset_pin_ = reset_pin; }
void set_clock_speed(int clock_speed) { this->clock_speed_ = clock_speed; }
void set_interface(spi_host_device_t interface) { this->interface_ = interface; }
#ifdef USE_SPI
void set_spi_parent(spi::SPIComponent *parent) { this->spi_parent_ = parent; }
#endif
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
void set_polling_interval(uint32_t polling_interval) { this->polling_interval_ = polling_interval; }
#endif
@@ -258,6 +264,11 @@ class EthernetComponent final : public Component {
int phy_addr_spi_{-1};
int clock_speed_;
spi_host_device_t interface_{SPI2_HOST};
#ifdef USE_SPI
// When set, the SPI bus is owned and initialized by this spi component
// and the ethernet chip only adds a device to it.
spi::SPIComponent *spi_parent_{nullptr};
#endif
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
uint32_t polling_interval_{0};
#endif
@@ -59,6 +59,9 @@
#ifdef USE_ETHERNET_SPI
#include <driver/gpio.h>
#include <driver/spi_master.h>
#ifdef USE_SPI
#include "esphome/components/spi/spi.h"
#endif
#endif
namespace esphome::ethernet {
@@ -168,25 +171,34 @@ void EthernetComponent::ethernet_lazy_init_() {
// Install GPIO ISR handler to be able to service SPI Eth modules interrupts
gpio_install_isr_service(0);
spi_bus_config_t buscfg = {
.mosi_io_num = this->mosi_pin_,
.miso_io_num = this->miso_pin_,
.sclk_io_num = this->clk_pin_,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.data4_io_num = -1,
.data5_io_num = -1,
.data6_io_num = -1,
.data7_io_num = -1,
.max_transfer_sz = 0,
.flags = 0,
.intr_flags = 0,
};
spi_host_device_t host;
#ifdef USE_SPI
if (this->spi_parent_ != nullptr) {
// The bus is owned and already initialized by the spi component; share its host.
host = this->spi_parent_->get_interface();
} else
#endif
{
spi_bus_config_t buscfg = {
.mosi_io_num = this->mosi_pin_,
.miso_io_num = this->miso_pin_,
.sclk_io_num = this->clk_pin_,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.data4_io_num = -1,
.data5_io_num = -1,
.data6_io_num = -1,
.data7_io_num = -1,
.max_transfer_sz = 0,
.flags = 0,
.intr_flags = 0,
};
auto host = this->interface_;
host = this->interface_;
err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO);
ESPHL_ERROR_CHECK(err, "SPI bus initialize error");
err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO);
ESPHL_ERROR_CHECK(err, "SPI bus initialize error");
}
#endif
// Network interface setup handled by network component
@@ -575,17 +587,25 @@ void EthernetComponent::dump_config() {
YESNO(this->is_connected()));
this->dump_connect_params_();
#ifdef USE_ETHERNET_SPI
ESP_LOGCONFIG(TAG,
" CLK Pin: %u\n"
" MISO Pin: %u\n"
" MOSI Pin: %u\n"
" CS Pin: %u",
this->clk_pin_, this->miso_pin_, this->mosi_pin_, this->cs_pin_);
const char *spi_interface = "spi3";
if (this->interface_ == SPI2_HOST) {
spi_interface = "spi2";
#ifdef USE_SPI
if (this->spi_parent_ != nullptr) {
// Pins and interface come from the shared spi bus; only CS is ours.
ESP_LOGCONFIG(TAG, " CS Pin: %u", this->cs_pin_);
} else
#endif
{
ESP_LOGCONFIG(TAG,
" CLK Pin: %u\n"
" MISO Pin: %u\n"
" MOSI Pin: %u\n"
" CS Pin: %u",
this->clk_pin_, this->miso_pin_, this->mosi_pin_, this->cs_pin_);
const char *spi_interface = "spi3";
if (this->interface_ == SPI2_HOST) {
spi_interface = "spi2";
}
ESP_LOGCONFIG(TAG, " Interface: %s", spi_interface);
}
ESP_LOGCONFIG(TAG, " Interface: %s", spi_interface);
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
if (this->polling_interval_ != 0) {
ESP_LOGCONFIG(TAG, " Polling Interval: %" PRIu32 " ms", this->polling_interval_);
@@ -55,8 +55,11 @@ int HOT IRAM_ATTR GPIOOneWireBus::reset_int() {
delayMicroseconds(1);
}
// delay J
delayMicroseconds(start + 480 - micros());
// delay J: finish the 480us slot, but never spin if it already elapsed
// (unsigned wrap here would busy-wait for minutes with interrupts off)
uint32_t elapsed = micros() - start;
if (elapsed < 480)
delayMicroseconds(480 - elapsed);
this->pin_.digital_write(true);
this->pin_.pin_mode(gpio::FLAG_OUTPUT);
return r ? 1 : 0;
+1 -3
View File
@@ -196,9 +196,7 @@ async def to_code(config: ConfigType) -> None:
# framework:
# advanced:
# use_full_certificate_bundle: true
esp32.add_idf_sdkconfig_option(
"CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True
)
esp32.require_certificate_bundle()
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_TLS_INSECURE",
@@ -14,6 +14,7 @@ void KeyCollector::loop() {
}
void KeyCollector::dump_config() {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_CONFIG
ESP_LOGCONFIG(TAG, "Key Collector:");
if (this->min_length_ > 0)
ESP_LOGCONFIG(TAG, " min length: %d", this->min_length_);
@@ -35,6 +36,7 @@ void KeyCollector::dump_config() {
ESP_LOGCONFIG(TAG, " allowed keys '%s'", this->allowed_keys_.c_str());
if (this->timeout_ > 0)
ESP_LOGCONFIG(TAG, " entry timeout: %0.1f", this->timeout_ / 1000.0);
#endif
}
void KeyCollector::add_provider(key_provider::KeyProvider *provider) {
+11 -22
View File
@@ -57,7 +57,6 @@ from .defines import (
CONF_ALIGN_TO_LAMBDA_ID,
CONF_ANIMATIONS,
LOGGER,
add_lv_use,
get_focused_widgets,
get_lv_images_used,
get_refreshed_widgets,
@@ -74,7 +73,6 @@ from .keypads import KEYPADS_CONFIG, keypads_to_code
from .lv_validation import lv_bool
from .lvcode import LvContext, LvglComponent, lv_event_t_ptr, lvgl_static
from .schemas import (
BASE_PROPS,
DISP_BG_SCHEMA,
FULL_STYLE_SCHEMA,
SET_STATE_SCHEMA,
@@ -83,6 +81,7 @@ from .schemas import (
STYLE_SCHEMA,
WIDGET_TYPES,
any_widget_schema,
apply_style_driven_defines,
container_schema,
container_schema_value,
theme_schema,
@@ -108,7 +107,6 @@ from .widgets import (
get_screen_active,
set_obj_properties,
)
from .widgets.img import CONF_IMAGE
# Import only what we actually use directly in this file
from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code
@@ -455,6 +453,15 @@ async def to_code(configs):
# Mark all widgets as completed so awaiters of ``wait_for_widgets`` proceed.
set_widgets_completed(True)
async with LvContext():
# Local import: lv_list imports meter, which imports obj_spec/set_obj_properties
# from this module's own namespace - a top-level import here would be circular.
from .widgets.lv_list import finish_list_triggers
# Must run before generate_triggers(): that's what actually processes other
# widgets' on_click etc. automations, which can include lvgl.list.add/remove/
# clear actions that fire a list's on_add/on_remove triggers - those need to
# already exist by then, not still be pending.
await finish_list_triggers()
await generate_triggers()
await generate_align_tos(configs[0])
for config in configs:
@@ -481,34 +488,16 @@ async def to_code(configs):
# This must be done after all widgets are created
styles_used = df.get_styles_used()
if any(BASE_PROPS.get(x) is lvalid.lv_image for x in styles_used):
add_lv_use(CONF_IMAGE)
apply_style_driven_defines(styles_used)
for use in df.get_lv_uses():
df.add_define(f"LV_USE_{use.upper()}")
cg.add_define(f"USE_LVGL_{use.upper()}")
if {
"transform_rotation",
"transform_scale",
"transform_scale_x",
"transform_scale_y",
} & styles_used:
df.add_define("LV_COLOR_SCREEN_TRANSP", "1")
if configs[0].get(df.CONF_THEME, {}).get(df.CONF_DARK_MODE):
df.add_define("LV_THEME_DEFAULT_DARK", "1")
# Currently always need RGB565 for the display buffer, and ARGB8888 is used for layer blending
lv_image_formats = {"RGB565", "ARGB8888"}
if {
"drop_shadow_color",
"drop_shadow_offset_x",
"drop_shadow_offset_y",
"drop_shadow_opa",
"drop_shadow_quality",
"drop_shadow_radius",
} & styles_used:
lv_image_formats.add("A8")
for image_id in get_lv_images_used():
await cg.get_variable(image_id)
+1 -1
View File
@@ -416,7 +416,7 @@ async def obj_set_z_index_to_code(config, action_id, template_arg, args):
widget.obj, literal(f"{lv_expr.obj_get_index(widget.obj)} + 1")
)
elif position == "DOWN":
with LvConditional(f"{lv_expr.obj_get_index(widget.obj)} > 0"):
with LvConditional(literal(f"{lv_expr.obj_get_index(widget.obj)} > 0")):
lv_obj.move_to_index(
widget.obj, literal(f"{lv_expr.obj_get_index(widget.obj)} - 1")
)
+15
View File
@@ -585,6 +585,21 @@ FLEX_FLOWS = LvConstant(
"COLUMN_WRAP_REVERSE",
)
TRANSFORM_STYLE_PROPS = frozenset(
{"transform_rotation", "transform_scale", "transform_scale_x", "transform_scale_y"}
)
DROP_SHADOW_STYLE_PROPS = frozenset(
{
"drop_shadow_color",
"drop_shadow_offset_x",
"drop_shadow_offset_y",
"drop_shadow_opa",
"drop_shadow_quality",
"drop_shadow_radius",
}
)
OBJ_FLAGS = (
"hidden",
"clickable",
+39 -2
View File
@@ -242,7 +242,7 @@ class LocalVariable(MockObj):
self.base.type, self.modifier, self.base.id
)
)
return MockObj(self.base)
return MockObj(self.base, "->" if self.modifier == "*" else ".")
def __exit__(self, *args):
CodeContext.end_block()
@@ -283,7 +283,15 @@ class MockLv:
class LvConditional:
def __init__(self, condition):
self.condition = condition
# Condition is embedded directly into a raw `if (...)` statement below, rather than
# going through the argument-list machinery (ExpressionList) that would otherwise
# convert a native Python value (e.g. a plain bool) to a proper Expression.
if isinstance(condition, str):
raise ValueError(
"LvConditional condition must not be a raw str; wrap it in literal() "
"if a string literal condition is really intended"
)
self.condition = cg.safe_exp(condition) if condition is not None else None
def __enter__(self):
if self.condition is not None:
@@ -303,6 +311,35 @@ class LvConditional:
CodeContext.code_context.indent()
class LvCountdown:
"""
Emits a C++ `for` loop that counts an int variable down from `count - 1` to `0` inclusive.
Used to iterate over a widget's children in reverse, e.g. to fire a trigger once per child
before they're all removed.
"""
def __init__(self, var_name: str, count):
self.var_name = var_name
self.count = count
def __enter__(self):
# Cast explicitly rather than relying on `count`'s (typically unsigned) type to wrap
# and then narrow back to a negative int when count is 0 -- true in practice on every
# toolchain ESPHome targets, but not worth leaning on.
CodeContext.append(
RawStatement(
f"for (int {self.var_name} = (int) ({self.count}) - 1; {self.var_name} >= 0; "
f"{self.var_name}--) {{"
)
)
CodeContext.code_context.indent()
return literal(self.var_name)
def __exit__(self, *args):
CodeContext.code_context.detent()
CodeContext.append(RawStatement("}"))
class ReturnStatement(ExpressionStatement):
def __str__(self):
return f"return {self.expression};"
+28 -9
View File
@@ -208,21 +208,21 @@ void LvglComponent::esphome_lvgl_init() {
lv_update_event = static_cast<lv_event_code_t>(lv_event_register_id());
}
void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event) {
lv_obj_add_event_cb(obj, callback, event, nullptr);
void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event, void *user_data) {
lv_obj_add_event_cb(obj, callback, event, user_data);
}
void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1,
lv_event_code_t event2) {
add_event_cb(obj, callback, event1);
add_event_cb(obj, callback, event2);
lv_event_code_t event2, void *user_data) {
add_event_cb(obj, callback, event1, user_data);
add_event_cb(obj, callback, event2, user_data);
}
void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1,
lv_event_code_t event2, lv_event_code_t event3) {
add_event_cb(obj, callback, event1);
add_event_cb(obj, callback, event2);
add_event_cb(obj, callback, event3);
lv_event_code_t event2, lv_event_code_t event3, void *user_data) {
add_event_cb(obj, callback, event1, user_data);
add_event_cb(obj, callback, event2, user_data);
add_event_cb(obj, callback, event3, user_data);
}
void LvglComponent::add_page(LvPageType *page) {
@@ -963,6 +963,25 @@ lv_obj_t *lv_container_create(lv_obj_t *parent) {
lv_obj_class_init_obj(obj);
return obj;
}
#ifdef USE_LVGL_LIST
int lv_list_get_row_index(lv_obj_t *list, lv_obj_t *child) {
for (lv_obj_t *obj = child; obj != nullptr; obj = lv_obj_get_parent(obj)) {
if (lv_obj_get_parent(obj) == list)
return lv_obj_get_index(obj);
}
ESP_LOGW(TAG, "lvgl.list: entry is not inside the list it was added to");
return -1;
}
lv_obj_t *lv_list_get_row_for_remove(lv_obj_t *list, int index) {
lv_obj_t *child = index < 0 ? nullptr : lv_obj_get_child(list, index);
if (child == nullptr) {
ESP_LOGW(TAG, "lvgl.list.remove: index %d is out of range, ignoring", index);
}
return child;
}
#endif // USE_LVGL_LIST
} // namespace esphome::lvgl
lv_result_t lv_mem_test_core() { return LV_RESULT_OK; }
+22 -3
View File
@@ -116,6 +116,18 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector<image::Image *> images
int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value);
#endif
#ifdef USE_LVGL_LIST
// Returns the index, within `list`, of the entry that contains `child`: `child` itself if it's a
// direct child of `list`, or the ancestor of `child` that is, when `child` is nested inside a
// widget hierarchy added via `lvgl.list.add`. Returns -1 if `child` isn't inside `list` at all.
int lv_list_get_row_index(lv_obj_t *list, lv_obj_t *child);
// Returns the entry at `index` within `list`, or nullptr (logging why) if `index` is out of
// range -- shared by every `lvgl.list.remove` call site, since a templatable index can go out of
// range at runtime in ways config validation can't catch (e.g. driven by a sensor value).
lv_obj_t *lv_list_get_row_for_remove(lv_obj_t *list, int index);
#endif
#ifdef USE_LVGL_GRADIENT
/**
*
@@ -135,6 +147,12 @@ class LvCompound {
lv_obj_t *obj{};
};
// Frees a heap-allocated LvCompound wrapper on LV_EVENT_DELETE, since lv_obj_del() only knows how to destroy LVGL's own
// object tree, not a separate C++ object paired with one of its nodes.
template<typename T> void delete_lv_compound_on_delete(lv_event_t *e) {
delete static_cast<T *>(lv_event_get_user_data(e));
}
class LvglComponent;
class LvPageType : public Parented<LvglComponent> {
@@ -241,10 +259,11 @@ class LvglComponent final : public PollingComponent {
static void esphome_lvgl_init();
// Convenience overloads for adding a callback for one or more events
static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event);
static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2);
static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event, void *user_data = nullptr);
static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2,
lv_event_code_t event3);
void *user_data = nullptr);
static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2,
lv_event_code_t event3, void *user_data = nullptr);
// change the state of a widget and fire an event if changed (only needed for CHECKED)
+20
View File
@@ -726,6 +726,26 @@ ALL_STYLES = {
}
def apply_style_driven_defines(props: set[str]) -> None:
"""Given a set of style-property names in use, registers everything their use
drives: add_lv_use(image) if any of them is image-typed (per BASE_PROPS), and
the LV_COLOR_SCREEN_TRANSP / LV_DRAW_SW_SUPPORT_A8 defines. Shared between
__init__.py (driven by df.get_styles_used(), for statically-declared widgets)
and lv_list.py's _register_dynamic_widget_style_uses (driven by scanning a
dynamically-added widget's own config), so a future style-driven define added
to one can't be missed in the other.
"""
# Local import: avoids a module-load-time cycle (widgets.img -> ... -> schemas).
from .widgets.img import CONF_IMAGE
if any(BASE_PROPS.get(prop) is lvalid.lv_image for prop in props):
df.add_lv_use(CONF_IMAGE)
if df.TRANSFORM_STYLE_PROPS & props:
df.add_define("LV_COLOR_SCREEN_TRANSP", "1")
if df.DROP_SHADOW_STYLE_PROPS & props:
df.add_define("LV_DRAW_SW_SUPPORT_A8", "1")
def strip_defaults(schema: cv.Schema):
"""
Take a schema and remove any default values, also convert Required to Optional.
+23 -4
View File
@@ -59,7 +59,10 @@ async def generate_triggers():
all_triggers = (
LV_EVENT_TRIGGERS + LV_DISPLAY_EVENT_TRIGGERS + LV_SCREEN_EVENT_TRIGGERS
)
for w in get_widget_map().values():
# Snapshot: building a trigger below can recurse into widget creation (e.g. a
# buttonmatrix's or tabview's to_code registers its own child widgets), which
# would otherwise mutate this dict mid-iteration.
for w in list(get_widget_map().values()):
config = w.config
if isinstance(w.type, LvScrActType):
w = get_screen_active(w.var)
@@ -141,7 +144,21 @@ def _get_event_literal(trigger: str | MockObj) -> MockObj:
return literal("LV_EVENT_" + TRIGGER_MAP[trigger.upper()])
async def add_trigger(conf, w, *events: str | MockObj, is_selected=None):
async def add_trigger(
conf, w, *events: str | MockObj, is_selected=None, attach_obj=None, user_data=None
):
"""
:param attach_obj: The object to actually register the callback on, if different
from `w.obj` - used when `w.obj` isn't valid at the point the callback gets
registered (e.g. a local variable that's only in scope inside the very
block this is called from, not from within the callback body itself; see
widgets/lv_list.py's dynamic widget creation). Defaults to `w.obj`.
:param user_data: Opaque pointer passed through to the registered event callback,
retrievable inside it via `lv_event_get_user_data(event)` - used to recover a
compound widget's C++ wrapper, which a captureless callback has no other way
to reach when it isn't a global variable (see widgets/lv_list.py). Defaults to
`nullptr`.
"""
is_selected = is_selected or w.is_selected()
tid = conf[CONF_TRIGGER_ID]
trigger = cg.new_Pvariable(tid)
@@ -158,12 +175,14 @@ async def add_trigger(conf, w, *events: str | MockObj, is_selected=None):
lv_add(trigger.trigger(*value, literal("event")))
callback = await context.get_lambda()
event_literals = [_get_event_literal(event) for event in events]
attach_obj = w.obj if attach_obj is None else attach_obj
user_data = nullptr if user_data is None else user_data
if str(events[0]) in DISPLAY_TRIGGERS:
assert len(events) == 1
lv.display_add_event_cb(
lv_expr.obj_get_display(w.obj), callback, event_literals[0], nullptr
lv_expr.obj_get_display(attach_obj), callback, event_literals[0], user_data
)
else:
lv_add(
lvgl_static.add_event_cb(w.obj, await context.get_lambda(), *event_literals)
lvgl_static.add_event_cb(attach_obj, callback, *event_literals, user_data)
)
+17 -13
View File
@@ -190,18 +190,7 @@ class WidgetType:
await self.on_create(var, config)
w = Widget.create(wid, var, self, config)
if theme := get_theme_widget_map().get(self.name):
for part, states in theme.items():
part = "LV_PART_" + part.upper()
for state, style in states.items():
state = "LV_STATE_" + state.upper()
if state == "LV_STATE_DEFAULT":
lv_state = literal(part)
elif part == "LV_PART_MAIN":
lv_state = literal(state)
else:
lv_state = join_enums((state, part))
w.add_style(style, lv_state)
apply_theme_styles(w)
await set_obj_properties(w, config)
await add_widgets(w, config)
await self.to_code(w, config)
@@ -230,7 +219,7 @@ class WidgetType:
:param config: Its configuration
"""
def get_uses(self):
def get_uses(self) -> tuple:
"""
Get a list of other widgets used by this one
:return:
@@ -267,6 +256,21 @@ class WidgetType:
"""
def apply_theme_styles(w: "Widget") -> None:
"""Apply the current theme's styles for this widget's type"""
for part, states in get_theme_widget_map().get(w.type.name, {}).items():
part = "LV_PART_" + part.upper()
for state, style in states.items():
state = "LV_STATE_" + state.upper()
if state == "LV_STATE_DEFAULT":
lv_state = literal(part)
elif part == "LV_PART_MAIN":
lv_state = literal(state)
else:
lv_state = join_enums((state, part))
w.add_style(style, lv_state)
class Widget:
"""
Represents a Widget.
+553
View File
@@ -0,0 +1,553 @@
from collections.abc import Generator
from dataclasses import dataclass, field
from typing import Any
from esphome import automation
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
CONF_BUTTON,
CONF_ID,
CONF_INDEX,
CONF_ON_BOOT,
CONF_ON_UPDATE,
CONF_ON_VALUE,
CONF_TEXT,
CONF_TRIGGER_ID,
)
from esphome.core import CORE
from esphome.coroutine import FakeAwaitable
from esphome.cpp_generator import MockObj
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from ..automation import action_to_code
from ..defines import (
CONF_ALIGN_TO,
CONF_MAIN,
CONF_PAD_ROW,
CONF_SCROLLBAR,
CONF_WIDGETS,
LV_EVENT_TRIGGERS,
SWIPE_TRIGGERS,
TYPE_FLEX,
add_lv_use,
literal,
)
from ..lv_validation import lv_int, lv_text, padding
from ..lvcode import (
UPDATE_EVENT,
LocalVariable,
LvConditional,
LvCountdown,
lv,
lv_add,
lv_expr,
lv_obj,
)
from ..schemas import (
ALL_STYLES,
WIDGET_TYPES,
any_widget_schema,
apply_style_driven_defines,
container_schema_value,
remap_property,
)
from ..trigger import add_trigger
from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t
from . import (
Widget,
WidgetType,
apply_theme_styles,
collect_parts,
get_widgets,
set_obj_properties,
)
from .buttonmatrix import CONF_BUTTONMATRIX
from .canvas import CONF_CANVAS
from .label import CONF_LABEL
from .meter import CONF_METER
from .tabview import CONF_TABVIEW
from .tileview import CONF_TILEVIEW
CONF_LIST = "list"
CONF_WIDGET = "widget"
CONF_ON_ADD = "on_add"
CONF_ON_REMOVE = "on_remove"
DOMAIN = "lvgl_list"
lv_list_t = LvType("lv_list_t")
@dataclass
class ListTriggers:
on_add: list = field(default_factory=list)
on_remove: list = field(default_factory=list)
def _get_list_triggers(list_id) -> ListTriggers:
"""
Trigger Pvariables built for a given list's `on_add`/`on_remove` config, indexed by the
list's own ID.
"""
triggers_by_list = CORE.data.setdefault(DOMAIN, {})
return triggers_by_list.setdefault(list_id, ListTriggers())
def _get_pending_list_triggers(list_id) -> ListTriggers:
"""
Same shape as _get_list_triggers(), but holding raw on_add/on_remove automation
configs, not yet built.
"""
pending_by_list = CORE.data.setdefault(DOMAIN + "_pending", {})
return pending_by_list.setdefault(list_id, ListTriggers())
def _list_triggers_completed_flag() -> list[bool]:
return CORE.data.setdefault(DOMAIN + "_completed", [False])
def _list_triggers_completed_generator() -> Generator[None, None, None]:
while True:
if _list_triggers_completed_flag()[0]:
return
yield
async def _wait_list_triggers_completed() -> None:
"""Waits until finish_list_triggers() has built every list's on_add/on_remove automations."""
if _list_triggers_completed_flag()[0]:
return
await FakeAwaitable(_list_triggers_completed_generator())
async def finish_list_triggers() -> None:
"""
Builds every list's on_add/on_remove automations, collected by ListType.to_code()
instead of being built there directly. Must run after set_widgets_completed(True).
"""
for list_id, pending in CORE.data.get(DOMAIN + "_pending", {}).items():
triggers = _get_list_triggers(list_id)
for conf in pending.on_add:
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID])
await automation.build_automation(trigger, [(cg.int_, "list_index")], conf)
triggers.on_add.append(trigger)
for conf in pending.on_remove:
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID])
await automation.build_automation(trigger, [(cg.int_, "list_index")], conf)
triggers.on_remove.append(trigger)
_list_triggers_completed_flag()[0] = True
def _fire_index_triggers(triggers: list, index) -> None:
for trigger in triggers:
lv_add(trigger.trigger(index))
async def _fire_on_add(list_id, list_obj, entry_obj) -> None:
await _wait_list_triggers_completed()
triggers = _get_list_triggers(list_id).on_add
if not triggers:
return
index = cg.RawExpression(f"lvgl::lv_list_get_row_index({list_obj}, {entry_obj})")
_fire_index_triggers(triggers, index)
async def _fire_on_remove(list_id, index) -> None:
await _wait_list_triggers_completed()
_fire_index_triggers(_get_list_triggers(list_id).on_remove, index)
LIST_SCHEMA = cv.Schema(
{
cv.Optional(CONF_PAD_ROW): padding,
}
)
LIST_CREATE_SCHEMA = LIST_SCHEMA.extend(
{
cv.Optional(CONF_ON_ADD): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
automation.Trigger.template(cg.int_)
),
}
),
cv.Optional(CONF_ON_REMOVE): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
automation.Trigger.template(cg.int_)
),
}
),
}
)
class ListType(WidgetType):
"""A plain wrapper around LVGL's native `lv_list`"""
def __init__(self):
super().__init__(
CONF_LIST,
lv_list_t,
(CONF_MAIN, CONF_SCROLLBAR),
LIST_CREATE_SCHEMA,
modify_schema=LIST_SCHEMA,
)
def get_uses(self):
return TYPE_FLEX, CONF_LABEL, CONF_BUTTON
async def to_code(self, w: Widget, config: dict):
on_add = config.get(CONF_ON_ADD, ())
on_remove = config.get(CONF_ON_REMOVE, ())
if not on_add and not on_remove:
return
pending = _get_pending_list_triggers(w.config[CONF_ID])
pending.on_add.extend(on_add)
pending.on_remove.extend(on_remove)
list_spec = ListType()
LIST_ID_SCHEMA = cv.Schema({cv.Required(CONF_ID): cv.use_id(lv_list_t)})
@automation.register_action(
"lvgl.list.add_text",
ObjUpdateAction,
LIST_ID_SCHEMA.extend(
{
cv.Required(CONF_TEXT): lv_text,
cv.Optional(CONF_INDEX): cv.templatable(cv.int_),
}
),
synchronous=True,
)
async def list_add_text_to_code(config, action_id, template_arg, args):
widgets = await get_widgets(config)
async def do_add_text(w: Widget):
text = await lv_text.process(config[CONF_TEXT])
with LocalVariable(
"list_entry", lv_obj_t, lv_expr.list_add_text(w.obj, text)
) as entry:
if (idx := config.get(CONF_INDEX)) is not None:
lv.obj_move_to_index(entry, await lv_int.process(idx))
await _fire_on_add(config[CONF_ID], w.obj, entry)
return await action_to_code(
widgets, do_add_text, action_id, template_arg, args, config
)
_DYNAMIC_WIDGET_UNSUPPORTED = (
CONF_BUTTONMATRIX,
CONF_TABVIEW,
CONF_TILEVIEW,
CONF_METER,
CONF_CANVAS,
)
def _check_dynamic_widget_supported(w_type_name: str, w_conf: dict) -> None:
# Each of these allocates a Pvariable, or registers children into the global widget
# map, once at boot - rebuilding them on every lvgl.list.add call would break that.
if w_type_name in _DYNAMIC_WIDGET_UNSUPPORTED:
raise cv.Invalid(
f"'{w_type_name}' cannot be used with lvgl.list.add - it manages its own "
"child widgets in a way that isn't compatible with widgets created at runtime"
)
for child in w_conf.get(CONF_WIDGETS, ()):
[(child_type, child_conf)] = child.items()
_check_dynamic_widget_supported(child_type, child_conf)
_UNSUPPORTED_DYNAMIC_KEYS = SWIPE_TRIGGERS + (CONF_ON_BOOT, CONF_ALIGN_TO)
def _check_no_unsupported_triggers(w_type_name: str, w_conf: dict) -> None:
# These triggers currently aren't supporte for dynamic widgets
for key in _UNSUPPORTED_DYNAMIC_KEYS:
if key in w_conf:
raise cv.Invalid(
f"'{key}' is not supported on a widget added via lvgl.list.add - it "
"would validate but generate nothing, since it's only wired for "
"widgets that exist at boot",
path=[w_type_name, key],
)
for child in w_conf.get(CONF_WIDGETS, ()):
[(child_type, child_conf)] = child.items()
_check_no_unsupported_triggers(child_type, child_conf)
def _check_no_explicit_widget_id(raw_value: dict) -> None:
for w_type_name, w_conf in raw_value.items():
if not isinstance(w_conf, dict):
continue
if CONF_ID in w_conf:
raise cv.Invalid(
"'id' is not allowed on a widget added via lvgl.list.add - it is "
"rebuilt fresh on every call and never registered anywhere it "
"could be looked up by",
path=[w_type_name, CONF_ID],
)
for child in w_conf.get(CONF_WIDGETS, ()):
if isinstance(child, dict):
_check_no_explicit_widget_id(child)
@schema_extractor("schema")
def list_add_schema(value: Any) -> Any:
# A plain cv.Schema can't express "id, an optional index, plus exactly one arbitrary
# widget-type key", since the set of widget types isn't fixed until validation time.
if value is SCHEMA_EXTRACT:
return LIST_ID_SCHEMA.extend(
{
cv.Optional(CONF_INDEX): cv.templatable(cv.int_),
**{
cv.Optional(name): container_schema_value(widget_type)
for name, widget_type in WIDGET_TYPES.items()
},
}
)
if not isinstance(value, dict):
raise cv.Invalid("Expected a mapping")
value = value.copy()
if CONF_ID not in value:
raise cv.Invalid(f"required key '{CONF_ID}' not provided")
with cv.prepend_path([CONF_ID]):
list_id = cv.use_id(lv_list_t)(value.pop(CONF_ID))
result = {CONF_ID: list_id}
if CONF_INDEX in value:
with cv.prepend_path([CONF_INDEX]):
result[CONF_INDEX] = cv.templatable(cv.int_)(value.pop(CONF_INDEX))
if len(value) != 1:
raise cv.Invalid(
"lvgl.list.add takes exactly one widget definition, e.g. 'label:' or 'button:', alongside 'id' and optional 'index'"
)
_check_no_explicit_widget_id(value)
result[CONF_WIDGET] = any_widget_schema()(value)
[(w_type_name, w_conf)] = result[CONF_WIDGET][0].items()
_check_dynamic_widget_supported(w_type_name, w_conf)
_check_no_unsupported_triggers(w_type_name, w_conf)
return result
def _register_lv_uses(w_type_name: str, w_conf: dict) -> None:
# Must run before this coroutine's first await.
widget_type = WIDGET_TYPES[w_type_name]
add_lv_use(w_type_name)
add_lv_use(*widget_type.get_uses())
for child in w_conf.get(CONF_WIDGETS, ()):
[(child_type, child_conf)] = child.items()
_register_lv_uses(child_type, child_conf)
def _register_dynamic_widget_style_uses(w_conf: dict) -> None:
props = {
remap_property(prop)
for part_states in collect_parts(w_conf).values()
for state_props in part_states.values()
for prop in state_props
if prop in ALL_STYLES
}
apply_style_driven_defines(props)
for child in w_conf.get(CONF_WIDGETS, ()):
[(_, child_conf)] = child.items()
_register_dynamic_widget_style_uses(child_conf)
@automation.register_action(
"lvgl.list.add",
ObjUpdateAction,
list_add_schema,
synchronous=True,
)
async def list_add_to_code(config, action_id, template_arg, args):
[(w_type_name, w_conf)] = config[CONF_WIDGET][0].items()
_register_lv_uses(w_type_name, w_conf)
_register_dynamic_widget_style_uses(w_conf)
widgets = await get_widgets(config)
async def do_add(w: Widget):
index = None
if (idx := config.get(CONF_INDEX)) is not None:
index = await lv_int.process(idx)
await _build_dynamic_widget(
w_type_name,
w_conf,
w.obj,
config[CONF_ID],
w.obj,
top_level=True,
index=index,
)
return await action_to_code(widgets, do_add, action_id, template_arg, args, config)
async def _build_dynamic_widget(
w_type_name: str,
w_conf: dict,
parent,
list_id,
list_obj,
top_level: bool = False,
index=None,
depth: int = 0,
) -> None:
# Builds one widget (recursively, with children and triggers) as a LocalVariable
# instead of a global Pvariable. Compound
# widgets are heap-allocated and freed via LV_EVENT_DELETE.
# `depth` suffixes the local variable's name below the row's top level.
widget_type = WIDGET_TYPES[w_type_name]
var_name = f"dyn_{w_type_name}" if depth == 0 else f"dyn_{w_type_name}_{depth}"
add_lv_use(w_type_name)
add_lv_use(*widget_type.get_uses())
async def finish_and_fire(w: Widget) -> None:
# Shared tail for both branches below - must run while var's LocalVariable
# block (opened by whichever branch calls this) is still open
await _finish_dynamic_widget(w, w_conf, list_id, list_obj, depth)
if top_level:
if index is not None:
lv.obj_move_to_index(w.obj, index)
await _fire_on_add(list_id, list_obj, w.obj)
if widget_type.is_compound():
with LocalVariable(
var_name, widget_type.w_type, widget_type.w_type.new()
) as var:
creator = await widget_type.obj_creator(parent, w_conf)
lv_add(var.set_obj(creator))
w = Widget(var, widget_type, w_conf)
lv_obj.add_event_cb(
w.obj,
literal(f"lvgl::delete_lv_compound_on_delete<{widget_type.w_type}>"),
literal("LV_EVENT_DELETE"),
var,
)
await finish_and_fire(w)
else:
creator = await widget_type.obj_creator(parent, w_conf)
with LocalVariable(var_name, lv_obj_t, creator) as var:
w = Widget(var, widget_type, w_conf)
await finish_and_fire(w)
async def _finish_dynamic_widget(
w: Widget, w_conf: dict, list_id, list_obj, depth: int = 0
) -> None:
await w.type.on_create(w.obj, w_conf)
apply_theme_styles(w)
await set_obj_properties(w, w_conf)
await w.type.to_code(w, w_conf)
await _wire_dynamic_triggers(w, w_conf)
for child in w_conf.get(CONF_WIDGETS, ()):
[(child_type, child_conf)] = child.items()
await _build_dynamic_widget(
child_type, child_conf, w.obj, list_id, list_obj, depth=depth + 1
)
async def _wire_dynamic_triggers(w: Widget, config: dict) -> None:
# Mirrors generate_triggers(), but runs immediately
if w.type.is_compound():
event_var = MockObj(
f"static_cast<{w.type.w_type} *>(lv_event_get_user_data(event))", "->"
)
user_data = w.var
else:
event_var = literal("static_cast<lv_obj_t *>(lv_event_get_target(event))")
user_data = None
event_target = Widget(event_var, w.type, config)
for event, conf in {
event: conf for event, conf in config.items() if event in LV_EVENT_TRIGGERS
}.items():
w.add_flag("LV_OBJ_FLAG_CLICKABLE")
await add_trigger(
conf[0], event_target, event, attach_obj=w.obj, user_data=user_data
)
for conf in config.get(CONF_ON_VALUE, ()):
await add_trigger(
conf,
event_target,
LV_EVENT.VALUE_CHANGED,
UPDATE_EVENT,
attach_obj=w.obj,
user_data=user_data,
)
for conf in config.get(CONF_ON_UPDATE, ()):
await add_trigger(
conf, event_target, UPDATE_EVENT, attach_obj=w.obj, user_data=user_data
)
LIST_REMOVE_SCHEMA = LIST_ID_SCHEMA.extend(
{
# positive_int, not int_: a negative index would silently delete the *last*
# row (lv_obj_get_child() counts back from the end) while reporting that
# same bogus value to on_remove's list_index.
cv.Required(CONF_INDEX): cv.templatable(cv.positive_int),
}
)
@automation.register_action(
"lvgl.list.remove",
ObjUpdateAction,
LIST_REMOVE_SCHEMA,
synchronous=True,
)
async def list_remove_to_code(config, action_id, template_arg, args):
widgets = await get_widgets(config)
async def do_remove(w: Widget):
index = await lv_int.process(config[CONF_INDEX])
# Materialised into a local since index is needed at two call sites below, and
# a lambda's body gets re-emitted (and re-run) at every point it's used.
with (
LocalVariable("list_index", cg.int_, index, modifier="") as idx,
# Out-of-range lookup/log lives in a shared C++ helper, not inline here:
# a config can have many lvgl.list.remove call sites.
LocalVariable(
"list_child",
lv_obj_t,
cg.RawExpression(f"lvgl::lv_list_get_row_for_remove({w.obj}, {idx})"),
) as child,
LvConditional(child),
):
await _fire_on_remove(config[CONF_ID], idx)
# Recursively destroys the whole subtree
lv.obj_del(child)
return await action_to_code(
widgets, do_remove, action_id, template_arg, args, config
)
@automation.register_action(
"lvgl.list.clear",
ObjUpdateAction,
LIST_ID_SCHEMA,
synchronous=True,
)
async def list_clear_to_code(config, action_id, template_arg, args):
widgets = await get_widgets(config)
async def do_clear(w: Widget):
await _wait_list_triggers_completed()
triggers = _get_list_triggers(config[CONF_ID]).on_remove
if triggers:
# Fire on_remove for every entry, newest to oldest, before wiping them all out,
# so on_remove's semantics ("an entry left the list") hold
with LvCountdown("list_index", lv_expr.obj_get_child_count(w.obj)) as index:
_fire_index_triggers(triggers, index)
# lv_obj_clean recursively destroys every child's whole subtree
lv.obj_clean(w.obj)
return await action_to_code(
widgets, do_clear, action_id, template_arg, args, config
)
+1 -1
View File
@@ -345,7 +345,7 @@ int MipiRgb::get_height() {
}
}
static const char *get_pin_name(GPIOPin *pin, std::span<char, GPIO_SUMMARY_MAX_LEN> buffer) {
[[maybe_unused]] static const char *get_pin_name(GPIOPin *pin, std::span<char, GPIO_SUMMARY_MAX_LEN> buffer) {
if (pin == nullptr)
return "None";
pin->dump_summary(buffer.data(), buffer.size());
@@ -49,6 +49,13 @@ void MitsubishiCN105::initialize() { this->set_state_(State::CONNECTING); }
bool MitsubishiCN105::update() {
switch (this->state_) {
case State::DEFERRED_STATUS_REQUEST:
// Defer the next request to a later loop iteration; some units might not respond if a request is sent
// immediately after a response. See https://github.com/esphome/esphome/issues/18099. No minimum RX-to-TX delay
// is enforced.
this->set_state_(State::UPDATING_STATUS);
return false;
case State::WAITING_FOR_SCHEDULED_STATUS_UPDATE:
if (this->pending_updates_.any()) {
this->status_update_wait_credit_ms_ =
@@ -101,12 +108,14 @@ bool MitsubishiCN105::should_transition(State from, State to) {
return from == State::CONNECTING;
case State::UPDATING_STATUS:
return from == State::CONNECTED || from == State::STATUS_UPDATED ||
from == State::WAITING_FOR_SCHEDULED_STATUS_UPDATE;
return from == State::DEFERRED_STATUS_REQUEST || from == State::WAITING_FOR_SCHEDULED_STATUS_UPDATE;
case State::STATUS_UPDATED:
return from == State::UPDATING_STATUS;
case State::DEFERRED_STATUS_REQUEST:
return from == State::CONNECTED || from == State::STATUS_UPDATED;
case State::SCHEDULE_NEXT_STATUS_UPDATE:
return from == State::STATUS_UPDATED || from == State::SETTINGS_APPLIED;
@@ -114,7 +123,7 @@ bool MitsubishiCN105::should_transition(State from, State to) {
return from == State::SCHEDULE_NEXT_STATUS_UPDATE;
case State::APPLYING_SETTINGS:
return from == State::WAITING_FOR_SCHEDULED_STATUS_UPDATE || from == State::STATUS_UPDATED;
return from == State::WAITING_FOR_SCHEDULED_STATUS_UPDATE;
case State::SETTINGS_APPLIED:
return from == State::APPLYING_SETTINGS;
@@ -122,9 +131,10 @@ bool MitsubishiCN105::should_transition(State from, State to) {
case State::READ_TIMEOUT:
return from == State::UPDATING_STATUS || from == State::APPLYING_SETTINGS || from == State::CONNECTING;
default:
case State::NOT_CONNECTED:
return false;
}
return false;
}
void MitsubishiCN105::did_transition_(State to) {
@@ -135,7 +145,7 @@ void MitsubishiCN105::did_transition_(State to) {
case State::CONNECTED:
this->current_status_msg_type_ = STATUS_MSG_SETTINGS;
this->set_state_(State::UPDATING_STATUS);
this->set_state_(State::DEFERRED_STATUS_REQUEST);
break;
case State::UPDATING_STATUS:
@@ -143,11 +153,14 @@ void MitsubishiCN105::did_transition_(State to) {
break;
case State::STATUS_UPDATED: {
if (this->pending_updates_.any() && this->is_status_initialized()) {
this->set_state_(State::APPLYING_SETTINGS);
} else if (this->current_status_msg_type_ == STATUS_MSG_SETTINGS && this->should_request_telemetry_()) {
// When present, pending settings are applied from WAITING_FOR_SCHEDULED_STATUS_UPDATE during the next update(),
// deferring transmission to a later loop iteration; some units might not respond if a request is sent
// immediately after a response, causing the request to time out.
const bool should_apply_pending_settings = this->pending_updates_.any() && this->is_status_initialized();
if (!should_apply_pending_settings && this->current_status_msg_type_ == STATUS_MSG_SETTINGS &&
this->should_request_telemetry_()) {
this->current_status_msg_type_ = STATUS_MSG_TELEMETRY;
this->set_state_(State::UPDATING_STATUS);
this->set_state_(State::DEFERRED_STATUS_REQUEST);
} else {
this->set_state_(State::SCHEDULE_NEXT_STATUS_UPDATE);
}
@@ -175,7 +188,9 @@ void MitsubishiCN105::did_transition_(State to) {
this->set_state_(State::CONNECTING);
break;
default:
case State::NOT_CONNECTED:
case State::DEFERRED_STATUS_REQUEST:
case State::WAITING_FOR_SCHEDULED_STATUS_UPDATE:
break;
}
}
@@ -359,6 +374,8 @@ const LogString *MitsubishiCN105::state_to_string(State state) {
return LOG_STR("UpdatingStatus");
case State::STATUS_UPDATED:
return LOG_STR("StatusUpdated");
case State::DEFERRED_STATUS_REQUEST:
return LOG_STR("DeferredStatusRequest");
case State::SCHEDULE_NEXT_STATUS_UPDATE:
return LOG_STR("ScheduleNextStatusUpdate");
case State::WAITING_FOR_SCHEDULED_STATUS_UPDATE:
@@ -101,6 +101,7 @@ class MitsubishiCN105 {
CONNECTED,
UPDATING_STATUS,
STATUS_UPDATED,
DEFERRED_STATUS_REQUEST,
SCHEDULE_NEXT_STATUS_UPDATE,
WAITING_FOR_SCHEDULED_STATUS_UPDATE,
APPLYING_SETTINGS,
+33
View File
@@ -45,6 +45,7 @@ ModbusClient = modbus_ns.class_("ModbusClientHub", Modbus)
ModbusDevice = modbus_ns.class_("ModbusDevice")
ModbusClientDevice = modbus_ns.class_("ModbusClientDevice")
ModbusServerDevice = modbus_ns.class_("ModbusServerDevice")
CommandOptions = modbus_ns.struct("CommandOptions")
MULTI_CONF = True
CONF_ROLE = "role"
@@ -81,6 +82,19 @@ def _command_options(direction: str) -> list[_CommandOption]:
raise ValueError(f"unknown command-options direction {direction!r}") from None
# The write (mutating) function codes, matching modbus::helpers::is_function_code_write(). 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 is_function_code_write(function_code: int) -> bool:
"""True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first,
so an exception-flagged code still classifies by its base code - stricter than the runtime hub,
whose classify() treats an exception-flagged code as a read. Keep in sync with
modbus::helpers::is_function_code_write()."""
return function_code & 0x7F in _WRITE_FUNCTION_CODES
def command_options_schema(
*, direction: Literal["read", "write"], templatable: bool = False
) -> dict[cv.Optional, Any]:
@@ -98,6 +112,25 @@ def command_options_schema(
}
def command_options_expression(
config: ConfigType, *, direction: Literal["read", "write"]
) -> cg.StructInitializer:
"""Build the modbus::CommandOptions initializer for a config validated with
command_options_schema() of the same direction. For static (non-templatable) options only;
actions with lambda values use register_templatable_command_options() instead.
"""
return cg.StructInitializer(
CommandOptions,
*(
# Construct the value as its declared cpp_type, so a future non-bool option (enum,
# uint16_t, ...) is emitted with the right type instead of whatever safe_exp() infers.
(option.field, option.cpp_type(config[option.conf_key]))
for option in _command_options(direction)
if option.conf_key in config
),
)
async def register_templatable_command_options(
var: MockObj, config: ConfigType, args: TemplateArgsType, direction: str
) -> None:
+1 -7
View File
@@ -157,10 +157,6 @@ _ACTION_BASE_SCHEMA = cv.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.
@@ -170,9 +166,7 @@ def _no_continuous_on_write(config: ConfigType) -> ConfigType:
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
and modbus.is_function_code_write(pdu[0])
):
raise cv.Invalid(
f"'{CONF_CONTINUOUS}: true' does not apply to a write PDU (function code "
@@ -11,7 +11,14 @@ from esphome.components.modbus.helpers import (
EntityType,
)
import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_NAME, CONF_OFFSET
from esphome.const import (
CONF_ADDRESS,
CONF_CONTINUOUS,
CONF_ID,
CONF_LAMBDA,
CONF_NAME,
CONF_OFFSET,
)
from esphome.core import CORE
from esphome.cpp_helpers import logging
import esphome.final_validate as fv
@@ -125,6 +132,7 @@ CONFIG_SCHEMA = cv.All(
),
cv.Optional(CONF_MAX_CMD_RETRIES, default=4): cv.positive_int,
cv.Optional(CONF_OFFLINE_SKIP_UPDATES, default=0): cv.positive_int,
**modbus.command_options_schema(direction="read"),
cv.Optional(
CONF_SERVER_REGISTERS,
): cv.invalid(
@@ -234,6 +242,35 @@ def migrate_custom_command(config: ConfigType) -> None:
del config[CONF_CUSTOM_COMMAND]
def _reject_continuous_write_custom_pdu(config: ConfigType) -> None:
"""Final-validate: a custom_pdu whose function code writes (e.g. 0x17 read/write-multiple) cannot be
polled continuously - the hub ignores continuous for mutating codes and would warn on every update
while that range silently does not stream. Reject the combination instead. Runs after
migrate_custom_command, so it sees custom_pdu whether written directly or migrated from
custom_command."""
pdu = config.get(CONF_CUSTOM_PDU)
if pdu is None or not modbus.is_function_code_write(pdu[0]):
return
fconf = fv.full_config.get()
path = fconf.get_path_for_id(config[CONF_MODBUS_CONTROLLER_ID])[:-1]
controller = fconf.get_config_for_path(path)
if controller.get(CONF_CONTINUOUS) is True:
raise cv.Invalid(
f"a '{CONF_CUSTOM_PDU}' with a write function code (0x{pdu[0] & 0x7F:02X}) can't be polled "
f"continuously: the hub ignores 'continuous' for mutating codes. Remove 'continuous: true' "
f"from the '{controller[CONF_ID]}' modbus_controller, or use a read function code.",
[CONF_CUSTOM_PDU],
)
def validate_custom_pdu_item(config: ConfigType) -> None:
"""Final-validate for the read platforms that accept custom_pdu (sensor, binary_sensor,
text_sensor): migrate the deprecated custom_command, then reject a write-coded custom_pdu under a
continuously-polling controller."""
migrate_custom_command(config)
_reject_continuous_write_custom_pdu(config)
def _final_validate(config: ConfigType) -> None:
modbus.final_validate_modbus_device("modbus_controller", role="client")(config)
@@ -314,6 +351,11 @@ async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES]))
cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES]))
cg.add(
var.set_read_options(
modbus.command_options_expression(config, direction="read")
)
)
await register_modbus_device(var, config)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
@@ -8,9 +8,9 @@ from .. import (
ModbusItemBaseSchema,
SensorItem,
add_modbus_base_properties,
migrate_custom_command,
modbus_calc_properties,
modbus_controller_ns,
validate_custom_pdu_item,
validate_modbus_register,
)
from ..const import (
@@ -40,7 +40,7 @@ CONFIG_SCHEMA = cv.All(
validate_modbus_register,
)
FINAL_VALIDATE_SCHEMA = migrate_custom_command
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
async def to_code(config):
@@ -167,6 +167,7 @@ void ModbusController::queue_command(ModbusCommandItem command) {
this->one_shot_command_items_.push_back(make_unique<ModbusCommandItem>(std::move(command)));
// A refused frame gets no terminal callback (see the hub contract), so reclaim the item here.
auto &item = this->one_shot_command_items_.back();
// We intentionally do not pass read_options_ here, because one-shot commands are usually writes, and are non-polling.
if (!item->send()) {
// The caller (e.g. a write entity) has usually already published optimistically - surface the loss.
ESP_LOGW(TAG, "Command refused by hub: type=0x%X address=0x%X", static_cast<uint8_t>(item->register_type()),
@@ -203,7 +204,9 @@ void ModbusController::update() {
ESP_LOGV(TAG, "Module offline - retrying");
this->cmd_non_responses_ = 0; // allow the probe through can_send()
for (auto &cmd : this->polling_command_items_) {
if (!cmd.send()) {
// Probes carry the read-side options too, so a recovering device resumes streaming on the
// probe itself rather than waiting for the next update_interval.
if (!cmd.send(this->read_options_)) {
ESP_LOGD(TAG, "Probe refused by hub for range 0x%X", cmd.register_address());
}
}
@@ -217,9 +220,11 @@ void ModbusController::update() {
if (this->can_send()) {
for (auto &cmd : this->polling_command_items_) {
ESP_LOGVV(TAG, "Updating range 0x%X", cmd.register_address());
// read_options_ carries the controller's continuous flag (the offline probe above sends it too).
// A refusal is already logged by the hub; note the affected range for controller-level diagnostics.
if (!cmd.send())
if (!cmd.send(this->read_options_)) {
ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", cmd.register_address());
}
}
}
this->update_counter_++;
@@ -495,16 +500,18 @@ ModbusCommandItem ModbusCommandItem::create_custom_command(
return cmd;
}
bool ModbusCommandItem::send() {
bool ModbusCommandItem::send(modbus::CommandOptions options) {
// Options pass straight through to the hub
bool accepted;
if (this->custom_pdu_ != nullptr) {
// Custom polling command: send the sensor's ready-made PDU (function code + data, no address byte)
// to this controller's own device address; the hub prepends the address and appends the CRC.
accepted = modbus::ModbusClientDevice::queue_pdu(std::span<const uint8_t>(*this->custom_pdu_));
accepted = modbus::ModbusClientDevice::queue_pdu(std::span<const uint8_t>(*this->custom_pdu_), options);
} else if (this->function_code_ != FunctionCode::CUSTOM) {
accepted = this->queue_pdu(modbus::helpers::create_client_pdu(
this->function_code_, this->start_address_, this->register_count_,
this->payload.empty() ? nullptr : this->payload.data(), this->payload.size()));
this->function_code_, this->start_address_, this->register_count_,
this->payload.empty() ? nullptr : this->payload.data(), this->payload.size()),
options);
} else {
// Factory custom command: payload holds a complete raw frame (address + PDU). Send the PDU to the
// frame's own address (which may differ from this controller's); the hub appends the CRC and routes
@@ -514,7 +521,7 @@ bool ModbusCommandItem::send() {
ESP_LOGW(TAG, "Empty custom command frame, not sent");
accepted = false;
} else {
accepted = this->parent_->queue_pdu(frame[0], frame.subspan(1), this);
accepted = this->parent_->queue_pdu(frame[0], frame.subspan(1), this, options);
}
}
// The on_command_sent trigger fires from on_sent() when the frame actually reaches the wire.
@@ -284,7 +284,9 @@ class ModbusCommandItem : public modbus::ModbusClientDevice {
/// Queue this command's frame on the hub. Returns false when refused, in which case no callback ever comes.
/// The item is the hub device, so it must stay alive until its terminal callback; a destroyed item's
/// pending frame is silently retired.
bool send();
/// Options pass straight through to the hub; the polling path passes the controller's read-side
/// options so reads re-queue after each success, one-shot commands keep the default.
bool send(modbus::CommandOptions options = {});
/// factory methods
/** Create modbus read command
@@ -452,6 +454,10 @@ class ModbusController final : public PollingComponent {
void set_max_cmd_retries(uint8_t max_cmd_retries) { this->max_cmd_retries_ = max_cmd_retries; }
/// get how many times a command will be (re)sent if no response is received
uint8_t get_max_cmd_retries() { return this->max_cmd_retries_; }
/// called by esphome generated code with the read-side command options applied to every poll
void set_read_options(modbus::CommandOptions options) { this->read_options_ = options; }
/// the read-side command options applied to every poll
const modbus::CommandOptions &read_options() const { return this->read_options_; }
protected:
/// parse sensormap_ and create range of sequential addresses
@@ -497,6 +503,8 @@ class ModbusController final : public PollingComponent {
uint16_t offline_skip_updates_{0};
/// How many times we will retry a command if we get no response
uint8_t max_cmd_retries_{4};
/// read-side command options applied to every poll
modbus::CommandOptions read_options_{};
/// Command sent callback
CallbackManager<void(int, int)> command_sent_callback_{};
/// Server online callback
@@ -18,9 +18,9 @@ from .. import (
ModbusItemBaseSchema,
SensorItem,
add_modbus_base_properties,
migrate_custom_command,
modbus_calc_properties,
modbus_controller_ns,
validate_custom_pdu_item,
)
from ..const import (
CONF_BITMASK,
@@ -86,7 +86,7 @@ CONFIG_SCHEMA = cv.All(
validate_modbus_number,
)
FINAL_VALIDATE_SCHEMA = migrate_custom_command
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
async def to_code(config):
@@ -8,9 +8,9 @@ from .. import (
ModbusItemBaseSchema,
SensorItem,
add_modbus_base_properties,
migrate_custom_command,
modbus_calc_properties,
modbus_controller_ns,
validate_custom_pdu_item,
validate_modbus_register,
)
from ..const import (
@@ -44,7 +44,7 @@ CONFIG_SCHEMA = cv.All(
validate_modbus_register,
)
FINAL_VALIDATE_SCHEMA = migrate_custom_command
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
async def to_code(config):
@@ -8,9 +8,9 @@ from .. import (
ModbusItemBaseSchema,
SensorItem,
add_modbus_base_properties,
migrate_custom_command,
modbus_calc_properties,
modbus_controller_ns,
validate_custom_pdu_item,
validate_modbus_register,
)
from ..const import (
@@ -45,7 +45,7 @@ CONFIG_SCHEMA = cv.All(
validate_modbus_register,
)
FINAL_VALIDATE_SCHEMA = migrate_custom_command
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
async def to_code(config):
@@ -8,9 +8,9 @@ from .. import (
ModbusItemBaseSchema,
SensorItem,
add_modbus_base_properties,
migrate_custom_command,
modbus_calc_properties,
modbus_controller_ns,
validate_custom_pdu_item,
validate_modbus_register,
)
from ..const import (
@@ -55,7 +55,7 @@ CONFIG_SCHEMA = cv.All(
validate_modbus_register,
)
FINAL_VALIDATE_SCHEMA = migrate_custom_command
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
async def to_code(config):
+30 -29
View File
@@ -5,7 +5,6 @@ from pathlib import Path
import platform
import shutil
import sys
import tempfile
import platformdirs
@@ -13,9 +12,8 @@ import esphome.config_validation as cv
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
from esphome.core import CORE, EsphomeError
from esphome.framework_helpers import (
archive_extract_all,
create_venv,
download_from_mirrors,
download_and_extract,
get_python_env_executable_path,
rmdir,
run_command_ok,
@@ -346,34 +344,37 @@ def check_and_install() -> None:
if not sentinel.exists():
rmdir(toolchains_dir, msg=f"Clean up {TOOLCHAIN_VERSION} toolchain environment")
sysname, machine, extension = _get_toolchain_platform_info()
with tempfile.NamedTemporaryFile() as tmp:
_LOGGER.info("Downloading Zephyr SDK %s minimal ...", TOOLCHAIN_VERSION)
download_from_mirrors(
SDK_NG_MINIMAL_MIRRORS,
{
"VERSION": TOOLCHAIN_VERSION,
"sysname": sysname,
"machine": machine,
"extension": extension,
},
tmp.file,
)
archive_extract_all(tmp.file, toolchains_dir, progress_header="Extracting")
with tempfile.NamedTemporaryFile() as tmp:
_LOGGER.info("Downloading %s toolchain ...", TOOLCHAIN_VERSION)
download_from_mirrors(
substitutions = {
"VERSION": TOOLCHAIN_VERSION,
"sysname": sysname,
"machine": machine,
"extension": extension,
}
# Downloaded next to the destination (not a temp file) so an
# interrupted download's .part file resumes on the next run.
for mirrors, extract_dir, what, slug in (
(SDK_NG_MINIMAL_MIRRORS, toolchains_dir, "Zephyr SDK minimal", "minimal"),
(
SDK_NG_TOOLCHAIN_MIRRORS,
{
"VERSION": TOOLCHAIN_VERSION,
"sysname": sysname,
"machine": machine,
"extension": extension,
},
tmp.file,
)
archive_extract_all(
tmp.file,
toolchains_dir / "arm-zephyr-eabi",
"toolchain",
"toolchain",
),
):
_LOGGER.info("Downloading %s %s ...", TOOLCHAIN_VERSION, what)
download_and_extract(
mirrors,
substitutions,
toolchains_dir.with_name(f"{toolchains_dir.name}.{slug}.archive"),
extract_dir,
progress_header="Extracting",
)
# Best-effort prune of resume leftovers, including a previous
# TOOLCHAIN_VERSION's orphans; the SDK archives are hundreds of MB.
# A locked file must not discard the just-completed install.
for leftover in toolchains_dir.parent.glob("*.archive.part*"):
try:
leftover.unlink()
except OSError as err:
_LOGGER.debug("Could not remove %s: %s", leftover, err)
sentinel.touch()
@@ -3,6 +3,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cstdio>
static const char *const TAG = "online_image";
static const char *const CONTENT_TYPE_HEADER_NAME = "content-type";
@@ -62,30 +63,11 @@ void OnlineImage::update() {
headers.push_back({IF_MODIFIED_SINCE_HEADER_NAME, this->last_modified_});
}
// Add Accept header based on image format
const char *accept_mime_type;
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";
break;
#endif
#ifdef USE_RUNTIME_IMAGE_JPEG
case runtime_image::JPEG:
accept_mime_type = "image/jpeg,*/*;q=0.8";
break;
#endif
#ifdef USE_RUNTIME_IMAGE_PNG
case runtime_image::PNG:
accept_mime_type = "image/png,*/*;q=0.8";
break;
#endif
default:
accept_mime_type = "image/*,*/*;q=0.8";
break;
}
headers.push_back({"Accept", accept_mime_type});
// Accept: "<mime>,*/*;q=0.8"; 32 covers the longest MIME type plus the suffix
char accept_header[32];
snprintf(accept_header, sizeof(accept_header), "%s,*/*;q=0.8", runtime_image::get_mime_type_for_format(format));
headers.push_back({"Accept", accept_header});
// User headers last so they can override any of the above
for (auto &header : this->request_headers_) {
@@ -122,32 +104,18 @@ void OnlineImage::update() {
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;
auto content_type = this->downloader_->get_response_header(CONTENT_TYPE_HEADER_NAME);
ESP_LOGV(TAG, "Content-Type: %s", content_type.c_str());
auto mime_format = esphome::runtime_image::get_format_for_mime_type(content_type.c_str());
if (mime_format.has_value()) {
format = *mime_format;
} 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");
if (content_type.empty()) {
ESP_LOGE(TAG, "Server sent no Content-Type header; cannot determine image format. Set `format:` explicitly");
} else if (str_contains_ignore_case(content_type.c_str(), "image/")) {
ESP_LOGE(TAG, "Image format '%s' not supported.", content_type.c_str());
} else {
ESP_LOGE(TAG, "Could not determine image format from Content-Type: '%s'. Set `format:` explicitly",
content_type);
ESP_LOGE(TAG, "Server did not return an image (Content-Type: '%s')", content_type.c_str());
}
this->end_connection_();
this->download_error_callback_.call();
-8
View File
@@ -1,8 +0,0 @@
#include "automation.h"
#include "esphome/core/log.h"
namespace esphome::output {
static const char *const TAG = "output.automation";
} // namespace esphome::output
+2 -5
View File
@@ -9,7 +9,7 @@ from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
preferences_ns = cg.esphome_ns.namespace("preferences")
IntervalSyncer = preferences_ns.class_("IntervalSyncer", cg.Component)
IntervalSyncer = preferences_ns.class_("IntervalSyncer", cg.PollingComponent)
CONF_FLASH_WRITE_INTERVAL = "flash_write_interval"
CONF_RTC_STORAGE = "rtc_storage"
@@ -31,10 +31,7 @@ CONFIG_SCHEMA = cv.Schema(
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
write_interval = config[CONF_FLASH_WRITE_INTERVAL]
if write_interval.total_milliseconds == 0:
cg.add_define("USE_PREFERENCES_SYNC_EVERY_LOOP")
else:
cg.add(var.set_write_interval(write_interval))
cg.add(var.set_update_interval(write_interval))
if config.get(CONF_RTC_STORAGE):
preferences.request_rtc_storage()
await cg.register_component(var, config)
+7 -14
View File
@@ -2,26 +2,19 @@
#include "esphome/core/preferences.h"
#include "esphome/core/component.h"
// Include for ESPDEPRECATED, Remove before 2027.3.0
#include "esphome/core/helpers.h"
namespace esphome::preferences {
class IntervalSyncer final : public Component {
class IntervalSyncer final : public PollingComponent {
public:
#ifdef USE_PREFERENCES_SYNC_EVERY_LOOP
void loop() override { global_preferences->sync(); }
#else
void set_write_interval(uint32_t write_interval) { this->write_interval_ = write_interval; }
void setup() override {
this->set_interval(this->write_interval_, []() { global_preferences->sync(); });
}
#endif
// Remove before 2027.3.0
ESPDEPRECATED("Use set_update_interval() instead. Removed in 2027.3.0", "2026.9.0")
void set_write_interval(uint32_t write_interval) { this->set_update_interval(write_interval); }
void update() override { global_preferences->sync(); }
void on_shutdown() override { global_preferences->sync(); }
float get_setup_priority() const override { return setup_priority::BUS; }
#ifndef USE_PREFERENCES_SYNC_EVERY_LOOP
protected:
uint32_t write_interval_{60000};
#endif
};
} // namespace esphome::preferences
+105 -5
View File
@@ -4,14 +4,15 @@ Scan modes:
continuous: true scan runs forever; never stops automatically.
continuous: false a started scan runs for `duration`, then stops. The first
start is external too; nothing starts a non-continuous
scan on boot. Until start/stop automation actions land
(follow-up PR), starting means a lambda:
`id(my_tracker).start_scan();`.
scan on boot use the rp2_ble_tracker.start_scan action
(e.g. from api: on_client_connected:).
"""
from esphome import automation
import esphome.codegen as cg
from esphome.components import ble_device_base, ota, rp2040_ble
from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW
from esphome.components.ble_device_base import automation as ble_automation
from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW
from esphome.components.rp2040_ble import CONF_RP2040_BLE_ID
import esphome.config_validation as cv
from esphome.const import (
@@ -20,7 +21,13 @@ from esphome.const import (
CONF_DURATION,
CONF_ID,
CONF_INTERVAL,
CONF_MANUFACTURER_ID,
CONF_ON_BLE_ADVERTISE,
CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE,
CONF_ON_BLE_SERVICE_DATA_ADVERTISE,
CONF_SERVICE_UUID,
)
from esphome.core import ID
from esphome.types import ConfigType
DEPENDENCIES = ["rp2"]
@@ -34,6 +41,14 @@ RP2BLETracker = rp2_ble_tracker_ns.class_(
"RP2BLETracker", ble_device_base.BLEHub, cg.Component
)
StartScanAction = rp2_ble_tracker_ns.class_("StartScanAction", automation.Action)
StopScanAction = rp2_ble_tracker_ns.class_("StopScanAction", automation.Action)
ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger
BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger
BLEManufacturerDataAdvertiseTrigger = ble_automation.BLEManufacturerDataAdvertiseTrigger
BLEEndOfScanTrigger = ble_automation.BLEEndOfScanTrigger
# interval defaults to 100 ms with the shared 30 ms window, a 30 % duty cycle —
# the same defaults as bk72xx_ble_tracker, leaving the radio mostly free for
@@ -48,6 +63,24 @@ CONFIG_SCHEMA = cv.Schema(
cv.GenerateID(): cv.declare_id(RP2BLETracker),
cv.GenerateID(CONF_RP2040_BLE_ID): cv.use_id(rp2040_ble.RP2040BLE),
cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA,
cv.Optional(CONF_ON_BLE_ADVERTISE): ble_automation.advertise_trigger_schema(
ESPBTAdvertiseTrigger
),
cv.Optional(
CONF_ON_BLE_SERVICE_DATA_ADVERTISE
): ble_automation.uuid_trigger_schema(
BLEServiceDataAdvertiseTrigger,
{cv.Required(CONF_SERVICE_UUID): ble_device_base.bt_uuid},
),
cv.Optional(
CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE
): ble_automation.uuid_trigger_schema(
BLEManufacturerDataAdvertiseTrigger,
{cv.Required(CONF_MANUFACTURER_ID): ble_device_base.bt_uuid},
),
cv.Optional(CONF_ON_SCAN_END): ble_automation.scan_end_trigger_schema(
BLEEndOfScanTrigger
),
}
).extend(cv.COMPONENT_SCHEMA)
@@ -76,4 +109,71 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW])))
cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds))
cg.add(var.set_scan_active(scan[CONF_ACTIVE]))
cg.add(var.set_scan_continuous(scan[CONF_CONTINUOUS]))
cg.add(var.set_configured_continuous(scan[CONF_CONTINUOUS]))
for conf in config.get(CONF_ON_BLE_ADVERTISE, []):
await ble_automation.advertise_trigger_to_code(conf, var)
for trigger_key, uuid_key, setter_prefix in (
(CONF_ON_BLE_SERVICE_DATA_ADVERTISE, CONF_SERVICE_UUID, "set_service_uuid"),
(
CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE,
CONF_MANUFACTURER_ID,
"set_manufacturer_uuid",
),
):
for conf in config.get(trigger_key, []):
await ble_automation.uuid_trigger_to_code(
conf, var, uuid_key, setter_prefix
)
for conf in config.get(CONF_ON_SCAN_END, []):
await ble_automation.scan_end_trigger_to_code(conf, var)
@automation.register_action(
"rp2_ble_tracker.start_scan",
StartScanAction,
cv.Schema(
{
cv.GenerateID(): cv.use_id(RP2BLETracker),
cv.Optional(CONF_CONTINUOUS): cv.templatable(cv.boolean),
}
),
synchronous=True,
)
async def start_scan_action_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: list,
) -> cg.MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
if (continuous := config.get(CONF_CONTINUOUS)) is not None:
template_ = await cg.templatable(continuous, args, cg.bool_)
cg.add(var.set_continuous(template_))
return var
@automation.register_action(
"rp2_ble_tracker.stop_scan",
StopScanAction,
automation.maybe_simple_id(
cv.Schema(
{
cv.GenerateID(): cv.use_id(RP2BLETracker),
}
)
),
synchronous=True,
)
async def stop_scan_action_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: list,
) -> cg.MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
return var
@@ -0,0 +1,47 @@
// Scan-control actions for rp2_ble_tracker. The automation triggers are the
// neutral ble_device_base classes (ble_device_base/automation.h).
#pragma once
#ifdef USE_RP2
#include "rp2_ble_tracker.h"
#include "esphome/core/automation.h"
#include "esphome/core/helpers.h"
namespace esphome::rp2_ble_tracker {
template<typename... Ts> class StartScanAction final : public Action<Ts...>, public Parented<RP2BLETracker> {
public:
TEMPLATABLE_VALUE(bool, continuous)
void play(const Ts &...x) override {
// With continuous: set, the action wins. Without it, the configured value
// is used - stop_scan() clears the runtime flag permanently, so a bare
// stop_scan/start_scan pair would otherwise never resume continuous mode.
const bool want =
this->continuous_.has_value() ? this->continuous_.value(x...) : this->parent_->configured_continuous();
if (this->parent_->scan_running()) {
// Same mode on a running scan is a no-op (esp32 parity): re-anchoring
// the duration window here would let a repeated action keep a one-shot
// scan alive forever. A real mode switch re-anchors so a change to
// one-shot runs a full duration from now.
if (want != this->parent_->scan_continuous()) {
this->parent_->set_scan_continuous(want);
this->parent_->restart_scan_duration();
}
return;
}
this->parent_->set_scan_continuous(want);
this->parent_->start_scan();
}
};
template<typename... Ts> class StopScanAction final : public Action<Ts...>, public Parented<RP2BLETracker> {
public:
void play(const Ts &...x) override { this->parent_->stop_scan(); }
};
} // namespace esphome::rp2_ble_tracker
#endif // USE_RP2
@@ -11,10 +11,8 @@ namespace esphome::rp2_ble_tracker {
static const char *const TAG = "rp2_ble_tracker";
// Minimum interval between scan start attempts on an active stack. The
// controller start has no failure mode once HCI is WORKING, so this fires at
// most once per enable cycle today; the floor is insurance against a future
// scan_start() failure being retried every main-loop iteration.
// Floor between controller start attempts; insurance against a failing
// scan_start() being retried every loop.
static constexpr uint32_t SCAN_START_RETRY_MS = 1000;
// One BLE scan unit in milliseconds; the controller programs interval/window in these units.
@@ -32,7 +30,8 @@ void RP2BLETracker::setup() {
// the OTA download on the shared CYW43 radio. Mirrors esp32_ble_tracker.
ota::get_global_ota_callback()->add_global_state_listener(this);
#endif
if (!this->scan_continuous_) {
// An on_boot start_scan runs before setup(); parking here would strand it.
if (!this->scan_continuous_ && !this->scan_running_ && !this->pending_start_) {
// Nothing to do until an external start_scan(); the loop is re-enabled there.
this->disable_loop();
}
@@ -41,12 +40,21 @@ void RP2BLETracker::setup() {
#ifdef USE_OTA_STATE_LISTENER
void RP2BLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) {
if (state == ota::OTA_STARTED) {
// Set before stop_scan(): its on_scan_end automations run synchronously and
// may call start_scan(), which must defer instead of resuming the radio.
this->ota_in_progress_ = true;
this->scan_continuous_before_ota_ = this->scan_continuous_;
// A one-shot scan counts as pending when it is running or still retrying
// its start (loop enabled); captured before stop_scan() disables the loop.
this->scan_pending_before_ota_ = !this->scan_continuous_ && (this->scan_running_ || this->is_in_loop_state());
// A one-shot scan counts as pending when it is running, latched, or still
// retrying its start (loop enabled); captured before stop_scan() parks it.
this->scan_pending_before_ota_ =
!this->scan_continuous_ && (this->scan_running_ || this->pending_start_ || this->is_in_loop_state());
// The pause's own stop is not a user stop, so it must not clear the latches
// captured just above.
this->ota_pausing_ = true;
this->stop_scan();
this->ota_pausing_ = false;
} else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) {
this->ota_in_progress_ = false;
// On success the device reboots, so restore only on a failed/aborted update;
// loop()'s retry branch restarts the scan on its next iteration.
if (this->scan_continuous_before_ota_) {
@@ -54,9 +62,7 @@ void RP2BLETracker::on_ota_global_state(ota::OTAState state, float progress, uin
this->scan_continuous_ = true;
this->enable_loop();
}
// A one-shot scan interrupted by the OTA resumes for a fresh duration
// rather than silently staying idle — an OTA failure does not reboot, so
// nothing external would restart it.
// A failed OTA does not reboot, so nothing else would restart a one-shot.
if (this->scan_pending_before_ota_) {
this->scan_pending_before_ota_ = false;
this->enable_loop();
@@ -66,27 +72,34 @@ void RP2BLETracker::on_ota_global_state(ota::OTAState state, float progress, uin
#endif // USE_OTA_STATE_LISTENER
void RP2BLETracker::loop() {
#ifdef USE_OTA_STATE_LISTENER
// Keeps "no radio during an OTA" local instead of emergent from the
// parking sites.
if (this->ota_in_progress_)
return;
#endif
const uint32_t now = App.get_loop_component_start_time();
if (this->pending_start_ && this->parent_->is_active()) {
// Latched start, applied once the stack is ACTIVE; earlier attempts would
// fail and arm the retry floor for nothing.
this->pending_start_ = false;
if (!this->scan_running_)
this->start_scan_();
}
// Deliver held scannable advertisements whose scan response never arrived —
// unmerged after the merger's timeout.
if (!this->merger_.empty())
this->merger_.sweep(now);
if (this->scan_running_ && !this->parent_->is_active()) {
// The controller was disabled underneath us (e.g. a lambda calling
// rp2040_ble's disable()); the scan died with the stack. Reconcile so the
// retry branch below takes over once the user re-enables the stack.
// Stack disabled underneath us; reconcile so the retry branch takes over.
this->scan_running_ = false;
this->fire_scan_end_();
}
if (!this->scan_running_) {
// A scan should be running but is not: continuous mode is always in this
// state until the start succeeds, and non-continuous mode only reaches
// here between start_scan() and a successful controller start, because
// stop_scan_() disables the loop otherwise.
// Should be scanning but is not: continuous until the start succeeds,
// one-shot only between start_scan() and a successful controller start.
if (!this->parent_->is_active()) {
// Stack not up (still booting, or the user called disable()) —
// scan_start() cannot succeed, so there is nothing to attempt; scanning
// starts on the first iteration after HCI reaches WORKING.
// Stack not up: scan_start() cannot succeed yet.
return;
}
if (now - this->last_scan_start_attempt_ >= SCAN_START_RETRY_MS) {
@@ -107,7 +120,7 @@ void RP2BLETracker::loop() {
// Non-continuous mode: run for scan_duration_ ms, then stop and fire on_scan_end.
// Restart is driven externally (e.g. api: on_client_connected:).
if (now - this->scan_period_start_ >= this->scan_duration_) {
if (now - this->scan_start_time_ >= this->scan_duration_) {
this->stop_scan_();
}
}
@@ -126,24 +139,20 @@ void RP2BLETracker::dump_config() {
YESNO(this->scan_continuous_));
}
// GAP advertising event types as BTstack reports them (Core spec advertising
// report event types; the tracker deliberately does not include BTstack
// headers). ADV_IND and ADV_SCAN_IND are the scannable types.
// Core spec advertising report event types (BTstack headers stay out of this
// TU). ADV_IND and ADV_SCAN_IND are the scannable ones.
static constexpr uint8_t ADV_EVENT_TYPE_ADV_IND = 0;
static constexpr uint8_t ADV_EVENT_TYPE_ADV_SCAN_IND = 2;
static constexpr uint8_t ADV_EVENT_TYPE_SCAN_RSP = 4;
// Demux advertisements vs scan responses into the shared merger: BTstack
// delivers the pair as separate reports; a scannable advertisement is held
// until its scan response arrives and delivered as one merged frame.
// BTstack delivers the pair as separate reports; the merger holds a scannable
// advertisement until its response arrives.
void RP2BLETracker::on_scan_report(const rp2040_ble::BLEScanReport &report) {
if (report.adv_event_type == ADV_EVENT_TYPE_SCAN_RSP) {
this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len);
return;
}
// Stash only while an active scan runs: a passive scan never gets a
// response, and after a stop nothing would sweep the merger, so a late
// report would surface minutes later as a fresh advertisement.
// Only while an active scan runs: nothing sweeps the merger after a stop.
if (this->scan_running_ && this->scan_active_ &&
(report.adv_event_type == ADV_EVENT_TYPE_ADV_IND || report.adv_event_type == ADV_EVENT_TYPE_ADV_SCAN_IND)) {
this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len,
@@ -157,20 +166,49 @@ void RP2BLETracker::on_scan_report(const rp2040_ble::BLEScanReport &report) {
void RP2BLETracker::start_scan() {
// Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via
// set_scan_continuous() first, then calls start_scan() to begin scanning.
#ifdef USE_OTA_STATE_LISTENER
if (this->ota_in_progress_) {
// Defer to the post-OTA resume path, carrying the requested mode. Not
// while ota_pausing_: scan_continuous_ is an artefact of the pause's own
// stop there, not intent.
if (!this->ota_pausing_) {
this->scan_continuous_before_ota_ = this->scan_continuous_;
this->scan_pending_before_ota_ = !this->scan_continuous_;
}
return;
}
#endif
this->enable_loop();
if (!this->is_ready() || !this->parent_->is_active()) {
// Pre-setup or stack not ACTIVE: latch, loop() applies it.
this->pending_start_ = true;
return;
}
// bk72xx force semantics: a user start jumps the floor only while the
// controller is healthy. loop()'s retry branch picks the request up.
if (this->last_start_failed_ &&
App.get_loop_component_start_time() - this->last_scan_start_attempt_ < SCAN_START_RETRY_MS) {
return;
}
this->start_scan_();
}
void RP2BLETracker::restart_scan_duration() {
if (!this->scan_running_)
return; // start_scan_() anchors the clock itself on the next real start
// One-shot clock only (bk72xx parity); re-anchoring the period would let
// repeated actions starve on_scan_end. Same clock as loop()'s now.
this->scan_start_time_ = App.get_loop_component_start_time();
}
bool RP2BLETracker::request_scan_mode(bool active) {
if (this->scan_active_ == active)
return true;
this->scan_active_ = active;
// V: the proxy's "Setting scanner mode" line already narrates this at D.
ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive");
// Apply to a running scan by restarting the CONTROLLER scan with the new
// mode, bypassing the tracker's stop/start bookkeeping: no on_scan_end (the
// scan logically continues, only the request mode changes), no period reset.
// An idle scanner picks the mode up on its next start.
// Restart the controller scan only: the scan logically continues, so no
// on_scan_end and no period reset. An idle scanner applies it on next start.
if (this->scan_running_) {
this->parent_->scan_stop();
if (!this->controller_scan_start_()) {
@@ -184,20 +222,34 @@ bool RP2BLETracker::request_scan_mode(bool active) {
}
void RP2BLETracker::stop_scan() {
// Cancel a start latched before setup(); without this an on_boot
// start_scan/stop_scan pair would still start at the first loop().
this->pending_start_ = false;
this->scan_continuous_ = false;
#ifdef USE_OTA_STATE_LISTENER
// A user stop during the OTA is the latest intent; the pause's own stop
// (ota_pausing_) is exempt - it armed that state.
if (this->ota_in_progress_ && !this->ota_pausing_) {
this->scan_pending_before_ota_ = false;
this->scan_continuous_before_ota_ = false;
}
#endif
this->stop_scan_();
// stop_scan_() early-returns when no scan is running, so disable the loop
// here too: a scan that never came up (stack still powering on at OTA start)
// must not keep attempting scan_start() from the loop's retry branch.
this->disable_loop();
// stop_scan_() early-returns when idle, so park here too - once set up, and
// re-checked: its synchronous on_scan_end may have restarted the scan.
if (this->is_ready() && !this->scan_running_ && !this->pending_start_) {
this->disable_loop();
}
}
// Stamp-and-start for every controller scan attempt: the stamp keeps the
// SCAN_START_RETRY_MS floor covering all callers, not only loop()'s retry.
bool RP2BLETracker::controller_scan_start_() {
this->last_scan_start_attempt_ = App.get_loop_component_start_time();
return this->parent_->scan_start(static_cast<uint16_t>(this->scan_interval_),
static_cast<uint16_t>(this->scan_window_), this->scan_active_);
const bool ok = this->parent_->scan_start(static_cast<uint16_t>(this->scan_interval_),
static_cast<uint16_t>(this->scan_window_), this->scan_active_);
this->last_start_failed_ = !ok;
return ok;
}
void RP2BLETracker::start_scan_() {
@@ -208,19 +260,15 @@ void RP2BLETracker::start_scan_() {
return;
this->scan_running_ = true;
// Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and
// in non-continuous mode each period is an explicit start, so asymmetric logging
// would read as the scanner failing to come back up.
// Symmetric with stop_scan_()'s stop log; asymmetry would read as the
// scanner failing to come back.
ESP_LOGD(TAG, "Scan started (%s, window=%.0fms, interval=%.0fms)",
this->scan_active_ ? LOG_STR_LITERAL("active") : LOG_STR_LITERAL("passive"),
this->scan_window_ * BLE_SCAN_UNIT_MS, this->scan_interval_ * BLE_SCAN_UNIT_MS);
// Re-anchor the scan period to every successful start — first start (so the
// period counts from the scan, not from boot) and every restart after a stop (so
// resuming after longer than scan_duration, e.g. a failed OTA restoring continuous
// mode 10 minutes later, does not fire on_scan_end before an advertisement can
// arrive). Same clock as loop()'s `now`: a fresh millis() here would be ahead of
// the cached loop time and make the same-iteration period check underflow.
// Anchor the period to the scan, not to boot, so a restart after a long gap
// does not fire on_scan_end immediately. Same clock as loop()'s now.
this->scan_period_start_ = App.get_loop_component_start_time();
this->scan_start_time_ = this->scan_period_start_;
}
void RP2BLETracker::stop_scan_() {
@@ -232,7 +280,9 @@ void RP2BLETracker::stop_scan_() {
this->fire_scan_end_();
// Reset the period clock so on_scan_end does not double-fire; same clock as loop().
this->scan_period_start_ = App.get_loop_component_start_time();
if (!this->scan_continuous_) {
// on_scan_end runs synchronously and may restart the scan; re-check before
// parking or that scan runs untimed.
if (!this->scan_continuous_ && !this->scan_running_ && !this->pending_start_) {
// Nothing left to time; start_scan() re-enables the loop.
this->disable_loop();
}
@@ -44,11 +44,20 @@ class RP2BLETracker : public Component,
void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; }
void set_scan_active(bool scan_active) { this->scan_active_ = scan_active; }
void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; }
void set_configured_continuous(bool scan_continuous) {
this->configured_continuous_ = scan_continuous;
this->scan_continuous_ = scan_continuous;
}
bool scan_continuous() const { return this->scan_continuous_; }
bool configured_continuous() const { return this->configured_continuous_; }
// ---- Public scan control ----
// Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan().
void start_scan();
void stop_scan();
// Re-anchors the one-shot duration clock only (bk72xx parity); no-op while
// idle. Policy lives in the action.
void restart_scan_duration();
// ---- ble_device_base::BLEHub contract ----
void register_listener(ble_device_base::ESPBTDeviceListener *listener) {
@@ -58,10 +67,8 @@ class RP2BLETracker : public Component,
this->dispatcher_.set_raw_advertisement_callback(callback);
}
static constexpr ble_device_base::HubCapabilities get_capabilities() {
// BTstack delivers scan responses as separate advertisement reports; this
// tracker merges the pair before delivery (shared ScanResponseMerger,
// Bluedroid semantics). GATT is available when the BTstack connection
// backend is compiled in (bluetooth_proxy active).
// Scan responses arrive separately and are merged before delivery
// (Bluedroid semantics). GATT needs the BTstack connection backend.
#ifdef USE_BLE_GATT_CLIENT
constexpr bool has_gatt = true;
#else
@@ -77,8 +84,7 @@ class RP2BLETracker : public Component,
bool request_scan_mode(bool active);
// ---- rp2040_ble::BLEScanListener ----
// Delivered by the controller's loop() on the ESPHome main loop — the
// IRQ → main-loop handoff already happened in the controller's queue.
// Delivered on the main loop; the controller's queue did the IRQ handoff.
void on_scan_report(const rp2040_ble::BLEScanReport &report) override;
protected:
@@ -93,20 +99,29 @@ class RP2BLETracker : public Component,
uint32_t scan_window_{48}; // 48 × 0.625 ms = 30 ms (30/100 = 30 %)
uint32_t scan_duration_{300000};
uint32_t last_scan_start_attempt_{0}; // loop time of last start_scan_() attempt; rate-limits retries
uint32_t scan_period_start_{0}; // loop time at start of current scan period; rate-limits on_scan_end()
bool scan_running_{false};
bool scan_active_{true};
uint32_t scan_period_start_{0}; // continuous-mode on_scan_end period clock
uint32_t scan_start_time_{0}; // one-shot duration clock (bk72xx parity: kept separate from the period)
// Bit-packed (C++20 default member initializers on bit-fields);
// scan_continuous_ stays a plain bool because the merger binds its address.
bool scan_running_ : 1 {false};
bool pending_start_ : 1 {false}; // start_scan() latched before setup() or while the stack is
// not ACTIVE; loop() applies it once it is
bool last_start_failed_ : 1 {false}; // last controller start failed; gates the public start_scan() floor
bool scan_active_ : 1 {true};
bool configured_continuous_ : 1 {true}; // YAML scan_parameters.continuous; runtime stop_scan() must not lose it
bool scan_continuous_{true};
#ifdef USE_OTA_STATE_LISTENER
bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure
bool scan_pending_before_ota_{false}; // one-shot scan in flight at OTA start, resumed on OTA failure
// Resume intent for a failed/aborted OTA: seeded at OTA start, overwritten
// by a start/stop during the download, except from the pause's own stop.
bool scan_continuous_before_ota_ : 1 {false}; // resume continuous
bool scan_pending_before_ota_ : 1 {false}; // resume a one-shot scan
bool ota_in_progress_ : 1 {false}; // OTA holds the radio; start_scan() defers to the resume path
bool ota_pausing_ : 1 {false}; // inside the OTA's own stop_scan(); its latch clear is skipped
#endif
// Shared adv + scan-response merge and frame dispatch (ble_device_base).
// All calls run on the main loop. Merger clock: stash_adv() reads the
// PARENT's cached loop time (on_scan_report runs inside rp2040_ble's queue
// drain), sweep() this component's — same App.loop() pass, so the delta
// stays non-negative and the 300 ms timeout holds.
// Shared merge + dispatch (ble_device_base), all on the main loop.
// stash_adv() uses the parent's cached loop time and sweep() this one's -
// same App.loop() pass, so the merger delta stays non-negative.
ble_device_base::ScanResponseMerger merger_;
ble_device_base::AdvDispatcher dispatcher_;
};
@@ -0,0 +1,44 @@
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "image_format.h"
namespace esphome::runtime_image {
struct MimeLookup {
const char *mime_type;
ImageFormat format;
};
// The first entry per format is its canonical MIME type; the rest are aliases
// seen from real servers (older IIS, CDNs, S3)
static constexpr MimeLookup MIME_LOOKUP_TABLE[] = {
#ifdef USE_RUNTIME_IMAGE_BMP
{"image/bmp", ImageFormat::BMP}, {"image/x-ms-bmp", ImageFormat::BMP}, {"image/x-bmp", ImageFormat::BMP},
#endif
#ifdef USE_RUNTIME_IMAGE_JPEG
{"image/jpeg", ImageFormat::JPEG}, {"image/jpg", ImageFormat::JPEG},
#endif
#ifdef USE_RUNTIME_IMAGE_PNG
{"image/png", ImageFormat::PNG}, {"image/x-png", ImageFormat::PNG},
#endif
};
const char *get_mime_type_for_format(ImageFormat format) {
for (const auto &entry : MIME_LOOKUP_TABLE) {
if (entry.format == format) {
return entry.mime_type;
}
}
return "image/*"; // AUTO or compiled-out format
}
std::optional<ImageFormat> get_format_for_mime_type(const char *mime_type) {
for (const auto &entry : MIME_LOOKUP_TABLE) {
if (str_contains_ignore_case(mime_type, entry.mime_type)) {
return entry.format;
}
}
return std::nullopt;
}
} // namespace esphome::runtime_image
@@ -1,5 +1,7 @@
#pragma once
#include <optional>
namespace esphome::runtime_image {
/**
@@ -17,4 +19,9 @@ enum ImageFormat {
BMP,
};
/// Canonical MIME type for a format; "image/*" for AUTO/unknown
const char *get_mime_type_for_format(ImageFormat format);
/// Case-insensitive substring match of known media types; nullopt if none found
std::optional<ImageFormat> get_format_for_mime_type(const char *mime_type);
} // namespace esphome::runtime_image
@@ -1,7 +1,6 @@
#include "runtime_image.h"
#include "image_decoder.h"
#include "esphome/core/log.h"
#include "esphome/core/helpers.h"
#include <algorithm>
#include <cstdint>
#include <cstring>
-8
View File
@@ -1,8 +0,0 @@
#include "automation.h"
#include "esphome/core/log.h"
namespace esphome::sensor {
static const char *const TAG = "sensor.automation";
} // namespace esphome::sensor
@@ -89,12 +89,12 @@ void SerialProxy::dump_config() {
this->dtr_pin_ != nullptr ? "configured" : "not configured");
}
void SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity,
uint8_t stop_bits, uint8_t data_size) {
SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control,
uint8_t parity, uint8_t stop_bits, uint8_t data_size) {
#ifdef USE_API
if (this->port_claimed_by_other_(api_connection)) {
ESP_LOGW(TAG, "Ignoring configure request from client without port access [%" PRIu32 "]", this->instance_index_);
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
}
#endif
ESP_LOGD(TAG,
@@ -105,25 +105,29 @@ void SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrat
auto *uart_comp = this->parent_;
if (uart_comp == nullptr) {
ESP_LOGE(TAG, "UART component not available");
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_ERROR;
}
// Validate all parameters before applying any (values come from a remote client)
if (baudrate == 0) {
ESP_LOGW(TAG, "Invalid baud rate: 0");
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT;
}
if (stop_bits < 1 || stop_bits > 2) {
ESP_LOGW(TAG, "Invalid stop bits: %u (must be 1 or 2)", stop_bits);
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT;
}
if (data_size < 5 || data_size > 8) {
ESP_LOGW(TAG, "Invalid data bits: %u (must be 5-8)", data_size);
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT;
}
if (parity > 2) {
ESP_LOGW(TAG, "Invalid parity: %u (must be 0-2)", parity);
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT;
}
if (flow_control) {
ESP_LOGW(TAG, "Hardware flow control requested but is not yet supported");
return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED;
}
// Apply validated parameters
@@ -143,10 +147,7 @@ void SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrat
#if defined(USE_ESP8266) || defined(USE_ESP32)
uart_comp->load_settings(true);
#endif
if (flow_control) {
ESP_LOGW(TAG, "Hardware flow control requested but is not yet supported");
}
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
void SerialProxy::write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {
@@ -163,13 +164,20 @@ void SerialProxy::write_from_client(api::APIConnection *api_connection, const ui
this->write_array(data, len);
}
void SerialProxy::set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {
SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {
#ifdef USE_API
if (this->port_claimed_by_other_(api_connection)) {
ESP_LOGW(TAG, "Ignoring modem pin request from client without port access [%" PRIu32 "]", this->instance_index_);
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
}
#endif
// Asserting a pin that is not configured must fail so the client learns the signal never
// reached the wire; deasserting an absent pin is harmless and stays allowed. Clients can
// avoid this by masking against SerialProxyInfo.configured_line_states.
if ((line_states & ~this->get_configured_modem_pins()) != 0) {
ESP_LOGW(TAG, "Requested modem pin not configured on serial proxy [%" PRIu32 "]", this->instance_index_);
return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED;
}
const bool rts = (line_states & SERIAL_PROXY_LINE_STATE_FLAG_RTS) != 0;
const bool dtr = (line_states & SERIAL_PROXY_LINE_STATE_FLAG_DTR) != 0;
ESP_LOGV(TAG, "Setting modem pins [%" PRIu32 "]: RTS=%s, DTR=%s", this->instance_index_, ONOFF(rts), ONOFF(dtr));
@@ -182,6 +190,7 @@ void SerialProxy::set_modem_pins(api::APIConnection *api_connection, uint32_t li
this->dtr_state_ = dtr;
this->dtr_pin_->digital_write(dtr);
}
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
uint32_t SerialProxy::get_modem_pins() const {
@@ -189,9 +198,26 @@ uint32_t SerialProxy::get_modem_pins() const {
(this->dtr_state_ ? static_cast<uint32_t>(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u);
}
uart::UARTFlushResult SerialProxy::flush_port() {
SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) {
#ifdef USE_API
// Flushing stalls the port, so it gets the same ownership check as writes
if (this->port_claimed_by_other_(api_connection)) {
ESP_LOGW(TAG, "Ignoring flush from client without port access [%" PRIu32 "]", this->instance_index_);
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
}
#endif
ESP_LOGV(TAG, "Flushing serial proxy [%" PRIu32 "]", this->instance_index_);
return this->flush();
switch (this->flush()) {
case uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS:
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
case uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS:
return SerialProxyResult::SERIAL_PROXY_RESULT_ASSUMED_SUCCESS;
case uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT:
return SerialProxyResult::SERIAL_PROXY_RESULT_TIMEOUT;
case uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED:
return SerialProxyResult::SERIAL_PROXY_RESULT_ERROR;
}
return SerialProxyResult::SERIAL_PROXY_RESULT_ERROR; // Unreachable; all enum values handled above
}
#ifdef USE_API
@@ -200,12 +226,13 @@ bool SerialProxy::port_claimed_by_other_(api::APIConnection *api_connection) con
this->api_connection_->is_connection_setup();
}
void SerialProxy::serial_proxy_request(api::APIConnection *api_connection, api::enums::SerialProxyRequestType type) {
SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_connection,
api::enums::SerialProxyRequestType type) {
switch (type) {
case api::enums::SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE:
if (this->api_connection_ == api_connection) {
ESP_LOGV(TAG, "API connection is already subscribed to serial proxy [%" PRIu32 "]", this->instance_index_);
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
if (this->api_connection_ != nullptr) {
// A living subscriber keeps exclusive access. Its connection may be dead without
@@ -213,26 +240,27 @@ void SerialProxy::serial_proxy_request(api::APIConnection *api_connection, api::
// in that case let the new client take over instead of locking it out.
if (this->api_connection_->is_connection_setup()) {
ESP_LOGE(TAG, "Only one API subscription is allowed at a time");
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
}
ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription");
}
this->api_connection_ = api_connection;
this->enable_loop();
ESP_LOGV(TAG, "API connection subscribed to serial proxy [%" PRIu32 "]", this->instance_index_);
break;
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
case api::enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE:
// Unsubscribe is idempotent: not being subscribed is not an error
if (this->api_connection_ != api_connection) {
ESP_LOGV(TAG, "API connection is not subscribed to serial proxy [%" PRIu32 "]", this->instance_index_);
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
this->api_connection_ = nullptr;
this->disable_loop();
ESP_LOGV(TAG, "API connection unsubscribed from serial proxy [%" PRIu32 "]", this->instance_index_);
break;
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
default:
ESP_LOGW(TAG, "Unknown serial proxy request type: %" PRIu32, static_cast<uint32_t>(type));
break;
return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED;
}
}
#endif
+23 -5
View File
@@ -38,6 +38,17 @@ enum SerialProxyLineStateFlag : uint32_t {
SERIAL_PROXY_LINE_STATE_FLAG_DTR = 1 << 1, ///< DTR (Data Terminal Ready)
};
/// Result of a client-initiated operation; mapped to api::enums::SerialProxyStatus by the API layer
enum class SerialProxyResult : uint8_t {
SERIAL_PROXY_RESULT_OK, ///< Operation completed or request accepted
SERIAL_PROXY_RESULT_ASSUMED_SUCCESS, ///< Platform cannot confirm TX drain; success assumed
SERIAL_PROXY_RESULT_PORT_IN_USE, ///< Denied: another live client holds the port
SERIAL_PROXY_RESULT_INVALID_ARGUMENT, ///< A parameter value is out of range
SERIAL_PROXY_RESULT_ERROR, ///< Driver or hardware error
SERIAL_PROXY_RESULT_TIMEOUT, ///< Timed out before TX completed
SERIAL_PROXY_RESULT_NOT_SUPPORTED, ///< Requested feature is not available on this instance
};
/// Maximum bytes to read from UART in a single loop iteration
inline constexpr size_t SERIAL_PROXY_MAX_READ_SIZE = 256;
@@ -73,14 +84,14 @@ class SerialProxy final : public uart::UARTDevice, public Component {
/// @param parity Parity setting (0=none, 1=even, 2=odd)
/// @param stop_bits Number of stop bits (1 or 2)
/// @param data_size Number of data bits (5-8)
void configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity,
uint8_t stop_bits, uint8_t data_size);
SerialProxyResult configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity,
uint8_t stop_bits, uint8_t data_size);
/// Get the currently subscribed API connection (nullptr if none)
api::APIConnection *get_api_connection() { return this->api_connection_; }
/// Handle a subscribe/unsubscribe request from an API client
void serial_proxy_request(api::APIConnection *api_connection, api::enums::SerialProxyRequestType type);
SerialProxyResult serial_proxy_request(api::APIConnection *api_connection, api::enums::SerialProxyRequestType type);
/// Write data received from an API client to the serial device
/// @param api_connection The API connection sending the data
@@ -89,13 +100,20 @@ class SerialProxy final : public uart::UARTDevice, public Component {
void write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len);
/// Set modem pin states from a bitmask of SerialProxyLineStateFlag values
void set_modem_pins(api::APIConnection *api_connection, uint32_t line_states);
SerialProxyResult set_modem_pins(api::APIConnection *api_connection, uint32_t line_states);
/// Get current modem pin states as a bitmask of SerialProxyLineStateFlag values
uint32_t get_modem_pins() const;
/// Get the modem pins this instance can drive as a bitmask of SerialProxyLineStateFlag values
uint32_t get_configured_modem_pins() const {
return (this->rts_pin_ != nullptr ? static_cast<uint32_t>(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) |
(this->dtr_pin_ != nullptr ? static_cast<uint32_t>(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u);
}
/// Flush the serial port (block until all TX data is sent)
uart::UARTFlushResult flush_port();
/// @param api_connection The API connection requesting the flush
SerialProxyResult flush_port(api::APIConnection *api_connection);
/// Set the RTS GPIO pin (from YAML configuration)
void set_rts_pin(GPIOPin *pin) { this->rts_pin_ = pin; }
+1
View File
@@ -0,0 +1 @@
CODEOWNERS = ["@NoQuarrel"]
+79
View File
@@ -0,0 +1,79 @@
import esphome.codegen as cg
from esphome.components import i2c, sensirion_common, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_FORMALDEHYDE,
CONF_HUMIDITY,
CONF_ID,
CONF_TEMPERATURE,
DEVICE_CLASS_GAS,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
ICON_FLASK_OUTLINE,
ICON_THERMOMETER,
ICON_WATER_PERCENT,
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
UNIT_PARTS_PER_BILLION,
UNIT_PERCENT,
)
DEPENDENCIES = ["i2c"]
AUTO_LOAD = ["sensirion_common"]
CONF_WAIT_FOR_READY = "wait_for_ready"
sfa40_ns = cg.esphome_ns.namespace("sfa40")
SFA40Component = sfa40_ns.class_(
"SFA40Component", cg.PollingComponent, sensirion_common.SensirionI2CDevice
)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(CONF_ID): cv.declare_id(SFA40Component),
cv.Optional(CONF_WAIT_FOR_READY, default=True): cv.boolean,
cv.Optional(CONF_FORMALDEHYDE): sensor.sensor_schema(
unit_of_measurement=UNIT_PARTS_PER_BILLION,
icon=ICON_FLASK_OUTLINE,
accuracy_decimals=1,
device_class=DEVICE_CLASS_GAS,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
icon=ICON_THERMOMETER,
accuracy_decimals=2,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_HUMIDITY): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
icon=ICON_WATER_PERCENT,
accuracy_decimals=2,
device_class=DEVICE_CLASS_HUMIDITY,
state_class=STATE_CLASS_MEASUREMENT,
),
}
)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x5D))
)
SENSOR_MAP = {
CONF_FORMALDEHYDE: "set_formaldehyde_sensor",
CONF_TEMPERATURE: "set_temperature_sensor",
CONF_HUMIDITY: "set_humidity_sensor",
}
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
cg.add(var.set_wait_for_ready(config[CONF_WAIT_FOR_READY]))
for key, func_name in SENSOR_MAP.items():
if sensor_config := config.get(key):
sens = await sensor.new_sensor(sensor_config)
cg.add(getattr(var, func_name)(sens))
+159
View File
@@ -0,0 +1,159 @@
#include "sfa40.h"
#include "esphome/core/log.h"
#include <cinttypes>
namespace esphome::sfa40 {
static const char *const TAG = "sfa40";
// SFA40 Datasheet: https://sensirion.com/media/documents/5B06EDD9/69F84BD8/Sensirion_Datasheet_SFA40.pdf
static const uint16_t SFA40_CMD_START_MEASUREMENT = 0x00AC;
static const uint16_t SFA40_CMD_STOP_MEASUREMENT = 0x50D2;
static const uint16_t SFA40_CMD_READ_MEASURE_PROD = 0xC0EB;
// B4 (engineering-sample) command codes. Commands from here: https://github.com/DFRobot/DFRobot_SFA40
static const uint16_t SFA40_CMD_READ_MEASURE_B4 = 0xE06D;
static const uint16_t SFA40_CMD_READ_ID_PROD = 0x02CE;
static const uint16_t SFA40_CMD_READ_ID_B4 = 0x0559;
static const uint8_t STATUS_NOT_READY = 0x01;
static const uint8_t STATUS_OUT_OF_SPEC = 0x02;
static uint64_t raw_to_serial(const uint16_t *raw, size_t words) {
uint64_t serial = 0;
for (size_t i = 0; i < words; i++) {
serial = (serial << 16) | raw[i];
}
return serial;
}
static void raw_to_marking(const uint16_t *raw, size_t words, char *out, size_t out_len) {
if (out_len < words * 2 + 1) {
return;
}
for (size_t i = 0; i < words; i++) {
out[i * 2] = static_cast<char>(raw[i] >> 8);
out[i * 2 + 1] = static_cast<char>(raw[i] & 0xFF);
}
out[words * 2] = '\0';
}
void SFA40Component::setup() {
this->write_command(SFA40_CMD_STOP_MEASUREMENT);
this->set_timeout(25, [this]() {
if (!this->detect_protocol_()) {
ESP_LOGE(TAG, "Failed to detect SFA40 protocol");
this->error_code_ = PROTOCOL_DETECTION_FAILED;
this->mark_failed();
return;
}
if (!this->write_command(SFA40_CMD_START_MEASUREMENT)) {
ESP_LOGE(TAG, "Failed to start measurements");
this->error_code_ = MEASUREMENT_INIT_FAILED;
this->mark_failed();
return;
}
this->initialized_ = true;
ESP_LOGD(TAG, "Measurement started");
});
}
bool SFA40Component::detect_protocol_() {
uint16_t raw[5] = {};
if (this->get_register(SFA40_CMD_READ_ID_PROD, raw, 3, 5)) {
this->protocol_version_ = ProtocolVersion::PRODUCTION;
this->serial_number_ = raw_to_serial(raw, 3);
ESP_LOGD(TAG, "Detected production SFA40, serial number: %012" PRIX64, this->serial_number_);
return true;
}
if (this->get_register(SFA40_CMD_READ_ID_B4, raw, 5, 5)) {
this->protocol_version_ = ProtocolVersion::PROTOTYPE;
raw_to_marking(raw, 5, this->device_marking_, sizeof(this->device_marking_));
ESP_LOGD(TAG, "Detected engineering-sample SFA40, marking: '%s'", this->device_marking_);
return true;
}
return false;
}
void SFA40Component::dump_config() {
ESP_LOGCONFIG(TAG, "sfa40:");
LOG_I2C_DEVICE(this);
if (this->is_failed()) {
switch (this->error_code_) {
case PROTOCOL_DETECTION_FAILED:
ESP_LOGW(TAG, "Protocol detection failed!");
break;
case MEASUREMENT_INIT_FAILED:
ESP_LOGW(TAG, "Measurement initialization failed!");
break;
default:
ESP_LOGW(TAG, "Unknown setup error!");
break;
}
}
LOG_UPDATE_INTERVAL(this);
switch (this->protocol_version_) {
case ProtocolVersion::PRODUCTION:
ESP_LOGCONFIG(TAG, " Protocol: production\n Serial Number: %012" PRIX64, this->serial_number_);
break;
case ProtocolVersion::PROTOTYPE:
ESP_LOGCONFIG(TAG, " Protocol: prototype (B4)\n Marking: '%s'", this->device_marking_);
break;
default:
ESP_LOGCONFIG(TAG, " Protocol: (detecting...)");
break;
}
ESP_LOGCONFIG(TAG, " Wait for ready: %s", YESNO(this->wait_for_ready_));
LOG_SENSOR(" ", "Formaldehyde", this->formaldehyde_sensor_);
LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
LOG_SENSOR(" ", "Humidity", this->humidity_sensor_);
}
void SFA40Component::update() {
if (!this->initialized_ || this->protocol_version_ == ProtocolVersion::UNKNOWN) {
return;
}
const uint16_t read_cmd = (this->protocol_version_ == ProtocolVersion::PRODUCTION) ? SFA40_CMD_READ_MEASURE_PROD
: SFA40_CMD_READ_MEASURE_B4;
if (!this->write_command(read_cmd)) {
ESP_LOGW(TAG, "Error reading measurement");
this->status_set_warning();
return;
}
this->set_timeout(5, [this]() {
uint16_t raw[4];
if (!this->read_data(raw, 4)) {
ESP_LOGW(TAG, "Error reading measurement data");
this->status_set_warning();
return;
}
const uint8_t status = raw[3] >> 8;
const bool sensor_not_ready = (status & STATUS_NOT_READY) != 0;
const bool sensor_out_of_spec = (status & STATUS_OUT_OF_SPEC) != 0;
if (this->formaldehyde_sensor_ != nullptr) {
if (sensor_out_of_spec) {
ESP_LOGW(TAG, "Skipping formaldehyde publish: sensor out of spec (status=0x%02X)", status);
} else if (this->wait_for_ready_ && sensor_not_ready) {
ESP_LOGD(TAG, "Skipping formaldehyde publish: sensor warming up");
} else {
this->formaldehyde_sensor_->publish_state(static_cast<float>(raw[0]) / 10.0f);
}
}
if (this->humidity_sensor_ != nullptr) {
this->humidity_sensor_->publish_state(clamp(125.0f * static_cast<float>(raw[1]) / 65535.0f - 6.0f, 0.0f, 100.0f));
}
if (this->temperature_sensor_ != nullptr) {
this->temperature_sensor_->publish_state(175.0f * (static_cast<float>(raw[2]) / 65535.0f) - 45.0f);
}
this->status_clear_warning();
});
}
} // namespace esphome::sfa40
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/sensirion_common/i2c_sensirion.h"
namespace esphome::sfa40 {
// SFA40 Datasheet: https://sensirion.com/media/documents/5B06EDD9/69F84BD8/Sensirion_Datasheet_SFA40.pdf
class SFA40Component final : public PollingComponent, public sensirion_common::SensirionI2CDevice {
public:
void setup() override;
void dump_config() override;
void update() override;
void set_formaldehyde_sensor(sensor::Sensor *formaldehyde) { this->formaldehyde_sensor_ = formaldehyde; }
void set_temperature_sensor(sensor::Sensor *temperature) { this->temperature_sensor_ = temperature; }
void set_humidity_sensor(sensor::Sensor *humidity) { this->humidity_sensor_ = humidity; }
void set_wait_for_ready(bool wait_for_ready) { this->wait_for_ready_ = wait_for_ready; }
protected:
enum ProtocolVersion : uint8_t {
UNKNOWN = 0,
PRODUCTION = 1,
PROTOTYPE = 2,
};
enum ErrorCode : uint8_t {
UNKNOWN_ERROR = 0,
PROTOCOL_DETECTION_FAILED,
MEASUREMENT_INIT_FAILED,
};
bool detect_protocol_();
ProtocolVersion protocol_version_{UNKNOWN};
ErrorCode error_code_{UNKNOWN_ERROR};
char device_marking_[11]{};
bool initialized_{false};
bool wait_for_ready_{true};
uint64_t serial_number_{0};
sensor::Sensor *formaldehyde_sensor_{nullptr};
sensor::Sensor *temperature_sensor_{nullptr};
sensor::Sensor *humidity_sensor_{nullptr};
};
} // namespace esphome::sfa40
+2
View File
@@ -352,6 +352,8 @@ class SPIComponent final : public Component {
this->using_hw_ = true;
}
SPIInterface get_interface() const { return this->interface_; }
void set_interface_name(const char *name) { this->interface_name_ = name; }
float get_setup_priority() const override { return setup_priority::BUS; }
-8
View File
@@ -1,8 +0,0 @@
#include "automation.h"
#include "esphome/core/log.h"
namespace esphome::switch_ {
static const char *const TAG = "switch.automation";
} // namespace esphome::switch_
+19 -2
View File
@@ -9,6 +9,7 @@ from esphome import automation
from esphome.automation import Condition
import esphome.codegen as cg
from esphome.components.zephyr import zephyr_add_prj_conf
from esphome.config_helpers import filter_source_files_from_defines
import esphome.config_validation as cv
from esphome.const import (
CONF_AT,
@@ -425,7 +426,12 @@ async def setup_time_core_(time_var, config):
raise EsphomeError(f"Invalid timezone: {timezone}") from e
_emit_parsed_timezone_fields(parsed)
for conf in config.get(CONF_ON_TIME, []):
on_time = config.get(CONF_ON_TIME, [])
on_time_sync = config.get(CONF_ON_TIME_SYNC, [])
if on_time or on_time_sync:
cg.add_define("USE_TIME_TRIGGERS")
for conf in on_time:
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var)
seconds = conf.get(CONF_SECONDS, list(range(61)))
@@ -444,7 +450,7 @@ async def setup_time_core_(time_var, config):
await cg.register_component(trigger, conf)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_TIME_SYNC, []):
for conf in on_time_sync:
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var)
await cg.register_component(trigger, conf)
@@ -475,3 +481,14 @@ async def to_code(config):
async def time_has_time_to_code(config, condition_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
return cg.new_Pvariable(condition_id, template_arg, paren)
# posix_tz.cpp is fully #ifdef'd on USE_TIME_TIMEZONE, set only when a
# timezone is configured or detected; automation.cpp holds the on_time and
# on_time_sync triggers and is #ifdef'd on USE_TIME_TRIGGERS.
FILTER_SOURCE_FILES = filter_source_files_from_defines(
{
"posix_tz.cpp": "USE_TIME_TIMEZONE",
"automation.cpp": "USE_TIME_TRIGGERS",
}
)
+3
View File
@@ -1,4 +1,5 @@
#include "automation.h"
#ifdef USE_TIME_TRIGGERS
#include "esphome/core/log.h"
@@ -98,3 +99,5 @@ SyncTrigger::SyncTrigger(RealTimeClock *rtc) : rtc_(rtc) {
}
} // namespace esphome::time
#endif // USE_TIME_TRIGGERS
+5
View File
@@ -1,5 +1,8 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_TIME_TRIGGERS
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/time.h"
@@ -49,3 +52,5 @@ class SyncTrigger final : public Trigger<>, public Component {
RealTimeClock *rtc_;
};
} // namespace esphome::time
#endif // USE_TIME_TRIGGERS
@@ -393,5 +393,16 @@ void IRAM_ATTR IDFUARTComponent::uart_rx_isr_callback(uart_port_t uart_num, uart
}
#endif // USE_UART_WAKE_LOOP_ON_RX
void IDFUARTComponent::on_shutdown() {
if (this->uart_num_ == UART_NUM_MAX || !uart_is_driver_installed(this->uart_num_))
return;
uart_wait_tx_done(this->uart_num_, pdMS_TO_TICKS(100));
// Keep the peripheral quiet across a soft reset so ROM output does not reach the attached device (#15472)
esp_err_t err = uart_driver_delete(this->uart_num_);
if (err != ESP_OK) {
ESP_LOGW(TAG, "uart_driver_delete failed: %s", esp_err_to_name(err));
}
}
} // namespace esphome::uart
#endif // USE_ESP32
@@ -52,9 +52,11 @@ class IDFUARTComponent final : public UARTComponent, public Component {
void load_settings(bool dump_config) override;
using UARTComponent::load_settings; // also bring in the no-arg overload for convenience
void on_shutdown() override;
protected:
void check_logger_conflict() override;
uart_port_t uart_num_;
uart_port_t uart_num_{UART_NUM_MAX};
uart_config_t get_config_();
bool has_peek_{false};
+2 -1
View File
@@ -61,8 +61,9 @@ async def to_code(config: ConfigType) -> None:
if time_id_config := config.get(CONF_TIME_ID):
time_id = await cg.get_variable(time_id_config)
cg.add(var.set_time(time_id))
cg.add_define("USE_UPTIME_TIMESTAMP")
FILTER_SOURCE_FILES = filter_source_files_from_defines(
{"uptime_timestamp_sensor.cpp": "USE_TIME"}
{"uptime_timestamp_sensor.cpp": "USE_UPTIME_TIMESTAMP"}
)
@@ -1,6 +1,6 @@
#include "uptime_timestamp_sensor.h"
#ifdef USE_TIME
#ifdef USE_UPTIME_TIMESTAMP
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
@@ -34,4 +34,4 @@ void UptimeTimestampSensor::dump_config() {
} // namespace esphome::uptime
#endif // USE_TIME
#endif // USE_UPTIME_TIMESTAMP
@@ -2,7 +2,7 @@
#include "esphome/core/defines.h"
#ifdef USE_TIME
#ifdef USE_UPTIME_TIMESTAMP
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/time/real_time_clock.h"
@@ -25,4 +25,4 @@ class UptimeTimestampSensor final : public sensor::Sensor, public Component {
} // namespace esphome::uptime
#endif // USE_TIME
#endif // USE_UPTIME_TIMESTAMP
@@ -20,6 +20,7 @@ async def to_code(config: ConfigType) -> None:
# Re-enable esp-tls (excluded by default to save compile time);
# web_server_idf.cpp includes <esp_tls_crypto.h> for digest auth
include_builtin_idf_component("esp-tls")
include_builtin_idf_component("esp_http_server")
# multipart.cpp is fully #ifdef'd on USE_WEBSERVER_OTA (set by the
+8
View File
@@ -951,6 +951,14 @@ class WiFiComponent final : public Component {
// On ESP8266, written from SDK system context (wifi_event_callback) —
// uint8_t writes are atomic on Xtensa LX106 so no synchronization is needed.
uint8_t sta_state_{0};
#endif
#ifdef USE_LIBRETINY
// First attempt since STA-up (re-armed on every STA off->on); the
// pre-attempt teardown is skipped then.
bool lt_first_connect_attempt_{true};
// A self-inflicted disconnect from that teardown is pending; it must not
// consume an ignored-disconnect slot.
bool lt_teardown_event_pending_{false};
#endif
RetryHiddenMode retry_hidden_mode_{RetryHiddenMode::BLIND_RETRY};
RoamingState roaming_state_{RoamingState::IDLE};
@@ -115,6 +115,8 @@ bool WiFiComponent::wifi_mode_(optional<bool> sta, optional<bool> ap) {
if (enable_sta && !current_sta) {
ESP_LOGV(TAG, "Enabling STA");
// Fresh STA stack: skip the pre-attempt teardown again.
this->lt_first_connect_attempt_ = true;
} else if (!enable_sta && current_sta) {
ESP_LOGV(TAG, "Disabling STA");
}
@@ -202,10 +204,21 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
if (!this->wifi_mode_(true, {}))
return false;
String ssid = WiFi.SSID();
if (ssid && strcmp(ssid.c_str(), ap.ssid_.c_str()) != 0) {
WiFi.disconnect();
// Tear down any live session so begin() re-fires its events; skipped on the
// first attempt after STA-up (nothing to tear down, and BK7231N on the older
// Beken SDK did not come back from it). The flag is per-attempt and armed
// only for a live session: an idle disconnect may emit no event, and a stale
// flag would swallow this attempt's first real failure.
this->lt_teardown_event_pending_ = false;
if (!this->lt_first_connect_attempt_) {
const bool was_live = WiFi.status() == WL_CONNECTED;
if (WiFi.disconnect()) {
this->lt_teardown_event_pending_ = was_live;
} else {
ESP_LOGD(TAG, "Pre-connect teardown returned false");
}
}
this->lt_first_connect_attempt_ = false;
#ifdef USE_WIFI_MANUAL_IP
if (!this->wifi_sta_ip_config_(ap.get_manual_ip())) {
@@ -227,7 +240,10 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
ap.get_channel(), // 0 = auto
ap.has_bssid() ? ap.get_bssid().data() : NULL);
if (status != WL_CONNECTED) {
ESP_LOGW(TAG, "esp_wifi_connect failed: %d", status);
ESP_LOGW(TAG, "WiFi.begin failed: %d", status);
// Without this reset the state machine stays at CONNECTING and each retry
// stalls for the full connect timeout (46 s).
this->sta_state_ = static_cast<uint8_t>(LTWiFiSTAState::ERROR_FAILED);
return false;
}
@@ -455,6 +471,9 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) {
break;
}
case ESPHOME_EVENT_ID_WIFI_STA_CONNECTED: {
// Processed in queue order, so a teardown event still ahead of this
// CONNECTED was already consumed; a leftover flag is stale.
this->lt_teardown_event_pending_ = false;
auto &it = event->data.sta_connected;
char bssid_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
format_mac_addr_upper(it.bssid, bssid_buf);
@@ -482,6 +501,14 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) {
case ESPHOME_EVENT_ID_WIFI_STA_DISCONNECTED: {
auto &it = event->data.sta_disconnected;
// Consume the disconnect our own teardown queued, without spending an
// ignore slot. Ungated on SSID and state: the flag is armed only for this
// attempt's teardown of a live session.
if (this->lt_teardown_event_pending_ && it.reason != WIFI_REASON_NO_AP_FOUND) {
this->lt_teardown_event_pending_ = false;
break;
}
// LibreTiny can send spurious disconnect events with empty ssid/bssid during connection.
// These are typically "Association Leave" events that don't indicate actual failures:
// [W][wifi_lt]: Disconnected ssid='' bssid=00:00:00:00:00:00 reason='Association Leave'
+13 -6
View File
@@ -28,6 +28,7 @@ from esphome.platformio.library import (
collect_filtered_files,
convert_libraries,
ensure_list,
lex_build_flags,
split_list_by_condition,
)
@@ -38,11 +39,13 @@ ZEPHYR_FRAMEWORK = "zephyr"
def _escape(p: PathType) -> str:
# In CMakeLists.txt, backslashes need to be escaped (mirrors the ESP-IDF
# backend's escape_entry). Doubling -- rather than rewriting '\' -> '/' --
# preserves content, so it's safe for arbitrary build flags (e.g. a -D value
# containing a backslash) as well as Windows paths.
return f'"{str(p)}"'.replace("\\", "\\\\")
# In CMakeLists.txt, backslashes and embedded quotes need escaping
# (mirrors the ESP-IDF backend's escape_entry; the lex round-trip makes
# a literal quote in a -D value reachable). Doubling backslashes --
# rather than rewriting '\' -> '/' -- preserves content, so it's safe
# for arbitrary build flags as well as Windows paths.
escaped = str(p).replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
def generate_module_yml(component: ConvertedLibrary) -> str:
@@ -80,7 +83,11 @@ def generate_cmakelists_txt(component: ConvertedLibrary) -> str:
build_include_dir = build.get("includeDir", DEFAULT_BUILD_INCLUDE_DIR)
build_src_filter = ensure_list(build.get("srcFilter", DEFAULT_BUILD_SRC_FILTER))
build_flags = ensure_list(build.get("flags", DEFAULT_BUILD_FLAGS))
# The shared lexer re-glues spaced entries and drops bare/empty
# arguments, same as the espidf emitter
build_flags = lex_build_flags(
build.get("flags", DEFAULT_BUILD_FLAGS), component.name
)
src_files = collect_filtered_files(
read_path / Path(build_src_dir), build_src_filter
+3
View File
@@ -9,6 +9,7 @@ SCALE = "scale"
CONF_ATTRIBUTE_ID = "attribute_id"
KEY_ZIGBEE_EP = "zigbee_ep"
KEY_ZIGBEE_EP_NO_NUM = "zigbee_ep_no_num"
KEY_ZIGBEE_FIRST_EP_CL = "zigbee_first_ep_cl"
DEVICE_ID = {
"RANGE_EXTENDER": cg.RawExpression("EZB_ZHA_RANGE_EXTENDER_DEVICE_ID"),
@@ -18,11 +19,13 @@ DEVICE_ID = {
cluster_id = cg.esphome_ns.enum("ezb_zcl_cluster_id_e")
CLUSTER_ID = {
"BASIC": cluster_id.EZB_ZCL_CLUSTER_ID_BASIC,
"TIME": cluster_id.EZB_ZCL_CLUSTER_ID_TIME,
"BINARY_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_BINARY_INPUT,
"ANALOG_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_ANALOG_INPUT,
}
CLUSTER_ROLE = {
"SERVER": cg.RawExpression("EZB_ZCL_CLUSTER_SERVER"),
"CLIENT": cg.RawExpression("EZB_ZCL_CLUSTER_CLIENT"),
}
attr_type = cg.esphome_ns.enum("ezb_zcl_attr_type_e")
ATTR_TYPE = {
+38 -10
View File
@@ -1,13 +1,15 @@
import esphome.codegen as cg
from esphome.components import time as time_
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.const import CONF_ID, CONF_UPDATE_INTERVAL
from esphome.core import CORE
from esphome.types import ConfigType
from .. import consume_endpoint
from ..const import zigbee_ns
from ..const_esp32 import ROLE
from ..const_zephyr import CONF_ZIGBEE_ID
from ..zigbee_ep_esp32 import add_clusters_to_first_ep, get_first_ep_num
from ..zigbee_zephyr import (
ZigbeeClusterDesc,
ZigbeeComponent,
@@ -22,26 +24,52 @@ DEPENDENCIES = ["zigbee"]
ZigbeeTime = zigbee_ns.class_("ZigbeeTime", time_.RealTimeClock)
def _validate_zigbee_time(config: ConfigType) -> ConfigType:
if CORE.is_nrf52:
return consume_endpoint(config)
if CORE.is_esp32:
cl = [
{
CONF_ID: "TIME",
ROLE: "CLIENT",
},
{
CONF_ID: "TIME",
ROLE: "SERVER",
},
]
add_clusters_to_first_ep(cl)
return config
CONFIG_SCHEMA = cv.All(
time_.TIME_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(ZigbeeTime),
cv.OnlyWith(CONF_ZIGBEE_ID, ["nrf52", "zigbee"]): cv.use_id(
ZigbeeComponent
),
cv.GenerateID(CONF_ZIGBEE_ID): cv.use_id(ZigbeeComponent),
cv.SplitDefault(
CONF_UPDATE_INTERVAL,
nrf52="1s",
esp32="15min",
): cv.update_interval, # override default from TIME_SCHEMA. Remove once nrf52 implementation is aligned.
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(cv.polling_component_schema("1s")),
consume_endpoint,
).extend(cv.COMPONENT_SCHEMA),
_validate_zigbee_time,
)
async def to_code(config: ConfigType) -> None:
CORE.add_job(_add_time, config)
if CORE.using_zephyr:
CORE.add_job(_add_time_zephyr, config)
if CORE.is_esp32:
zb = await cg.get_variable(config[CONF_ZIGBEE_ID])
var = cg.new_Pvariable(config[CONF_ID], zb, get_first_ep_num())
await cg.register_component(var, config)
await time_.register_time(var, config)
async def _add_time(config: ConfigType) -> None:
async def _add_time_zephyr(config: ConfigType) -> None:
slot_index = get_slot_index()
# Create unique names for this sensor's variables based on slot index
@@ -0,0 +1,118 @@
#include "zigbee_time_esp32.h"
#if defined(USE_ZIGBEE) && defined(USE_ESP32) && defined(USE_TIME)
#include "esphome/core/log.h"
#include "esphome/core/application.h"
namespace esphome::zigbee {
static const char *const TAG = "zigbee.time";
// This time standard is the number of
// seconds since 0 hrs 0 mins 0 sec on 1st January 2000 UTC (Universal Coordinated Time).
constexpr time_t EPOCH_2000 = 946684800;
static ZigbeeTime *global_time = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
void ZigbeeTime::setup() {
global_time = this;
if (this->parent_->is_started()) {
this->register_zb_time_();
} else {
this->parent_->add_on_start_callback([this]() { this->register_zb_time_(); });
}
}
void ZigbeeTime::register_zb_time_() {
ezb_zcl_time_interface_t time_interface = {
.get_utc_time = esphome::zigbee::ZigbeeTime::get_utc_time,
.set_utc_time = esphome::zigbee::ZigbeeTime::set_utc_time,
};
ezb_err_t ret;
if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) {
this->set_timeout("zb_time_register", 100, [this]() { this->register_zb_time_(); });
return;
}
ret = ezb_zcl_time_server_interface_register(this->endpoint_, time_interface);
esp_zigbee_lock_release();
if (ret != EZB_ERR_NONE) {
ESP_LOGW(TAG, "Setup failed: %d", ret);
this->mark_failed();
return;
}
this->registered_ = true;
this->parent_->add_on_join_callback([this](bool x) { this->update(); });
if (this->parent_->is_joined()) {
this->update();
}
}
void ZigbeeTime::status_cb(ezb_err_t status) {
if (status == EZB_ERR_NONE) {
ESP_LOGV(TAG, "Time synchronization successful");
} else if (status == EZB_ERR_TIMEOUT) {
ESP_LOGW(TAG, "Time synchronization timed out");
} else {
ESP_LOGW(TAG, "Time synchronization failed with error: %d", status);
}
}
void ZigbeeTime::update() {
if (this->parent_->is_joined() && this->registered_) {
if (esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) {
ESP_LOGV(TAG, "Updating time sync from Zigbee network...");
ezb_zcl_time_server_synchronize_time(this->endpoint_, 10, esphome::zigbee::ZigbeeTime::status_cb,
EZB_ZCL_TIME_SERVER_RANK_MASTER);
esp_zigbee_lock_release();
this->retry_count_ = 0;
} else {
if (this->retry_count_ == 0) {
ESP_LOGW(TAG, "Could not acquire Zigbee lock to synchronize time, will retry maximum 3 times");
}
if (this->retry_count_ < 3) {
this->set_timeout("zb_time_sync", 100, [this]() { this->update(); });
this->retry_count_++;
} else {
ESP_LOGW(TAG, "Could not acquire Zigbee lock to synchronize time");
this->retry_count_ = 0;
}
}
} else {
ESP_LOGD(TAG, "Not connected to Zigbee network, cannot synchronize time");
}
}
uint32_t ZigbeeTime::get_utc_time() {
const time_t now = global_time->timestamp_now();
if (now < EPOCH_2000) {
return 0xFFFFFFFF; // ZCL invalid UTCTime
}
return (uint32_t) (now - EPOCH_2000);
}
void ZigbeeTime::set_utc_time(uint32_t utc) {
// prevent overflow
if (utc <= (std::numeric_limits<uint32_t>::max() - EPOCH_2000)) {
global_time->set_epoch_time(utc + EPOCH_2000);
}
}
void ZigbeeTime::set_epoch_time(uint32_t utc) {
// called from zigbee task, defer to main loop
this->defer([this, utc]() {
ESP_LOGV(TAG, "Setting device time to UTC: %u", static_cast<unsigned>(utc));
this->synchronize_epoch_(utc);
});
App.wake_loop_threadsafe();
}
void ZigbeeTime::dump_config() {
ESP_LOGCONFIG(TAG,
"Zigbee Time\n"
" Endpoint: %u",
this->endpoint_);
RealTimeClock::dump_config();
}
} // namespace esphome::zigbee
#endif

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