Compare commits

..
Author SHA1 Message Date
J. Nick Koston 8f198ffb2e [esp32] Pin static loop task stack to internal DRAM
The static loop task TCB and stack added in #15659 had no explicit
section attribute, so under some sdkconfig combinations (notably
CONFIG_SPIRAM_ALLOW_BSS_SEG_EXTERNAL_MEMORY) the linker can place them
in PSRAM. That is unsafe when CONFIG_SPIRAM_XIP_FROM_PSRAM is also
enabled, because the running task's stack must stay reachable while the
flash cache is disabled (asserted by esp_task_stack_is_sane_cache_disabled).
DRAM_ATTR keeps both objects in internal DRAM regardless of those configs.
2026-05-22 21:31:10 -05:00
Jonathan Swoboda b0dc688c14 [esp32] Demote IDF #warning deprecations from error under ESP-IDF toolchain (#16584) 2026-05-22 20:30:25 -04:00
J. Nick KostonandClyde Stubbs 2b422cbd99 [lvgl] Build widget update action schemas lazily (#16569)
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
2026-05-23 00:20:39 +00:00
dependabot[bot] 9930b3c216 Bump github/codeql-action from 4.35.5 to 4.36.0 (#16579)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-22 19:18:31 -05:00
dependabot[bot] 55f4e5cb75 Bump the docker-actions group across 1 directory with 2 updates (#16578)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-22 19:18:20 -05:00
J. Nick Koston 71550bb3be [lvgl] Memoize and lazily build container_schema (#16567) 2026-05-22 18:39:25 -05:00
J. Nick Koston a58b4edb6a [ci] Gate unconditional CI jobs on a single determine-jobs output instead of a path filter (#16580) 2026-05-22 18:39:06 -05:00
Clyde Stubbs f85fdb475a [homeassistant] Reduce log spam for sensors (#16555) 2026-05-23 08:07:51 +10:00
dependabot[bot] 4a78c8d45a Bump pytest-codspeed from 5.0.2 to 5.0.3 (#16575)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-22 15:55:49 -05:00
Kevin Ahrendt c3bef24389 [i2s_audio] Reset dout GPIO when stopping speaker driver (#16573) 2026-05-22 14:43:50 -05:00
J. Nick Koston 7182b1a8ae [uart] Wake main loop on ESP8266 software serial RX (#16562) 2026-05-22 14:30:43 -05:00
J. Nick Koston 64e32ebe04 [esp8266] Use os_timer-based esp_delay() in delay() (#16563) 2026-05-22 14:30:28 -05:00
Edvard Filistovič 94b10981e1 [libretiny] Fix LN882H IRAM_ATTR injection point in patch_linker.py (#16570) 2026-05-22 14:09:32 -05:00
J. Nick Koston 680c9fc9c0 [dashboard] Fix flaky test_websocket_refresh_command on Windows CI (#16565) 2026-05-22 08:49:03 -05:00
dependabot[bot] 99de741f99 Bump docker/build-push-action from 7.1.0 to 7.2.0 in /.github/actions/build-image (#16545)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-22 08:08:29 -05:00
dependabot[bot] ac530c33b0 Bump actions/stale from 10.2.0 to 10.3.0 (#16544)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-22 08:08:14 -05:00
Kevin Ahrendt 0b5e7ae8fa [sendspin] Bump sendspin-cpp to v0.6.1 (#16553) 2026-05-22 06:43:08 -04:00
Jesse Hills 0b2eb6481f [light] Add light.effect.next / light.effect.previous actions (#16491) 2026-05-22 13:42:50 +12:00
Jesse Hills 1d3eea098e [core] Support YAML frontmatter for arbitrary user metadata (#16552) 2026-05-22 13:00:22 +12:00
dependabot[bot]andJ. Nick Koston 4ff8eb4b15 Bump ruff from 0.15.13 to 0.15.14 (#16543)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-21 22:08:56 +00:00
J. Nick Koston aea1e4d136 [core] Refresh compiled config cache after upload/logs fallback (#16548) 2026-05-21 17:05:17 -05:00
Jonathan Swoboda 38b8b41ccc [sx126x] Assert NSS before wait_busy so commands wake the chip from sleep (#16546) 2026-05-21 18:03:07 -04:00
J. Nick Koston 96eced0378 [api] Break api_connection/api_server include cycle to drop custom unique_ptr deleter (#16542) 2026-05-21 15:42:57 -05:00
Jonathan Swoboda 1ea95264bd [tuya] Restore null guard on status_pin lost in #16353 (#16539) 2026-05-21 18:08:09 +00:00
Jonathan Swoboda d2bda0a402 [esp32] Defer esp_panic_handler wrap so arduino-esp32 IDF component skips it (#16538) 2026-05-21 14:03:55 -04:00
Jonathan Swoboda 56fd77e4c8 [espidf] Honor the dict shorthand for library.json dependencies (#16537) 2026-05-21 13:01:54 -05:00
Jonathan Swoboda 3719ea740a [espidf] Default to remote HEAD when cg.add_library URL has no #ref (#16535) 2026-05-21 13:01:19 -05:00
Jonathan Swoboda 750ae56778 [espidf] Backport ninja linux-arm64 entry into tools.json on aarch64 hosts (#16527) 2026-05-21 12:05:27 -04:00
Kevin Ahrendt 01494f7431 [audio] Bump esp-audio-libs to v3.1.0 (#16519) 2026-05-21 11:57:32 -04:00
J. Nick Koston 233a60f106 [ci] Pin uv version in setup-uv to fix Windows manifest fetch flake (#16534) 2026-05-21 10:53:34 -05:00
Jonathan Swoboda e0076cb1a8 [core] Persist & restore CORE.toolchain through StorageJSON (#16531) 2026-05-21 10:37:46 -05:00
Jonathan Swoboda b619e3e8c7 [espidf] Write version.txt after extract so bootloader shows the real version (#16532) 2026-05-21 10:37:10 -05:00
Jonathan Swoboda f2bfe5cd17 [espidf] Fix tarfile extract crashing on Python 3.11 with None mode (#16530) 2026-05-21 10:36:27 -05:00
Jonathan Swoboda 90715373f2 [espidf] Filter noisy 'git rev-parse' errors when .git is stripped (#16521) 2026-05-21 10:35:51 -05:00
Jonathan Swoboda 52e7d3ccfb [esp32] Use new sdkconfig key names that replaced deprecated ones (#16522) 2026-05-21 10:35:25 -05:00
dependabot[bot] a70e358cea Bump zeroconf from 0.149.13 to 0.149.16 (#16533)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-21 09:55:28 -05:00
dependabot[bot] 43a1c2067e Bump zeroconf from 0.149.12 to 0.149.13 (#16520)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-21 08:28:09 -05:00
Jesse Hills 11760307f7 Merge branch 'release' into dev 2026-05-21 13:49:17 +12:00
Jesse Hills 15c546b809 Merge pull request #16523 from esphome/bump-2026.5.0
2026.5.0
2026-05-21 13:48:28 +12:00
Jesse Hills 104c8bed41 Bump version to 2026.5.0 2026-05-21 11:16:58 +12:00
Jesse Hills 49bfa12eb7 Merge branch 'beta' into dev 2026-05-21 10:14:14 +12:00
Jesse Hills ca859de212 Merge pull request #16518 from esphome/bump-2026.5.0b4
2026.5.0b4
2026-05-21 10:13:39 +12:00
Jesse Hills de783e72d5 Bump version to 2026.5.0b4 2026-05-21 09:10:52 +12:00
Jonathan Swoboda cd7e2d79c4 [esp32] Decouple esp-idf toolchain version check from PIO, honor framework source: override (#16516) 2026-05-21 09:10:52 +12:00
Jonathan Swoboda ecf823b871 [espidf] Drop version field from generated idf_component.yml (#16511) 2026-05-21 09:10:52 +12:00
dependabot[bot] 9fdad68138 Bump aioesphomeapi from 45.0.3 to 45.0.4 (#16513)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-21 09:10:51 +12:00
dependabot[bot] b79a306d02 Bump zeroconf from 0.149.7 to 0.149.12 (#16510)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-21 09:10:51 +12:00
Jonathan Swoboda 870f628637 [esp32] Decouple esp-idf toolchain version check from PIO, honor framework source: override (#16516) 2026-05-20 20:40:59 +00:00
Jonathan Swoboda 52c9a2d07b [espidf] Drop version field from generated idf_component.yml (#16511) 2026-05-20 14:31:58 -04:00
Jonathan Swoboda 60afad442c [esp32] Fix sdkconfig int values silently clamped to default (#16515) 2026-05-20 13:36:18 -04:00
dependabot[bot] fbe212944b Bump aioesphomeapi from 45.0.3 to 45.0.4 (#16513)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 10:51:53 -05:00
dependabot[bot] 8927ade789 Bump zeroconf from 0.149.7 to 0.149.12 (#16510)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:40:20 +00:00
Jesse Hills 63fe977adb Merge branch 'beta' into dev 2026-05-20 14:34:33 +12:00
Jesse Hills 94badfcb19 Merge pull request #16507 from esphome/bump-2026.5.0b3
2026.5.0b3
2026-05-20 14:33:57 +12:00
Jesse Hills 19c4da2aa5 Bump version to 2026.5.0b3 2026-05-20 12:53:26 +12:00
Kevin Ahrendt e4c8d1f430 [sendspin] Bump sendspin to v0.6.0 (#16496) 2026-05-20 12:53:26 +12:00
Kevin AhrendtandKeith Burzinski 302938f875 [i2s_audio] Optimize SPDIF encoder and suport higher bit depth audio (#16504)
Co-authored-by: Keith Burzinski <kbx81x@gmail.com>
2026-05-20 12:53:26 +12:00
Jonathan Swoboda 65e1e210de [espidf] Print RAM summary on ESP32-S3 / unified-DIRAM variants (#16494) 2026-05-20 12:53:26 +12:00
luar123 43cc9fc879 [zigbee] don't allow zigbee + thread or access point (#16499) 2026-05-20 12:53:25 +12:00
Kevin Ahrendt 41ad2ba763 [i2s_audio] Compute ring buffer size with SPDIF sample count (#16400) 2026-05-20 12:53:25 +12:00
Brandon Harvey 25739091da [sen6x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16465) 2026-05-20 12:53:25 +12:00
Brandon Harvey bbf5fe8450 [sgp4x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16464) 2026-05-20 12:53:25 +12:00
Brandon Harvey e9ef58d99d [sen5x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16463) 2026-05-20 12:53:25 +12:00
dependabot[bot] e1793a1eff Bump zeroconf from 0.149.3 to 0.149.7 (#16492)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 12:53:25 +12:00
Jesse Hills 9bb70d568d [ci] Move ha-addon and schema release triggers to version-notifier (#16490) 2026-05-20 12:53:25 +12:00
Kevin Ahrendt 0912122634 [sendspin] Bump sendspin to v0.6.0 (#16496) 2026-05-19 15:16:00 -04:00
Kevin AhrendtandKeith Burzinski 9924d998f1 [i2s_audio] Optimize SPDIF encoder and suport higher bit depth audio (#16504)
Co-authored-by: Keith Burzinski <kbx81x@gmail.com>
2026-05-19 13:37:41 -05:00
dependabot[bot] e979d461f0 Bump codecov/codecov-action from 6.0.0 to 6.0.1 (#16500)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-19 12:15:50 -05:00
J. Nick Koston 863af482ec [esp32_ble_server] Honor client offset and MTU in long reads (#16458) 2026-05-19 12:14:15 -05:00
J. Nick Koston 80ed541032 [core] Add progmem_memcpy HAL helper (#16470) 2026-05-19 12:13:58 -05:00
Jonathan Swoboda 1d0ddfac5d [espidf] Print RAM summary on ESP32-S3 / unified-DIRAM variants (#16494) 2026-05-19 12:57:18 -04:00
luar123 c0e71fc713 [zigbee] don't allow zigbee + thread or access point (#16499) 2026-05-19 12:53:36 -04:00
Kevin Ahrendt 7ecfe4b5c9 [i2s_audio] Compute ring buffer size with SPDIF sample count (#16400) 2026-05-18 21:53:19 -05:00
Brandon Harvey 36fc36071d [sen6x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16465) 2026-05-19 11:20:08 +12:00
Brandon Harvey cb581271ed [sgp4x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16464) 2026-05-19 11:19:51 +12:00
Brandon Harvey b0af4a9f0d [sen5x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16463) 2026-05-19 11:19:48 +12:00
dependabot[bot] edb59476b1 Bump zeroconf from 0.149.3 to 0.149.7 (#16492)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 23:29:03 -07:00
Jesse Hills 9c696f5de1 [ci] Move ha-addon and schema release triggers to version-notifier (#16490) 2026-05-18 15:56:44 +12:00
Jesse Hills 6804965bd8 Merge branch 'beta' into dev 2026-05-18 15:31:01 +12:00
Jesse Hills 213df0412d Merge pull request #16488 from esphome/bump-2026.5.0b2
2026.5.0b2
2026-05-18 15:28:53 +12:00
Jesse Hills cdf74c180e Bump version to 2026.5.0b2 2026-05-18 11:11:54 +12:00
Jonathan Swoboda df31c72e4e [espidf] Switch direct framework downloader to esphome-libs/esp-idf tarballs (#16484) 2026-05-18 11:11:54 +12:00
dependabot[bot] 4f188bf9bb Bump zeroconf from 0.148.0 to 0.149.3 (#16480)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 11:11:54 +12:00
dependabot[bot] 20f92ad5e9 Bump aioesphomeapi from 45.0.2 to 45.0.3 (#16479)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 11:11:54 +12:00
J. Nick Koston f301e90fd9 [ci] Use larger app partition for esp32-s3-idf component test grouping (#16430) 2026-05-18 11:11:54 +12:00
dependabot[bot] 2dbaaf1efd Bump aioesphomeapi from 45.0.1 to 45.0.2 (#16469)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 11:11:54 +12:00
Clyde Stubbs da237b5070 [lvgl] Fix image define (#16468) 2026-05-18 11:11:54 +12:00
Clyde Stubbs 6a8f24b951 [ft5x06] Fix setting calibration values (#16446) 2026-05-18 11:11:54 +12:00
dependabot[bot] 26907f17f5 Bump aioesphomeapi from 45.0.0 to 45.0.1 (#16467)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 11:11:54 +12:00
Jonathan Swoboda c6a74222f1 [esp32_hosted][fingerprint_grow] Fix two remaining ESP32 toolchain warnings (#16442) 2026-05-18 11:11:54 +12:00
J. Nick Koston 5ec0879a10 [core] Fix KeyError: 'esp32' on upload when validated-config cache is used (#16457) 2026-05-18 11:11:54 +12:00
J. Nick Koston 50495c7085 [wifi] Refuse to compile when wifi_ssid is the device-builder placeholder (#16444) 2026-05-18 11:11:54 +12:00
Kevin Ahrendt 25dbef83de [sound_level] Use RingBufferAudioSource (#16436) 2026-05-18 11:11:54 +12:00
Kevin Ahrendt 4f895425ca [audio] Bump microMP3 to v0.2.1 (#16429) 2026-05-18 11:11:54 +12:00
Jonathan Swoboda c037058c19 [esp32_hosted] Bump esp_hosted to 2.12.7 (#16440) 2026-05-18 11:11:54 +12:00
Jonathan Swoboda ecac6b64ec [espidf] Gate esp_idf_size --ng on IDF version (#16441) 2026-05-18 11:11:54 +12:00
Jonathan Swoboda 3831aa809f [multiple] Fix -Wformat= mismatches in component .cpp sources (#16433) 2026-05-18 11:11:54 +12:00
Jonathan SwobodaandCopilot Autofix powered by AI da8286f554 [docker] Install libusb-1.0 so ESP-IDF tools can validate openocd (#16424)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-18 11:11:54 +12:00
Jonathan Swoboda d5c6efb2fe [tests] Fix -Wformat= mismatches in test YAML lambdas/logger.log (#16435) 2026-05-18 11:11:54 +12:00
Jonathan Swoboda dd1818661c [esp32] Sweep ESP-IDF toolchain warnings + bump deprecated mark_failed (#16432) 2026-05-18 11:11:53 +12:00
Keith BurzinskiandClaude Opus 4.7 fb659f9ac4 [tinyusb] Reject logger.hardware_uart: USB_CDC (#16417)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 11:11:53 +12:00
Keith BurzinskiandClaude Opus 4.7 ab273a1f8f [tinyusb] Reject tinyusb: configured without a USB class companion (#16413)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 11:11:53 +12:00
Jonathan Swoboda 84b5931299 [espidf] Trim has_outdated_files watch list; embed IDF version in sdkconfig (#16416) 2026-05-18 11:11:53 +12:00
Jonathan Swoboda c863d58999 [espidf] Stop perpetual reconfigure loop on native ESP-IDF builds (#16415) 2026-05-18 11:11:53 +12:00
Jesse Hills 42ad2a6272 [espidf] Accept list input in _str_to_lst_of_str helper (#16485) 2026-05-18 10:49:03 +12:00
Jonathan Swoboda 6690725860 [espidf] Switch direct framework downloader to esphome-libs/esp-idf tarballs (#16484) 2026-05-18 07:29:38 +12:00
dependabot[bot] 155232875a Bump zeroconf from 0.148.0 to 0.149.3 (#16480)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-16 19:08:19 -07:00
dependabot[bot] 01c0d3163e Bump aioesphomeapi from 45.0.2 to 45.0.3 (#16479)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-16 18:41:09 -07:00
J. Nick Koston 7c5d5f75dc [ci] Use larger app partition for esp32-s3-idf component test grouping (#16430) 2026-05-15 22:16:52 -07:00
dependabot[bot] fb0bfea1c8 Bump aioesphomeapi from 45.0.1 to 45.0.2 (#16469)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-15 22:06:33 -07:00
J. Nick Koston 48d17571c8 [tests] Mock determine_cpp_unit_tests in clang_tidy_mode tests (#16456) 2026-05-15 15:50:58 -07:00
Clyde Stubbs df100681e0 [lvgl] Fix image define (#16468) 2026-05-15 22:36:53 +00:00
Clyde Stubbs 1a287bf785 [ft5x06] Fix setting calibration values (#16446) 2026-05-16 10:30:04 +12:00
dependabot[bot] ff34e1061b Bump resvg-py from 0.3.1 to 0.3.2 (#16466)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-15 14:43:08 -07:00
dependabot[bot] b78b78cbbb Bump aioesphomeapi from 45.0.0 to 45.0.1 (#16467)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-15 14:42:51 -07:00
dependabot[bot] 4c090c6b85 Bump github/codeql-action from 4.35.4 to 4.35.5 (#16461)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-15 12:37:56 -07:00
dependabot[bot] ec6669fa67 Bump requests from 2.34.1 to 2.34.2 (#16460)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-15 12:37:41 -07:00
dependabot[bot] 59f8c1019f Bump pytest-codspeed from 5.0.1 to 5.0.2 (#16459)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-15 12:37:28 -07:00
david-collettDavepre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
fb70095ba1 [esp32_ble_server] Fix incorrect BLECharacteristic read truncation (#16420) (#16422)
Co-authored-by: Dave <dave@morty>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-05-15 10:47:26 -07:00
Jonathan Swoboda 65d6bb18ed [esp32_hosted][fingerprint_grow] Fix two remaining ESP32 toolchain warnings (#16442) 2026-05-15 17:32:51 +00:00
J. Nick Koston 47eb2adbf2 [core] Fix KeyError: 'esp32' on upload when validated-config cache is used (#16457) 2026-05-15 10:29:15 -07:00
J. Nick Koston 35631be260 [writer] Mark storage_should_clean as public API for device-builder (#16443) 2026-05-15 10:20:31 -07:00
J. Nick Koston 96106d25bc [wifi] Refuse to compile when wifi_ssid is the device-builder placeholder (#16444) 2026-05-15 10:20:15 -07:00
J. Nick Koston 1674ed9744 [ci] Use uv for pip installs across CI workflows (#16451) 2026-05-15 10:18:27 -07:00
J. Nick Koston 46be0f4f62 [ci] Log top 30 pytest durations (#16455) 2026-05-15 10:18:07 -07:00
J. Nick Koston ec1826a6ed [yaml_util] Promote include-discovery helper, share it with bundle (#16447) 2026-05-15 10:17:50 -07:00
Simone ChemelliandJ. Nick Koston 8b3bc47547 [uptime] Update device_class for Uptime sensor (#16434)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-05-15 17:16:01 +00:00
J. Nick Koston 4381a8baaa [ci] pr-title-check: skip all bot authors, not just dependabot (#16453) 2026-05-15 09:59:35 -07:00
esphome[bot]andesphomebot 4189979391 Synchronise Device Classes from Home Assistant (#16452)
Co-authored-by: esphomebot <esphome@openhomefoundation.org>
2026-05-15 09:57:49 -07:00
J. Nick Koston 1b1e21d470 [ci] sync-device-classes: drop branch-switch hack, skip no-commit-to-branch instead (#16450) 2026-05-15 09:54:01 -07:00
J. Nick Koston 5b6c54c961 [ci] sync-device-classes: use uv for installs and skip pylint (#16449) 2026-05-15 09:45:11 -07:00
92 changed files with 2982 additions and 805 deletions
+2 -2
View File
@@ -47,7 +47,7 @@ runs:
- name: Build and push to ghcr by digest - name: Build and push to ghcr by digest
id: build-ghcr id: build-ghcr
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
env: env:
DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false DOCKER_BUILD_RECORD_UPLOAD: false
@@ -73,7 +73,7 @@ runs:
- name: Build and push to dockerhub by digest - name: Build and push to dockerhub by digest
id: build-dockerhub id: build-dockerhub
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
env: env:
DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false DOCKER_BUILD_RECORD_UPLOAD: false
+16 -4
View File
@@ -27,6 +27,18 @@ runs:
path: venv path: venv
# yamllint disable-line rule:line-length # yamllint disable-line rule:line-length
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ inputs.cache-key }} key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ inputs.cache-key }}
- name: Set up uv
# Only needed on cache miss to populate the venv. ``uv pip install``
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
# downstream jobs rely on is preserved.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Create Python virtual environment - name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os != 'Windows' if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os != 'Windows'
shell: bash shell: bash
@@ -34,8 +46,8 @@ runs:
python -m venv venv python -m venv venv
source venv/bin/activate source venv/bin/activate
python --version python --version
pip install -r requirements.txt -r requirements_test.txt uv pip install -r requirements.txt -r requirements_test.txt
pip install -e . uv pip install -e .
- name: Create Python virtual environment - name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os == 'Windows' if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os == 'Windows'
shell: bash shell: bash
@@ -43,5 +55,5 @@ runs:
python -m venv venv python -m venv venv
source ./venv/Scripts/activate source ./venv/Scripts/activate
python --version python --version
pip install -r requirements.txt -r requirements_test.txt uv pip install -r requirements.txt -r requirements_test.txt
pip install -e . uv pip install -e .
+11 -1
View File
@@ -26,6 +26,16 @@ jobs:
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: "3.11" python-version: "3.11"
- name: Set up uv
# ``--system`` (below) installs into the setup-python interpreter;
# no venv is created or restored by this workflow.
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Install apt dependencies - name: Install apt dependencies
run: | run: |
@@ -34,7 +44,7 @@ jobs:
sudo apt install -y protobuf-compiler sudo apt install -y protobuf-compiler
protoc --version protoc --version
- name: Install python dependencies - name: Install python dependencies
run: pip install aioesphomeapi -c requirements.txt -r requirements_dev.txt run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt
- name: Generate files - name: Generate files
run: script/api_protobuf/api_protobuf.py run: script/api_protobuf/api_protobuf.py
- name: Check for changes - name: Check for changes
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
with: with:
python-version: "3.11" python-version: "3.11"
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Set TAG - name: Set TAG
run: | run: |
+43 -18
View File
@@ -6,14 +6,6 @@ on:
branches: [dev, beta, release] branches: [dev, beta, release]
pull_request: pull_request:
paths:
- "**"
- "!.github/workflows/*.yml"
- "!.github/actions/build-image/*"
- ".github/workflows/ci.yml"
- "!.yamllint"
- "!.github/dependabot.yml"
- "!docker/**"
merge_group: merge_group:
permissions: permissions:
@@ -52,14 +44,26 @@ jobs:
path: venv path: venv
# yamllint disable-line rule:line-length # yamllint disable-line rule:line-length
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ steps.cache-key.outputs.key }} key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ steps.cache-key.outputs.key }}
- name: Set up uv
# Only needed on cache miss to populate the venv. ``uv pip install``
# detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
# that ``. venv/bin/activate`` see an identical layout.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Create Python virtual environment - name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
run: | run: |
python -m venv venv python -m venv venv
. venv/bin/activate . venv/bin/activate
python --version python --version
pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit
pip install -e . uv pip install -e .
pylint: pylint:
name: Check pylint name: Check pylint
@@ -89,6 +93,8 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
needs: needs:
- common - common
- determine-jobs
if: needs.determine-jobs.outputs.core-ci == 'true'
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -167,6 +173,10 @@ jobs:
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with: with:
enable-cache: true enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Install device-builder + esphome from PR - name: Install device-builder + esphome from PR
# Install device-builder with its esphome + test extras # Install device-builder with its esphome + test extras
# first so its pinned versions of pytest/etc. land, then # first so its pinned versions of pytest/etc. land, then
@@ -181,7 +191,7 @@ jobs:
# own CI). No ``--cov`` here -- this is purely a downstream # own CI). No ``--cov`` here -- this is purely a downstream
# smoke check against this PR's esphome code. # smoke check against this PR's esphome code.
working-directory: device-builder working-directory: device-builder
run: pytest -q -n auto --maxfail=5 --durations=10 --no-cov --ignore=tests/benchmarks run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks
pytest: pytest:
name: Run pytest name: Run pytest
@@ -207,6 +217,8 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
needs: needs:
- common - common
- determine-jobs
if: needs.determine-jobs.outputs.core-ci == 'true'
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -222,14 +234,14 @@ jobs:
if: matrix.os == 'windows-latest' if: matrix.os == 'windows-latest'
run: | run: |
. ./venv/Scripts/activate.ps1 . ./venv/Scripts/activate.ps1
pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Run pytest - name: Run pytest
if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest' if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest'
run: | run: |
. venv/bin/activate . venv/bin/activate
pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
- name: Save Python virtual environment cache - name: Save Python virtual environment cache
@@ -245,6 +257,7 @@ jobs:
needs: needs:
- common - common
outputs: outputs:
core-ci: ${{ steps.determine.outputs.core-ci }}
integration-tests: ${{ steps.determine.outputs.integration-tests }} integration-tests: ${{ steps.determine.outputs.integration-tests }}
integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }} integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }}
clang-tidy: ${{ steps.determine.outputs.clang-tidy }} clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
@@ -298,6 +311,7 @@ jobs:
echo "$output" | jq echo "$output" | jq
# Extract individual fields # Extract individual fields
echo "core-ci=$(echo "$output" | jq -r '.core_ci')" >> $GITHUB_OUTPUT
echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT
echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT
echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT
@@ -351,14 +365,24 @@ jobs:
with: with:
path: venv path: venv
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
- name: Set up uv
# Only needed on cache miss to populate the venv.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Create Python virtual environment - name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
run: | run: |
python -m venv venv python -m venv venv
. venv/bin/activate . venv/bin/activate
python --version python --version
pip install -r requirements.txt -r requirements_test.txt uv pip install -r requirements.txt -r requirements_test.txt
pip install -e . uv pip install -e .
- name: Register matcher - name: Register matcher
run: echo "::add-matcher::.github/workflows/matchers/pytest.json" run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
- name: Run integration tests - name: Run integration tests
@@ -370,7 +394,7 @@ jobs:
. venv/bin/activate . venv/bin/activate
mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]') mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]')
echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests" echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests"
pytest -vv --no-cov --tb=native -n auto "${test_files[@]}" pytest -vv --no-cov --tb=native --durations=30 -n auto "${test_files[@]}"
cpp-unit-tests: cpp-unit-tests:
name: Run C++ unit tests name: Run C++ unit tests
@@ -943,7 +967,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
- common - common
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') - 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'
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }} build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1 exit 1
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with: with:
category: "/language:${{matrix.language}}" category: "/language:${{matrix.language}}"
+4 -3
View File
@@ -29,10 +29,11 @@ jobs:
} = require('./.github/scripts/detect-tags.js'); } = require('./.github/scripts/detect-tags.js');
const title = context.payload.pull_request.title; const title = context.payload.pull_request.title;
const author = context.payload.pull_request.user.login; const user = context.payload.pull_request.user;
// Skip bot PRs (e.g. dependabot) - they have their own title format // Skip bot PRs (e.g. dependabot, esphome[bot] device-class sync) -
if (author === 'dependabot[bot]') { // they have their own title formats.
if (user.type === 'Bot') {
return; return;
} }
+7 -75
View File
@@ -99,15 +99,15 @@ jobs:
python-version: "3.11" python-version: "3.11"
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to docker hub - name: Log in to docker hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
username: ${{ secrets.DOCKER_USER }} username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry - name: Log in to the GitHub container registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -178,17 +178,17 @@ jobs:
merge-multiple: true merge-multiple: true
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to docker hub - name: Log in to docker hub
if: matrix.registry == 'dockerhub' if: matrix.registry == 'dockerhub'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
username: ${{ secrets.DOCKER_USER }} username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry - name: Log in to the GitHub container registry
if: matrix.registry == 'ghcr' if: matrix.registry == 'ghcr'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -212,74 +212,6 @@ jobs:
docker buildx imagetools create $(jq -Rcnr 'inputs | . / "," | map("-t " + .) | join(" ")' <<< "${{ steps.tags.outputs.tags}}") \ docker buildx imagetools create $(jq -Rcnr 'inputs | . / "," | map("-t " + .) | join(" ")' <<< "${{ steps.tags.outputs.tags}}") \
$(printf '${{ steps.tags.outputs.image }}@sha256:%s ' *) $(printf '${{ steps.tags.outputs.image }}@sha256:%s ' *)
deploy-ha-addon-repo:
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
runs-on: ubuntu-latest
needs:
- init
- deploy-manifest
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
owner: esphome
repositories: home-assistant-addon
permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token)
- name: Trigger Workflow
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
let description = "ESPHome";
if (context.eventName == "release") {
description = ${{ toJSON(github.event.release.body) }};
}
github.rest.actions.createWorkflowDispatch({
owner: "esphome",
repo: "home-assistant-addon",
workflow_id: "bump-version.yml",
ref: "main",
inputs: {
version: "${{ needs.init.outputs.tag }}",
content: description
}
})
deploy-esphome-schema:
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
runs-on: ubuntu-latest
needs: [init]
environment: ${{ needs.init.outputs.deploy_env }}
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
owner: esphome
repositories: esphome-schema
permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token)
- name: Trigger Workflow
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
github.rest.actions.createWorkflowDispatch({
owner: "esphome",
repo: "esphome-schema",
workflow_id: "generate-schemas.yml",
ref: "main",
inputs: {
version: "${{ needs.init.outputs.tag }}",
}
})
version-notifier: version-notifier:
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -302,7 +234,7 @@ jobs:
with: with:
github-token: ${{ steps.generate-token.outputs.token }} github-token: ${{ steps.generate-token.outputs.token }}
script: | script: |
github.rest.actions.createWorkflowDispatch({ await github.rest.actions.createWorkflowDispatch({
owner: "esphome", owner: "esphome",
repo: "version-notifier", repo: "version-notifier",
workflow_id: "notify.yml", workflow_id: "notify.yml",
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Stale - name: Stale
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with: with:
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
remove-stale-when-updated: true remove-stale-when-updated: true
@@ -50,6 +50,10 @@ jobs:
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with: with:
enable-cache: true enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Install Home Assistant - name: Install Home Assistant
run: | run: |
+1 -1
View File
@@ -11,7 +11,7 @@ ci:
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: v0.15.12 rev: v0.15.14
hooks: hooks:
# Run the linter. # Run the linter.
- id: ruff - id: ruff
+22 -1
View File
@@ -50,6 +50,7 @@ from esphome.const import (
CONF_TOPIC, CONF_TOPIC,
CONF_USERNAME, CONF_USERNAME,
CONF_WEB_SERVER, CONF_WEB_SERVER,
CONF_WIFI,
ENV_NOGITIGNORE, ENV_NOGITIGNORE,
KEY_CORE, KEY_CORE,
KEY_TARGET_PLATFORM, KEY_TARGET_PLATFORM,
@@ -733,6 +734,13 @@ def write_cpp_file() -> int:
def compile_program(args: ArgsProtocol, config: ConfigType) -> int: def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
# Keep this gate here, NOT in config validation: device-builder needs
# `esphome config` to keep succeeding with placeholders so onboarding can run.
if CONF_WIFI in config:
from esphome.components.wifi import check_placeholder_credentials
check_placeholder_credentials(config)
# NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py # NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py
# If you change this format, update the regex in that script as well # If you change this format, update the regex in that script as well
_LOGGER.info("Compiling app... Build path: %s", CORE.build_path) _LOGGER.info("Compiling app... Build path: %s", CORE.build_path)
@@ -2441,7 +2449,10 @@ def run_esphome(argv):
# Skipped when -s overrides are passed, since the cache was written # Skipped when -s overrides are passed, since the cache was written
# against the previous substitution set. # against the previous substitution set.
config: ConfigType | None = None config: ConfigType | None = None
if args.command in ("upload", "logs") and not command_line_substitutions: cache_eligible = (
args.command in ("upload", "logs") and not command_line_substitutions
)
if cache_eligible:
from esphome.compiled_config import load_compiled_config from esphome.compiled_config import load_compiled_config
config = load_compiled_config(conf_path) config = load_compiled_config(conf_path)
@@ -2456,6 +2467,16 @@ def run_esphome(argv):
command_line_substitutions, command_line_substitutions,
skip_external_update=skip_external, skip_external_update=skip_external,
) )
# Refresh the cache so the next upload/logs hits the fast path
# instead of re-running read_config. Skip when the storage
# sidecar is absent (no compile has run): the cache would
# never be loaded back, so writing secrets to disk is wasted.
if cache_eligible and config is not None:
from esphome.compiled_config import save_compiled_config
from esphome.storage_json import ext_storage_path
if ext_storage_path(conf_path.name).exists():
save_compiled_config(config)
if config is None: if config is None:
return 2 return 2
CORE.config = config CORE.config = config
+12 -85
View File
@@ -260,42 +260,20 @@ class ConfigBundleCreator:
def _discover_yaml_includes(self) -> None: def _discover_yaml_includes(self) -> None:
"""Discover YAML files loaded during config parsing. """Discover YAML files loaded during config parsing.
Deliberately uses a fresh re-parse and force-loads every deferred Delegates to :func:`yaml_util.discover_user_yaml_files`, which does a
``IncludeFile`` to include *all* potentially-reachable includes, fresh re-parse and force-loads every deferred ``IncludeFile`` so that
even branches not selected by the local substitutions. Bundles are *all* potentially-reachable includes are captured (even branches not
meant to be compiled on another system where command-line selected by local substitutions). Bundles are meant to be compiled on
substitution overrides may choose a different branch — e.g. another system where command-line substitution overrides may choose a
``!include network/${eth_model}/config.yaml`` must ship every different branch — e.g. ``!include network/${eth_model}/config.yaml``
candidate so the remote build can pick any one. must ship every candidate so the remote build can pick any one.
Entries with unresolved substitution variables in the filename
path are skipped with a warning (they cannot be resolved without
the substitution pass).
Secrets files are tracked separately so we can filter them to
only include the keys this config actually references.
""" """
# Must be a fresh parse: IncludeFile.load() caches its result in discovered = yaml_util.discover_user_yaml_files(self._config_path)
# _content, and we discover files by listening for loader calls. On self._secrets_paths.update(discovered.secrets)
# an already-parsed tree the cache is populated, .load() returns config_resolved = self._config_path.resolve()
# without calling the loader, the listener never fires, and the for fpath in discovered.files:
# referenced files would be silently dropped from the bundle. if fpath == config_resolved:
with yaml_util.track_yaml_loads() as loaded_files:
try:
data = yaml_util.load_yaml(self._config_path)
except EsphomeError:
_LOGGER.debug(
"Bundle: re-loading YAML for include discovery failed, "
"proceeding with partial file list"
)
else:
_force_load_include_files(data)
for fpath in loaded_files:
if fpath == self._config_path.resolve():
continue # Already added as config continue # Already added as config
if fpath.name in const.SECRETS_FILES:
self._secrets_paths.add(fpath)
self._add_file(fpath) self._add_file(fpath)
def _discover_component_files(self) -> None: def _discover_component_files(self) -> None:
@@ -625,57 +603,6 @@ def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None:
tar.addfile(info, io.BytesIO(data)) tar.addfile(info, io.BytesIO(data))
def _force_load_include_files(obj: Any, _seen: set[int] | None = None) -> None:
"""Recursively resolve any ``IncludeFile`` instances in a YAML tree.
Nested ``!include`` returns a deferred ``IncludeFile`` that is only
resolved during the substitution pass. During bundle discovery we need
the referenced files to actually load so the ``track_yaml_loads``
listener fires for them.
``IncludeFile`` instances with unresolved substitution variables in the
filename cannot be loaded — we skip and warn about those.
"""
if _seen is None:
_seen = set()
if isinstance(obj, yaml_util.IncludeFile):
if id(obj) in _seen:
return
_seen.add(id(obj))
if obj.has_unresolved_expressions():
_LOGGER.warning(
"Bundle: cannot resolve !include %s (referenced from %s) "
"with substitutions in path",
obj.file,
obj.parent_file,
)
return
try:
loaded = obj.load()
except EsphomeError as err:
_LOGGER.warning(
"Bundle: failed to load !include %s (referenced from %s): %s",
obj.file,
obj.parent_file,
err,
)
return
_force_load_include_files(loaded, _seen)
elif isinstance(obj, dict):
if id(obj) in _seen:
return
_seen.add(id(obj))
for value in obj.values():
_force_load_include_files(value, _seen)
elif isinstance(obj, (list, tuple)):
if id(obj) in _seen:
return
_seen.add(id(obj))
for item in obj:
_force_load_include_files(item, _seen)
def _resolve_include_path(include_path: Any) -> Path | None: def _resolve_include_path(include_path: Any) -> Path | None:
"""Resolve an include path to absolute, skipping system includes.""" """Resolve an include path to absolute, skipping system includes."""
if isinstance(include_path, str) and include_path.startswith("<"): if isinstance(include_path, str) and include_path.startswith("<"):
@@ -1,5 +1,6 @@
#include "api_connection.h" #include "api_connection.h"
#ifdef USE_API #ifdef USE_API
#include "api_connection_buffer.h" // for encode_to_buffer / get_batch_delay_ms_ inlines
#ifdef USE_API_NOISE #ifdef USE_API_NOISE
#include "api_frame_helper_noise.h" #include "api_frame_helper_noise.h"
#endif #endif
+11 -40
View File
@@ -11,7 +11,8 @@
#endif #endif
#include "api_pb2.h" #include "api_pb2.h"
#include "api_pb2_service.h" #include "api_pb2_service.h"
#include "api_server.h" #include "list_entities.h"
#include "subscribe_state.h"
#include "esphome/core/application.h" #include "esphome/core/application.h"
#include "esphome/core/component.h" #include "esphome/core/component.h"
#ifdef USE_ESP32_CRASH_HANDLER #ifdef USE_ESP32_CRASH_HANDLER
@@ -36,6 +37,9 @@ class ComponentIterator;
namespace esphome::api { namespace esphome::api {
// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h.
class APIServer;
// Keepalive timeout in milliseconds // Keepalive timeout in milliseconds
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000; static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
// Maximum number of entities to process in a single batch during initial state/info sending // Maximum number of entities to process in a single batch during initial state/info sending
@@ -411,44 +415,10 @@ class APIConnection final : public APIServerConnectionBase {
// Non-template buffer management for send_message // 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, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg);
// Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes. // Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites.
// ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites. // Defined in api_connection_buffer.h (needs APIServer complete).
static inline uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, static uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
const void *msg, APIConnection *conn, const void *msg, APIConnection *conn, uint32_t remaining_size);
uint32_t remaining_size) {
#ifdef HAS_PROTO_MESSAGE_DUMP
if (conn->flags_.log_only_mode) {
auto *proto_msg = static_cast<const ProtoMessage *>(msg);
DumpBuffer dump_buf;
conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
return 1;
}
#endif
const uint8_t footer_size = conn->helper_->frame_footer_size();
// First message uses max padding (already in buffer), subsequent use exact header size
size_t to_add;
if (conn->flags_.batch_first_message) {
conn->flags_.batch_first_message = false;
conn->batch_header_size_ = conn->helper_->frame_header_padding();
to_add = calculated_size;
} else {
conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
to_add = calculated_size + conn->batch_header_size_ + footer_size;
}
// Check if it fits (using actual header size, not max padding)
uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
if (total_calculated_size > remaining_size)
return 0;
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
shared_buf.resize(shared_buf.size() + to_add);
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
return total_calculated_size;
}
// Noinline version of encode_to_buffer for cold paths (entity info, zero-payload messages). // Noinline version of encode_to_buffer for cold paths (entity info, zero-payload messages).
// All cold callers share this single copy instead of each getting an ALWAYS_INLINE expansion. // All cold callers share this single copy instead of each getting an ALWAYS_INLINE expansion.
@@ -792,7 +762,8 @@ class APIConnection final : public APIServerConnectionBase {
// Read by process_batch_multi_ to pass into MessageInfo. // Read by process_batch_multi_ to pass into MessageInfo.
uint8_t batch_header_size_{0}; uint8_t batch_header_size_{0};
uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); } // Defined in api_connection_buffer.h (needs APIServer complete).
uint32_t get_batch_delay_ms_() const;
// Message will use 8 more bytes than the minimum size, and typical // Message will use 8 more bytes than the minimum size, and typical
// MTU is 1500. Sometimes users will see as low as 1460 MTU. // MTU is 1500. Sometimes users will see as low as 1460 MTU.
// If its IPv6 the header is 40 bytes, and if its IPv4 // If its IPv6 the header is 40 bytes, and if its IPv4
@@ -0,0 +1,54 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_API
// Inline APIConnection methods that need APIServer complete. Include this
// instead of api_connection.h when calling encode_to_buffer or get_batch_delay_ms_.
#include "api_connection.h"
#include "api_server.h"
namespace esphome::api {
inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t calculated_size,
MessageEncodeFn encode_fn, const void *msg,
APIConnection *conn, uint32_t remaining_size) {
#ifdef HAS_PROTO_MESSAGE_DUMP
if (conn->flags_.log_only_mode) {
auto *proto_msg = static_cast<const ProtoMessage *>(msg);
DumpBuffer dump_buf;
conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
return 1;
}
#endif
const uint8_t footer_size = conn->helper_->frame_footer_size();
// First message uses max padding (already in buffer), subsequent use exact header size
size_t to_add;
if (conn->flags_.batch_first_message) {
conn->flags_.batch_first_message = false;
conn->batch_header_size_ = conn->helper_->frame_header_padding();
to_add = calculated_size;
} else {
conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
to_add = calculated_size + conn->batch_header_size_ + footer_size;
}
// Check if it fits (using actual header size, not max padding)
uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
if (total_calculated_size > remaining_size)
return 0;
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
shared_buf.resize(shared_buf.size() + to_add);
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
return total_calculated_size;
}
inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
} // namespace esphome::api
#endif
-5
View File
@@ -30,11 +30,6 @@ APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-c
APIServer::APIServer() { global_api_server = this; } APIServer::APIServer() { global_api_server = this; }
// Custom deleter defined here so `delete` sees the complete APIConnection type.
// This prevents libc++ from emitting an "incomplete type" error when other
// translation units only have the forward declaration of APIConnection.
void APIServer::APIConnectionDeleter::operator()(APIConnection *p) const { delete p; }
void APIServer::socket_failed_(const LogString *msg) { void APIServer::socket_failed_(const LogString *msg) {
ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno); ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno);
this->destroy_socket_(); this->destroy_socket_();
+4 -10
View File
@@ -3,6 +3,8 @@
#include "esphome/core/defines.h" #include "esphome/core/defines.h"
#ifdef USE_API #ifdef USE_API
#include "api_buffer.h" #include "api_buffer.h"
// Must precede clients_ so APIConnection is complete for default_delete (libc++).
#include "api_connection.h"
#include "api_noise_context.h" #include "api_noise_context.h"
#include "api_pb2.h" #include "api_pb2.h"
#include "api_pb2_service.h" #include "api_pb2_service.h"
@@ -12,8 +14,6 @@
#include "esphome/core/controller.h" #include "esphome/core/controller.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
#include "esphome/core/string_ref.h" #include "esphome/core/string_ref.h"
#include "list_entities.h"
#include "subscribe_state.h"
#ifdef USE_LOGGER #ifdef USE_LOGGER
#include "esphome/components/logger/logger.h" #include "esphome/components/logger/logger.h"
#endif #endif
@@ -191,15 +191,9 @@ class APIServer final : public Component,
bool is_connected_with_state_subscription() const; bool is_connected_with_state_subscription() const;
// Range-for view over the populated slice [0, api_connection_count_). Read-only with respect // Range-for view over the populated slice [0, api_connection_count_). Read-only with respect
// to ownership callers get `const unique_ptr&` so they can invoke non-const methods on the // to ownership; callers get `const unique_ptr&` so they can invoke non-const methods on the
// APIConnection but cannot reset/move the slot and break the count invariant. // APIConnection but cannot reset/move the slot and break the count invariant.
// Custom deleter is defined out-of-line in api_server.cpp so libc++ does not using APIConnectionPtr = std::unique_ptr<APIConnection>;
// eagerly instantiate `delete static_cast<APIConnection *>(p)` here, where
// only the forward declaration of APIConnection is visible (incomplete type).
struct APIConnectionDeleter {
void operator()(APIConnection *p) const;
};
using APIConnectionPtr = std::unique_ptr<APIConnection, APIConnectionDeleter>;
class ActiveClientsView { class ActiveClientsView {
const APIConnectionPtr *begin_; const APIConnectionPtr *begin_;
const APIConnectionPtr *end_; const APIConnectionPtr *end_;
+1 -1
View File
@@ -335,7 +335,7 @@ async def to_code(config):
add_idf_component( add_idf_component(
name="esphome/esp-audio-libs", name="esphome/esp-audio-libs",
ref="3.0.0", ref="3.1.0",
) )
data = _get_data() data = _get_data()
@@ -1,5 +1,6 @@
#include "bluetooth_proxy.h" #include "bluetooth_proxy.h"
#include "esphome/components/api/api_server.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
#include "esphome/core/macros.h" #include "esphome/core/macros.h"
#include "esphome/core/application.h" #include "esphome/core/application.h"
+91 -42
View File
@@ -113,6 +113,7 @@ ARDUINO_FRAMEWORK_NAME = "framework-arduinoespressif32"
ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}" ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}"
ARDUINO_LIBS_NAME = f"{ARDUINO_FRAMEWORK_NAME}-libs" ARDUINO_LIBS_NAME = f"{ARDUINO_FRAMEWORK_NAME}-libs"
ARDUINO_LIBS_PKG = f"pioarduino/{ARDUINO_LIBS_NAME}" ARDUINO_LIBS_PKG = f"pioarduino/{ARDUINO_LIBS_NAME}"
ARDUINO_ESP32_COMPONENT_NAME = "espressif/arduino-esp32"
LOG_LEVELS_IDF = [ LOG_LEVELS_IDF = [
"NONE", "NONE",
@@ -792,19 +793,15 @@ PLATFORM_VERSION_LOOKUP = {
} }
def _check_pio_versions(config): def _resolve_framework_version(value: ConfigType) -> cv.Version:
config = config.copy() """Resolve a named or raw framework version and validate the minimum.
value = config[CONF_FRAMEWORK]
Normalises value[CONF_VERSION] to its string form and returns the parsed
cv.Version. Shared between the PIO and esp-idf toolchain paths; toolchain-
specific concerns (source defaults, platform_version) live in the per-
toolchain functions.
"""
if value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP: if value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP:
if CONF_SOURCE in value or CONF_PLATFORM_VERSION in value:
raise cv.Invalid(
"Version needs to be explicitly set when a custom source or platform_version is used."
)
platform_lookup = PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]]
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup))
if value[CONF_TYPE] == FRAMEWORK_ARDUINO: if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
version = ARDUINO_FRAMEWORK_VERSION_LOOKUP[value[CONF_VERSION]] version = ARDUINO_FRAMEWORK_VERSION_LOOKUP[value[CONF_VERSION]]
else: else:
@@ -817,7 +814,38 @@ def _check_pio_versions(config):
if value[CONF_TYPE] == FRAMEWORK_ARDUINO: if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
if version < cv.Version(3, 0, 0): if version < cv.Version(3, 0, 0):
raise cv.Invalid("Only Arduino 3.0+ is supported.") raise cv.Invalid("Only Arduino 3.0+ is supported.")
recommended_version = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"] recommended = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"]
else:
if version < cv.Version(5, 0, 0):
raise cv.Invalid("Only ESP-IDF 5.0+ is supported.")
recommended = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]
if version != recommended:
_LOGGER.warning(
"The selected framework version is not the recommended one. "
"If there are connectivity or build issues please remove the manual version."
)
return version
def _check_pio_versions(config: ConfigType) -> ConfigType:
config = config.copy()
value = config[CONF_FRAMEWORK]
is_named_version = value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP
if is_named_version and (CONF_SOURCE in value or CONF_PLATFORM_VERSION in value):
raise cv.Invalid(
"Version needs to be explicitly set when a custom source or platform_version is used."
)
if is_named_version:
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(
str(PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]])
)
version = _resolve_framework_version(value)
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
platform_lookup = ARDUINO_PLATFORM_VERSION_LOOKUP.get(version) platform_lookup = ARDUINO_PLATFORM_VERSION_LOOKUP.get(version)
value[CONF_SOURCE] = value.get( value[CONF_SOURCE] = value.get(
CONF_SOURCE, _format_framework_arduino_version(version) CONF_SOURCE, _format_framework_arduino_version(version)
@@ -825,9 +853,6 @@ def _check_pio_versions(config):
if _is_framework_url(value[CONF_SOURCE]): if _is_framework_url(value[CONF_SOURCE]):
value[CONF_SOURCE] = f"{ARDUINO_FRAMEWORK_PKG}@{value[CONF_SOURCE]}" value[CONF_SOURCE] = f"{ARDUINO_FRAMEWORK_PKG}@{value[CONF_SOURCE]}"
else: else:
if version < cv.Version(5, 0, 0):
raise cv.Invalid("Only ESP-IDF 5.0+ is supported.")
recommended_version = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]
platform_lookup = ESP_IDF_PLATFORM_VERSION_LOOKUP.get(version) platform_lookup = ESP_IDF_PLATFORM_VERSION_LOOKUP.get(version)
value[CONF_SOURCE] = value.get( value[CONF_SOURCE] = value.get(
CONF_SOURCE, CONF_SOURCE,
@@ -843,12 +868,6 @@ def _check_pio_versions(config):
) )
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup)) value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup))
if version != recommended_version:
_LOGGER.warning(
"The selected framework version is not the recommended one. "
"If there are connectivity or build issues please remove the manual version."
)
if value[CONF_PLATFORM_VERSION] != _parse_pio_platform_version( if value[CONF_PLATFORM_VERSION] != _parse_pio_platform_version(
str(PLATFORM_VERSION_LOOKUP["recommended"]) str(PLATFORM_VERSION_LOOKUP["recommended"])
): ):
@@ -860,19 +879,26 @@ def _check_pio_versions(config):
return config return config
def _check_esp_idf_versions(config): def _check_esp_idf_versions(config: ConfigType) -> ConfigType:
config = _check_pio_versions(config) config = config.copy()
value = config[CONF_FRAMEWORK] value = config[CONF_FRAMEWORK]
# Remove unwanted keys if present # platform_version is a PlatformIO concept; drop it if a user carried it
for key in (CONF_SOURCE, CONF_PLATFORM_VERSION): # over from a PIO-style config. CONF_SOURCE, on the other hand, is kept:
value.pop(key, None) # it lets a user override the framework tarball URL under the esp-idf
# toolchain (the espidf framework downloader consults it).
value.pop(CONF_PLATFORM_VERSION, None)
# Official ESP-IDF frameworks don't use extra version = _resolve_framework_version(value)
version = cv.Version.parse(value[CONF_VERSION])
version = cv.Version(version.major, version.minor, version.patch)
value[CONF_VERSION] = str(version) if CONF_SOURCE in value:
_LOGGER.warning(
"A custom framework source is set. "
"If there are connectivity or build issues please remove the manual source."
)
# Official ESP-IDF frameworks don't use the 'extra' semver component.
value[CONF_VERSION] = str(cv.Version(version.major, version.minor, version.patch))
return config return config
@@ -1718,6 +1744,31 @@ async def _add_yaml_idf_components(components: list[ConfigType]):
) )
@coroutine_with_priority(CoroPriority.FINAL - 1)
async def _finalize_arduino_aware_flags():
"""Build flags that depend on whether arduino-esp32 is linked in.
Scheduler runs lower priority values later, so ``FINAL - 1`` fires
after every ``FINAL`` job (incl. ``_add_yaml_idf_components``) --
by then ``KEY_COMPONENTS`` is fully populated.
- Skip our esp_panic_handler wrap when Arduino is linked; Arduino
wraps the same symbol and the linker errors on the duplicate.
- Define USE_ARDUINO in the hybrid esp-idf+arduino-esp32-component
case so ESPHome's ``#ifdef USE_ARDUINO`` paths light up. The
framework=arduino branch already adds it inline in to_code.
"""
arduino_linked = (
CORE.using_arduino
or ARDUINO_ESP32_COMPONENT_NAME in CORE.data[KEY_ESP32][KEY_COMPONENTS]
)
if not arduino_linked:
cg.add_build_flag("-Wl,--wrap=esp_panic_handler")
cg.add_define("USE_ESP32_CRASH_HANDLER")
elif not CORE.using_arduino:
cg.add_build_flag("-DUSE_ARDUINO")
async def to_code(config): async def to_code(config):
framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
conf = config[CONF_FRAMEWORK] conf = config[CONF_FRAMEWORK]
@@ -1770,6 +1821,7 @@ async def to_code(config):
cg.add_build_flag("-Wno-error=overloaded-virtual") cg.add_build_flag("-Wno-error=overloaded-virtual")
cg.add_build_flag("-Wno-error=reorder") cg.add_build_flag("-Wno-error=reorder")
cg.add_build_flag("-Wno-error=volatile") cg.add_build_flag("-Wno-error=volatile")
cg.add_build_flag("-Wno-error=cpp")
# -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates # -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates
cg.add_build_flag("-Wno-missing-field-initializers") cg.add_build_flag("-Wno-missing-field-initializers")
@@ -1777,11 +1829,8 @@ async def to_code(config):
cg.add_build_flag("-DUSE_ESP32") cg.add_build_flag("-DUSE_ESP32")
cg.add_define("USE_NATIVE_64BIT_TIME") cg.add_define("USE_NATIVE_64BIT_TIME")
cg.add_build_flag("-Wl,-z,noexecstack") cg.add_build_flag("-Wl,-z,noexecstack")
# Arduino already wraps esp_panic_handler for its own backtrace handler, # Deferred so KEY_COMPONENTS is fully populated -- see the coroutine.
# so only add our wrap when using ESP-IDF framework to avoid linker conflicts. CORE.add_job(_finalize_arduino_aware_flags)
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
cg.add_build_flag("-Wl,--wrap=esp_panic_handler")
cg.add_define("USE_ESP32_CRASH_HANDLER")
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
variant = config[CONF_VARIANT] variant = config[CONF_VARIANT]
cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}") cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}")
@@ -1962,7 +2011,7 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_HEAP_PLACE_FUNCTION_INTO_FLASH", True) add_idf_sdkconfig_option("CONFIG_HEAP_PLACE_FUNCTION_INTO_FLASH", True)
# Setup watchdog # Setup watchdog
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT", True) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_INIT", True)
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True)
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0", False) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0", False)
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1", False) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1", False)
@@ -2004,7 +2053,8 @@ async def to_code(config):
if not advanced[CONF_ENABLE_LWIP_MDNS_QUERIES]: if not advanced[CONF_ENABLE_LWIP_MDNS_QUERIES]:
add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False) add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False)
if not advanced[CONF_ENABLE_LWIP_BRIDGE_INTERFACE]: if not advanced[CONF_ENABLE_LWIP_BRIDGE_INTERFACE]:
add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0) # Kconfig range is [1,63]; 0 gets clamped to the default.
add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 1)
_configure_lwip_max_sockets(conf) _configure_lwip_max_sockets(conf)
@@ -2096,7 +2146,6 @@ async def to_code(config):
for key, flag in ASSERTION_LEVELS.items(): for key, flag in ASSERTION_LEVELS.items():
add_idf_sdkconfig_option(flag, assertion_level == key) add_idf_sdkconfig_option(flag, assertion_level == key)
add_idf_sdkconfig_option("CONFIG_COMPILER_OPTIMIZATION_DEFAULT", False)
compiler_optimization = advanced[CONF_COMPILER_OPTIMIZATION] compiler_optimization = advanced[CONF_COMPILER_OPTIMIZATION]
for key, flag in COMPILER_OPTIMIZATIONS.items(): for key, flag in COMPILER_OPTIMIZATIONS.items():
add_idf_sdkconfig_option(flag, compiler_optimization == key) add_idf_sdkconfig_option(flag, compiler_optimization == key)
@@ -2251,7 +2300,8 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 2) add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 2)
elif advanced[CONF_DISABLE_FATFS]: elif advanced[CONF_DISABLE_FATFS]:
add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", True) add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", True)
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 0) # Kconfig range is [1,10]; 0 gets clamped to the default.
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 1)
for name, value in conf[CONF_SDKCONFIG_OPTIONS].items(): for name, value in conf[CONF_SDKCONFIG_OPTIONS].items():
add_idf_sdkconfig_option(name, RawSdkconfigValue(value)) add_idf_sdkconfig_option(name, RawSdkconfigValue(value))
@@ -2488,9 +2538,8 @@ def _write_sdkconfig():
def _platformio_library_to_dependency(library: Library) -> tuple[str, dict[str, str]]: def _platformio_library_to_dependency(library: Library) -> tuple[str, dict[str, str]]:
dependency: dict[str, str] = {} dependency: dict[str, str] = {}
name, version, path = generate_idf_component(library) name, _version, path = generate_idf_component(library)
dependency["override_path"] = str(path) dependency["override_path"] = str(path)
dependency["version"] = version
return name, dependency return name, dependency
@@ -2542,7 +2591,7 @@ def _write_idf_component_yml():
if CORE.using_toolchain_esp_idf: if CORE.using_toolchain_esp_idf:
add_idf_component( add_idf_component(
name="espressif/arduino-esp32", name=ARDUINO_ESP32_COMPONENT_NAME,
ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]), ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]),
) )
+9 -4
View File
@@ -3,6 +3,7 @@
#include "esphome/core/application.h" #include "esphome/core/application.h"
#include "esphome/core/defines.h" #include "esphome/core/defines.h"
#include "preferences.h" #include "preferences.h"
#include <esp_attr.h>
#include <freertos/FreeRTOS.h> #include <freertos/FreeRTOS.h>
#include <freertos/task.h> #include <freertos/task.h>
@@ -14,10 +15,14 @@ extern "C" __attribute__((weak)) void initArduino() {}
namespace esphome { namespace esphome {
// HAL functions live in hal.cpp. This file keeps only the loop task setup. // HAL functions live in hal.cpp. This file keeps only the loop task setup.
TaskHandle_t loop_task_handle = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) // Force the static TCB and stack into internal DRAM. Otherwise the linker may
static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) // place them in PSRAM under configs like CONFIG_SPIRAM_ALLOW_BSS_SEG_EXTERNAL_MEMORY,
static StackType_t // which is unsafe when CONFIG_SPIRAM_XIP_FROM_PSRAM is enabled because the running
loop_task_stack[ESPHOME_LOOP_TASK_STACK_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) // task's stack must be reachable while the flash cache is disabled.
TaskHandle_t loop_task_handle = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static StaticTask_t DRAM_ATTR loop_task_tcb{}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static StackType_t DRAM_ATTR
loop_task_stack[ESPHOME_LOOP_TASK_STACK_SIZE]{}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
void loop_task(void *pv_params) { void loop_task(void *pv_params) {
setup(); setup();
@@ -196,42 +196,35 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt
(*this->on_read_callback_)(param->read.conn_id); (*this->on_read_callback_)(param->read.conn_id);
} }
uint16_t max_offset = 22; // Use the client-supplied offset for long reads; short reads always start at 0.
// The Bluedroid stack truncates ATT_READ_RSP / ATT_READ_BLOB_RSP to MTU-1, so we
// just provide as much data as we have from the requested offset and let the stack
// handle framing. The client issues subsequent blob reads with increasing offsets
// until it has received the whole value.
const uint16_t offset = param->read.is_long ? param->read.offset : 0;
esp_gatt_status_t status = ESP_GATT_OK;
esp_gatt_rsp_t response; esp_gatt_rsp_t response;
if (param->read.is_long) { response.attr_value.offset = offset;
if (this->value_read_offset_ >= this->value_.size()) {
response.attr_value.len = 0; if (offset > this->value_.size()) {
response.attr_value.offset = this->value_read_offset_; status = ESP_GATT_INVALID_OFFSET;
this->value_read_offset_ = 0; response.attr_value.len = 0;
} else if (this->value_.size() - this->value_read_offset_ < max_offset) {
// Last message in the chain
response.attr_value.len = this->value_.size() - this->value_read_offset_;
response.attr_value.offset = this->value_read_offset_;
memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len);
this->value_read_offset_ = 0;
} else {
response.attr_value.len = max_offset;
response.attr_value.offset = this->value_read_offset_;
memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len);
this->value_read_offset_ += max_offset;
}
} else { } else {
response.attr_value.offset = 0; size_t remaining = this->value_.size() - offset;
if (this->value_.size() + 1 > max_offset) { if (remaining > ESP_GATT_MAX_ATTR_LEN) {
response.attr_value.len = max_offset; ESP_LOGW(TAG, "Characteristic length %u exceeds buffer size of %u, truncating",
this->value_read_offset_ = max_offset; static_cast<unsigned>(remaining), ESP_GATT_MAX_ATTR_LEN);
} else { remaining = ESP_GATT_MAX_ATTR_LEN;
response.attr_value.len = this->value_.size();
} }
memcpy(response.attr_value.value, this->value_.data(), response.attr_value.len); response.attr_value.len = remaining;
memcpy(response.attr_value.value, this->value_.data() + offset, remaining);
} }
response.attr_value.handle = this->handle_; response.attr_value.handle = this->handle_;
response.attr_value.auth_req = ESP_GATT_AUTH_REQ_NONE; response.attr_value.auth_req = ESP_GATT_AUTH_REQ_NONE;
esp_err_t err = esp_err_t err =
esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, ESP_GATT_OK, &response); esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, status, &response);
if (err != ESP_OK) { if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ble_gatts_send_response failed: %d", err); ESP_LOGE(TAG, "esp_ble_gatts_send_response failed: %d", err);
} }
@@ -79,7 +79,6 @@ class BLECharacteristic {
esp_gatt_char_prop_t properties_; esp_gatt_char_prop_t properties_;
uint16_t handle_{0xFFFF}; uint16_t handle_{0xFFFF};
uint16_t value_read_offset_{0};
std::vector<uint8_t> value_; std::vector<uint8_t> value_;
std::vector<BLEDescriptor *> descriptors_; std::vector<BLEDescriptor *> descriptors_;
@@ -121,7 +121,7 @@ void Esp32HostedUpdate::setup() {
} }
} else { } else {
ESP_LOGW(TAG, "Invalid app description magic word: 0x%08" PRIx32 " (expected 0x%08" PRIx32 ")", ESP_LOGW(TAG, "Invalid app description magic word: 0x%08" PRIx32 " (expected 0x%08" PRIx32 ")",
app_desc->magic_word, ESP_APP_DESC_MAGIC_WORD); app_desc->magic_word, static_cast<uint32_t>(ESP_APP_DESC_MAGIC_WORD));
this->state_ = update::UPDATE_STATE_NO_UPDATE; this->state_ = update::UPDATE_STATE_NO_UPDATE;
} }
} else { } else {
+12 -12
View File
@@ -5,6 +5,7 @@
#include <Arduino.h> #include <Arduino.h>
#include <core_esp8266_features.h> #include <core_esp8266_features.h>
#include <coredecls.h>
extern "C" { extern "C" {
#include <user_interface.h> #include <user_interface.h>
@@ -71,23 +72,22 @@ uint32_t IRAM_ATTR HOT millis() {
return result; return result;
} }
// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object // Delegate to Arduino's 1-arg esp_delay(), which uses os_timer + esp_suspend to
// call to the original millis() that --wrap can't intercept, so calling ::delay() // suspend the cont task for `ms` milliseconds without polling millis(). This
// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still // matches pre-2026.5.0 behavior (when esphome::delay() forwarded to ::delay())
// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and // and lets the SDK run freely while we wait, which timing-sensitive
// WiFi run correctly. Theoretically less power-efficient than Arduino's // interrupt-driven code (e.g. ESP8266 software-serial RX in components like
// os_timer-based delay() for long waits, but nearly all ESPHome delays are short // fingerprint_grow) depends on. The poll-based busy-wait that this replaced
// (sensor/I²C/SPI settling in the 1100 ms range) where the difference is // rarely yielded inside short waits like delay(1), starving WiFi/SDK tasks and
// negligible. // extending interrupt latency. Unlike ::delay(), esp_delay()'s 1-arg form does
// not call millis(), so the slow Arduino millis() body is not pulled into IRAM
// by this path (the --wrap=millis goal of #15662 is preserved).
void HOT delay(uint32_t ms) { void HOT delay(uint32_t ms) {
if (ms == 0) { if (ms == 0) {
optimistic_yield(1000); optimistic_yield(1000);
return; return;
} }
uint32_t start = millis(); esp_delay(ms);
while (millis() - start < ms) {
optimistic_yield(1000);
}
} }
void arch_restart() { void arch_restart() {
+6
View File
@@ -58,6 +58,12 @@ __attribute__((always_inline)) inline const char *progmem_read_ptr(const char *c
__attribute__((always_inline)) inline uint16_t progmem_read_uint16(const uint16_t *addr) { __attribute__((always_inline)) inline uint16_t progmem_read_uint16(const uint16_t *addr) {
return pgm_read_word(addr); // NOLINT return pgm_read_word(addr); // NOLINT
} }
// Bulk PROGMEM copy: routes to the SDK's aligned-flash `memcpy_P` so callers
// don't have to drop to a byte-by-byte `progmem_read_byte` loop, which on
// ESP8266 is ~4x as many flash accesses as the bulk path.
__attribute__((always_inline)) inline void progmem_memcpy(void *dst, const void *src, size_t len) {
memcpy_P(dst, src, len); // NOLINT
}
// NOLINTNEXTLINE(readability-identifier-naming) // NOLINTNEXTLINE(readability-identifier-naming)
__attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } __attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); }
@@ -206,6 +206,7 @@ uint8_t FingerprintGrowComponent::save_fingerprint_() {
break; break;
case ENROLL_MISMATCH: case ENROLL_MISMATCH:
ESP_LOGE(TAG, "Scans do not match"); ESP_LOGE(TAG, "Scans do not match");
[[fallthrough]];
default: default:
return this->data_[0]; return this->data_[0];
} }
@@ -15,6 +15,16 @@ void FT5x06Touchscreen::setup() {
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
} }
// reading the chip registers to get max x/y does not seem to work.
if (this->display_ != nullptr) {
if (this->x_raw_max_ == this->x_raw_min_) {
this->x_raw_max_ = this->display_->get_native_width();
}
if (this->y_raw_max_ == this->y_raw_min_) {
this->y_raw_max_ = this->display_->get_native_height();
}
}
// wait 200ms after reset. // wait 200ms after reset.
this->set_timeout(200, [this] { this->continue_setup_(); }); this->set_timeout(200, [this] { this->continue_setup_(); });
} }
@@ -39,15 +49,6 @@ void FT5x06Touchscreen::continue_setup_() {
this->mark_failed(); this->mark_failed();
return; return;
} }
// reading the chip registers to get max x/y does not seem to work.
if (this->display_ != nullptr) {
if (this->x_raw_max_ == this->x_raw_min_) {
this->x_raw_max_ = this->display_->get_native_width();
}
if (this->y_raw_max_ == this->y_raw_min_) {
this->y_raw_max_ = this->display_->get_native_height();
}
}
} }
void FT5x06Touchscreen::update_touches() { void FT5x06Touchscreen::update_touches() {
@@ -71,7 +72,7 @@ void FT5x06Touchscreen::update_touches() {
uint16_t x = encode_uint16(data[i][0] & 0x0F, data[i][1]); uint16_t x = encode_uint16(data[i][0] & 0x0F, data[i][1]);
uint16_t y = encode_uint16(data[i][2] & 0xF, data[i][3]); uint16_t y = encode_uint16(data[i][2] & 0xF, data[i][3]);
ESP_LOGD(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y); ESP_LOGV(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y);
if (status == 0 || status == 2) { if (status == 0 || status == 2) {
this->add_raw_touch_position_(id, x, y); this->add_raw_touch_position_(id, x, y);
} }
@@ -17,9 +17,9 @@ void HomeassistantSensor::setup() {
} }
if (this->attribute_ != nullptr) { if (this->attribute_ != nullptr) {
ESP_LOGD(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val); ESP_LOGV(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val);
} else { } else {
ESP_LOGD(TAG, "'%s': Got state %.2f", this->entity_id_, *val); ESP_LOGV(TAG, "'%s': Got state %.2f", this->entity_id_, *val);
} }
this->publish_state(*val); this->publish_state(*val);
}); });
@@ -89,10 +89,10 @@ def _set_num_channels_from_config(config):
def _set_stream_limits(config): def _set_stream_limits(config):
if config.get(CONF_SPDIF_MODE, False): if config.get(CONF_SPDIF_MODE, False):
# SPDIF mode: fixed to 16-bit stereo at configured sample rate # SPDIF mode: 16/24/32-bit audio and stereo at configured sample rate
audio.set_stream_limits( audio.set_stream_limits(
min_bits_per_sample=16, min_bits_per_sample=16,
max_bits_per_sample=16, max_bits_per_sample=32,
min_channels=2, min_channels=2,
max_channels=2, max_channels=2,
min_sample_rate=config.get(CONF_SAMPLE_RATE), min_sample_rate=config.get(CONF_SAMPLE_RATE),
@@ -213,9 +213,6 @@ def _final_validate(config):
) )
if config[CONF_CHANNEL] != CONF_STEREO: if config[CONF_CHANNEL] != CONF_STEREO:
raise cv.Invalid("SPDIF mode only supports stereo channel configuration") raise cv.Invalid("SPDIF mode only supports stereo channel configuration")
# bits_per_sample is converted to float by the schema
if config[CONF_BITS_PER_SAMPLE] != 16:
raise cv.Invalid("SPDIF mode only supports 16 bits per sample")
if not config[CONF_USE_APLL]: if not config[CONF_USE_APLL]:
raise cv.Invalid( raise cv.Invalid(
"SPDIF mode requires 'use_apll: true' for accurate clock generation" "SPDIF mode requires 'use_apll: true' for accurate clock generation"
@@ -138,21 +138,21 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
// Reset lockstep records queue so it starts paired with the (also-reset) i2s_event_queue_. // Reset lockstep records queue so it starts paired with the (also-reset) i2s_event_queue_.
xQueueReset(this->write_records_queue_); xQueueReset(this->write_records_queue_);
const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * SPDIF_DMA_BUFFERS_COUNT;
// Ensure ring buffer duration is at least the duration of all DMA buffers
const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_);
// The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info // The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info
const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1); const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1);
// Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and
// avoids unnecessary single-frame splices.
const size_t ring_buffer_size =
(this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame;
// For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames // For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames (~4 ms at 48 kHz),
// not the ~15 ms a standard I2S DMA buffer holds. Derive the DMA floor from actual block size.
const uint32_t frames_to_fill_single_dma_buffer = SPDIF_BLOCK_SAMPLES; const uint32_t frames_to_fill_single_dma_buffer = SPDIF_BLOCK_SAMPLES;
const size_t bytes_to_fill_single_dma_buffer = const size_t bytes_to_fill_single_dma_buffer =
this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer); this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer);
const size_t dma_buffers_floor_bytes = bytes_to_fill_single_dma_buffer * SPDIF_DMA_BUFFERS_COUNT;
// Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and
// avoids unnecessary single-frame splices. Ensure it is at least large enough to cover all DMA buffers.
const size_t requested_ring_buffer_bytes =
(this->current_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame;
const size_t ring_buffer_size = std::max(dma_buffers_floor_bytes, requested_ring_buffer_bytes);
bool successful_setup = false; bool successful_setup = false;
std::unique_ptr<audio::RingBufferAudioSource> audio_source; std::unique_ptr<audio::RingBufferAudioSource> audio_source;
@@ -177,7 +177,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
// on_sent events drain in lockstep without crediting any audio frames. // on_sent events drain in lockstep without crediting any audio frames.
this->spdif_encoder_->set_preload_mode(true); this->spdif_encoder_->set_preload_mode(true);
for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) { for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) {
esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS)); // i2s_channel_preload_data is non-blocking (returns immediately when the preload buffer fills), so no wait.
esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(0);
if (preload_err != ESP_OK) { if (preload_err != ESP_OK) {
break; // DMA preload buffer full or error break; // DMA preload buffer full or error
} }
@@ -410,8 +411,9 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s
this->sample_rate_, audio_stream_info.get_sample_rate()); this->sample_rate_, audio_stream_info.get_sample_rate());
return ESP_ERR_NOT_SUPPORTED; return ESP_ERR_NOT_SUPPORTED;
} }
if (audio_stream_info.get_bits_per_sample() != 16) { const uint8_t bits_per_sample = audio_stream_info.get_bits_per_sample();
ESP_LOGE(TAG, "Only supports 16 bits per sample"); if (bits_per_sample != 16 && bits_per_sample != 24 && bits_per_sample != 32) {
ESP_LOGE(TAG, "Only supports 16, 24, or 32 bits per sample (got %u)", (unsigned) bits_per_sample);
return ESP_ERR_NOT_SUPPORTED; return ESP_ERR_NOT_SUPPORTED;
} }
if (audio_stream_info.get_channels() != 2) { if (audio_stream_info.get_channels() != 2) {
@@ -419,11 +421,8 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s
return ESP_ERR_NOT_SUPPORTED; return ESP_ERR_NOT_SUPPORTED;
} }
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO && // Tell the encoder what input width to expect. 32-bit input is truncated to 24-bit on the wire.
(i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) { this->spdif_encoder_->set_bytes_per_sample(bits_per_sample / 8);
ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration");
return ESP_ERR_NOT_SUPPORTED;
}
if (!this->parent_->try_lock()) { if (!this->parent_->try_lock()) {
ESP_LOGE(TAG, "Parent bus is busy"); ESP_LOGE(TAG, "Parent bus is busy");
@@ -2,6 +2,7 @@
#ifdef USE_ESP32 #ifdef USE_ESP32
#include <driver/gpio.h>
#include <driver/i2s_std.h> #include <driver/i2s_std.h>
#include "esphome/components/audio/audio.h" #include "esphome/components/audio/audio.h"
@@ -299,6 +300,15 @@ void I2SAudioSpeakerBase::stop_i2s_driver_() {
i2s_channel_disable(this->tx_handle_); i2s_channel_disable(this->tx_handle_);
i2s_del_channel(this->tx_handle_); i2s_del_channel(this->tx_handle_);
this->tx_handle_ = nullptr; this->tx_handle_ = nullptr;
// i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only
// clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that
// setup installed. If another speaker reuses this port (shared bus), its audio still reaches our
// dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a
// SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise.
gpio_reset_pin(this->dout_pin_);
gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT);
gpio_set_level(this->dout_pin_, 0);
} }
this->parent_->unlock(); this->parent_->unlock();
} }
@@ -19,7 +19,6 @@
namespace esphome::i2s_audio { namespace esphome::i2s_audio {
// Shared constants used by both standard and SPDIF speaker implementations // Shared constants used by both standard and SPDIF speaker implementations
static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15;
static constexpr size_t TASK_STACK_SIZE = 4096; static constexpr size_t TASK_STACK_SIZE = 4096;
static constexpr ssize_t TASK_PRIORITY = 19; static constexpr ssize_t TASK_PRIORITY = 19;
@@ -16,6 +16,7 @@ namespace esphome::i2s_audio {
static const char *const TAG = "i2s_audio.speaker.std"; static const char *const TAG = "i2s_audio.speaker.std";
static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15;
static constexpr size_t DMA_BUFFERS_COUNT = 4; static constexpr size_t DMA_BUFFERS_COUNT = 4;
// Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight, // Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight,
// doubled so that a transient backlog never overruns the queue (which would desync the lockstep // doubled so that a transient backlog never overruns the queue (which would desync the lockstep
@@ -17,7 +17,7 @@ static constexpr uint8_t PREAMBLE_M = 0x1d; // Left channel (not block start)
static constexpr uint8_t PREAMBLE_W = 0x1b; // Right channel static constexpr uint8_t PREAMBLE_W = 0x1b; // Right channel
// BMC encoding of 4 zero bits starting at phase HIGH: 00_11_00_11 = 0x33 // BMC encoding of 4 zero bits starting at phase HIGH: 00_11_00_11 = 0x33
// Since both aux nibbles (bits 4-7, 8-11) are zero for 16-bit audio and phase is preserved, both are 0x33. // Used as a constant in the 16-bit subframe path, where bits 4-11 are always zero.
static constexpr uint32_t BMC_ZERO_NIBBLE = 0x33; static constexpr uint32_t BMC_ZERO_NIBBLE = 0x33;
// Constexpr BMC encoder for compile-time LUT generation. // Constexpr BMC encoder for compile-time LUT generation.
@@ -36,21 +36,43 @@ static constexpr uint16_t bmc_lut_encode(uint32_t data, uint8_t num_bits) {
return bmc; return bmc;
} }
// 4-bit BMC lookup table: 16 entries (16 bytes in flash) // Compile-time parity helper (constexpr-friendly, runs only at LUT build time).
// Index: 4-bit data value (0-15), always phase=true start static constexpr uint32_t bmc_lut_parity(uint32_t value, uint32_t num_bits) {
uint32_t p = 0;
for (uint32_t b = 0; b < num_bits; b++)
p ^= (value >> b) & 1u;
return p;
}
// Combined BMC + phase-delta lookup tables.
// Each entry packs the BMC pattern (lower bits, phase=high start) together with
// a phase-mask delta in bits 16-31 (0xFFFF if the input has odd parity, else 0).
// XORing the delta into the running phase mask propagates parity across chunks
// without an explicit popcount.
// 4-bit BMC lookup table: 16 entries x uint32_t = 64 bytes in flash.
// Bits 0-7 : 8-bit BMC pattern (phase=high start)
// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0)
static constexpr auto BMC_LUT_4 = [] { static constexpr auto BMC_LUT_4 = [] {
std::array<uint8_t, 16> t{}; std::array<uint32_t, 16> t{};
for (uint32_t i = 0; i < 16; i++) for (uint32_t i = 0; i < 16; i++) {
t[i] = static_cast<uint8_t>(bmc_lut_encode(i, 4)); uint32_t bmc = bmc_lut_encode(i, 4);
uint32_t delta = bmc_lut_parity(i, 4) ? 0xFFFF0000u : 0u;
t[i] = bmc | delta;
}
return t; return t;
}(); }();
// 8-bit BMC lookup table: 256 entries (512 bytes in flash) // 8-bit BMC lookup table: 256 entries x uint32_t = 1024 bytes in flash.
// Index: 8-bit data value (0-255), always phase=true start // Bits 0-15 : 16-bit BMC pattern (phase=high start)
// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0)
static constexpr auto BMC_LUT_8 = [] { static constexpr auto BMC_LUT_8 = [] {
std::array<uint16_t, 256> t{}; std::array<uint32_t, 256> t{};
for (uint32_t i = 0; i < 256; i++) for (uint32_t i = 0; i < 256; i++) {
t[i] = bmc_lut_encode(i, 8); uint32_t bmc = bmc_lut_encode(i, 8);
uint32_t delta = bmc_lut_parity(i, 8) ? 0xFFFF0000u : 0u;
t[i] = bmc | delta;
}
return t; return t;
}(); }();
@@ -63,7 +85,7 @@ bool SPDIFEncoder::setup() {
} }
ESP_LOGV(TAG, "Buffer allocated (%zu bytes)", SPDIF_BLOCK_SIZE_BYTES); ESP_LOGV(TAG, "Buffer allocated (%zu bytes)", SPDIF_BLOCK_SIZE_BYTES);
// Build initial channel status block with default sample rate // Build initial channel status block with default sample rate and width
this->build_channel_status_(); this->build_channel_status_();
this->reset(); this->reset();
@@ -73,7 +95,7 @@ bool SPDIFEncoder::setup() {
void SPDIFEncoder::reset() { void SPDIFEncoder::reset() {
this->spdif_block_ptr_ = this->spdif_block_buf_.get(); this->spdif_block_ptr_ = this->spdif_block_buf_.get();
this->frame_in_block_ = 0; this->frame_in_block_ = 0;
this->is_left_channel_ = true; this->block_buf_is_silence_block_ = false;
} }
void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) { void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) {
@@ -84,31 +106,27 @@ void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) {
} }
} }
void SPDIFEncoder::set_bytes_per_sample(uint8_t bytes_per_sample) {
if (bytes_per_sample != 2 && bytes_per_sample != 3 && bytes_per_sample != 4) {
ESP_LOGE(TAG, "Unsupported bytes per sample: %u", (unsigned) bytes_per_sample);
return;
}
if (this->bytes_per_sample_ != bytes_per_sample) {
this->bytes_per_sample_ = bytes_per_sample;
this->build_channel_status_();
// Discard any partial block built at the previous width so we never mix widths on the wire.
this->reset();
ESP_LOGD(TAG, "Input width set to %u-bit", (unsigned) bytes_per_sample * 8);
}
}
void SPDIFEncoder::build_channel_status_() { void SPDIFEncoder::build_channel_status_() {
// IEC 60958-3 Consumer Channel Status Block (192 bits = 24 bytes) // IEC 60958-3 Consumer Channel Status Block (192 bits = 24 bytes)
// Transmitted LSB-first within each byte, one bit per frame via C bit // Transmitted LSB-first within each byte, one bit per frame via C bit.
//
// Byte 0: Control bits // Any cached silence block was built for the previous channel status; it is now stale.
// Bit 0: 0 = Consumer format (not professional AES3) this->block_buf_is_silence_block_ = false;
// Bit 1: 0 = PCM audio (not non-audio data like AC3)
// Bit 2: 0 = No copyright assertion
// Bits 3-5: 000 = No pre-emphasis
// Bits 6-7: 00 = Mode 0 (basic consumer format)
//
// Byte 1: Category code (0x00 = general, 0x01 = CD, etc.)
//
// Byte 2: Source/channel numbers
// Bits 0-3: Source number (0 = unspecified)
// Bits 4-7: Channel number (0 = unspecified)
//
// Byte 3: Sample frequency and clock accuracy
// Bits 0-3: Sample frequency code
// Bits 4-5: Clock accuracy (00 = Level II, ±1000 ppm, appropriate for ESP32)
// Bits 6-7: Reserved (0)
//
// Bytes 4-23: Reserved (zeros for basic compliance)
// Clear all bytes first
this->channel_status_.fill(0); this->channel_status_.fill(0);
// Byte 0: Consumer, PCM audio, no copyright, no pre-emphasis, Mode 0 // Byte 0: Consumer, PCM audio, no copyright, no pre-emphasis, Mode 0
@@ -140,132 +158,148 @@ void SPDIFEncoder::build_channel_status_() {
// Byte 3: freq_code in bits 0-3, clock accuracy (00) in bits 4-5 // Byte 3: freq_code in bits 0-3, clock accuracy (00) in bits 4-5
this->channel_status_[3] = freq_code; // Clock accuracy bits 4-5 are already 0 this->channel_status_[3] = freq_code; // Clock accuracy bits 4-5 are already 0
// Bytes 4-23 remain zero (word length not specified, no original sample freq, etc.) // Byte 4: Word length encoding (IEC 60958-3 consumer)
// bit 0: max length flag (0 = max 20 bits, 1 = max 24 bits)
// bits 1-3: word length code relative to the max
// For our supported widths:
// 16-bit (max 20): 0b0010 = 0x02 -- "16 bits, max 20"
// 24-bit (max 24): 0b1101 = 0x0D -- "24 bits, max 24"
// 32-bit input is truncated to 24-bit on the wire, so use the 24-bit code.
uint8_t word_length_code;
switch (this->bytes_per_sample_) {
case 2:
word_length_code = 0x02;
break;
case 3: // Shared case
case 4:
word_length_code = 0x0D;
break;
default:
word_length_code = 0x00; // not specified
break;
}
this->channel_status_[4] = word_length_code;
} }
HOT void SPDIFEncoder::encode_sample_(const uint8_t *pcm_sample) { // Extract the C bit for the given frame from channel_status_ and shift it into bit 30
// ============================================================================ // so it can be OR'd directly into a raw subframe.
// Build raw 32-bit subframe (IEC 60958 format) ESPHOME_ALWAYS_INLINE static inline uint32_t c_bit_for_frame(const std::array<uint8_t, 24> &channel_status,
// ============================================================================ uint32_t frame) {
// Bit layout: return static_cast<uint32_t>((channel_status[frame >> 3] >> (frame & 7)) & 1u) << 30;
// Bits 0-3: Preamble (handled separately, not in raw_subframe) }
// Bits 4-7: Auxiliary audio data (zeros for 16-bit audio)
// Bits 8-11: Audio LSB extension (zeros for 16-bit audio)
// Bits 12-27: 16-bit audio sample (MSB-aligned in 20-bit audio field)
// Bit 28: V (Validity) - 0 = valid audio
// Bit 29: U (User data) - 0
// Bit 30: C (Channel status) - from channel status block
// Bit 31: P (Parity) - even parity over bits 4-31
// ============================================================================
// Place 16-bit audio sample at bits 12-27 (little-endian input: [0]=LSB, [1]=MSB) // ============================================================================
uint32_t raw_subframe = (static_cast<uint32_t>(pcm_sample[1]) << 20) | (static_cast<uint32_t>(pcm_sample[0]) << 12); // IEC 60958 subframe bit layout
// ============================================================================
// Bits 0-3: Preamble (handled separately, not in raw_subframe)
// Bits 4-7: Auxiliary audio data / 24-bit audio LSB
// Bits 8-11: Audio LSB extension (zero for 16-bit, low nibble of audio for 24-bit)
// Bits 12-27: Audio sample (16 high bits in 16-bit mode, mid 16 bits in 24-bit mode)
// Bit 28: V (Validity) - 0 = valid audio
// Bit 29: U (User data) - 0
// Bit 30: C (Channel status) - from channel status block
// Bit 31: P (Parity) - even parity over bits 4-31
// ============================================================================
// V = 0 (valid audio), U = 0 (no user data) // Build a raw IEC 60958 subframe from PCM little-endian input of width Bps bytes.
// C = channel status bit for current frame (same bit used for both L and R subframes) // Caller is responsible for OR-ing in the C bit and parity.
bool c_bit = this->get_channel_status_bit_(this->frame_in_block_); template<uint8_t Bps> ESPHOME_ALWAYS_INLINE static inline uint32_t build_raw_subframe(const uint8_t *pcm_sample) {
if (c_bit) { static_assert(Bps == 2 || Bps == 3 || Bps == 4, "Unsupported bytes per sample");
raw_subframe |= (1U << 30); if constexpr (Bps == 2) {
// 16-bit input: MSB-aligned in the 20-bit audio field, bits 12-27.
return (static_cast<uint32_t>(pcm_sample[1]) << 20) | (static_cast<uint32_t>(pcm_sample[0]) << 12);
} else if constexpr (Bps == 3) {
// 24-bit input: full 24-bit audio field, bits 4-27.
return (static_cast<uint32_t>(pcm_sample[2]) << 20) | (static_cast<uint32_t>(pcm_sample[1]) << 12) |
(static_cast<uint32_t>(pcm_sample[0]) << 4);
} else { // Bps == 4
// 32-bit input truncated to 24-bit: drop the lowest byte.
return (static_cast<uint32_t>(pcm_sample[3]) << 20) | (static_cast<uint32_t>(pcm_sample[2]) << 12) |
(static_cast<uint32_t>(pcm_sample[1]) << 4);
} }
}
// Calculate even parity over bits 4-30 // BMC-encode a subframe and write the two output uint32 words to dst. Caller passes
// This ensures consistent BMC ending phase regardless of audio content // raw_subframe with the C bit set (bit 30) and the P bit cleared (bit 31 = 0). P is
uint32_t bits_4_30 = (raw_subframe >> 4) & 0x07FFFFFF; // 27 bits (4-30) // derived from the cumulative parity-mask delta of the per-byte LUT lookups.
uint32_t ones_count = __builtin_popcount(bits_4_30); //
uint32_t parity = ones_count & 1; // 1 if odd count, 0 if even // I2S halfword swap means word[0] transmits as: bits 24-31, 16-23, 8-15, 0-7.
raw_subframe |= parity << 31; // Set P bit to make total even // word[1] transmits as: bits 16-31, 0-15. Within each halfword, MSB-first.
// All preambles end at phase HIGH, so phase=true at the start of bit 4.
//
// P-bit derivation: BMC_LUT_*'s upper half encodes the parity of the input chunk. Each
// chunk's parity delta is shifted down (`lut >> 16`) into a phase_mask that lives in the
// low 16 bits, so the same value can also be XORed against subsequent BMC patterns to
// invert phase. XOR'ing those deltas through all chunks (with bit 31 = 0) yields the
// parity of bits 4-30 in the low bits of phase_mask -- the required value of the P bit
// for even total parity. The BMC of bit 31 lives in bit 0 of the high-byte BMC output
// (i = 7 maps to position (8-1-7)*2 = 0); flipping the source bit flips only the lower
// BMC bit (= phase XOR bit), so applying P is `bmc_24_31 ^= phase_mask & 1u`.
template<uint8_t Bps>
ESPHOME_ALWAYS_INLINE static inline void bmc_encode_subframe(uint32_t raw_subframe, uint8_t preamble, uint32_t *dst) {
if constexpr (Bps == 2) {
// 16-bit path: bits 4-11 are zero, encoded inline as BMC_ZERO_NIBBLE constants.
// Eight zero source bits with start phase=HIGH end at phase=HIGH (popcount of zeros is even),
// so encoding of bits 12-15 starts at phase=true. Zeros contribute 0 to parity.
uint32_t nibble = (raw_subframe >> 12) & 0xF;
uint32_t lut_n = BMC_LUT_4[nibble];
uint32_t bmc_12_15 = lut_n & 0xFFu;
uint32_t phase_mask = lut_n >> 16; // 0xFFFFu if odd parity, else 0
// ============================================================================ uint32_t byte_mid = (raw_subframe >> 16) & 0xFF;
// Select preamble based on position in block and channel uint32_t lut_m = BMC_LUT_8[byte_mid];
// ============================================================================ uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask;
// B = block start (left channel, frame 0 of 192-frame block) phase_mask ^= lut_m >> 16;
// M = left channel (frames 1-191)
// W = right channel (all frames) uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition
uint8_t preamble; uint32_t lut_h = BMC_LUT_8[byte_hi];
if (this->is_left_channel_) { uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask;
preamble = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M; phase_mask ^= lut_h >> 16;
// phase_mask now reflects parity of bits 4-30. Apply P by flipping bit 0 of bmc_24_31.
bmc_24_31 ^= phase_mask & 1u;
dst[0] = bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast<uint32_t>(preamble) << 24);
dst[1] = bmc_24_31 | (bmc_16_23 << 16);
} else { } else {
preamble = PREAMBLE_W; // 24-bit (and 32-bit truncated) path: bits 4-11 are live audio.
uint32_t byte_lo = (raw_subframe >> 4) & 0xFF;
uint32_t lut_l = BMC_LUT_8[byte_lo];
uint32_t bmc_4_11 = lut_l & 0xFFFFu;
uint32_t phase_mask = lut_l >> 16; // 0xFFFFu if odd parity, else 0
uint32_t nibble = (raw_subframe >> 12) & 0xF;
uint32_t lut_n = BMC_LUT_4[nibble];
uint32_t bmc_12_15 = (lut_n & 0xFFu) ^ (phase_mask & 0xFFu);
phase_mask ^= lut_n >> 16;
uint32_t byte_mid = (raw_subframe >> 16) & 0xFF;
uint32_t lut_m = BMC_LUT_8[byte_mid];
uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask;
phase_mask ^= lut_m >> 16;
uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition
uint32_t lut_h = BMC_LUT_8[byte_hi];
uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask;
phase_mask ^= lut_h >> 16;
bmc_24_31 ^= phase_mask & 1u;
// word[0]: bits 24-31 = preamble, bits 8-23 = bmc(4-11), bits 0-7 = bmc(12-15)
// word[1]: bits 16-31 = bmc(16-23), bits 0-15 = bmc(24-31)
dst[0] = bmc_12_15 | (bmc_4_11 << 8) | (static_cast<uint32_t>(preamble) << 24);
dst[1] = bmc_24_31 | (bmc_16_23 << 16);
} }
}
// ============================================================================ template<uint8_t Bps> void SPDIFEncoder::encode_silence_frame_() {
// BMC encode the data portion (bits 4-31) using lookup tables static constexpr uint8_t SILENCE[4] = {0, 0, 0, 0};
// ============================================================================ uint32_t raw = build_raw_subframe<Bps>(SILENCE) | c_bit_for_frame(this->channel_status_, this->frame_in_block_);
// The I2S uses 16-bit halfword swap: bits 16-31 transmit before bits 0-15. uint8_t preamble_l = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M;
// This applies to BOTH word[0] and word[1]. bmc_encode_subframe<Bps>(raw, preamble_l, this->spdif_block_ptr_);
// bmc_encode_subframe<Bps>(raw, PREAMBLE_W, this->spdif_block_ptr_ + 2);
// word[0] transmission order: [16-23] → [24-31] → [0-7] → [8-15] this->spdif_block_ptr_ += 4;
// For correct S/PDIF subframe order (preamble → aux → audio): if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) {
// - bits 16-23: preamble (8 BMC bits) this->frame_in_block_ = 0;
// - bits 24-31: BMC(subframe bits 4-7) - first aux nibble
// - bits 0-7: BMC(subframe bits 8-11) - second aux nibble
// - bits 8-15: BMC(subframe bits 12-15) - audio low nibble
//
// word[1] transmission order: [16-31] → [0-15]
// For correct S/PDIF subframe order:
// - bits 16-31: BMC(subframe bits 16-23) - audio mid byte
// - bits 0-15: BMC(subframe bits 24-31) - audio high nibble + VUCP
// ============================================================================
// All preambles end at phase HIGH. Bits 4-11 are always zero for 16-bit audio;
// two zero nibbles flip phase 8 times total → back to HIGH.
// So bits 12-15 always start encoding at phase=true.
// Bits 12-15: 4-bit LUT lookup (always phase=true start)
uint32_t nibble = (raw_subframe >> 12) & 0xF;
uint32_t bmc_12_15 = BMC_LUT_4[nibble];
// Phase tracking via branchless XOR mask:
// - 0x0000 means phase=true (use LUT value directly)
// - 0xFFFF means phase=false (complement LUT value)
// End phase = start XOR (popcount & 1) since zero-bits flip phase,
// and for even bit widths: #zeros parity == popcount parity.
uint32_t phase_mask = -(__builtin_popcount(nibble) & 1u) & 0xFFFF;
// Bits 16-23: 8-bit LUT lookup with phase correction
uint32_t byte_mid = (raw_subframe >> 16) & 0xFF;
uint32_t bmc_16_23 = BMC_LUT_8[byte_mid] ^ phase_mask;
phase_mask ^= -(__builtin_popcount(byte_mid) & 1u) & 0xFFFF;
// Bits 24-31: 8-bit LUT lookup with phase correction
uint32_t byte_hi = (raw_subframe >> 24) & 0xFF;
uint32_t bmc_24_31 = BMC_LUT_8[byte_hi] ^ phase_mask;
// ============================================================================
// Combine with correct positioning for I2S transmission
// ============================================================================
// I2S with halfword swap: transmits bits 16-31, then bits 0-15.
// Within each halfword, MSB (highest bit) is transmitted first.
//
// For upper halfword (bits 16-31): bit 31 → bit 16
// For lower halfword (bits 0-15): bit 15 → bit 0
//
// Desired S/PDIF order: preamble → bmc_4_7 → bmc_8_11 → bmc_12_15
//
// word[0] layout for correct transmission:
// bits 24-31: preamble (transmitted 1st, as MSB of upper halfword)
// bits 16-23: BMC_ZERO_NIBBLE (transmitted 2nd, aux bits 4-7)
// bits 8-15: BMC_ZERO_NIBBLE (transmitted 3rd, aux bits 8-11)
// bits 0-7: bmc_12_15 (transmitted 4th, audio low nibble)
//
// word[1] layout:
// bits 16-31: bmc_16_23 (transmitted 5th)
// bits 0-15: bmc_24_31 (transmitted 6th)
this->spdif_block_ptr_[0] =
bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast<uint32_t>(preamble) << 24);
this->spdif_block_ptr_[1] = bmc_24_31 | (bmc_16_23 << 16);
this->spdif_block_ptr_ += 2;
// ============================================================================
// Update position tracking
// ============================================================================
if (!this->is_left_channel_) {
// Completed a stereo frame, advance frame counter
if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) {
this->frame_in_block_ = 0;
}
} }
this->is_left_channel_ = !this->is_left_channel_;
} }
esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) { esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) {
@@ -295,79 +329,162 @@ esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) {
return err; return err;
} }
size_t SPDIFEncoder::get_pending_pcm_bytes() const { template<uint8_t Bps>
if (this->spdif_block_ptr_ == nullptr || this->spdif_block_buf_ == nullptr) { HOT esp_err_t SPDIFEncoder::write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait,
return 0; uint32_t *blocks_sent, size_t *bytes_consumed) {
const uint8_t *pcm_data = src;
const uint8_t *const pcm_end = src + size;
uint32_t block_count = 0;
// Hot state lives in locals so the compiler can keep it in registers across the
// per-frame encoding work; byte writes through block_ptr may alias the member fields,
// which would block register allocation if the encoding read them directly from this->*.
uint32_t *block_ptr = this->spdif_block_ptr_;
uint32_t *const block_buf = this->spdif_block_buf_.get();
uint32_t *const block_end = block_buf + SPDIF_BLOCK_SIZE_U32;
uint32_t frame = this->frame_in_block_;
const std::array<uint8_t, 24> &channel_status = this->channel_status_;
auto save_state = [&]() {
this->spdif_block_ptr_ = block_ptr;
this->frame_in_block_ = static_cast<uint8_t>(frame);
};
auto report_out_params = [&]() {
if (blocks_sent != nullptr)
*blocks_sent = block_count;
if (bytes_consumed != nullptr)
*bytes_consumed = pcm_data - src;
};
// Send a completed block if the buffer is full, propagating any error.
// send_block_ resets this->spdif_block_ptr_ to block_buf on success and leaves it
// unchanged on error -- mirror both behaviors in our local block_ptr.
auto maybe_send = [&]() -> esp_err_t {
if (block_ptr >= block_end) {
esp_err_t err = this->send_block_(ticks_to_wait);
if (err != ESP_OK) {
save_state();
report_out_params();
return err;
}
block_ptr = block_buf;
++block_count;
}
return ESP_OK;
};
// Hot path: encode L+R pairs in two peeled sub-loops. Frame 0 carries the only
// buffer-full check and uses PREAMBLE_B (a block fills exactly when frame wraps from
// 191 back to 0). Frames 1..191 use PREAMBLE_M and need no buffer-full check or
// preamble branch. The encoding body is inlined here so block_ptr lives in a register
// for the duration of the loop.
while (pcm_data + 2 * Bps <= pcm_end) {
if (frame == 0) {
esp_err_t err = maybe_send();
if (err != ESP_OK)
return err;
uint32_t c_bit = c_bit_for_frame(channel_status, 0);
uint32_t raw_l = build_raw_subframe<Bps>(pcm_data) | c_bit;
uint32_t raw_r = build_raw_subframe<Bps>(pcm_data + Bps) | c_bit;
bmc_encode_subframe<Bps>(raw_l, PREAMBLE_B, block_ptr);
bmc_encode_subframe<Bps>(raw_r, PREAMBLE_W, block_ptr + 2);
block_ptr += 4;
frame = 1;
pcm_data += 2 * Bps;
}
// The inner loop runs until min(SPDIF_BLOCK_SAMPLES, frame + input_frames). The
// input-size bound is folded into end_frame so a single `frame < end_frame` test
// governs termination.
uint32_t input_frames = static_cast<uint32_t>(pcm_end - pcm_data) / (2u * Bps);
uint32_t end_frame = SPDIF_BLOCK_SAMPLES;
if (frame + input_frames < end_frame)
end_frame = frame + input_frames;
while (frame < end_frame) {
uint32_t c_bit = c_bit_for_frame(channel_status, frame);
uint32_t raw_l = build_raw_subframe<Bps>(pcm_data) | c_bit;
uint32_t raw_r = build_raw_subframe<Bps>(pcm_data + Bps) | c_bit;
bmc_encode_subframe<Bps>(raw_l, PREAMBLE_M, block_ptr);
bmc_encode_subframe<Bps>(raw_r, PREAMBLE_W, block_ptr + 2);
block_ptr += 4;
++frame;
pcm_data += 2 * Bps;
}
if (frame >= SPDIF_BLOCK_SAMPLES)
frame = 0;
} }
// Each PCM sample (2 bytes) produces 2 uint32_t values in the SPDIF buffer
// So pending uint32s / 2 = pending samples, and each sample is 2 bytes // Send any complete block that was just finished.
size_t pending_uint32s = this->spdif_block_ptr_ - this->spdif_block_buf_.get(); if (block_ptr >= block_end) {
size_t pending_samples = pending_uint32s / 2; esp_err_t err = this->send_block_(ticks_to_wait);
return pending_samples * 2; // 2 bytes per sample if (err != ESP_OK) {
save_state();
report_out_params();
return err;
}
block_ptr = block_buf;
++block_count;
}
save_state();
report_out_params();
return ESP_OK;
} }
HOT esp_err_t SPDIFEncoder::write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent, HOT esp_err_t SPDIFEncoder::write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent,
size_t *bytes_consumed) { size_t *bytes_consumed) {
const uint8_t *pcm_data = src; if (size > 0) {
const uint8_t *pcm_end = src + size; // Real PCM is about to be encoded into the buffer, so it is no longer a full-silence block.
uint32_t block_count = 0; this->block_buf_is_silence_block_ = false;
}
switch (this->bytes_per_sample_) {
case 2:
return this->write_typed_<2>(src, size, ticks_to_wait, blocks_sent, bytes_consumed);
case 3:
return this->write_typed_<3>(src, size, ticks_to_wait, blocks_sent, bytes_consumed);
case 4:
return this->write_typed_<4>(src, size, ticks_to_wait, blocks_sent, bytes_consumed);
default:
return ESP_ERR_INVALID_STATE;
}
}
while (pcm_data < pcm_end) { template<uint8_t Bps> esp_err_t SPDIFEncoder::flush_with_silence_typed_(TickType_t ticks_to_wait) {
// Check if there's a pending complete block from a previous failed send // If a complete block is already pending (from a previous failed send), emit just that block.
if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { // Otherwise pad the partial block with silence (or generate a full silence block if empty) and
esp_err_t err = this->send_block_(ticks_to_wait); // send. Always emits exactly one block on success.
if (err != ESP_OK) { if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
if (blocks_sent != nullptr) { const bool was_empty = (this->spdif_block_ptr_ == this->spdif_block_buf_.get());
*blocks_sent = block_count; // Continuous-silence idle case: a full silence block is byte-identical every time for the
} // active channel status, so when the buffer already holds one, re-send it as-is.
if (bytes_consumed != nullptr) { if (was_empty && this->block_buf_is_silence_block_) {
*bytes_consumed = pcm_data - src; return this->send_block_(ticks_to_wait);
}
return err;
}
++block_count;
} }
// Pad with silence frames at the configured width.
// Encode one 16-bit sample while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
this->encode_sample_(pcm_data); this->encode_silence_frame_<Bps>();
pcm_data += 2;
}
// Send any complete block that was just finished
if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
esp_err_t err = this->send_block_(ticks_to_wait);
if (err != ESP_OK) {
if (blocks_sent != nullptr) {
*blocks_sent = block_count;
}
if (bytes_consumed != nullptr) {
*bytes_consumed = pcm_data - src;
}
return err;
} }
++block_count; // The buffer is a reusable full-silence block only if it was built entirely from silence; a
// partial real-audio block padded out with silence is not.
this->block_buf_is_silence_block_ = was_empty;
} }
return this->send_block_(ticks_to_wait);
if (blocks_sent != nullptr) {
*blocks_sent = block_count;
}
if (bytes_consumed != nullptr) {
*bytes_consumed = size;
}
return ESP_OK;
} }
esp_err_t SPDIFEncoder::flush_with_silence(TickType_t ticks_to_wait) { esp_err_t SPDIFEncoder::flush_with_silence(TickType_t ticks_to_wait) {
// If a complete block is already pending (from a previous failed send), emit just that block. switch (this->bytes_per_sample_) {
// Otherwise pad the partial block with silence (or generate a full silence block if empty) case 2:
// and send. Always emits exactly one block on success. return this->flush_with_silence_typed_<2>(ticks_to_wait);
if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { case 3:
static const uint8_t SILENCE[2] = {0, 0}; return this->flush_with_silence_typed_<3>(ticks_to_wait);
while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { case 4:
this->encode_sample_(SILENCE); return this->flush_with_silence_typed_<4>(ticks_to_wait);
} default:
return ESP_ERR_INVALID_STATE;
} }
return this->send_block_(ticks_to_wait);
} }
} // namespace esphome::i2s_audio } // namespace esphome::i2s_audio
@@ -24,8 +24,6 @@ static constexpr uint16_t SPDIF_BLOCK_SIZE_BYTES = SPDIF_BLOCK_SAMPLES * (EMULAT
static constexpr uint32_t SPDIF_BLOCK_SIZE_U32 = SPDIF_BLOCK_SIZE_BYTES / sizeof(uint32_t); // 3072 bytes / 4 = 768 static constexpr uint32_t SPDIF_BLOCK_SIZE_U32 = SPDIF_BLOCK_SIZE_BYTES / sizeof(uint32_t); // 3072 bytes / 4 = 768
// I2S frame count for one SPDIF block (for new driver where frame = 8 bytes for 32-bit stereo) // I2S frame count for one SPDIF block (for new driver where frame = 8 bytes for 32-bit stereo)
static constexpr uint32_t SPDIF_BLOCK_I2S_FRAMES = SPDIF_BLOCK_SIZE_BYTES / 8; // 3072 / 8 = 384 frames static constexpr uint32_t SPDIF_BLOCK_I2S_FRAMES = SPDIF_BLOCK_SIZE_BYTES / 8; // 3072 / 8 = 384 frames
// PCM bytes needed for one complete SPDIF block (192 stereo frames * 2 bytes per sample * 2 channels)
static constexpr uint16_t SPDIF_PCM_BYTES_PER_BLOCK = SPDIF_BLOCK_SAMPLES * 2 * 2; // = 768 bytes
/// Callback signature for block completion (raw function pointer for minimal overhead) /// Callback signature for block completion (raw function pointer for minimal overhead)
/// @param user_ctx User context pointer passed during callback registration /// @param user_ctx User context pointer passed during callback registration
@@ -64,8 +62,16 @@ class SPDIFEncoder {
/// @brief Check if currently in preload mode /// @brief Check if currently in preload mode
bool is_preload_mode() const { return this->preload_mode_; } bool is_preload_mode() const { return this->preload_mode_; }
/// @brief Set input PCM width: 2 = 16-bit, 3 = 24-bit, 4 = 32-bit (truncated to 24-bit on the wire).
/// Must be called before write() if input width changes from the default (16-bit). Triggers a
/// channel-status rebuild to reflect the new word length.
void set_bytes_per_sample(uint8_t bytes_per_sample);
/// @brief Get the configured input PCM width in bytes per sample
uint8_t get_bytes_per_sample() const { return this->bytes_per_sample_; }
/// @brief Convert PCM audio data to SPDIF BMC encoded data /// @brief Convert PCM audio data to SPDIF BMC encoded data
/// @param src Source PCM audio data (16-bit stereo) /// @param src Source PCM audio data (stereo, width matches set_bytes_per_sample)
/// @param size Size of source data in bytes /// @param size Size of source data in bytes
/// @param ticks_to_wait Timeout for blocking writes /// @param ticks_to_wait Timeout for blocking writes
/// @param blocks_sent Optional pointer to receive the number of complete SPDIF blocks sent /// @param blocks_sent Optional pointer to receive the number of complete SPDIF blocks sent
@@ -74,17 +80,6 @@ class SPDIFEncoder {
esp_err_t write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent = nullptr, esp_err_t write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent = nullptr,
size_t *bytes_consumed = nullptr); size_t *bytes_consumed = nullptr);
/// @brief Get the number of PCM bytes currently pending in the partial block buffer
/// @return Number of pending PCM bytes (0 to SPDIF_PCM_BYTES_PER_BLOCK - 1)
size_t get_pending_pcm_bytes() const;
/// @brief Get the number of PCM frames currently pending in the partial block buffer
/// @return Number of pending PCM frames (0 to SPDIF_BLOCK_SAMPLES - 1)
uint32_t get_pending_frames() const { return this->get_pending_pcm_bytes() / 4; }
/// @brief Check if there is a partial block pending
bool has_pending_data() const { return this->spdif_block_ptr_ != this->spdif_block_buf_.get(); }
/// @brief Emit one complete SPDIF block: pad any pending partial block with silence and send, /// @brief Emit one complete SPDIF block: pad any pending partial block with silence and send,
/// or send a full silence block if nothing is pending. Always produces exactly one block on success. /// or send a full silence block if nothing is pending. Always produces exactly one block on success.
/// @param ticks_to_wait Timeout for blocking writes /// @param ticks_to_wait Timeout for blocking writes
@@ -95,7 +90,7 @@ class SPDIFEncoder {
void reset(); void reset();
/// @brief Set the sample rate for Channel Status Block encoding /// @brief Set the sample rate for Channel Status Block encoding
/// @param sample_rate Sample rate in Hz (e.g., 44100, 48000, 96000) /// @param sample_rate Sample rate in Hz (e.g., 44100, 48000)
/// Call this before writing audio data to ensure correct channel status. /// Call this before writing audio data to ensure correct channel status.
void set_sample_rate(uint32_t sample_rate); void set_sample_rate(uint32_t sample_rate);
@@ -103,8 +98,19 @@ class SPDIFEncoder {
uint32_t get_sample_rate() const { return this->sample_rate_; } uint32_t get_sample_rate() const { return this->sample_rate_; }
protected: protected:
/// @brief Encode a single 16-bit PCM sample into the current block position /// @brief Encode a single stereo silence frame at the current block position.
HOT void encode_sample_(const uint8_t *pcm_sample); /// @note Used only by flush_with_silence_typed_ to pad; the hot write path inlines the
/// encoding body directly into write_typed_ to keep block_ptr / frame_in_block_ in registers.
template<uint8_t Bps> void encode_silence_frame_();
/// @brief Templated write loop. Called from the public write() via runtime dispatch on bytes_per_sample_.
template<uint8_t Bps>
HOT esp_err_t write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent,
size_t *bytes_consumed);
/// @brief Templated flush-with-silence. Pads the pending block with zeros at the configured width
/// (or builds a full silence block when nothing is pending) and sends it. Always emits one block.
template<uint8_t Bps> esp_err_t flush_with_silence_typed_(TickType_t ticks_to_wait);
/// @brief Send the completed block via the appropriate callback /// @brief Send the completed block via the appropriate callback
esp_err_t send_block_(TickType_t ticks_to_wait); esp_err_t send_block_(TickType_t ticks_to_wait);
@@ -112,15 +118,6 @@ class SPDIFEncoder {
/// @brief Build the channel status block from current configuration /// @brief Build the channel status block from current configuration
void build_channel_status_(); void build_channel_status_();
/// @brief Get the channel status bit for a specific frame
/// @param frame Frame number (0-191)
/// @return The C bit value for this frame
ESPHOME_ALWAYS_INLINE inline bool get_channel_status_bit_(uint8_t frame) const {
// Channel status is 192 bits transmitted over 192 frames
// Bit N is transmitted in frame N, LSB-first within each byte
return (this->channel_status_[frame >> 3] >> (frame & 7)) & 1;
}
// Member ordering optimized to minimize padding (largest alignment first) // Member ordering optimized to minimize padding (largest alignment first)
// 4-byte aligned members (pointers and uint32_t) // 4-byte aligned members (pointers and uint32_t)
@@ -133,9 +130,13 @@ class SPDIFEncoder {
uint32_t sample_rate_{48000}; // Sample rate for Channel Status Block encoding uint32_t sample_rate_{48000}; // Sample rate for Channel Status Block encoding
// 1-byte aligned members (grouped together to avoid internal padding) // 1-byte aligned members (grouped together to avoid internal padding)
uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block uint8_t bytes_per_sample_{2}; // Input PCM width: 2/3/4 (16/24/32-bit). 32-bit truncates to 24-bit on the wire.
bool is_left_channel_{true}; // Alternates L/R for stereo samples uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block
bool preload_mode_{false}; // Whether to use preload callback vs write callback bool preload_mode_{false}; // Whether to use preload callback vs write callback
// True when spdif_block_buf_ currently holds a complete full-silence block valid for the active
// channel status. A full silence block is deterministic for a given sample rate and word length,
// so when this is set flush_with_silence() can re-send the buffer verbatim instead of re-encoding.
bool block_buf_is_silence_block_{false};
// Channel Status Block (192 bits = 24 bytes, transmitted over 192 frames) // Channel Status Block (192 bits = 24 bytes, transmitted over 192 frames)
// Placed last since std::array<uint8_t> has 1-byte alignment // Placed last since std::array<uint8_t> has 1-byte alignment
@@ -13,7 +13,9 @@ import subprocess
# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it. # - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it.
# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it. # - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it.
# - LN882H: stock linker has no glob for ".sram.text", so we inject # - LN882H: stock linker has no glob for ".sram.text", so we inject
# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH). # KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH)
# immediately after KEEP(*(.vectors)), so the vector table stays at
# __copysection_ram0_start (0x20000000) for correct Cortex-M4 VTOR alignment.
# #
# All families also get a post-link summary showing where IRAM_ATTR landed. # All families also get a post-link summary showing where IRAM_ATTR landed.
@@ -27,7 +29,11 @@ _KEEP_LINE = (
"__esphome_sram_text_end = .; " "__esphome_sram_text_end = .; "
+ _MARKER + "\n" + _MARKER + "\n"
) )
_LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") # Inject after KEEP(*(.vectors)) so the vector table stays at
# __copysection_ram0_start (0x20000000). Cortex-M4 VTOR requires a 512-byte-
# aligned address; injecting before the vectors would push them to an
# unaligned offset and mis-route every IRQ handler.
_LN_COPY = re.compile(r"(KEEP\(\*\(\.vectors\)\)[^\n]*\n)")
def _detect(env): def _detect(env):
@@ -56,7 +62,7 @@ KNOWN_VARIANTS = frozenset({
def _inject_keep(host_section): def _inject_keep(host_section):
"""Return a patcher that injects _KEEP_LINE at the top of `host_section`.""" """Return a patcher that injects _KEEP_LINE after `host_section` match."""
def patch(content): def patch(content):
if _MARKER in content: if _MARKER in content:
return content return content
+31 -2
View File
@@ -86,10 +86,22 @@ class EffectRef:
component_path: list[str | int] # path_context when the action was validated component_path: list[str | int] # path_context when the action was validated
@dataclass
class EffectCycleRef:
"""A pending light.effect.next/previous action to validate.
Records that the referenced light needs at least one effect configured.
"""
light_id: ID
component_path: list[str | int]
@dataclass @dataclass
class LightData: class LightData:
gamma_tables: dict = field(default_factory=dict) # gamma_value -> fwd_arr gamma_tables: dict = field(default_factory=dict) # gamma_value -> fwd_arr
effect_refs: list[EffectRef] = field(default_factory=list) effect_refs: list[EffectRef] = field(default_factory=list)
effect_cycle_refs: list[EffectCycleRef] = field(default_factory=list)
def _get_data() -> LightData: def _get_data() -> LightData:
@@ -160,13 +172,15 @@ def _final_validate(config: ConfigType) -> ConfigType:
this never runs but the ID validator will catch the missing light ID separately. this never runs but the ID validator will catch the missing light ID separately.
""" """
data = _get_data() data = _get_data()
if not data.effect_refs: if not data.effect_refs and not data.effect_cycle_refs:
return config return config
# Drain the list so we only validate once even though # Drain the lists so we only validate once even though
# FINAL_VALIDATE_SCHEMA runs for each light platform instance. # FINAL_VALIDATE_SCHEMA runs for each light platform instance.
refs = data.effect_refs refs = data.effect_refs
data.effect_refs = [] data.effect_refs = []
cycle_refs = data.effect_cycle_refs
data.effect_cycle_refs = []
fconf = fv.full_config.get() fconf = fv.full_config.get()
@@ -188,6 +202,21 @@ def _final_validate(config: ConfigType) -> ConfigType:
path=[cv.ROOT_CONFIG_PATH] + ref.component_path, path=[cv.ROOT_CONFIG_PATH] + ref.component_path,
) )
for ref in cycle_refs:
try:
light_path = fconf.get_path_for_id(ref.light_id)[:-1]
light_config = fconf.get_config_for_path(light_path)
except KeyError:
continue
if not light_config.get(CONF_EFFECTS):
raise cv.FinalExternalInvalid(
f"Light '{ref.light_id}' has no effects configured, but a "
f"'light.effect.next' or 'light.effect.previous' action "
f"references it. Add at least one effect to the light.",
path=[cv.ROOT_CONFIG_PATH] + ref.component_path,
)
return config return config
+41
View File
@@ -104,6 +104,47 @@ template<bool HasTransitionLength, typename... Ts> class DimRelativeAction : pub
transition_length_{}; transition_length_{};
}; };
// Cycle through the light's configured effects. `Forward` selects direction
// at compile time so the chosen branch is the only one that gets instantiated
// per action site. `include_none` is runtime so a single set of templates
// covers both the "wrap through None" and "skip None" variants.
template<bool Forward, typename... Ts> class LightEffectCycleAction : public Action<Ts...> {
public:
explicit LightEffectCycleAction(LightState *parent) : parent_(parent) {}
void set_include_none(bool include_none) { this->include_none_ = include_none; }
void play(const Ts &...) override {
size_t count = this->parent_->get_effect_count();
if (count == 0) {
return;
}
uint32_t current = this->parent_->get_current_effect_index();
uint32_t next;
if (this->include_none_) {
uint32_t total = static_cast<uint32_t>(count) + 1;
if constexpr (Forward) {
next = (current + 1) % total;
} else {
next = (current + total - 1) % total;
}
} else {
if constexpr (Forward) {
next = (current % static_cast<uint32_t>(count)) + 1;
} else {
next = (current <= 1) ? static_cast<uint32_t>(count) : current - 1;
}
}
auto call = this->parent_->turn_on();
call.set_effect(next);
call.perform();
}
protected:
LightState *parent_;
bool include_none_{false};
};
template<typename... Ts> class LightIsOnCondition : public Condition<Ts...> { template<typename... Ts> class LightIsOnCondition : public Condition<Ts...> {
public: public:
explicit LightIsOnCondition(LightState *state) : state_(state) {} explicit LightIsOnCondition(LightState *state) : state_(state) {}
+74 -2
View File
@@ -26,8 +26,8 @@ from esphome.const import (
CONF_WARM_WHITE, CONF_WARM_WHITE,
CONF_WHITE, CONF_WHITE,
) )
from esphome.core import CORE, EsphomeError, Lambda from esphome.core import CORE, ID, EsphomeError, Lambda
from esphome.cpp_generator import LambdaExpression from esphome.cpp_generator import LambdaExpression, MockObj, TemplateArgsType
from esphome.types import ConfigType from esphome.types import ConfigType
from .types import ( from .types import (
@@ -39,12 +39,15 @@ from .types import (
DimRelativeAction, DimRelativeAction,
LightCall, LightCall,
LightControlAction, LightControlAction,
LightEffectCycleAction,
LightIsOffCondition, LightIsOffCondition,
LightIsOnCondition, LightIsOnCondition,
LightState, LightState,
ToggleAction, ToggleAction,
) )
CONF_INCLUDE_NONE = "include_none"
@automation.register_action( @automation.register_action(
"light.toggle", "light.toggle",
@@ -253,6 +256,75 @@ async def light_control_to_code(config, action_id, template_arg, args):
return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda)
def _record_effect_cycle_ref(config: ConfigType) -> ConfigType:
"""Record a cycle-action reference for later validation against the target light."""
from . import EffectCycleRef, _get_data
_get_data().effect_cycle_refs.append(
EffectCycleRef(
light_id=config[CONF_ID],
component_path=path_context.get(),
)
)
return config
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA = cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(LightState),
cv.Optional(CONF_INCLUDE_NONE, default=False): cv.boolean,
}
)
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA.add_extra(_record_effect_cycle_ref)
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA = automation.maybe_simple_id(
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA
)
@automation.register_action(
"light.effect.next",
LightEffectCycleAction,
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA,
synchronous=True,
)
async def light_effect_next_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
return await _light_effect_cycle_to_code(config, action_id, template_arg, True)
@automation.register_action(
"light.effect.previous",
LightEffectCycleAction,
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA,
synchronous=True,
)
async def light_effect_previous_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
return await _light_effect_cycle_to_code(config, action_id, template_arg, False)
async def _light_effect_cycle_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
forward: bool,
) -> MockObj:
paren = await cg.get_variable(config[CONF_ID])
cycle_template_arg = cg.TemplateArguments(forward, *template_arg)
var = cg.new_Pvariable(action_id, cycle_template_arg, paren)
cg.add(var.set_include_none(config[CONF_INCLUDE_NONE]))
return var
CONF_RELATIVE_BRIGHTNESS = "relative_brightness" CONF_RELATIVE_BRIGHTNESS = "relative_brightness"
LIGHT_DIM_RELATIVE_ACTION_SCHEMA = cv.Schema( LIGHT_DIM_RELATIVE_ACTION_SCHEMA = cv.Schema(
{ {
+1
View File
@@ -39,6 +39,7 @@ LIMIT_MODES = {
# Actions # Actions
ToggleAction = light_ns.class_("ToggleAction", automation.Action) ToggleAction = light_ns.class_("ToggleAction", automation.Action)
LightControlAction = light_ns.class_("LightControlAction", automation.Action) LightControlAction = light_ns.class_("LightControlAction", automation.Action)
LightEffectCycleAction = light_ns.class_("LightEffectCycleAction", automation.Action)
DimRelativeAction = light_ns.class_("DimRelativeAction", automation.Action) DimRelativeAction = light_ns.class_("DimRelativeAction", automation.Action)
AddressableSet = light_ns.class_("AddressableSet", automation.Action) AddressableSet = light_ns.class_("AddressableSet", automation.Action)
LightIsOnCondition = light_ns.class_("LightIsOnCondition", automation.Condition) LightIsOnCondition = light_ns.class_("LightIsOnCondition", automation.Condition)
+5
View File
@@ -55,6 +55,7 @@ from .automation import layers_to_code, lvgl_update
from .defines import ( from .defines import (
CONF_ALIGN_TO_LAMBDA_ID, CONF_ALIGN_TO_LAMBDA_ID,
LOGGER, LOGGER,
add_lv_use,
get_focused_widgets, get_focused_widgets,
get_lv_images_used, get_lv_images_used,
get_refreshed_widgets, get_refreshed_widgets,
@@ -71,6 +72,7 @@ from .keypads import KEYPADS_CONFIG, keypads_to_code
from .lv_validation import lv_bool from .lv_validation import lv_bool
from .lvcode import LvContext, LvglComponent, lv_event_t_ptr, lvgl_static from .lvcode import LvContext, LvglComponent, lv_event_t_ptr, lvgl_static
from .schemas import ( from .schemas import (
BASE_PROPS,
DISP_BG_SCHEMA, DISP_BG_SCHEMA,
FULL_STYLE_SCHEMA, FULL_STYLE_SCHEMA,
STYLE_REMAP, STYLE_REMAP,
@@ -100,6 +102,7 @@ from .widgets import (
get_screen_active, get_screen_active,
set_obj_properties, set_obj_properties,
) )
from .widgets.img import CONF_IMAGE
# Import only what we actually use directly in this file # Import only what we actually use directly in this file
from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code
@@ -433,6 +436,8 @@ async def to_code(configs):
# This must be done after all widgets are created # This must be done after all widgets are created
styles_used = df.get_styles_used() 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)
for use in df.get_lv_uses(): for use in df.get_lv_uses():
df.add_define(f"LV_USE_{use.upper()}") df.add_define(f"LV_USE_{use.upper()}")
cg.add_define(f"USE_LVGL_{use.upper()}") cg.add_define(f"USE_LVGL_{use.upper()}")
+6 -4
View File
@@ -74,11 +74,11 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) {
lv_style_set_text_font(style, font->get_lv_font()); lv_style_set_text_font(style, font->get_lv_font());
} }
#endif #endif
#if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE)
#if LV_USE_IMAGE #ifdef USE_IMAGE
#ifdef USE_LVGL_IMAGE
// Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda. // Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda.
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); } inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); }
#endif // LV_USE_IMAGE
inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) {
::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector); ::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector);
@@ -93,7 +93,8 @@ inline void lv_style_set_bg_image_src(lv_style_t *style, image::Image *image) {
inline void lv_style_set_bitmap_mask_src(lv_style_t *style, image::Image *image) { inline void lv_style_set_bitmap_mask_src(lv_style_t *style, image::Image *image) {
::lv_style_set_bitmap_mask_src(style, image->get_lv_image_dsc()); ::lv_style_set_bitmap_mask_src(style, image->get_lv_image_dsc());
} }
#endif // USE_LVGL_IMAGE #endif
#ifdef USE_LVGL_ANIMIMG #ifdef USE_LVGL_ANIMIMG
inline void lv_animimg_set_src(lv_obj_t *img, std::vector<image::Image *> images) { inline void lv_animimg_set_src(lv_obj_t *img, std::vector<image::Image *> images) {
auto *dsc = static_cast<std::vector<lv_image_dsc_t *> *>(lv_obj_get_user_data(img)); auto *dsc = static_cast<std::vector<lv_image_dsc_t *> *>(lv_obj_get_user_data(img));
@@ -109,6 +110,7 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector<image::Image *> images
lv_animimg_set_src(img, (const void **) dsc->data(), dsc->size()); lv_animimg_set_src(img, (const void **) dsc->data(), dsc->size());
} }
#endif // USE_LVGL_ANIMIMG #endif // USE_LVGL_ANIMIMG
#endif // USE_IMAGE
#ifdef USE_LVGL_METER #ifdef USE_LVGL_METER
int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value); int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value);
+32 -10
View File
@@ -1,4 +1,5 @@
from collections.abc import Callable from collections.abc import Callable
from typing import Any
from esphome import config_validation as cv from esphome import config_validation as cv
from esphome.automation import Trigger, validate_automation from esphome.automation import Trigger, validate_automation
@@ -534,7 +535,16 @@ def strip_defaults(schema: cv.Schema):
return cv.Schema({cv.Optional(k): v for k, v in schema.schema.items()}) return cv.Schema({cv.Optional(k): v for k, v in schema.schema.items()})
def container_schema(widget_type: WidgetType, extras=None): # Keyed by (id(widget_type), id(extras)); strong refs in the value keep both
# alive so id() can't be recycled.
_CONTAINER_SCHEMA_CACHE: dict[
tuple[int, int], tuple[Any, Any, Callable[[Any], Any]]
] = {}
def container_schema(
widget_type: WidgetType, extras: Any = None
) -> Callable[[Any], Any]:
""" """
Create a schema for a container widget of a given type. All obj properties are available, plus Create a schema for a container widget of a given type. All obj properties are available, plus
the extras passed in, plus any defined for the specific widget being specified. the extras passed in, plus any defined for the specific widget being specified.
@@ -542,19 +552,31 @@ def container_schema(widget_type: WidgetType, extras=None):
:param extras: Additional options to be made available, e.g. layout properties for children :param extras: Additional options to be made available, e.g. layout properties for children
:return: The schema for this type of widget. :return: The schema for this type of widget.
""" """
schema = obj_schema(widget_type).extend( cache_key = (id(widget_type), id(extras))
{cv.GenerateID(): cv.declare_id(widget_type.w_type)} cached = _CONTAINER_SCHEMA_CACHE.get(cache_key)
) if cached is not None:
if extras: cached_widget_type, cached_extras, cached_validator = cached
schema = schema.extend(extras) if cached_widget_type is widget_type and cached_extras is extras:
# Delayed evaluation for recursion return cached_validator
schema = schema.extend(widget_type.schema) cached_schema: cv.Schema | None = None
def validator(value): def get_schema() -> cv.Schema:
nonlocal cached_schema
if cached_schema is None:
schema = obj_schema(widget_type).extend(
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
)
if extras:
schema = schema.extend(extras)
cached_schema = schema.extend(widget_type.schema)
return cached_schema
def validator(value: Any) -> Any:
value = value or {} value = value or {}
return append_layout_schema(schema, value)(value) return append_layout_schema(get_schema(), value)(value)
_CONTAINER_SCHEMA_CACHE[cache_key] = (widget_type, extras, validator)
return validator return validator
+2
View File
@@ -9,6 +9,7 @@ from .defines import (
CONF_THEME, CONF_THEME,
LValidator, LValidator,
add_lv_use, add_lv_use,
get_styles_used,
get_theme_widget_map, get_theme_widget_map,
literal, literal,
) )
@@ -25,6 +26,7 @@ def has_style_props(config) -> bool:
async def style_set(svar, style): async def style_set(svar, style):
for prop, validator in ALL_STYLES.items(): for prop, validator in ALL_STYLES.items():
if (value := style.get(prop)) is not None: if (value := style.get(prop)) is not None:
get_styles_used().add(prop)
if isinstance(validator, LValidator): if isinstance(validator, LValidator):
value = await validator.process(value) value = await validator.process(value)
if isinstance(value, list): if isinstance(value, list):
+33 -3
View File
@@ -1,4 +1,6 @@
from collections.abc import Callable
import sys import sys
from typing import Any
from esphome import codegen as cg, config_validation as cv from esphome import codegen as cg, config_validation as cv
from esphome.automation import register_action from esphome.automation import register_action
@@ -15,6 +17,7 @@ from esphome.const import (
from esphome.core import ID, EsphomeError, TimePeriod from esphome.core import ID, EsphomeError, TimePeriod
from esphome.coroutine import FakeAwaitable from esphome.coroutine import FakeAwaitable
from esphome.cpp_generator import MockObj from esphome.cpp_generator import MockObj
from esphome.schema_extractors import EnableSchemaExtraction
from esphome.types import Expression from esphome.types import Expression
from ..defines import ( from ..defines import (
@@ -73,6 +76,34 @@ from ..types import (
EVENT_LAMB = "event_lamb__" EVENT_LAMB = "event_lamb__"
def _build_update_schema(widget_type: "WidgetType") -> Schema:
# Local import: ..schemas imports WidgetType from this module.
from ..schemas import base_update_schema
return base_update_schema(widget_type, widget_type.parts).extend(
widget_type.modify_schema
)
def _update_action_schema(
widget_type: "WidgetType",
) -> Schema | Callable[[Any], Any]:
# Eager when extracting so build_language_schema.py sees the mapping;
# lazy otherwise to skip ~200 ms of import-time voluptuous work.
if EnableSchemaExtraction:
return _build_update_schema(widget_type)
cached: Schema | None = None
def validator(value: Any) -> Any:
nonlocal cached
if cached is None:
cached = _build_update_schema(widget_type)
return cached(value)
return validator
class WidgetType: class WidgetType:
""" """
Describes a type of Widget, e.g. "bar" or "line" Describes a type of Widget, e.g. "bar" or "line"
@@ -113,18 +144,17 @@ class WidgetType:
# Local import to avoid circular import # Local import to avoid circular import
from ..automation import update_to_code from ..automation import update_to_code
from ..schemas import WIDGET_TYPES, base_update_schema from ..schemas import WIDGET_TYPES
if not is_mock: if not is_mock:
if self.name in WIDGET_TYPES: if self.name in WIDGET_TYPES:
raise EsphomeError(f"Duplicate definition of widget type '{self.name}'") raise EsphomeError(f"Duplicate definition of widget type '{self.name}'")
WIDGET_TYPES[self.name] = self WIDGET_TYPES[self.name] = self
# Register the update action automatically, adding widget-specific properties
register_action( register_action(
f"lvgl.{self.name}.update", f"lvgl.{self.name}.update",
ObjUpdateAction, ObjUpdateAction,
base_update_schema(self, self.parts).extend(self.modify_schema), _update_action_schema(self),
synchronous=True, synchronous=True,
)(update_to_code) )(update_to_code)
-2
View File
@@ -25,7 +25,6 @@ from esphome.const import (
CONF_TEMPERATURE_COMPENSATION, CONF_TEMPERATURE_COMPENSATION,
CONF_TIME_CONSTANT, CONF_TIME_CONSTANT,
CONF_VOC, CONF_VOC,
DEVICE_CLASS_AQI,
DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PM1, DEVICE_CLASS_PM1,
DEVICE_CLASS_PM10, DEVICE_CLASS_PM10,
@@ -77,7 +76,6 @@ def _gas_sensor(
return sensor.sensor_schema( return sensor.sensor_schema(
icon=ICON_RADIATOR, icon=ICON_RADIATOR,
accuracy_decimals=0, accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT, state_class=STATE_CLASS_MEASUREMENT,
).extend( ).extend(
{ {
-3
View File
@@ -14,7 +14,6 @@ from esphome.const import (
CONF_TEMPERATURE, CONF_TEMPERATURE,
CONF_TYPE, CONF_TYPE,
CONF_VOC, CONF_VOC,
DEVICE_CLASS_AQI,
DEVICE_CLASS_CARBON_DIOXIDE, DEVICE_CLASS_CARBON_DIOXIDE,
DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PM1, DEVICE_CLASS_PM1,
@@ -93,13 +92,11 @@ CONFIG_SCHEMA = (
cv.Optional(CONF_VOC): sensor.sensor_schema( cv.Optional(CONF_VOC): sensor.sensor_schema(
icon=ICON_RADIATOR, icon=ICON_RADIATOR,
accuracy_decimals=0, accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT, state_class=STATE_CLASS_MEASUREMENT,
), ),
cv.Optional(CONF_NOX): sensor.sensor_schema( cv.Optional(CONF_NOX): sensor.sensor_schema(
icon=ICON_RADIATOR, icon=ICON_RADIATOR,
accuracy_decimals=0, accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT, state_class=STATE_CLASS_MEASUREMENT,
), ),
cv.Optional(CONF_CO2): sensor.sensor_schema( cv.Optional(CONF_CO2): sensor.sensor_schema(
+1 -1
View File
@@ -206,7 +206,7 @@ async def to_code(config: ConfigType) -> None:
) )
# sendspin-cpp library # sendspin-cpp library
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.5.0") esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1")
cg.add_define("USE_SENDSPIN", True) # for MDNS cg.add_define("USE_SENDSPIN", True) # for MDNS
+2
View File
@@ -96,6 +96,7 @@ from esphome.const import (
DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_TEMPERATURE_DELTA, DEVICE_CLASS_TEMPERATURE_DELTA,
DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_TIMESTAMP,
DEVICE_CLASS_UPTIME,
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS,
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS,
DEVICE_CLASS_VOLTAGE, DEVICE_CLASS_VOLTAGE,
@@ -174,6 +175,7 @@ DEVICE_CLASSES = [
DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_TEMPERATURE_DELTA, DEVICE_CLASS_TEMPERATURE_DELTA,
DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_TIMESTAMP,
DEVICE_CLASS_UPTIME,
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS,
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS,
DEVICE_CLASS_VOLTAGE, DEVICE_CLASS_VOLTAGE,
-3
View File
@@ -15,7 +15,6 @@ from esphome.const import (
CONF_STORE_BASELINE, CONF_STORE_BASELINE,
CONF_TEMPERATURE_SOURCE, CONF_TEMPERATURE_SOURCE,
CONF_VOC, CONF_VOC,
DEVICE_CLASS_AQI,
ICON_RADIATOR, ICON_RADIATOR,
STATE_CLASS_MEASUREMENT, STATE_CLASS_MEASUREMENT,
) )
@@ -72,13 +71,11 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_VOC): sensor.sensor_schema( cv.Optional(CONF_VOC): sensor.sensor_schema(
icon=ICON_RADIATOR, icon=ICON_RADIATOR,
accuracy_decimals=0, accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT, state_class=STATE_CLASS_MEASUREMENT,
).extend(VOC_SENSOR), ).extend(VOC_SENSOR),
cv.Optional(CONF_NOX): sensor.sensor_schema( cv.Optional(CONF_NOX): sensor.sensor_schema(
icon=ICON_RADIATOR, icon=ICON_RADIATOR,
accuracy_decimals=0, accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT, state_class=STATE_CLASS_MEASUREMENT,
).extend(NOX_SENSOR), ).extend(NOX_SENSOR),
cv.Optional(CONF_STORE_BASELINE, default=True): cv.boolean, cv.Optional(CONF_STORE_BASELINE, default=True): cv.boolean,
+6 -6
View File
@@ -30,8 +30,8 @@ static constexpr uint8_t OCP_140MA = 0x38; // 140 mA max current
static constexpr float LOW_DATA_RATE_OPTIMIZE_THRESHOLD = 16.38f; // 16.38 ms static constexpr float LOW_DATA_RATE_OPTIMIZE_THRESHOLD = 16.38f; // 16.38 ms
uint8_t SX126x::read_fifo_(uint8_t offset, std::vector<uint8_t> &packet) { uint8_t SX126x::read_fifo_(uint8_t offset, std::vector<uint8_t> &packet) {
this->wait_busy_();
this->enable(); this->enable();
this->wait_busy_();
this->transfer_byte(RADIO_READ_BUFFER); this->transfer_byte(RADIO_READ_BUFFER);
this->transfer_byte(offset); this->transfer_byte(offset);
uint8_t status = this->transfer_byte(0x00); uint8_t status = this->transfer_byte(0x00);
@@ -43,8 +43,8 @@ uint8_t SX126x::read_fifo_(uint8_t offset, std::vector<uint8_t> &packet) {
} }
void SX126x::write_fifo_(uint8_t offset, const std::vector<uint8_t> &packet) { void SX126x::write_fifo_(uint8_t offset, const std::vector<uint8_t> &packet) {
this->wait_busy_();
this->enable(); this->enable();
this->wait_busy_();
this->transfer_byte(RADIO_WRITE_BUFFER); this->transfer_byte(RADIO_WRITE_BUFFER);
this->transfer_byte(offset); this->transfer_byte(offset);
for (const uint8_t &byte : packet) { for (const uint8_t &byte : packet) {
@@ -55,8 +55,8 @@ void SX126x::write_fifo_(uint8_t offset, const std::vector<uint8_t> &packet) {
} }
uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
this->wait_busy_();
this->enable(); this->enable();
this->wait_busy_();
this->transfer_byte(opcode); this->transfer_byte(opcode);
uint8_t status = this->transfer_byte(0x00); uint8_t status = this->transfer_byte(0x00);
for (int32_t i = 0; i < size; i++) { for (int32_t i = 0; i < size; i++) {
@@ -67,8 +67,8 @@ uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
} }
void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
this->wait_busy_();
this->enable(); this->enable();
this->wait_busy_();
this->transfer_byte(opcode); this->transfer_byte(opcode);
for (int32_t i = 0; i < size; i++) { for (int32_t i = 0; i < size; i++) {
this->transfer_byte(data[i]); this->transfer_byte(data[i]);
@@ -78,8 +78,8 @@ void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
} }
void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) { void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) {
this->wait_busy_();
this->enable(); this->enable();
this->wait_busy_();
this->write_byte(RADIO_READ_REGISTER); this->write_byte(RADIO_READ_REGISTER);
this->write_byte((reg >> 8) & 0xFF); this->write_byte((reg >> 8) & 0xFF);
this->write_byte((reg >> 0) & 0xFF); this->write_byte((reg >> 0) & 0xFF);
@@ -91,8 +91,8 @@ void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) {
} }
void SX126x::write_register_(uint16_t reg, uint8_t *data, uint8_t size) { void SX126x::write_register_(uint16_t reg, uint8_t *data, uint8_t size) {
this->wait_busy_();
this->enable(); this->enable();
this->wait_busy_();
this->write_byte(RADIO_WRITE_REGISTER); this->write_byte(RADIO_WRITE_REGISTER);
this->write_byte((reg >> 8) & 0xFF); this->write_byte((reg >> 8) & 0xFF);
this->write_byte((reg >> 0) & 0xFF); this->write_byte((reg >> 0) & 0xFF);
+9 -7
View File
@@ -206,15 +206,17 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff
if (this->status_pin_reported_ != -1) { if (this->status_pin_reported_ != -1) {
this->init_state_ = TuyaInitState::INIT_DATAPOINT; this->init_state_ = TuyaInitState::INIT_DATAPOINT;
this->send_empty_command_(TuyaCommandType::DATAPOINT_QUERY); this->send_empty_command_(TuyaCommandType::DATAPOINT_QUERY);
bool is_pin_equals = if (this->status_pin_ != nullptr) {
this->status_pin_ != nullptr && this->status_pin_->get_pin() == this->status_pin_reported_; if (this->status_pin_->get_pin() != this->status_pin_reported_) {
// Configure status pin toggling (if reported and configured) or WIFI_STATE periodic send ESP_LOGW(TAG, "Supplied status_pin does not equal the reported pin %i. Using supplied pin anyway.",
if (!is_pin_equals) { this->status_pin_reported_);
ESP_LOGW(TAG, "Supplied status_pin does not equals the reported pin %i. Using supplied pin anyway.", }
ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin());
this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); });
} else {
ESP_LOGW(TAG, "MCU reported status_pin %i but no status_pin was configured; running in limited mode.",
this->status_pin_reported_); this->status_pin_reported_);
} }
ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin());
this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); });
} else { } else {
this->init_state_ = TuyaInitState::INIT_WIFI; this->init_state_ = TuyaInitState::INIT_WIFI;
ESP_LOGV(TAG, "Configured WIFI_STATE periodic send"); ESP_LOGV(TAG, "Configured WIFI_STATE periodic send");
+5 -4
View File
@@ -513,10 +513,11 @@ async def uart_write_to_code(config, action_id, template_arg, args):
@coroutine_with_priority(CoroPriority.FINAL) @coroutine_with_priority(CoroPriority.FINAL)
async def final_step(): async def final_step():
"""Final code generation step to configure optional UART features.""" """Final code generation step to configure optional UART features."""
if CORE.is_esp32 and CORE.has_networking: if (CORE.is_esp32 or CORE.is_esp8266) and CORE.has_networking:
# Wake-on-RX is essentially free on ESP32 (just an ISR function pointer # Wake-on-RX is essentially free (just an ISR function pointer
# registration) — enable by default to reduce RX buffer overflow risk # registration on ESP32, an inline flag set on ESP8266 software
# by waking the main loop immediately when data arrives. # serial) — enable by default to reduce RX buffer overflow risk by
# waking the main loop immediately when data arrives.
cg.add_define("USE_UART_WAKE_LOOP_ON_RX") cg.add_define("USE_UART_WAKE_LOOP_ON_RX")
@@ -4,6 +4,9 @@
#include "esphome/core/defines.h" #include "esphome/core/defines.h"
#include "esphome/core/helpers.h" #include "esphome/core/helpers.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
#ifdef USE_UART_WAKE_LOOP_ON_RX
#include "esphome/core/wake.h"
#endif
#ifdef USE_LOGGER #ifdef USE_LOGGER
#include "esphome/components/logger/logger.h" #include "esphome/components/logger/logger.h"
@@ -149,7 +152,11 @@ void ESP8266UartComponent::dump_config() {
if (this->hw_serial_ != nullptr) { if (this->hw_serial_ != nullptr) {
ESP_LOGCONFIG(TAG, " Using hardware serial interface."); ESP_LOGCONFIG(TAG, " Using hardware serial interface.");
} else { } else {
ESP_LOGCONFIG(TAG, " Using software serial"); ESP_LOGCONFIG(TAG, " Using software serial"
#ifdef USE_UART_WAKE_LOOP_ON_RX
"\n Wake on data RX: ENABLED"
#endif
);
} }
this->check_logger_conflict(); this->check_logger_conflict();
} }
@@ -266,6 +273,12 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) {
arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_; arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_;
// Clear RX pin so that the interrupt doesn't re-trigger right away again. // Clear RX pin so that the interrupt doesn't re-trigger right away again.
arg->rx_pin_.clear_interrupt(); arg->rx_pin_.clear_interrupt();
#ifdef USE_UART_WAKE_LOOP_ON_RX
// Wake the main loop so the consuming component drains the byte promptly
// instead of waiting for the next loop_interval_ tick. Important for timing
// sensitive setups that poll read() in a tight loop (e.g. fingerprint_grow).
wake_loop_isrsafe();
#endif
} }
void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) { void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) {
if (this->gpio_tx_pin_ == nullptr) { if (this->gpio_tx_pin_ == nullptr) {
+2 -3
View File
@@ -4,7 +4,7 @@ import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
CONF_TIME_ID, CONF_TIME_ID,
DEVICE_CLASS_DURATION, DEVICE_CLASS_DURATION,
DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_UPTIME,
ENTITY_CATEGORY_DIAGNOSTIC, ENTITY_CATEGORY_DIAGNOSTIC,
ICON_TIMER, ICON_TIMER,
STATE_CLASS_TOTAL_INCREASING, STATE_CLASS_TOTAL_INCREASING,
@@ -33,9 +33,8 @@ CONFIG_SCHEMA = cv.typed_schema(
).extend(cv.polling_component_schema("60s")), ).extend(cv.polling_component_schema("60s")),
"timestamp": sensor.sensor_schema( "timestamp": sensor.sensor_schema(
UptimeTimestampSensor, UptimeTimestampSensor,
icon=ICON_TIMER,
accuracy_decimals=0, accuracy_decimals=0,
device_class=DEVICE_CLASS_TIMESTAMP, device_class=DEVICE_CLASS_UPTIME,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC, entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
) )
.extend( .extend(
+51 -1
View File
@@ -54,10 +54,18 @@ from esphome.const import (
CONF_TTLS_PHASE_2, CONF_TTLS_PHASE_2,
CONF_USE_ADDRESS, CONF_USE_ADDRESS,
CONF_USERNAME, CONF_USERNAME,
CONF_WIFI,
PLACEHOLDER_WIFI_SSID,
Platform, Platform,
PlatformFramework, PlatformFramework,
) )
from esphome.core import CORE, CoroPriority, HexInt, coroutine_with_priority from esphome.core import (
CORE,
CoroPriority,
EsphomeError,
HexInt,
coroutine_with_priority,
)
import esphome.final_validate as fv import esphome.final_validate as fv
from esphome.types import ConfigType from esphome.types import ConfigType
@@ -903,3 +911,45 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
"wifi_component_pico_w.cpp": {PlatformFramework.RP2040_ARDUINO}, "wifi_component_pico_w.cpp": {PlatformFramework.RP2040_ARDUINO},
} }
) )
def _placeholder_wifi_credentials(config: ConfigType) -> list[str]:
"""Return human-readable locations where the dashboard's placeholder wifi
values still appear. Empty list means no placeholders were found.
"""
placeholders: list[str] = []
wifi_conf = config.get(CONF_WIFI)
if not wifi_conf:
return placeholders
for idx, network in enumerate(wifi_conf.get(CONF_NETWORKS, [])):
ssid = network.get(CONF_SSID)
if isinstance(ssid, str) and ssid == PLACEHOLDER_WIFI_SSID:
placeholders.append(f"wifi.networks[{idx}].ssid")
ap_conf = wifi_conf.get(CONF_AP)
if ap_conf:
ap_ssid = ap_conf.get(CONF_SSID)
if isinstance(ap_ssid, str) and ap_ssid == PLACEHOLDER_WIFI_SSID:
placeholders.append("wifi.ap.ssid")
return placeholders
def check_placeholder_credentials(config: ConfigType) -> None:
"""Raise EsphomeError if any wifi credential is the dashboard placeholder.
Call only at compile time. NEVER from CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA,
or any path reached by `esphome config`; device-builder relies on
validation passing with the placeholders still in place.
"""
locations = _placeholder_wifi_credentials(config)
if not locations:
return
formatted = ", ".join(locations)
raise EsphomeError(
f"wifi configuration still contains the dashboard placeholder value "
f"'{PLACEHOLDER_WIFI_SSID}' at: {formatted}. "
f"Open secrets.yaml and replace 'wifi_ssid' (and 'wifi_password') "
f"with your real wifi credentials before flashing."
)
+2
View File
@@ -50,6 +50,8 @@ _LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@luar123", "@tomaszduda23"] CODEOWNERS = ["@luar123", "@tomaszduda23"]
CONFLICTS_WITH = ["openthread"]
BASE_SCHEMA = cv.Schema( BASE_SCHEMA = cv.Schema(
{ {
cv.Optional(CONF_REPORT): cv.All( cv.Optional(CONF_REPORT): cv.All(
+3 -7
View File
@@ -117,15 +117,11 @@ def final_validate_esp32(config: ConfigType) -> ConfigType:
if not CORE.is_esp32: if not CORE.is_esp32:
return config return config
if CONF_WIFI in fv.full_config.get(): if CONF_WIFI in fv.full_config.get():
if config[CONF_ROUTER] and CONF_AP in fv.full_config.get()[CONF_WIFI]:
raise cv.Invalid(
"Only Zigbee End Device can be used together with a Wifi Access Point."
)
if CONF_AP in fv.full_config.get()[CONF_WIFI]: if CONF_AP in fv.full_config.get()[CONF_WIFI]:
_LOGGER.warning( raise cv.Invalid(
"Wifi Access Point might be unstable while Zigbee is active, use only as fallback." "A Wifi Access Point can not be used together with Zigbee."
) )
elif config[CONF_ROUTER]: if config[CONF_ROUTER]:
_LOGGER.warning( _LOGGER.warning(
"The Zigbee Router might miss packets while Wifi is active and could destabilize " "The Zigbee Router might miss packets while Wifi is active and could destabilize "
"your network. Use only if Wifi is off most of the time." "your network. Use only if Wifi is off most of the time."
+10
View File
@@ -1367,6 +1367,7 @@ DEVICE_CLASS_TEMPERATURE = "temperature"
DEVICE_CLASS_TEMPERATURE_DELTA = "temperature_delta" DEVICE_CLASS_TEMPERATURE_DELTA = "temperature_delta"
DEVICE_CLASS_TIMESTAMP = "timestamp" DEVICE_CLASS_TIMESTAMP = "timestamp"
DEVICE_CLASS_UPDATE = "update" DEVICE_CLASS_UPDATE = "update"
DEVICE_CLASS_UPTIME = "uptime"
DEVICE_CLASS_VIBRATION = "vibration" DEVICE_CLASS_VIBRATION = "vibration"
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds"
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts" DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts"
@@ -1415,3 +1416,12 @@ ENTITY_CATEGORY_DIAGNOSTIC = "diagnostic"
# The corresponding constant exists in c++ # The corresponding constant exists in c++
# when update_interval is set to never, it becomes SCHEDULER_DONT_RUN milliseconds # when update_interval is set to never, it becomes SCHEDULER_DONT_RUN milliseconds
SCHEDULER_DONT_RUN = 4294967295 SCHEDULER_DONT_RUN = 4294967295
# Sentinel values written by the esphome-device-builder dashboard into
# secrets.yaml on first boot so that !secret wifi_ssid / !secret wifi_password
# references resolve cleanly through validation before the user has finished
# the onboarding wizard. Compilation refuses if these reach the binary so that
# a user who dismisses onboarding can't accidentally flash a device that will
# never associate with their wifi.
PLACEHOLDER_WIFI_SSID = "REPLACE_WITH_YOUR_WIFI_NETWORK"
PLACEHOLDER_WIFI_PASSWORD = "REPLACE_WITH_YOUR_WIFI_PASSWORD" # noqa: S105
+8 -1
View File
@@ -5,7 +5,7 @@ import math
import os import os
from pathlib import Path from pathlib import Path
import re import re
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
from esphome.const import ( from esphome.const import (
CONF_COMMENT, CONF_COMMENT,
@@ -569,6 +569,12 @@ class EsphomeCore:
self.build_path: Path | None = None self.build_path: Path | None = None
# The validated configuration, this is None until the config has been validated # The validated configuration, this is None until the config has been validated
self.config: ConfigType | None = None self.config: ConfigType | None = None
# YAML frontmatter loaded from user YAML files. Frontmatter is a leading
# YAML document separated by `---` from the actual configuration. It is
# ignored by config validation and code generation, but kept here so it
# can be inspected by callers (tooling, future features). Keyed by the
# resolved Path of the source file.
self.frontmatter: dict[Path, Any] = {}
# The pending tasks in the task queue (mostly for C++ generation) # The pending tasks in the task queue (mostly for C++ generation)
# This is a priority queue (with heapq) # This is a priority queue (with heapq)
# Each item is a tuple of form: (-priority, unique number, task) # Each item is a tuple of form: (-priority, unique number, task)
@@ -634,6 +640,7 @@ class EsphomeCore:
self.config_path = None self.config_path = None
self.build_path = None self.build_path = None
self.config = None self.config = None
self.frontmatter = {}
self.event_loop = _FakeEventLoop() self.event_loop = _FakeEventLoop()
self.task_counter = 0 self.task_counter = 0
self.variables = {} self.variables = {}
+5 -1
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include <string>
#include <cstdint> #include <cstdint>
#include <cstring>
#include <string>
#include "gpio.h" #include "gpio.h"
#include "esphome/core/defines.h" #include "esphome/core/defines.h"
#include "esphome/core/time_64.h" #include "esphome/core/time_64.h"
@@ -42,6 +43,9 @@ void __attribute__((noreturn)) arch_restart();
inline uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } inline uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; }
inline const char *progmem_read_ptr(const char *const *addr) { return *addr; } inline const char *progmem_read_ptr(const char *const *addr) { return *addr; }
inline uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; } inline uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; }
// Bulk copy out of PROGMEM. PROGMEM is a no-op everywhere except ESP8266, so a
// plain `std::memcpy` is correct and the fast path here.
inline void progmem_memcpy(void *dst, const void *src, size_t len) { std::memcpy(dst, src, len); }
#endif #endif
} // namespace esphome } // namespace esphome
+38 -61
View File
@@ -93,7 +93,7 @@ class URLSource(Source):
class GitSource(Source): class GitSource(Source):
def __init__(self, url: str, ref: str): def __init__(self, url: str, ref: str | None):
self.url = url self.url = url
self.ref = ref self.ref = ref
@@ -109,7 +109,7 @@ class GitSource(Source):
return path return path
def __str__(self): def __str__(self):
return f"{self.url}#{self.ref}" return f"{self.url}#{self.ref}" if self.ref else self.url
class InvalidIDFComponent(Exception): class InvalidIDFComponent(Exception):
@@ -154,41 +154,6 @@ class IDFComponent:
self.path = self.source.download(self.get_sanitized_name(), force=force) self.path = self.source.download(self.get_sanitized_name(), force=force)
def _sanitize_version(version: str) -> str:
"""
Sanitize a version string by removing common requirement prefixes or a leading v.
Args:
version: Version string to clean.
Returns:
Cleaned version string without common requirement symbols.
"""
version = version.strip()
prefixes = (
"^",
"~=",
"~",
">=",
"<=",
"==",
"!=",
">",
"<",
"=",
"v",
"V",
)
for p in prefixes:
if version.startswith(p):
version = version[len(p) :]
break
return version.strip()
def _get_package_from_pio_registry( def _get_package_from_pio_registry(
username: str | None, pkgname: str, requirements: str username: str | None, pkgname: str, requirements: str
) -> tuple[str, str, str | None, str | None]: ) -> tuple[str, str, str | None, str | None]:
@@ -387,7 +352,6 @@ def _convert_library_to_component(library: Library) -> IDFComponent:
IDFComponent: The resolved component with name, version, and URL IDFComponent: The resolved component with name, version, and URL
Raises: Raises:
ValueError: If a repository URL is missing a reference (#)
RuntimeError: If no artifact can be found for the library RuntimeError: If no artifact can be found for the library
""" """
name = None name = None
@@ -396,20 +360,25 @@ def _convert_library_to_component(library: Library) -> IDFComponent:
# Repository is provided directly # Repository is provided directly
if library.repository: if library.repository:
# Parse repository URL to extract name and version # Parse repository URL: path becomes the component name, fragment
# (if any) becomes the git ref stored on GitSource. A missing
# fragment is fine -- clone_or_update leaves the depth-1 clone on
# the remote's default branch, matching PIO's lib_deps behavior
# and external_components handling.
split_result = urlsplit(library.repository) split_result = urlsplit(library.repository)
if not split_result.fragment.strip():
raise ValueError(f"Missing ref in URL {library.repository}")
# Sanitize name # Sanitize name
name = str(split_result.path).strip("/") name = str(split_result.path).strip("/")
name = name.removesuffix(".git") name = name.removesuffix(".git")
# Sanitize version # IDF Component Manager only accepts "*", a 40-char commit hash, or
version = _sanitize_version(split_result.fragment) # semver here. The actual git ref is preserved in GitSource.ref;
# override_path makes this field cosmetic at build time.
version = "*"
repository = urlunsplit(split_result._replace(fragment="")) repository = urlunsplit(split_result._replace(fragment=""))
source = GitSource(str(repository), split_result.fragment) ref = split_result.fragment.strip() or None
source = GitSource(str(repository), ref)
# Version is provided - resolve using PlatformIO registry # Version is provided - resolve using PlatformIO registry
elif library.version: elif library.version:
@@ -619,9 +588,6 @@ def generate_idf_component_yml(component: IDFComponent) -> str:
if description: if description:
data["description"] = description data["description"] = description
# Do not use the version from library.json/library.properties; it may be incorrect.
data["version"] = component.version
repository = component.data.get("repository", {}).get("url", None) repository = component.data.get("repository", {}).get("url", None)
if repository: if repository:
data["repository"] = repository data["repository"] = repository
@@ -631,20 +597,11 @@ def generate_idf_component_yml(component: IDFComponent) -> str:
if "dependencies" not in data: if "dependencies" not in data:
data["dependencies"] = {} data["dependencies"] = {}
# Add this dependency to dependencies # Every dependency goes through _generate_idf_component →
dep = {} # component.download() before this runs, so .path is always set.
dep["version"] = dependency.version data["dependencies"][dependency.get_sanitized_name()] = {
"override_path": str(dependency.path),
# Should use dependency.path as override path }
try:
dep["override_path"] = str(dependency.path)
except RuntimeError as e:
# No local path: only a GitSource can substitute its URL.
if not isinstance(dependency.source, GitSource):
raise e
dep["git"] = dependency.source.url
data["dependencies"][dependency.get_sanitized_name()] = dep
return yaml_util.dump(data) return yaml_util.dump(data)
@@ -699,6 +656,26 @@ def _process_dependencies(component: IDFComponent):
if not dependencies: if not dependencies:
return return
# PIO's library.json accepts both the list-of-dicts form and the
# shorthand dict form ``{"owner/Name": "version_spec"}``. Normalize
# the dict form so the loop below sees a uniform list. Iterating a
# dict gives string keys, which would silently fail the
# ``"name" in dependency`` substring check and skip every entry.
if isinstance(dependencies, dict):
normalized = []
for raw_name, spec in dependencies.items():
if "/" in raw_name:
owner, pkgname = raw_name.split("/", 1)
else:
owner, pkgname = None, raw_name
entry = {"name": pkgname, "owner": owner}
if isinstance(spec, dict):
entry.update(spec)
else:
entry["version"] = spec
normalized.append(entry)
dependencies = normalized
_LOGGER.info("Processing %s@%s component dependencies...", name, version) _LOGGER.info("Processing %s@%s component dependencies...", name, version)
for dependency in dependencies: for dependency in dependencies:
# Validate dependency structure # Validate dependency structure
+140 -16
View File
@@ -7,6 +7,7 @@ import json
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
import platform
import shutil import shutil
import subprocess import subprocess
import sys import sys
@@ -17,7 +18,7 @@ import requests
from esphome.config_validation import Version from esphome.config_validation import Version
from esphome.core import CORE from esphome.core import CORE
from esphome.helpers import ProgressBar, get_str_env, rmtree from esphome.helpers import ProgressBar, get_str_env, rmtree, write_file_if_changed
PathType = str | os.PathLike PathType = str | os.PathLike
@@ -26,16 +27,18 @@ _LOGGER = logging.getLogger(__name__)
_SCRIPTS_DIR = Path(__file__).parent _SCRIPTS_DIR = Path(__file__).parent
def _str_to_lst_of_str(a: str) -> list[str]: def _str_to_lst_of_str(a: str | list[str]) -> list[str]:
""" """
Convert a string to a list of string Convert a string to a list of string
Args: Args:
a: A string containing semicolon-separated values a: A string containing semicolon-separated values, or an already-split list
Returns: Returns:
list of strings list of strings
""" """
if isinstance(a, list):
return a
return list(f.strip() for f in a.split(";") if f.strip()) return list(f.strip() for f in a.split(";") if f.strip())
@@ -67,10 +70,11 @@ ESPHOME_IDF_DEFAULT_FEATURES = _str_to_lst_of_str(
) )
ESPHOME_IDF_FRAMEWORK_MIRRORS = _str_to_lst_of_str( ESPHOME_IDF_FRAMEWORK_MIRRORS = _str_to_lst_of_str(
os.environ.get( os.environ.get("ESPHOME_IDF_FRAMEWORK_MIRRORS")
"ESPHOME_IDF_FRAMEWORK_MIRRORS", or [
"https://github.com/espressif/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.zip;https://github.com/espressif/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.zip", "https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz",
) "https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.tar.xz",
]
) )
ESP_IDF_CONSTRAINTS_MIRRORS = _str_to_lst_of_str( ESP_IDF_CONSTRAINTS_MIRRORS = _str_to_lst_of_str(
@@ -546,11 +550,11 @@ def _tar_extract_all(
if not (mode & stat.S_IXUSR): if not (mode & stat.S_IXUSR):
mode &= ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) mode &= ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
mode |= stat.S_IRUSR | stat.S_IWUSR mode |= stat.S_IRUSR | stat.S_IWUSR
elif member.isdir() or member.issym(): elif not (member.isdir() or member.issym()):
# Ignore mode for directories & symlinks # Block special files. Directories and symlinks keep
mode = None # their masked-original mode — passing None here would
else: # crash tarfile.extract on Python <3.12 (its chmod
# Block special files # path calls os.chmod unconditionally).
continue continue
member.mode = mode member.mode = mode
@@ -780,12 +784,109 @@ def download_from_mirrors(
return None return None
def _write_idf_version_txt(framework_path: Path, version: str) -> None:
"""Write <framework_path>/version.txt if missing.
IDF's build.cmake picks the version it embeds in the firmware (and
stamps onto the bootloader) in this order: ``${IDF_PATH}/version.txt``
if present, else ``git describe`` against IDF_PATH, else the
``IDF_VERSION_MAJOR/MINOR/PATCH`` triplet from ``tools/cmake/version.cmake``.
On a clean esphome-libs tarball ``.git`` is fully stripped, so
git_describe returns ``HEAD-HASH-NOTFOUND`` (falsy) and the triplet
wins -- correct by luck. But a *partial* ``.git`` (e.g. a custom
framework.source pointed at a real git URL where build artifacts
mark the tree dirty) makes git_describe return ``<hash>-dirty``,
which is what then gets baked into the bootloader. Dropping
version.txt forces the right answer regardless.
"""
version_txt = framework_path / "version.txt"
if version_txt.exists():
return
try:
version_txt.write_text(f"v{version}\n", encoding="utf-8")
except OSError as e:
_LOGGER.warning(
"Could not write %s (%s); bootloader version string may be incorrect.",
version_txt,
e,
)
# Backport of espressif/esp-idf#18272: every ESPHome-supported IDF release
# through v6.0 ships a tools.json whose ninja 1.12.1 entry has no
# ``linux-arm64`` source. ``idf_tools.py`` then either fails to find a
# matching binary or grabs the x86_64 one, which can't execute on
# aarch64. cmake is already populated across the same release range; we
# only need to inject ninja. Values lifted verbatim from the IDF v6.0.1
# tools.json where the fix landed natively.
_NINJA_ARM64_BACKPORT: dict[str, dict[str, str | int]] = {
"1.12.1": {
"rename_dist": "ninja-linux-arm64-v1.12.1.zip",
"sha256": "5c25c6570b0155e95fce5918cb95f1ad9870df5768653afe128db822301a05a1",
"size": 121787,
"url": "https://github.com/ninja-build/ninja/releases/download/v1.12.1/ninja-linux-aarch64.zip",
},
}
def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None:
"""Inject ninja linux-arm64 entries into the framework's tools.json on aarch64.
Idempotent: a tools.json that already has the entry, or a host that
isn't aarch64, is a no-op. Applied unconditionally on every install
check so a build dir extracted before the backport got fixed up
without forcing a clean.
"""
if platform.machine() != "aarch64":
return
tools_json = framework_path / "tools" / "tools.json"
if not tools_json.is_file():
return
try:
with open(tools_json, encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
_LOGGER.warning(
"Could not parse %s for linux-arm64 backport (%s); "
"skipping. A clean reinstall of the framework directory "
"may be needed.",
tools_json,
e,
)
return
changed = False
for tool in data.get("tools", []):
if tool.get("name") != "ninja":
continue
for ver in tool.get("versions", []):
entry = _NINJA_ARM64_BACKPORT.get(ver.get("name"))
if entry is None or ver.get("linux-arm64"):
continue
ver["linux-arm64"] = entry
changed = True
if changed:
# write_file_if_changed stages a tempfile in the destination dir
# and atomically replaces — safe against mid-write interruption
# and concurrent invocations.
write_file_if_changed(tools_json, json.dumps(data, indent=2) + "\n")
_LOGGER.info(
"Patched %s to add ninja linux-arm64 download "
"(espressif/esp-idf#18272 backport).",
tools_json,
)
def _check_esphome_idf_framework_install( def _check_esphome_idf_framework_install(
version: str, version: str,
targets: list[str], targets: list[str],
tools: list[str], tools: list[str],
force: bool = False, force: bool = False,
env: dict[str, str] | None = None, env: dict[str, str] | None = None,
source_url: str | None = None,
) -> tuple[Path, bool]: ) -> tuple[Path, bool]:
""" """
Check and install ESP-IDF framework. Check and install ESP-IDF framework.
@@ -796,6 +897,11 @@ def _check_esphome_idf_framework_install(
tools: list of tools to install tools: list of tools to install
force: If True, force reinstallation force: If True, force reinstallation
env: Optional dictionary of environment variables to set env: Optional dictionary of environment variables to set
source_url: Optional override URL for the framework tarball. Supports
the same ``{VERSION}`` / ``{MAJOR}`` / ``{MINOR}`` / ``{PATCH}`` /
``{EXTRA}`` substitutions as ESPHOME_IDF_FRAMEWORK_MIRRORS. When
set, it replaces the default mirror list no implicit fallback,
so a misspelled URL fails loudly.
Returns: Returns:
tuple of (framework_path, install_flag) tuple of (framework_path, install_flag)
@@ -817,6 +923,10 @@ def _check_esphome_idf_framework_install(
env_stamp_file = framework_path / ESPHOME_STAMP_FILE env_stamp_file = framework_path / ESPHOME_STAMP_FILE
idf_tools_path = framework_path / "tools" / "idf_tools.py" idf_tools_path = framework_path / "tools" / "idf_tools.py"
_LOGGER.info("Checking ESP-IDF %s framework ...", version) _LOGGER.info("Checking ESP-IDF %s framework ...", version)
# Logged every invocation (not just on install) so the user can verify the
# override. A changed URL needs ``esphome clean`` to force a re-download.
if source_url:
_LOGGER.info("Using framework source override: %s", source_url)
# 2. Download and extract the framework if not already extracted. # 2. Download and extract the framework if not already extracted.
# The marker is written last after extraction succeeds, so its presence # The marker is written last after extraction succeeds, so its presence
@@ -844,14 +954,23 @@ def _check_esphome_idf_framework_install(
except ValueError: except ValueError:
pass pass
download_from_mirrors( mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS
ESPHOME_IDF_FRAMEWORK_MIRRORS, substitutions, tmp.file download_from_mirrors(mirrors, substitutions, tmp.file)
)
_LOGGER.info("Extracting ESP-IDF %s framework ...", version) _LOGGER.info("Extracting ESP-IDF %s framework ...", version)
archive_extract_all(tmp.file, framework_path, progress_header="Extracting") archive_extract_all(tmp.file, framework_path, progress_header="Extracting")
extracted_marker.touch() extracted_marker.touch()
# Idempotent post-extract patch: written every invocation so a build
# dir extracted before this fix gets the file too, without forcing a
# clean. Skips when version.txt already exists.
_write_idf_version_txt(framework_path, version)
# Apply the ninja linux-arm64 backport on every invocation, not just on
# fresh extracts — idempotent and cheap, and lets a build dir carrying
# a pre-patch tools.json get fixed up without forcing a clean.
_patch_tools_json_for_linux_arm64(framework_path)
# 3. Check if the framework tools are the same and correctly installed # 3. Check if the framework tools are the same and correctly installed
if not install: if not install:
install = True install = True
@@ -1008,6 +1127,7 @@ def check_esp_idf_install(
tools: list[str] | None = None, tools: list[str] | None = None,
features: list[str] | None = None, features: list[str] | None = None,
force: bool = False, force: bool = False,
source_url: str | None = None,
) -> tuple[Path, Path]: ) -> tuple[Path, Path]:
""" """
Check and install ESP-IDF framework and Python environment. Check and install ESP-IDF framework and Python environment.
@@ -1018,6 +1138,10 @@ def check_esp_idf_install(
tools: list of tools to install tools: list of tools to install
features: Features to install features: Features to install
force: If True, force reinstallation force: If True, force reinstallation
source_url: Optional override URL for the framework tarball. When
set, it replaces the default mirror list (no fallback). Forwarded
to ``_check_esphome_idf_framework_install``; supports the same URL
substitutions.
Returns: Returns:
tuple of (framework_path, python_env_path) tuple of (framework_path, python_env_path)
@@ -1040,7 +1164,7 @@ def check_esp_idf_install(
# 1) Framework # 1) Framework
framework_path, installed = _check_esphome_idf_framework_install( framework_path, installed = _check_esphome_idf_framework_install(
version, targets, tools, force=force, env=env version, targets, tools, force=force, env=env, source_url=source_url
) )
features = features or ESPHOME_IDF_DEFAULT_FEATURES features = features or ESPHOME_IDF_DEFAULT_FEATURES
+6
View File
@@ -66,6 +66,12 @@ FILTER_IDF_LINES: list[str] = [
# Drop the blank line rich emits after the note so the build log # Drop the blank line rich emits after the note so the build log
# doesn't end with an orphan gap before ESPHome's own status lines. # doesn't end with an orphan gap before ESPHome's own status lines.
r"\s*$", r"\s*$",
# ESP-IDF shells out to ``git rev-parse`` to embed a commit hash;
# esphome-libs strips ``.git`` from the tarball so those probes fail
# noisily without affecting the build.
r"-- git rev-parse returned ",
r"fatal: not a git repository",
r"Stopping at filesystem boundary",
] ]
+4 -3
View File
@@ -94,9 +94,10 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None:
_LOGGER.debug("Skipping size summary: %s", e) _LOGGER.debug("Skipping size summary: %s", e)
return return
dram = data.get("memory_types", {}).get("DRAM") or {} memory_types = data.get("memory_types", {})
ram_used = dram.get("used") ram_region = memory_types.get("DRAM") or memory_types.get("DIRAM") or {}
ram_total = dram.get("size") ram_used = ram_region.get("used")
ram_total = ram_region.get("size")
if ram_total and ram_used is not None: if ram_total and ram_used is not None:
print(f"RAM: {_format_bar(ram_used, ram_total)}") print(f"RAM: {_format_bar(ram_used, ram_total)}")
+16 -1
View File
@@ -10,6 +10,7 @@ import shutil
import subprocess import subprocess
from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION
from esphome.const import CONF_FRAMEWORK, CONF_SOURCE
from esphome.core import CORE, EsphomeError from esphome.core import CORE, EsphomeError
from esphome.espidf.framework import check_esp_idf_install, get_framework_env from esphome.espidf.framework import check_esp_idf_install, get_framework_env
from esphome.espidf.size_summary import print_summary from esphome.espidf.size_summary import print_summary
@@ -37,13 +38,27 @@ def _get_core_framework_version():
return str(CORE.data[KEY_ESP32][KEY_IDF_VERSION]) return str(CORE.data[KEY_ESP32][KEY_IDF_VERSION])
def _get_framework_source_override() -> str | None:
"""Return the user-supplied esp32.framework.source override, if any.
The override lets a user point the IDF tarball download at a custom URL
(mirror, fork, local server). Substitutions like ``{VERSION}`` /
``{MAJOR}`` etc. work the same as in the default mirror list.
"""
if CORE.config is None:
return None
return CORE.config.get(KEY_ESP32, {}).get(CONF_FRAMEWORK, {}).get(CONF_SOURCE)
def _get_esphome_esp_idf_paths( def _get_esphome_esp_idf_paths(
version: str | None = None, version: str | None = None,
) -> tuple[os.PathLike, os.PathLike]: ) -> tuple[os.PathLike, os.PathLike]:
version = version or _get_core_framework_version() version = version or _get_core_framework_version()
paths = _cache().paths paths = _cache().paths
if version not in paths: if version not in paths:
paths[version] = check_esp_idf_install(version) paths[version] = check_esp_idf_install(
version, source_url=_get_framework_source_override()
)
return paths[version] return paths[version]
+2 -2
View File
@@ -2,7 +2,7 @@ dependencies:
bblanchon/arduinojson: bblanchon/arduinojson:
version: "7.4.2" version: "7.4.2"
esphome/esp-audio-libs: esphome/esp-audio-libs:
version: 3.0.0 version: 3.1.0
esphome/esp-micro-speech-features: esphome/esp-micro-speech-features:
version: 1.2.3 version: 1.2.3
esphome/micro-decoder: esphome/micro-decoder:
@@ -100,6 +100,6 @@ dependencies:
esp32async/asynctcp: esp32async/asynctcp:
version: 3.4.91 version: 3.4.91
sendspin/sendspin-cpp: sendspin/sendspin-cpp:
version: 0.5.0 version: 0.6.1
lvgl/lvgl: lvgl/lvgl:
version: 9.5.0 version: 9.5.0
+32 -1
View File
@@ -14,6 +14,7 @@ from esphome.const import (
KEY_CORE, KEY_CORE,
KEY_TARGET_FRAMEWORK, KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM, KEY_TARGET_PLATFORM,
Toolchain,
) )
from esphome.core import CORE from esphome.core import CORE
from esphome.helpers import write_file_if_changed from esphome.helpers import write_file_if_changed
@@ -98,6 +99,7 @@ class StorageJSON:
no_mdns: bool, no_mdns: bool,
framework: str | None = None, framework: str | None = None,
core_platform: str | None = None, core_platform: str | None = None,
toolchain: str | None = None,
) -> None: ) -> None:
# Version of the storage JSON schema # Version of the storage JSON schema
assert storage_version is None or isinstance(storage_version, int) assert storage_version is None or isinstance(storage_version, int)
@@ -134,6 +136,8 @@ class StorageJSON:
self.framework = framework self.framework = framework
# The core platform of this firmware. Like "esp32", "rp2040", "host" etc. # The core platform of this firmware. Like "esp32", "rp2040", "host" etc.
self.core_platform = core_platform self.core_platform = core_platform
# The toolchain used for the build ("platformio" / "esp-idf")
self.toolchain = toolchain
def as_dict(self): def as_dict(self):
return { return {
@@ -153,6 +157,7 @@ class StorageJSON:
"no_mdns": self.no_mdns, "no_mdns": self.no_mdns,
"framework": self.framework, "framework": self.framework,
"core_platform": self.core_platform, "core_platform": self.core_platform,
"toolchain": self.toolchain,
} }
def to_json(self): def to_json(self):
@@ -189,6 +194,7 @@ class StorageJSON:
), ),
framework=esph.target_framework, framework=esph.target_framework,
core_platform=esph.target_platform, core_platform=esph.target_platform,
toolchain=esph.toolchain.value if esph.toolchain is not None else None,
) )
@staticmethod @staticmethod
@@ -236,6 +242,7 @@ class StorageJSON:
no_mdns = storage.get("no_mdns", False) no_mdns = storage.get("no_mdns", False)
framework = storage.get("framework") framework = storage.get("framework")
core_platform = storage.get("core_platform") core_platform = storage.get("core_platform")
toolchain = storage.get("toolchain")
return StorageJSON( return StorageJSON(
storage_version, storage_version,
name, name,
@@ -253,6 +260,7 @@ class StorageJSON:
no_mdns, no_mdns,
framework, framework,
core_platform, core_platform,
toolchain,
) )
@staticmethod @staticmethod
@@ -273,10 +281,33 @@ class StorageJSON:
""" """
CORE.name = self.name CORE.name = self.name
CORE.build_path = self.build_path CORE.build_path = self.build_path
# Restore toolchain so upload/logs picks the right firmware_bin path.
# An unknown value (corrupt sidecar, or written by a newer ESPHome)
# just leaves CORE.toolchain None — the fallback then picks PlatformIO.
if self.toolchain and CORE.toolchain is None:
try:
CORE.toolchain = Toolchain(self.toolchain)
except ValueError:
_LOGGER.debug(
"Ignoring unknown toolchain %r from %s",
self.toolchain,
storage_path(),
)
target_platform = self.core_platform or self.target_platform.lower()
CORE.data[KEY_CORE] = { CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: self.core_platform or self.target_platform.lower(), KEY_TARGET_PLATFORM: target_platform,
KEY_TARGET_FRAMEWORK: self.framework, KEY_TARGET_FRAMEWORK: self.framework,
} }
# The compile pipeline populates CORE.data[KEY_ESP32] when esp32's
# validator runs; on the cache fast path that validator is skipped,
# so populate the variant upload_using_esptool reads via
# esp32.get_esp32_variant(). target_platform on disk is the variant
# (e.g. "ESP32S3"); core_platform is the family (e.g. "esp32").
if target_platform == const.PLATFORM_ESP32:
from esphome.components.esp32.const import KEY_ESP32
from esphome.const import KEY_VARIANT
CORE.data[KEY_ESP32] = {KEY_VARIANT: self.target_platform}
def __eq__(self, o) -> bool: def __eq__(self, o) -> bool:
return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict()
+15
View File
@@ -87,6 +87,21 @@ def replace_file_content(text, pattern, repl):
def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool:
"""Return True when the build tree must be wiped before reuse.
Predicate is True when *old* is missing (first build),
``src_version`` differs, ``build_path`` differs, or a previously
loaded integration was removed in *new*. Adding integrations or
changing unrelated fields (friendly name, esphome version, etc.)
does not trigger a clean.
Used by esphome-device-builder (esphome/device-builder) to gate
its remote-build artifact materialiser so a local remote local
cycle preserves PlatformIO's local object cache instead of wiping
it on every cycle. The signature, semantics, and ``None`` handling
for *old* are part of the public contract; keep them stable so the
offloader's wipe decision tracks core's.
"""
if old is None: if old is None:
return True return True
+152 -2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from collections.abc import Callable, Generator from collections.abc import Callable, Generator
from contextlib import contextmanager, suppress from contextlib import contextmanager, suppress
from dataclasses import dataclass, field
import functools import functools
import inspect import inspect
from io import BytesIO, TextIOBase, TextIOWrapper from io import BytesIO, TextIOBase, TextIOWrapper
@@ -233,6 +234,130 @@ class IncludeFile:
return has_substitution_or_expression(str(self.file)) return has_substitution_or_expression(str(self.file))
def force_load_include_files(
obj: Any,
*,
warn_on_unresolved: bool = True,
_seen: set[int] | None = None,
) -> None:
"""Recursively resolve any deferred ``IncludeFile`` instances in a YAML tree.
Nested ``!include`` returns a deferred ``IncludeFile`` that is only resolved
later (substitution / packages pass). Callers that need every referenced
file to actually load bundle discovery, on-device YAML recovery invoke
this while a :func:`track_yaml_loads` listener is active so the underlying
loader fires and records every reachable file.
``IncludeFile`` instances whose path contains unresolved substitution
variables cannot be loaded. By default a warning is logged for each one;
pass ``warn_on_unresolved=False`` (used by discovery paths that run on a
fresh re-parse where substitutions haven't been applied yet) to demote it
to a debug log.
"""
if _seen is None:
_seen = set()
if isinstance(obj, IncludeFile):
if id(obj) in _seen:
return
_seen.add(id(obj))
if obj.has_unresolved_expressions():
log = _LOGGER.warning if warn_on_unresolved else _LOGGER.debug
log(
"Cannot resolve !include %s (referenced from %s) with substitutions in path",
obj.file,
obj.parent_file,
)
return
try:
loaded = obj.load()
except EsphomeError as err:
_LOGGER.warning(
"Failed to load !include %s (referenced from %s): %s",
obj.file,
obj.parent_file,
err,
)
return
force_load_include_files(
loaded, warn_on_unresolved=warn_on_unresolved, _seen=_seen
)
elif isinstance(obj, dict):
if id(obj) in _seen:
return
_seen.add(id(obj))
for value in obj.values():
force_load_include_files(
value, warn_on_unresolved=warn_on_unresolved, _seen=_seen
)
elif isinstance(obj, (list, tuple)):
if id(obj) in _seen:
return
_seen.add(id(obj))
for item in obj:
force_load_include_files(
item, warn_on_unresolved=warn_on_unresolved, _seen=_seen
)
@dataclass(slots=True)
class DiscoveredYamlFiles:
"""Result of :func:`discover_user_yaml_files`.
``files`` contains every resolved path the YAML loader touched while we
were re-parsing the user's config; ``secrets`` is the subset whose
*un-resolved* filename matched :data:`esphome.const.SECRETS_FILES` (so
a ``secrets.yaml`` symlinked to a differently-named target is still
flagged as secrets).
"""
files: list[Path] = field(default_factory=list)
secrets: set[Path] = field(default_factory=set)
def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles:
"""Fresh-re-parse ``config_path`` and report every file the YAML loader
pulled in, plus which of them came in under a secrets filename.
Does NOT run schema validation, substitutions, or package resolution so
component-internal YAML loaded by validators (LVGL helpers, dashboard
imports, etc.) is *not* captured. Deferred ``!include`` references whose
paths don't depend on substitutions are force-loaded here so they're
captured too.
Must run on a fresh parse because :meth:`IncludeFile.load` caches its
result; on an already-resolved tree :meth:`load` returns without invoking
the loader and the listener would not fire for the referenced files.
"""
from esphome.const import SECRETS_FILES
secrets: set[Path] = set()
def _capture_secret(fname: Path) -> None:
if Path(fname).name in SECRETS_FILES:
secrets.add(Path(fname).resolve())
with track_yaml_loads() as loaded:
_load_listeners.append(_capture_secret)
try:
try:
data = load_yaml(config_path)
except EsphomeError:
return DiscoveredYamlFiles(list(loaded), secrets)
force_load_include_files(data, warn_on_unresolved=False)
finally:
_load_listeners.remove(_capture_secret)
# Deduplicate while preserving first-seen order.
seen: set[Path] = set()
unique: list[Path] = []
for path in loaded:
if path not in seen:
seen.add(path)
unique.append(path)
return DiscoveredYamlFiles(unique, secrets)
def _add_data_ref(fn): def _add_data_ref(fn):
@functools.wraps(fn) @functools.wraps(fn)
def wrapped(loader, node): def wrapped(loader, node):
@@ -643,10 +768,35 @@ def _load_yaml_internal_with_type(
content: TextIOWrapper, content: TextIOWrapper,
yaml_loader: Callable[[Path], dict[str, Any]], yaml_loader: Callable[[Path], dict[str, Any]],
) -> Any: ) -> Any:
"""Load a YAML file.""" """Load a YAML file.
Supports an optional leading YAML frontmatter document: when the file
contains two YAML documents separated by ``---``, the first document is
treated as metadata and stored in :attr:`CORE.frontmatter` keyed by the
resolved file path, while the second document is returned as the actual
configuration. Frontmatter is ignored by config validation and code
generation.
"""
loader = loader_type(content, fname, yaml_loader) loader = loader_type(content, fname, yaml_loader)
try: try:
return loader.get_single_data() or OrderedDict() documents: list[Any] = []
while loader.check_data():
documents.append(loader.get_data())
if len(documents) > 2:
raise EsphomeError(
f"YAML file '{fname}' contains {len(documents)} documents but "
f"at most two are supported (an optional frontmatter document "
f"followed by the configuration)."
)
if len(documents) == 2:
frontmatter = documents[0]
config = documents[1]
if frontmatter is not None:
CORE.frontmatter[Path(fname).resolve()] = frontmatter
return config if config is not None else OrderedDict()
if len(documents) == 1:
return documents[0] or OrderedDict()
return OrderedDict()
except yaml.YAMLError as exc: except yaml.YAMLError as exc:
raise EsphomeError(exc) from exc raise EsphomeError(exc) from exc
finally: finally:
+4 -4
View File
@@ -12,19 +12,19 @@ platformio==6.1.19
esptool==5.2.0 esptool==5.2.0
click==8.3.3 click==8.3.3
esphome-dashboard==20260425.0 esphome-dashboard==20260425.0
aioesphomeapi==45.0.0 aioesphomeapi==45.0.4
zeroconf==0.148.0 zeroconf==0.149.16
puremagic==1.30 puremagic==1.30
ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml==0.19.1 # dashboard_import
ruamel.yaml.clib==0.2.15 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import
esphome-glyphsets==0.2.0 esphome-glyphsets==0.2.0
pillow==12.2.0 pillow==12.2.0
resvg-py==0.3.1 resvg-py==0.3.2
freetype-py==2.5.1 freetype-py==2.5.1
jinja2==3.1.6 jinja2==3.1.6
bleak==2.1.1 bleak==2.1.1
smpclient==6.0.0 smpclient==6.0.0
requests==2.34.1 requests==2.34.2
# esp-idf >= 5.0 requires this # esp-idf >= 5.0 requires this
pyparsing >= 3.3.2 pyparsing >= 3.3.2
+2 -2
View File
@@ -1,6 +1,6 @@
pylint==4.0.5 pylint==4.0.5
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
ruff==0.15.13 # also change in .pre-commit-config.yaml when updating ruff==0.15.14 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
pre-commit pre-commit
@@ -16,7 +16,7 @@ hypothesis==6.92.1
# CodSpeed benchmarks under tests/benchmarks/python/ # CodSpeed benchmarks under tests/benchmarks/python/
# (skipped via pytest.importorskip when missing -- only required for the # (skipped via pytest.importorskip when missing -- only required for the
# benchmarks job in .github/workflows/ci.yml) # benchmarks job in .github/workflows/ci.yml)
pytest-codspeed==5.0.1 pytest-codspeed==5.0.3
# Used by the import-time regression check (.github/workflows/ci.yml → import-time job) # Used by the import-time regression check (.github/workflows/ci.yml → import-time job)
importtime-waterfall==1.0.0 importtime-waterfall==1.0.0
+80
View File
@@ -5,6 +5,7 @@ This script is a centralized way to determine which CI jobs need to run based on
what files have changed. It outputs JSON with the following structure: what files have changed. It outputs JSON with the following structure:
{ {
"core_ci": true/false,
"integration_tests": true/false, "integration_tests": true/false,
"integration_test_buckets": [{"name": "1/3", "tests": ["tests/integration/test_foo.py", ...]}, ...], "integration_test_buckets": [{"name": "1/3", "tests": ["tests/integration/test_foo.py", ...]}, ...],
"clang_tidy": true/false, "clang_tidy": true/false,
@@ -22,6 +23,11 @@ what files have changed. It outputs JSON with the following structure:
} }
The CI workflow uses this information to: The CI workflow uses this information to:
- Gate the unconditional jobs (ci-custom, pytest, pre-commit-ci-lite) via core_ci;
false when a pull_request only touches CI-irrelevant meta paths (other workflow
files, .github/actions/build-image/*, .yamllint, .github/dependabot.yml, docker/**)
so workflow-only PRs satisfy the required CI Status check without running the
unconditional jobs. Always true on non-pull_request events and under --force-all.
- Skip or run integration tests - Skip or run integration tests
- Skip or run clang-tidy (and whether to do a full scan) - Skip or run clang-tidy (and whether to do a full scan)
- Skip or run clang-format - Skip or run clang-format
@@ -712,6 +718,69 @@ def should_run_benchmarks(branch: str | None = None) -> bool:
return any(get_component_from_path(f) in benchmarked_components for f in files) return any(get_component_from_path(f) in benchmarked_components for f in files)
# Files / path patterns whose changes alone don't warrant running the
# unconditional CI jobs (`ci-custom`, `pytest`, `pre-commit-ci-lite`).
# Single source of truth for what we treat as "CI-irrelevant" on
# pull_request events; ci.yml used to encode this in its own
# `pull_request.paths` filter, but that hid the required `CI Status`
# check on PRs that only touched these files (dependabot Action bumps,
# dependabot.yml edits, docker/ changes, etc.) and forced admin
# force-merges.
#
# ci.yml itself is deliberately *not* ignored — editing the CI workflow
# must still run CI. Workflows that have their own dedicated triggers
# (codeql.yml, ci-docker.yml, ...) are matched via the
# `.github/workflows/*.yml` prefix below and exclude ci.yml explicitly.
CI_IRRELEVANT_EXACT_FILES = frozenset(
{
".yamllint",
".github/dependabot.yml",
}
)
def _is_ci_irrelevant_path(path: str) -> bool:
"""Whether a single changed path is irrelevant to the unconditional CI jobs."""
if path in CI_IRRELEVANT_EXACT_FILES:
return True
# docker/** — all descendants
if path.startswith("docker/"):
return True
# .github/workflows/*.yml — top-level workflow files other than ci.yml
# (ci.yml itself must still trigger full CI when edited).
if path.startswith(".github/workflows/") and path.endswith(".yml"):
if path == ".github/workflows/ci.yml":
return False
if "/" not in path[len(".github/workflows/") :]:
return True
# .github/actions/build-image/* — direct children only, matches the
# single-star glob the workflow used to encode.
if path.startswith(".github/actions/build-image/"):
rest = path[len(".github/actions/build-image/") :]
if rest and "/" not in rest:
return True
return False
def should_run_core_ci(branch: str | None = None) -> bool:
"""Determine if the unconditional CI jobs (ci-custom/pytest/pre-commit-ci-lite) should run.
Returns False only when every changed file is in the CI-irrelevant set
above (see ``_is_ci_irrelevant_path``). Empty diffs return True so we
never accidentally skip CI when the diff probe fails.
Args:
branch: Branch to compare against. If None, uses default.
Returns:
True if the unconditional CI jobs should run, False otherwise.
"""
files = changed_files(branch)
if not files:
return True
return any(not _is_ci_irrelevant_path(f) for f in files)
def _any_changed_file_endswith(branch: str | None, extensions: tuple[str, ...]) -> bool: def _any_changed_file_endswith(branch: str | None, extensions: tuple[str, ...]) -> bool:
"""Check if a changed file ends with any of the specified extensions.""" """Check if a changed file ends with any of the specified extensions."""
return any(file.endswith(extensions) for file in changed_files(branch)) return any(file.endswith(extensions) for file in changed_files(branch))
@@ -1075,6 +1144,16 @@ def main() -> None:
args = parser.parse_args() args = parser.parse_args()
# Determine what should run # Determine what should run
# core_ci gates the unconditional jobs in ci.yml (ci-custom, pytest,
# pre-commit-ci-lite). Non-pull_request events (push to dev/beta/release
# and merge_group) always run them so behavior like venv-cache saves on
# push to dev is preserved.
event_name = os.environ.get("GITHUB_EVENT_NAME", "")
run_core_ci = (
True
if args.force_all or event_name != "pull_request"
else should_run_core_ci(args.branch)
)
if args.force_all: if args.force_all:
integration_run_all, integration_test_files = True, [] integration_run_all, integration_test_files = True, []
run_clang_tidy = True run_clang_tidy = True
@@ -1255,6 +1334,7 @@ def main() -> None:
component_test_batches = [] component_test_batches = []
output: dict[str, Any] = { output: dict[str, Any] = {
"core_ci": run_core_ci,
"integration_tests": run_integration, "integration_tests": run_integration,
"integration_test_buckets": integration_test_buckets, "integration_test_buckets": integration_test_buckets,
"clang_tidy": run_clang_tidy, "clang_tidy": run_clang_tidy,
@@ -9,13 +9,17 @@ import pytest
from esphome import config_validation as cv from esphome import config_validation as cv
from esphome.components.light import ( from esphome.components.light import (
EffectCycleRef,
EffectRef, EffectRef,
_final_validate, _final_validate,
_get_data, _get_data,
available_effects_str, available_effects_str,
find_effect_index, find_effect_index,
) )
from esphome.components.light.automation import _record_effect_ref from esphome.components.light.automation import (
_record_effect_cycle_ref,
_record_effect_ref,
)
from esphome.config import Config, path_context from esphome.config import Config, path_context
from esphome.const import CONF_EFFECT, CONF_EFFECTS, CONF_ID, CONF_NAME from esphome.const import CONF_EFFECT, CONF_EFFECTS, CONF_ID, CONF_NAME
from esphome.core import ID, Lambda from esphome.core import ID, Lambda
@@ -215,6 +219,111 @@ def test_final_validate_drains_refs() -> None:
fv.full_config.reset(token) fv.full_config.reset(token)
# --- _final_validate: EffectCycleRef ---
def _setup_cycle_final_validate(
cycle_refs: list[EffectCycleRef],
light_configs: list[ConfigType],
declare_ids: list[tuple[ID, list[str | int]]],
) -> Token:
"""Set up CORE.data and fv.full_config for EffectCycleRef final_validate tests."""
data = _get_data()
data.effect_cycle_refs = cycle_refs
full_conf = Config()
full_conf["light"] = light_configs
for id_, path in declare_ids:
full_conf.declare_ids.append((id_, path))
return fv.full_config.set(full_conf)
def test_final_validate_cycle_accepts_light_with_effects() -> None:
"""Cycle ref against a light with effects should not raise."""
light_id = ID("led1", is_declaration=True)
token = _setup_cycle_final_validate(
cycle_refs=[
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
],
light_configs=[{CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse")}],
declare_ids=[(light_id, ["light", 0, CONF_ID])],
)
try:
_final_validate({})
finally:
fv.full_config.reset(token)
def test_final_validate_cycle_rejects_light_without_effects_key() -> None:
"""Cycle ref against a light with no CONF_EFFECTS key should raise."""
light_id = ID("led1", is_declaration=True)
token = _setup_cycle_final_validate(
cycle_refs=[
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
],
light_configs=[{CONF_ID: light_id}],
declare_ids=[(light_id, ["light", 0, CONF_ID])],
)
try:
with pytest.raises(cv.FinalExternalInvalid, match="no effects configured"):
_final_validate({})
finally:
fv.full_config.reset(token)
def test_final_validate_cycle_rejects_light_with_empty_effects() -> None:
"""Cycle ref against a light with empty effects list should raise."""
light_id = ID("led1", is_declaration=True)
token = _setup_cycle_final_validate(
cycle_refs=[
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
],
light_configs=[{CONF_ID: light_id, CONF_EFFECTS: []}],
declare_ids=[(light_id, ["light", 0, CONF_ID])],
)
try:
with pytest.raises(cv.FinalExternalInvalid, match="no effects configured"):
_final_validate({})
finally:
fv.full_config.reset(token)
def test_final_validate_cycle_unknown_light_id_skipped() -> None:
"""Cycle refs to unknown light IDs should be silently skipped."""
data = _get_data()
data.effect_cycle_refs = [
EffectCycleRef(
light_id=ID("nonexistent", is_declaration=True),
component_path=["esphome"],
)
]
full_conf = Config()
token = fv.full_config.set(full_conf)
try:
_final_validate({})
finally:
fv.full_config.reset(token)
def test_final_validate_drains_cycle_refs() -> None:
"""Cycle refs should be drained after validation to avoid redundant runs."""
light_id = ID("led1", is_declaration=True)
token = _setup_cycle_final_validate(
cycle_refs=[
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
],
light_configs=[{CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse")}],
declare_ids=[(light_id, ["light", 0, CONF_ID])],
)
try:
_final_validate({})
assert _get_data().effect_cycle_refs == []
finally:
fv.full_config.reset(token)
# --- _record_effect_ref --- # --- _record_effect_ref ---
@@ -278,3 +387,19 @@ def test_record_effect_ref_skips_no_effect_key() -> None:
config: ConfigType = {CONF_ID: ID("led1", is_declaration=True)} config: ConfigType = {CONF_ID: ID("led1", is_declaration=True)}
_record_effect_ref(config) _record_effect_ref(config)
assert _get_data().effect_refs == [] assert _get_data().effect_refs == []
# --- _record_effect_cycle_ref ---
@pytest.mark.usefixtures("_path_ctx")
def test_record_effect_cycle_ref() -> None:
"""Cycle-action config should be recorded with light_id and path."""
light_id = ID("led1", is_declaration=True)
config: ConfigType = {CONF_ID: light_id}
result = _record_effect_cycle_ref(config)
assert result is config
data = _get_data()
assert len(data.effect_cycle_refs) == 1
assert data.effect_cycle_refs[0].light_id is light_id
assert data.effect_cycle_refs[0].component_path == ["esphome"]
@@ -0,0 +1,87 @@
"""Tests for container_schema() memoization and lazy build."""
from __future__ import annotations
from collections.abc import Generator
from unittest.mock import patch
import pytest
from esphome import config_validation as cv
import esphome.components.lvgl # noqa: F401
from esphome.components.lvgl import schemas as lvgl_schemas
from esphome.components.lvgl.schemas import WIDGET_TYPES, container_schema
@pytest.fixture(autouse=True)
def _clear_container_schema_cache() -> Generator[None]:
cache = getattr(lvgl_schemas, "_CONTAINER_SCHEMA_CACHE", None)
if cache is not None:
cache.clear()
yield
if cache is not None:
cache.clear()
def _widget_type(name: str = "obj"):
wt = WIDGET_TYPES.get(name)
assert wt is not None, f"widget type {name!r} not registered"
return wt
def test_same_args_return_same_validator() -> None:
wt = _widget_type("obj")
assert container_schema(wt) is container_schema(wt)
def test_extras_none_vs_truthy_get_different_validators() -> None:
wt = _widget_type("obj")
no_extras = container_schema(wt)
extras = {cv.Optional("custom_extra"): cv.string}
assert no_extras is not container_schema(wt, extras)
def test_different_widget_types_get_different_validators() -> None:
assert container_schema(_widget_type("obj")) is not container_schema(
_widget_type("label")
)
def test_schema_build_is_deferred_until_first_validation() -> None:
wt = _widget_type("obj")
with patch.object(
lvgl_schemas, "obj_schema", wraps=lvgl_schemas.obj_schema
) as obj_schema_mock:
validator = container_schema(wt)
assert obj_schema_mock.call_count == 0
validator({})
assert obj_schema_mock.call_count == 1
validator({})
assert obj_schema_mock.call_count == 1
def test_cached_validator_produces_equivalent_output() -> None:
wt = _widget_type("obj")
cached = container_schema(wt)
cached_result = cached({})
lvgl_schemas._CONTAINER_SCHEMA_CACHE.clear()
reference = container_schema(wt)
assert cached is not reference
assert cached_result == reference({})
def test_id_recycling_is_caught_by_identity_guard() -> None:
wt = _widget_type("obj")
real_extras = {cv.Optional("a"): cv.int_}
validator_a = container_schema(wt, real_extras)
cache_key = (id(wt), id(real_extras))
cached_entry = lvgl_schemas._CONTAINER_SCHEMA_CACHE[cache_key]
sentinel = {cv.Optional("a"): cv.int_}
lvgl_schemas._CONTAINER_SCHEMA_CACHE[cache_key] = (
cached_entry[0],
sentinel,
cached_entry[2],
)
assert container_schema(wt, real_extras) is not validator_a
@@ -0,0 +1,53 @@
"""Tests for lvgl.<widget>.update lazy schema build."""
from __future__ import annotations
from unittest.mock import patch
from esphome.automation import ACTION_REGISTRY
import esphome.components.lvgl # noqa: F401
from esphome.components.lvgl.schemas import WIDGET_TYPES
from esphome.components.lvgl.widgets import _update_action_schema
from esphome.config_validation import Schema
def _widget_type(name: str = "obj"):
wt = WIDGET_TYPES.get(name)
assert wt is not None, f"widget type {name!r} not registered"
return wt
def test_registry_entry_uses_lazy_validator() -> None:
entry = ACTION_REGISTRY["lvgl.label.update"]
assert callable(entry.raw_schema)
assert not isinstance(entry.raw_schema, Schema)
def test_lazy_validator_defers_build_until_first_call() -> None:
wt = _widget_type("label")
with patch(
"esphome.components.lvgl.widgets._build_update_schema",
wraps=lambda w: Schema({}),
) as build_mock:
validator = _update_action_schema(wt)
assert build_mock.call_count == 0
validator({})
assert build_mock.call_count == 1
validator({})
assert build_mock.call_count == 1
def test_eager_build_when_schema_extraction_enabled() -> None:
wt = _widget_type("label")
with patch("esphome.components.lvgl.widgets.EnableSchemaExtraction", True):
result = _update_action_schema(wt)
assert isinstance(result, Schema)
def test_lazy_and_eager_produce_equivalent_validation() -> None:
wt = _widget_type("label")
with patch("esphome.components.lvgl.widgets.EnableSchemaExtraction", True):
eager = _update_action_schema(wt)
lazy = _update_action_schema(wt)
sample = {"id": "label_id"}
assert lazy(sample) == eager(sample)
@@ -1,13 +1,3 @@
substitutions:
i2s_bclk_pin: GPIO27
i2s_lrclk_pin: GPIO26
i2s_mclk_pin: GPIO25
i2s_dout_pin: GPIO12
spdif_data_pin: GPIO4
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
i2s_audio: i2s_audio:
- id: i2s_output - id: i2s_output
@@ -20,6 +10,5 @@ speaker:
use_apll: true use_apll: true
timeout: 2s timeout: 2s
sample_rate: 48000 sample_rate: 48000
bits_per_sample: 16bit
channel: stereo channel: stereo
i2s_mode: primary i2s_mode: primary
@@ -0,0 +1,8 @@
substitutions:
i2s_bclk_pin: GPIO27
i2s_lrclk_pin: GPIO26
i2s_mclk_pin: GPIO25
i2s_dout_pin: GPIO12
spdif_data_pin: GPIO4
<<: !include common-spdif_mode.yaml
+10
View File
@@ -103,6 +103,16 @@ esphome:
- light.turn_on: - light.turn_on:
id: test_monochromatic_light id: test_monochromatic_light
effect: !lambda 'return iteration > 1 ? "Strobe" : "none";' effect: !lambda 'return iteration > 1 ? "Strobe" : "none";'
# Cycle through configured effects (skip "None")
- light.effect.next: test_monochromatic_light
- light.effect.previous: test_monochromatic_light
# Cycle through effects including "None"
- light.effect.next:
id: test_monochromatic_light
include_none: true
- light.effect.previous:
id: test_monochromatic_light
include_none: true
- light.dim_relative: - light.dim_relative:
id: test_monochromatic_light id: test_monochromatic_light
relative_brightness: 5% relative_brightness: 5%
+8 -3
View File
@@ -1503,13 +1503,18 @@ async def test_websocket_refresh_command(
) -> None: ) -> None:
"""Test WebSocket refresh command triggers dashboard update.""" """Test WebSocket refresh command triggers dashboard update."""
with patch("esphome.dashboard.web_server.DASHBOARD_SUBSCRIBER") as mock_subscriber: with patch("esphome.dashboard.web_server.DASHBOARD_SUBSCRIBER") as mock_subscriber:
mock_subscriber.request_refresh = Mock() # Signal an asyncio.Event when request_refresh is invoked so the
# test can deterministically wait for the server-side handler to run
# instead of relying on a fixed sleep (flaky on Windows CI under load).
called = asyncio.Event()
mock_subscriber.request_refresh = Mock(side_effect=called.set)
# Send refresh command # Send refresh command
await websocket_client.write_message(json.dumps({"event": "refresh"})) await websocket_client.write_message(json.dumps({"event": "refresh"}))
# Give it a moment to process # Wait for the server to process the message and invoke request_refresh
await asyncio.sleep(0.01) async with asyncio.timeout(5):
await called.wait()
# Verify request_refresh was called # Verify request_refresh was called
mock_subscriber.request_refresh.assert_called_once() mock_subscriber.request_refresh.assert_called_once()
+99
View File
@@ -775,6 +775,88 @@ def test_should_run_import_time_with_branch() -> None:
mock_changed.assert_called_once_with("release") mock_changed.assert_called_once_with("release")
@pytest.mark.parametrize(
("path", "expected_result"),
[
# Exact-file matches in the CI-irrelevant set.
(".yamllint", True),
(".github/dependabot.yml", True),
# Other top-level workflow files are irrelevant; ci.yml itself is not.
(".github/workflows/codeql.yml", True),
(".github/workflows/release.yml", True),
(".github/workflows/ci.yml", False),
# Nested files under workflows/ are not matched by the single-star glob.
(".github/workflows/matchers/gcc.json", False),
# build-image action: direct children only (single-star glob).
(".github/actions/build-image/action.yml", True),
(".github/actions/build-image/nested/file.yml", False),
# Other actions are CI-relevant.
(".github/actions/restore-python/action.yml", False),
# docker/** covers everything under docker/.
("docker/Dockerfile", True),
("docker/scripts/run.sh", True),
# Regular source files are CI-relevant.
("esphome/__main__.py", False),
("esphome/components/wifi/wifi_component.cpp", False),
("README.md", False),
("tests/script/test_determine_jobs.py", False),
],
)
def test_is_ci_irrelevant_path(path: str, expected_result: bool) -> None:
"""Test _is_ci_irrelevant_path mirrors the historic ci.yml path filter."""
assert determine_jobs._is_ci_irrelevant_path(path) == expected_result
@pytest.mark.parametrize(
("changed_files", "expected_result"),
[
# Empty diffs default to True — don't accidentally skip CI on a
# broken probe.
([], True),
# Any CI-relevant file flips the result to True.
(["esphome/__main__.py"], True),
(["esphome/components/wifi/wifi_component.cpp"], True),
(["README.md"], True),
# All-irrelevant diffs return False.
([".github/workflows/codeql.yml"], False),
(
[".github/workflows/codeql.yml", ".github/workflows/release.yml"],
False,
),
([".yamllint"], False),
([".github/dependabot.yml"], False),
(["docker/Dockerfile"], False),
(
[
".github/workflows/codeql.yml",
".github/dependabot.yml",
"docker/Dockerfile",
],
False,
),
# Mixed diffs always trigger CI.
(
[".github/workflows/codeql.yml", "esphome/__main__.py"],
True,
),
# ci.yml itself is treated as CI-relevant.
([".github/workflows/ci.yml"], True),
],
)
def test_should_run_core_ci(changed_files: list[str], expected_result: bool) -> None:
"""Test should_run_core_ci function."""
with patch.object(determine_jobs, "changed_files", return_value=changed_files):
assert determine_jobs.should_run_core_ci() == expected_result
def test_should_run_core_ci_with_branch() -> None:
"""Test should_run_core_ci passes the branch through to changed_files."""
with patch.object(determine_jobs, "changed_files") as mock_changed:
mock_changed.return_value = []
determine_jobs.should_run_core_ci("release")
mock_changed.assert_called_once_with("release")
@pytest.mark.parametrize( @pytest.mark.parametrize(
("changed_files", "expected_result"), ("changed_files", "expected_result"),
[ [
@@ -1518,6 +1600,7 @@ def test_clang_tidy_mode_full_scan(
mock_should_run_clang_tidy: Mock, mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock, mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock, mock_should_run_python_linters: Mock,
mock_determine_cpp_unit_tests: Mock,
mock_changed_files: Mock, mock_changed_files: Mock,
capsys: pytest.CaptureFixture[str], capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
@@ -1529,6 +1612,9 @@ def test_clang_tidy_mode_full_scan(
mock_should_run_clang_tidy.return_value = True mock_should_run_clang_tidy.return_value = True
mock_should_run_clang_format.return_value = False mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = False mock_should_run_python_linters.return_value = False
# Without this mock, main() runs the real determine_cpp_unit_tests
# which loads the full component graph (~5s import of every component).
mock_determine_cpp_unit_tests.return_value = (False, [])
# Mock changed_files to return no component files # Mock changed_files to return no component files
mock_changed_files.return_value = [] mock_changed_files.return_value = []
@@ -1584,6 +1670,7 @@ def test_clang_tidy_mode_targeted_scan(
mock_should_run_clang_tidy: Mock, mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock, mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock, mock_should_run_python_linters: Mock,
mock_determine_cpp_unit_tests: Mock,
mock_changed_files: Mock, mock_changed_files: Mock,
capsys: pytest.CaptureFixture[str], capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
@@ -1595,6 +1682,9 @@ def test_clang_tidy_mode_targeted_scan(
mock_should_run_clang_tidy.return_value = True mock_should_run_clang_tidy.return_value = True
mock_should_run_clang_format.return_value = False mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = False mock_should_run_python_linters.return_value = False
# Without this mock, main() runs the real determine_cpp_unit_tests
# which loads the full component graph (~5s import of every component).
mock_determine_cpp_unit_tests.return_value = (False, [])
# Create component names # Create component names
components = [f"comp{i}" for i in range(component_count)] components = [f"comp{i}" for i in range(component_count)]
@@ -2651,6 +2741,15 @@ def test_main_force_all_overrides_detection(
return_value={"should_run": "false"}, return_value={"should_run": "false"},
), ),
patch.object(determine_jobs, "should_run_benchmarks", return_value=False), patch.object(determine_jobs, "should_run_benchmarks", return_value=False),
# create_intelligent_batches scans every tests/components/<name>/*.yaml
# under --force-all (~2500 YAML loads, ~10s in CI). This test only
# asserts that main() routes to it and returns non-empty -- the
# batching logic itself has its own dedicated tests.
patch.object(
determine_jobs,
"create_intelligent_batches",
return_value=([["fake_batch"]], None),
),
): ):
determine_jobs.main() determine_jobs.main()
@@ -7,6 +7,9 @@ esp32:
variant: ESP32S3 variant: ESP32S3
framework: framework:
type: esp-idf type: esp-idf
# Use custom partition table with larger app partition (3MB)
# Default IDF partitions only allow 1.75MB which is too small for grouped tests
partitions: ../partitions_testing.csv
logger: logger:
level: VERY_VERBOSE level: VERY_VERBOSE
+76 -2
View File
@@ -3,8 +3,20 @@
import pytest import pytest
from esphome.components.esp32 import const from esphome.components.esp32 import const
from esphome.components.wifi import has_native_wifi, variant_has_wifi from esphome.components.wifi import (
from esphome.const import Platform check_placeholder_credentials,
has_native_wifi,
variant_has_wifi,
)
from esphome.const import (
CONF_AP,
CONF_NETWORKS,
CONF_SSID,
CONF_WIFI,
PLACEHOLDER_WIFI_SSID,
Platform,
)
from esphome.core import EsphomeError, Lambda
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -123,3 +135,65 @@ def test_has_native_wifi_esp32_without_variant_assumes_wifi() -> None:
def test_has_native_wifi_rp2040_without_board_assumes_wifi() -> None: def test_has_native_wifi_rp2040_without_board_assumes_wifi() -> None:
"""RP2040 without a board id falls open to True (custom-board default).""" """RP2040 without a board id falls open to True (custom-board default)."""
assert has_native_wifi(platform=Platform.RP2040) is True assert has_native_wifi(platform=Platform.RP2040) is True
def _wifi_config(
*,
networks: list[dict] | None = None,
ap: dict | None = None,
) -> dict:
"""Build a minimal config dict matching the post-validation shape."""
wifi: dict = {}
if networks is not None:
wifi[CONF_NETWORKS] = networks
if ap is not None:
wifi[CONF_AP] = ap
return {CONF_WIFI: wifi}
def test_check_placeholder_credentials_passes_with_real_ssid() -> None:
"""A real SSID compiles without complaint."""
config = _wifi_config(networks=[{CONF_SSID: "home_network"}])
assert check_placeholder_credentials(config) is None
def test_check_placeholder_credentials_refuses_placeholder_ssid() -> None:
"""The placeholder SSID is rejected with an actionable message."""
config = _wifi_config(networks=[{CONF_SSID: PLACEHOLDER_WIFI_SSID}])
with pytest.raises(EsphomeError) as exc_info:
check_placeholder_credentials(config)
message = str(exc_info.value)
assert "wifi.networks[0].ssid" in message
assert "secrets.yaml" in message
def test_check_placeholder_credentials_refuses_placeholder_in_second_network() -> None:
"""Index reporting picks the placeholder out of a mixed network list."""
config = _wifi_config(
networks=[
{CONF_SSID: "home_network"},
{CONF_SSID: PLACEHOLDER_WIFI_SSID},
],
)
with pytest.raises(EsphomeError) as exc_info:
check_placeholder_credentials(config)
assert "wifi.networks[1].ssid" in str(exc_info.value)
def test_check_placeholder_credentials_refuses_placeholder_ap_ssid() -> None:
"""An AP using the placeholder broadcast name is also refused."""
config = _wifi_config(ap={CONF_SSID: PLACEHOLDER_WIFI_SSID})
with pytest.raises(EsphomeError) as exc_info:
check_placeholder_credentials(config)
assert "wifi.ap.ssid" in str(exc_info.value)
def test_check_placeholder_credentials_no_wifi_passes() -> None:
"""Ethernet-only / wifi-less configs skip the check entirely."""
assert check_placeholder_credentials({}) is None
def test_check_placeholder_credentials_skips_template_ssid() -> None:
"""A templated (Lambda) SSID is not a string and is skipped."""
config = _wifi_config(networks=[{CONF_SSID: Lambda('return "x";')}])
assert check_placeholder_credentials(config) is None
+6 -6
View File
@@ -22,13 +22,13 @@ from esphome.bundle import (
_add_bytes_to_tar, _add_bytes_to_tar,
_default_target_dir, _default_target_dir,
_find_used_secret_keys, _find_used_secret_keys,
_force_load_include_files,
extract_bundle, extract_bundle,
is_bundle_path, is_bundle_path,
prepare_bundle_for_compile, prepare_bundle_for_compile,
read_bundle_manifest, read_bundle_manifest,
) )
from esphome.core import CORE, EsphomeError from esphome.core import CORE, EsphomeError
from esphome.yaml_util import force_load_include_files
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
@@ -947,7 +947,7 @@ def test_discover_files_nested_include_load_failure(
paths = [f.path for f in files] paths = [f.path for f in files]
assert "test.yaml" in paths assert "test.yaml" in paths
assert any( assert any(
"failed to load !include" in r.message and "missing.yaml" in r.message "failed to load !include" in r.message.lower() and "missing.yaml" in r.message
for r in caplog.records for r in caplog.records
) )
@@ -974,8 +974,8 @@ def test_force_load_skips_duplicate_include_file() -> None:
# Same instance appears twice — second visit must hit the _seen guard. # Same instance appears twice — second visit must hit the _seen guard.
tree = {"a": stub, "b": [stub]} tree = {"a": stub, "b": [stub]}
with patch("esphome.bundle.yaml_util.IncludeFile", _StubInclude): with patch("esphome.yaml_util.IncludeFile", _StubInclude):
_force_load_include_files(tree) force_load_include_files(tree)
assert stub.load_calls == 1 assert stub.load_calls == 1
@@ -989,8 +989,8 @@ def test_force_load_handles_cyclic_containers() -> None:
cyclic_list.append(cyclic_list) cyclic_list.append(cyclic_list)
# Should return without recursing forever # Should return without recursing forever
_force_load_include_files(cyclic_dict) force_load_include_files(cyclic_dict)
_force_load_include_files(cyclic_list) force_load_include_files(cyclic_list)
def test_discover_files_yaml_reload_failure( def test_discover_files_yaml_reload_failure(
+153 -3
View File
@@ -22,6 +22,7 @@ from esphome.const import (
KEY_CORE, KEY_CORE,
KEY_TARGET_FRAMEWORK, KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM, KEY_TARGET_PLATFORM,
KEY_VARIANT,
) )
from esphome.core import CORE from esphome.core import CORE
@@ -47,7 +48,12 @@ wifi:
""" """
def _write_storage(storage_path: Path) -> None: def _write_storage(
storage_path: Path,
*,
esp_platform: str = "ESP32",
core_platform: str | None = "esp32",
) -> None:
"""Write a vanilla StorageJSON sidecar for the cache tests.""" """Write a vanilla StorageJSON sidecar for the cache tests."""
storage_path.parent.mkdir(parents=True, exist_ok=True) storage_path.parent.mkdir(parents=True, exist_ok=True)
data = { data = {
@@ -59,14 +65,14 @@ def _write_storage(storage_path: Path) -> None:
"src_version": 1, "src_version": 1,
"address": "192.168.1.42", "address": "192.168.1.42",
"web_port": None, "web_port": None,
"esp_platform": "ESP32", "esp_platform": esp_platform,
"build_path": "/build/lite_test", "build_path": "/build/lite_test",
"firmware_bin_path": "/build/lite_test/firmware.bin", "firmware_bin_path": "/build/lite_test/firmware.bin",
"loaded_integrations": ["api", "logger", "ota", "wifi"], "loaded_integrations": ["api", "logger", "ota", "wifi"],
"loaded_platforms": [], "loaded_platforms": [],
"no_mdns": False, "no_mdns": False,
"framework": "arduino", "framework": "arduino",
"core_platform": "esp32", "core_platform": core_platform,
} }
storage_path.write_text(json.dumps(data)) storage_path.write_text(json.dumps(data))
@@ -123,6 +129,50 @@ def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None:
assert CORE.build_path == Path("/build/lite_test") assert CORE.build_path == Path("/build/lite_test")
assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32" assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32"
assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino" assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino"
# upload_using_esptool reads get_esp32_variant() off CORE.data[KEY_ESP32].
from esphome.components.esp32.const import KEY_ESP32
assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32"
def test_load_compiled_config_populates_esp32_variant(tmp_path: Path) -> None:
"""ESP32 variants survive the cache fast path so esptool gets the right --chip."""
from esphome.components.esp32.const import KEY_ESP32
yaml_path = tmp_path / "lite_test.yaml"
yaml_path.write_text("esphome:\n name: lite_test\n")
CORE.config_path = yaml_path
storage_dir = tmp_path / ".esphome" / "storage"
_write_storage(storage_dir / "lite_test.yaml.json", esp_platform="ESP32S3")
cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml")
_set_cache_mtime(cache, yaml_path, offset=5)
assert load_compiled_config(yaml_path) is not None
assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32S3"
def test_load_compiled_config_skips_esp32_block_for_other_platforms(
tmp_path: Path,
) -> None:
"""Non-esp32 targets shouldn't fabricate an esp32 data block."""
from esphome.components.esp32.const import KEY_ESP32
yaml_path = tmp_path / "lite_test.yaml"
yaml_path.write_text("esphome:\n name: lite_test\n")
CORE.config_path = yaml_path
storage_dir = tmp_path / ".esphome" / "storage"
_write_storage(
storage_dir / "lite_test.yaml.json",
esp_platform="ESP8266",
core_platform="esp8266",
)
cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml")
_set_cache_mtime(cache, yaml_path, offset=5)
assert load_compiled_config(yaml_path) is not None
assert KEY_ESP32 not in CORE.data
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -203,6 +253,106 @@ def test_run_esphome_upload_and_logs_fall_back_when_no_cache(
mock_read.assert_called_once() mock_read.assert_called_once()
def test_run_esphome_upload_does_not_refresh_cache_without_sidecar(
tmp_path: Path,
) -> None:
"""Without a StorageJSON sidecar (no compile has run), the fallback
skips the cache write -- load_compiled_config requires the sidecar,
so writing the rendered (secret-resolved) YAML would be inert and
leak secrets to disk for nothing."""
yaml_path = tmp_path / "lite_test.yaml"
yaml_path.write_text("esphome:\n name: lite_test\n")
CORE.config_path = yaml_path
with (
patch(
"esphome.__main__.read_config",
return_value={"esphome": {"name": "lite_test"}},
),
patch("esphome.compiled_config.save_compiled_config") as mock_save,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
{"upload": lambda args, config: 0},
),
):
run_esphome(["esphome", "upload", str(yaml_path)])
mock_save.assert_not_called()
@pytest.mark.parametrize("command", ["upload", "logs"])
def test_run_esphome_upload_and_logs_refresh_cache_on_fallback(
tmp_path: Path, command: str
) -> None:
"""A stale-cache fallback rewrites the cache so the next call hits
the fast path. Without this, every upload/logs after a YAML edit
pays for read_config() until the next compile rewrites the cache."""
yaml_path = tmp_path / "lite_test.yaml"
yaml_path.write_text("esphome:\n name: lite_test\n")
CORE.config_path = yaml_path
storage_dir = tmp_path / ".esphome" / "storage"
_write_storage(storage_dir / "lite_test.yaml.json")
cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml")
_set_cache_mtime(cache, yaml_path, offset=-60) # stale
fresh_config = {"esphome": {"name": "lite_test"}, "logger": {}}
with (
patch("esphome.__main__.read_config", return_value=fresh_config),
patch(
"esphome.compiled_config.save_compiled_config", wraps=save_compiled_config
) as mock_save,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
{command: lambda args, config: 0},
),
):
assert run_esphome(["esphome", command, str(yaml_path)]) == 0
mock_save.assert_called_once_with(fresh_config)
# mtime is now newer than the source YAML, so a follow-up call hits
# the fast path instead of repeating read_config.
assert cache.stat().st_mtime >= yaml_path.stat().st_mtime
def test_run_esphome_upload_with_substitution_does_not_refresh_cache(
fresh_cache_files: Path,
) -> None:
"""`-s` substitutions skip the cache on both read and write -- saving
here would clobber the cache with a substitution-specific config."""
with (
patch("esphome.__main__.read_config", return_value={"esphome": {}}),
patch("esphome.compiled_config.save_compiled_config") as mock_save,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
{"upload": lambda args, config: 0},
),
):
run_esphome(["esphome", "-s", "var", "val", "upload", str(fresh_cache_files)])
mock_save.assert_not_called()
def test_run_esphome_compile_does_not_refresh_cache_via_fallback(
fresh_cache_files: Path,
) -> None:
"""Compile writes the cache through update_storage_json, not via the
upload/logs fallback path -- the fallback save would skip the
storage_should_clean check."""
with (
patch("esphome.__main__.read_config", return_value={"esphome": {}}),
patch("esphome.compiled_config.save_compiled_config") as mock_save,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
{"compile": lambda args, config: 0},
),
):
run_esphome(["esphome", "compile", str(fresh_cache_files)])
mock_save.assert_not_called()
def test_run_esphome_upload_with_substitution_skips_cache( def test_run_esphome_upload_with_substitution_skips_cache(
fresh_cache_files: Path, fresh_cache_files: Path,
) -> None: ) -> None:
+141 -11
View File
@@ -203,7 +203,7 @@ def test_generate_idf_component_yml_basic(tmp_component):
tmp_component.data = {"description": "test", "repository": {"url": "http://aaa"}} tmp_component.data = {"description": "test", "repository": {"url": "http://aaa"}}
result = generate_idf_component_yml(tmp_component) result = generate_idf_component_yml(tmp_component)
assert result == "description: test\nversion: 1.0.0\nrepository: http://aaa\n" assert result == "description: test\nrepository: http://aaa\n"
def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path): def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path):
@@ -217,18 +217,16 @@ def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path):
assert ( assert (
result result
== f"""version: 1.0.0 == f"""dependencies:
dependencies:
dep: dep:
version: '1.0'
override_path: {dep.path} override_path: {dep.path}
""" """
) )
def test_generate_idf_component_yml_missing_path_reraises(tmp_component): def test_generate_idf_component_yml_missing_path_raises(tmp_component):
# A dep without a path and without a recognised source should re-raise # A dep without a path is a contract violation — every dep is expected
# the underlying RuntimeError instead of silently producing a bad manifest. # to have been downloaded before YAML generation. Raise loudly.
dep = IDFComponent("foo/bar", "1.0", source=None) dep = IDFComponent("foo/bar", "1.0", source=None)
tmp_component.dependencies = [dep] tmp_component.dependencies = [dep]
@@ -422,15 +420,37 @@ def test_convert_library_with_repository():
result = _convert_library_to_component(lib) result = _convert_library_to_component(lib)
assert result.name == "foo/bar" assert result.name == "foo/bar"
assert result.version == "1.2.3" assert result.version == "*"
assert isinstance(result.source, GitSource) assert isinstance(result.source, GitSource)
assert result.source.ref == "v1.2.3"
def test_convert_library_missing_ref(): def test_convert_library_with_branch_ref():
lib = Library("name", None, "https://github.com/foo/bar.git#some-branch")
result = _convert_library_to_component(lib)
assert result.name == "foo/bar"
assert result.version == "*"
assert isinstance(result.source, GitSource)
assert result.source.ref == "some-branch"
def test_convert_library_missing_ref_uses_default_branch():
"""A bare URL with no #ref clones the remote's default branch.
Matches PIO's lib_deps behavior and external_components handling --
git.clone_or_update with ref=None leaves the depth-1 clone on
whatever branch the remote HEAD points at.
"""
lib = Library("name", None, "https://github.com/foo/bar.git") lib = Library("name", None, "https://github.com/foo/bar.git")
with pytest.raises(ValueError): result = _convert_library_to_component(lib)
_convert_library_to_component(lib)
assert result.name == "foo/bar"
assert result.version == "*"
assert isinstance(result.source, GitSource)
assert result.source.ref is None
def test_convert_library_registry(monkeypatch): def test_convert_library_registry(monkeypatch):
@@ -485,3 +505,113 @@ def test_process_dependencies_skips_invalid(tmp_component):
_process_dependencies(tmp_component) _process_dependencies(tmp_component)
assert tmp_component.dependencies == [] assert tmp_component.dependencies == []
def test_process_dependencies_dict_form(tmp_component, monkeypatch):
"""PIO library.json shorthand ``{"owner/Name": "version"}`` is honored.
Iterating a dict gives string keys, which would silently fail the
``"name" in dependency`` substring check. Normalize to list-of-dicts
first so the dict form (used by e.g. tesla-ble for its nanopb dep)
is treated the same as the verbose list form.
"""
captured: list[Library] = []
def fake_generate(library):
captured.append(library)
return IDFComponent(
library.name, library.version, source=URLSource("http://dummy.com")
)
tmp_component.data = {
"dependencies": {
"nanopb/Nanopb": "^0.4.91",
"BareName": "1.2.3",
}
}
monkeypatch.setattr(
esphome.espidf.component, "_generate_idf_component", fake_generate
)
monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None)
_process_dependencies(tmp_component)
assert len(tmp_component.dependencies) == 2
names = sorted(lib.name for lib in captured)
versions = sorted(lib.version for lib in captured)
assert names == ["BareName", "nanopb/Nanopb"]
assert versions == ["1.2.3", "^0.4.91"]
def test_process_dependencies_dict_form_with_url_value(tmp_component, monkeypatch):
"""A dict-value that's a URL gets routed to ``repository`` like the list form."""
captured: list[Library] = []
def fake_generate(library):
captured.append(library)
return IDFComponent(library.name, "*", source=URLSource("http://dummy.com"))
tmp_component.data = {
"dependencies": {
"foo/Bar": "https://github.com/foo/bar.git#main",
}
}
monkeypatch.setattr(
esphome.espidf.component, "_generate_idf_component", fake_generate
)
monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None)
_process_dependencies(tmp_component)
assert len(captured) == 1
assert captured[0].name == "foo/Bar"
assert captured[0].version is None
assert captured[0].repository == "https://github.com/foo/bar.git#main"
def test_process_dependencies_dict_form_with_nested_spec(tmp_component, monkeypatch):
"""A dict-value that's itself a dict is merged into the entry.
PIO's library.json allows ``{"owner/Name": {"version": "...", ...}}``
for entries that need fields beyond just a version (platforms,
frameworks, etc.). The extra fields flow into _check_library_data
via the entry merge.
"""
captured: list[Library] = []
checked: list[dict] = []
def fake_generate(library):
captured.append(library)
return IDFComponent(
library.name, library.version, source=URLSource("http://dummy.com")
)
tmp_component.data = {
"dependencies": {
"nanopb/Nanopb": {"version": "^0.4.91", "platforms": "espidf"},
}
}
monkeypatch.setattr(
esphome.espidf.component, "_generate_idf_component", fake_generate
)
monkeypatch.setattr(
esphome.espidf.component,
"_check_library_data",
checked.append,
)
_process_dependencies(tmp_component)
assert len(captured) == 1
assert captured[0].name == "nanopb/Nanopb"
assert captured[0].version == "^0.4.91"
# Extra spec fields reach _check_library_data so platform/framework
# gating still applies.
assert checked == [
{
"name": "Nanopb",
"owner": "nanopb",
"version": "^0.4.91",
"platforms": "espidf",
}
]
+58
View File
@@ -0,0 +1,58 @@
"""Tests for esphome.espidf.toolchain helpers."""
# pylint: disable=protected-access
from unittest.mock import patch
from esphome.const import CONF_FRAMEWORK, CONF_SOURCE
from esphome.core import CORE
from esphome.espidf import toolchain
def test_get_framework_source_override_no_config():
"""When CORE.config hasn't been set, no override is returned."""
CORE.config = None
assert toolchain._get_framework_source_override() is None
def test_get_framework_source_override_no_esp32_section():
"""A config without an esp32 section yields no override."""
CORE.config = {}
assert toolchain._get_framework_source_override() is None
def test_get_framework_source_override_no_framework_source():
"""An esp32 section without framework.source yields no override."""
CORE.config = {"esp32": {CONF_FRAMEWORK: {}}}
assert toolchain._get_framework_source_override() is None
def test_get_framework_source_override_returns_value():
"""A user-supplied framework source is returned verbatim."""
url = "https://example.com/esp-idf-v{VERSION}.tar.xz"
CORE.config = {"esp32": {CONF_FRAMEWORK: {CONF_SOURCE: url}}}
assert toolchain._get_framework_source_override() == url
def test_get_esphome_esp_idf_paths_forwards_source_override():
"""_get_esphome_esp_idf_paths threads the override into check_esp_idf_install."""
url = "https://my-mirror/esp-idf-v{VERSION}.tar.xz"
CORE.config = {"esp32": {CONF_FRAMEWORK: {CONF_SOURCE: url}}}
# Hit a fresh cache key so check_esp_idf_install is actually called.
toolchain._cache().paths.clear()
with patch.object(
toolchain, "check_esp_idf_install", return_value=("/fw", "/penv")
) as mock_install:
toolchain._get_esphome_esp_idf_paths("5.5.4")
mock_install.assert_called_once_with("5.5.4", source_url=url)
def test_get_esphome_esp_idf_paths_no_override():
"""When no source override is configured, source_url=None is passed."""
CORE.config = {}
toolchain._cache().paths.clear()
with patch.object(
toolchain, "check_esp_idf_install", return_value=("/fw", "/penv")
) as mock_install:
toolchain._get_esphome_esp_idf_paths("5.5.4")
mock_install.assert_called_once_with("5.5.4", source_url=None)
+128
View File
@@ -0,0 +1,128 @@
"""Tests for esphome.espidf.size_summary.print_summary."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from esphome.espidf.size_summary import print_summary
def _write_size_json(tmp_path: Path, data: dict) -> Path:
"""Drop a fake esp_idf_size.json under ``tmp_path`` and return the path."""
out = tmp_path / "esp_idf_size.json"
out.write_text(json.dumps(data))
return out
def _esp32_size_data() -> dict:
"""Synthetic esp_idf_size.json for the original ESP32 (split IRAM/DRAM)."""
return {
"image_size": 827455,
"memory_types": {
"DRAM": {
"size": 180736,
"used": 47332,
"sections": {
".dram0.bss": {"abbrev_name": ".bss", "size": 30616},
".dram0.data": {"abbrev_name": ".data", "size": 16716},
},
},
"IRAM": {
"size": 131072,
"used": 80351,
"sections": {
".iram0.text": {"abbrev_name": ".text", "size": 79323},
".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028},
},
},
},
}
def _s3_size_data() -> dict:
"""Synthetic esp_idf_size.json for ESP32-S3 (unified DIRAM)."""
return {
"image_size": 724215,
"memory_types": {
"DIRAM": {
"size": 341760,
"used": 104999,
"sections": {
".iram0.text": {"abbrev_name": ".text", "size": 58051},
".dram0.bss": {"abbrev_name": ".bss", "size": 27088},
".dram0.data": {"abbrev_name": ".data", "size": 19708},
".noinit": {"abbrev_name": ".noinit", "size": 152},
},
},
"IRAM": {
"size": 16384,
"used": 16384,
"sections": {
".iram0.text": {"abbrev_name": ".text", "size": 15356},
".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028},
},
},
},
}
def test_print_summary_esp32_uses_dram(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Original ESP32: DRAM has no ``.text``, so RAM = DRAM.used / DRAM.size unchanged."""
size_json = _write_size_json(tmp_path, _esp32_size_data())
print_summary(size_json, partitions_csv=None)
out = capsys.readouterr().out
assert "RAM:" in out
assert "used 47332 bytes from 180736 bytes" in out
def test_print_summary_s3_falls_back_to_diram(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""ESP32-S3 with no DRAM key falls back to DIRAM and reports raw region usage."""
size_json = _write_size_json(tmp_path, _s3_size_data())
print_summary(size_json, partitions_csv=None)
out = capsys.readouterr().out
assert "used 104999 bytes from 341760 bytes" in out
def test_print_summary_skips_when_diram_total_collapses(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A zero-size region drops the RAM line rather than divide by zero."""
size_json = _write_size_json(
tmp_path,
{
"memory_types": {
"DIRAM": {
"size": 0,
"used": 0,
"sections": {},
},
},
},
)
print_summary(size_json, partitions_csv=None)
out = capsys.readouterr().out
assert "RAM:" not in out
def test_print_summary_handles_missing_json(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Missing size json is non-fatal and prints nothing."""
print_summary(tmp_path / "does_not_exist.json", partitions_csv=None)
assert capsys.readouterr().out == ""
def test_print_summary_handles_no_memory_types(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A size json without ``memory_types`` still doesn't crash."""
size_json = _write_size_json(tmp_path, {"image_size": 0})
print_summary(size_json, partitions_csv=None)
assert capsys.readouterr().out == ""
+72 -1
View File
@@ -9,7 +9,7 @@ from unittest.mock import MagicMock, Mock, patch
import pytest import pytest
from esphome import storage_json from esphome import storage_json
from esphome.const import CONF_DISABLED, CONF_MDNS from esphome.const import CONF_DISABLED, CONF_MDNS, Toolchain
from esphome.core import CORE from esphome.core import CORE
@@ -308,6 +308,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None:
mock_core.loaded_platforms = {"sensor"} mock_core.loaded_platforms = {"sensor"}
mock_core.config = {CONF_MDNS: {CONF_DISABLED: True}} mock_core.config = {CONF_MDNS: {CONF_DISABLED: True}}
mock_core.target_framework = "esp-idf" mock_core.target_framework = "esp-idf"
mock_core.toolchain = Toolchain.ESP_IDF
with patch("esphome.components.esp32.get_esp32_variant") as mock_variant: with patch("esphome.components.esp32.get_esp32_variant") as mock_variant:
mock_variant.return_value = "ESP32-C3" mock_variant.return_value = "ESP32-C3"
@@ -327,6 +328,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None:
assert result.no_mdns is True assert result.no_mdns is True
assert result.framework == "esp-idf" assert result.framework == "esp-idf"
assert result.core_platform == "esp32" assert result.core_platform == "esp32"
assert result.toolchain == "esp-idf"
def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None:
@@ -345,10 +347,12 @@ def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None:
mock_core.loaded_platforms = set() mock_core.loaded_platforms = set()
mock_core.config = {} # No MDNS config means enabled mock_core.config = {} # No MDNS config means enabled
mock_core.target_framework = "arduino" mock_core.target_framework = "arduino"
mock_core.toolchain = None
result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None) result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None)
assert result.no_mdns is False assert result.no_mdns is False
assert result.toolchain is None
def test_storage_json_load_valid_file(tmp_path: Path) -> None: def test_storage_json_load_valid_file(tmp_path: Path) -> None:
@@ -470,6 +474,73 @@ def test_storage_json_equality() -> None:
assert storage1 != "not a storage object" assert storage1 != "not a storage object"
def _make_storage_with_toolchain(
toolchain: str | None,
) -> storage_json.StorageJSON:
return storage_json.StorageJSON(
storage_version=1,
name="dev",
friendly_name=None,
comment=None,
esphome_version="2024.1.0",
src_version=1,
address="dev.local",
web_port=None,
target_platform="ESP32",
build_path=Path("/build"),
firmware_bin_path=Path("/build/firmware.bin"),
loaded_integrations=set(),
loaded_platforms=set(),
no_mdns=False,
framework="esp-idf",
core_platform="esp32",
toolchain=toolchain,
)
def test_storage_json_toolchain_round_trip(setup_core: Path) -> None:
"""Sidecar toolchain survives save -> load -> apply_to_core."""
storage = _make_storage_with_toolchain("esp-idf")
path = setup_core / "storage.json"
path.write_text(storage.to_json())
# Serialization key is stable -- device-builder relies on it.
assert json.loads(path.read_text())["toolchain"] == "esp-idf"
loaded = storage_json.StorageJSON.load(path)
assert loaded is not None
assert loaded.toolchain == "esp-idf"
CORE.toolchain = None
with patch("esphome.components.esp32.get_esp32_variant"):
loaded.apply_to_core()
assert CORE.toolchain == Toolchain.ESP_IDF
def test_storage_json_apply_to_core_preserves_cli_toolchain(
setup_core: Path,
) -> None:
"""A CLI-set CORE.toolchain wins over the sidecar value."""
loaded = _make_storage_with_toolchain("esp-idf")
CORE.toolchain = Toolchain.PLATFORMIO
with patch("esphome.components.esp32.get_esp32_variant"):
loaded.apply_to_core()
assert CORE.toolchain == Toolchain.PLATFORMIO
def test_storage_json_apply_to_core_ignores_unknown_toolchain(
setup_core: Path,
) -> None:
"""Unknown enum values (corrupt sidecar / newer ESPHome) fall through to None."""
loaded = _make_storage_with_toolchain("gcc")
CORE.toolchain = None
with patch("esphome.components.esp32.get_esp32_variant"):
loaded.apply_to_core()
assert CORE.toolchain is None
def test_esphome_storage_json_as_dict() -> None: def test_esphome_storage_json_as_dict() -> None:
"""Test EsphomeStorageJSON.as_dict returns correct dictionary.""" """Test EsphomeStorageJSON.as_dict returns correct dictionary."""
storage = storage_json.EsphomeStorageJSON( storage = storage_json.EsphomeStorageJSON(
+374
View File
@@ -12,11 +12,15 @@ import esphome.config_validation as cv
from esphome.core import DocumentLocation, DocumentRange, EsphomeError from esphome.core import DocumentLocation, DocumentRange, EsphomeError
from esphome.util import OrderedDict from esphome.util import OrderedDict
from esphome.yaml_util import ( from esphome.yaml_util import (
DiscoveredYamlFiles,
ESPHomeDataBase, ESPHomeDataBase,
ESPLiteralValue, ESPLiteralValue,
discover_user_yaml_files,
force_load_include_files,
format_path, format_path,
make_data_base, make_data_base,
make_literal, make_literal,
track_yaml_loads,
) )
@@ -30,6 +34,14 @@ def clear_secrets_cache() -> None:
yaml_util._SECRET_CACHE.clear() yaml_util._SECRET_CACHE.clear()
@pytest.fixture(autouse=True)
def clear_core_frontmatter() -> None:
"""Reset CORE.frontmatter between tests."""
core.CORE.frontmatter = {}
yield
core.CORE.frontmatter = {}
def test_include_with_vars(fixture_path: Path) -> None: def test_include_with_vars(fixture_path: Path) -> None:
yaml_file = fixture_path / "yaml_util" / "includetest.yaml" yaml_file = fixture_path / "yaml_util" / "includetest.yaml"
@@ -966,3 +978,365 @@ def test_make_literal_blocks_substitution() -> None:
# undefined in the context. # undefined in the context.
assert result == {"pin": "${PIN}"} assert result == {"pin": "${PIN}"}
assert isinstance(result, ESPLiteralValue) assert isinstance(result, ESPLiteralValue)
# ---------------------------------------------------------------------------
# force_load_include_files / discover_user_yaml_files
# ---------------------------------------------------------------------------
class _StubInclude:
"""Stand-in for `IncludeFile` that records how `load()` was called.
Patched in via `esphome.yaml_util.IncludeFile` so the recursion in
`force_load_include_files` treats instances as deferred includes without
needing an actual on-disk file.
"""
def __init__(
self,
file: str = "stub.yaml",
parent_file: Path | None = None,
*,
unresolved: bool = False,
load_result: object = None,
raise_on_load: EsphomeError | None = None,
) -> None:
self.file = Path(file)
self.parent_file = parent_file or Path("/tmp/parent.yaml")
self._unresolved = unresolved
self._load_result = load_result if load_result is not None else {}
self._raise = raise_on_load
self.load_calls = 0
def has_unresolved_expressions(self) -> bool:
return self._unresolved
def load(self) -> object:
self.load_calls += 1
if self._raise is not None:
raise self._raise
return self._load_result
@pytest.fixture
def patch_include_file():
"""Replace `IncludeFile` with `_StubInclude` so isinstance checks in
`force_load_include_files` match the stubs constructed by tests."""
with patch("esphome.yaml_util.IncludeFile", _StubInclude):
yield
def test_force_load_include_files_resolves_nested_includes(
patch_include_file: None,
) -> None:
"""A tree of dict/list/IncludeFile is walked and every IncludeFile is loaded."""
inner = _StubInclude("inner.yaml")
outer = _StubInclude("outer.yaml", load_result={"nested": inner})
force_load_include_files([{"a": outer}, "scalar"])
assert outer.load_calls == 1
assert inner.load_calls == 1
def test_force_load_include_files_seen_guard_prevents_double_load(
patch_include_file: None,
) -> None:
"""The same IncludeFile referenced from two branches loads once."""
stub = _StubInclude("once.yaml")
force_load_include_files({"a": stub, "b": [stub]})
assert stub.load_calls == 1
def test_force_load_include_files_handles_cyclic_containers() -> None:
"""Cyclic dict/list references don't trigger infinite recursion."""
cyclic_dict: dict[str, object] = {}
cyclic_dict["self"] = cyclic_dict
cyclic_list: list[object] = []
cyclic_list.append(cyclic_list)
# Both calls must return without recursing forever.
force_load_include_files(cyclic_dict)
force_load_include_files(cyclic_list)
@pytest.mark.parametrize(
("warn_on_unresolved", "expect_level"),
[
pytest.param(True, "WARNING", id="default-warns"),
pytest.param(False, "DEBUG", id="opt-in-demotes"),
],
)
def test_force_load_include_files_unresolved_log_level(
patch_include_file: None,
caplog: pytest.LogCaptureFixture,
warn_on_unresolved: bool,
expect_level: str,
) -> None:
"""Substitution-templated include paths skip the load and log at the
level chosen by `warn_on_unresolved`."""
stub = _StubInclude("${var}.yaml", unresolved=True)
with caplog.at_level("DEBUG", logger="esphome.yaml_util"):
force_load_include_files({"k": stub}, warn_on_unresolved=warn_on_unresolved)
assert stub.load_calls == 0
matching = [
r.levelname for r in caplog.records if "Cannot resolve !include" in r.message
]
assert matching == [expect_level]
def test_force_load_include_files_warns_on_load_failure(
patch_include_file: None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""An `EsphomeError` raised by `load()` is caught and logged, not propagated."""
stub = _StubInclude("missing.yaml", raise_on_load=EsphomeError("boom"))
with caplog.at_level("WARNING", logger="esphome.yaml_util"):
force_load_include_files({"k": stub})
assert any(
"Failed to load !include" in r.message and "missing.yaml" in r.message
for r in caplog.records
)
def test_discovered_yaml_files_holds_files_and_secrets() -> None:
"""`DiscoveredYamlFiles` is a small data carrier; both fields are mandatory."""
files = [Path("/tmp/a.yaml")]
secrets = {Path("/tmp/a.yaml")}
discovered = DiscoveredYamlFiles(files, secrets)
assert discovered.files is files
assert discovered.secrets is secrets
def _write(tmp_path: Path, name: str, content: str) -> Path:
"""Write `content` to `tmp_path/name`, creating parent dirs as needed."""
path = tmp_path / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
return path
def _write_entry_including(tmp_path: Path, included_name: str) -> Path:
"""Write a minimal entry yaml that `!include`s `included_name`."""
return _write(
tmp_path,
"entry.yaml",
f"esphome:\n name: test\nwifi: !include {included_name}\n",
)
def test_discover_user_yaml_files_captures_includes(tmp_path: Path) -> None:
"""A `!include` in the entry yaml is force-loaded so the listener fires."""
_write(tmp_path, "wifi.yaml", "ssid: my_ssid\npassword: my_pw\n")
discovered = discover_user_yaml_files(_write_entry_including(tmp_path, "wifi.yaml"))
names = {p.name for p in discovered.files}
assert names == {"entry.yaml", "wifi.yaml"}
assert discovered.secrets == set()
@pytest.mark.parametrize(
"secret_name",
[
pytest.param("secrets.yaml", id="yaml"),
pytest.param("secrets.yml", id="yml"),
],
)
def test_discover_user_yaml_files_flags_secrets_filename(
tmp_path: Path, secret_name: str
) -> None:
"""Both `secrets.yaml` and `secrets.yml` get flagged in `.secrets`."""
_write(tmp_path, secret_name, "key: value\n")
discovered = discover_user_yaml_files(_write_entry_including(tmp_path, secret_name))
assert (tmp_path / secret_name).resolve() in discovered.secrets
def test_discover_user_yaml_files_flags_secrets_symlink(tmp_path: Path) -> None:
"""`secrets.yaml` symlinked to a non-secrets-named target is still flagged
because the un-resolved basename is what gets recorded."""
target = _write(tmp_path, "real_creds.yaml", "key: value\n")
(tmp_path / "secrets.yaml").symlink_to(target)
discovered = discover_user_yaml_files(
_write_entry_including(tmp_path, "secrets.yaml")
)
# The recorded "secret path" is the resolved target — even though its
# basename is `real_creds.yaml`, it's still in `.secrets`.
assert target.resolve() in discovered.secrets
def test_discover_user_yaml_files_swallows_parse_errors(tmp_path: Path) -> None:
"""A YAML parse failure returns whatever was tracked so far without raising."""
entry = _write(tmp_path, "entry.yaml", "esphome: [unterminated\n")
discovered = discover_user_yaml_files(entry)
assert isinstance(discovered, DiscoveredYamlFiles)
def test_discover_user_yaml_files_deduplicates(tmp_path: Path) -> None:
"""The same file referenced twice appears once in `.files`."""
_write(tmp_path, "wifi.yaml", "ssid: a\n")
entry = _write(
tmp_path,
"entry.yaml",
"esphome:\n name: test\nwifi: !include wifi.yaml\nfoo: !include wifi.yaml\n",
)
discovered = discover_user_yaml_files(entry)
wifi_resolved = (tmp_path / "wifi.yaml").resolve()
assert discovered.files.count(wifi_resolved) == 1
def test_track_yaml_loads_records_resolved_paths(tmp_path: Path) -> None:
"""`track_yaml_loads` is the building block — sanity-check it resolves
symlinks so callers can dedupe by identity."""
target = _write(tmp_path, "actual.yaml", "esphome:\n name: t\n")
link = tmp_path / "alias.yaml"
link.symlink_to(target)
with track_yaml_loads() as loaded:
yaml_util.load_yaml(link)
assert target.resolve() in loaded
# ---------------------------------------------------------------------------
# YAML frontmatter
# ---------------------------------------------------------------------------
def test_frontmatter_parsed_and_stored_on_core(tmp_path: Path) -> None:
"""A leading `---`-separated YAML document is stored as frontmatter and
stripped from the returned config."""
yaml_file = tmp_path / "main.yaml"
yaml_file.write_text(
"author: Jesse\nlabels: [office, climate]\n---\nesphome:\n name: my_node\n"
)
config = yaml_util.load_yaml(yaml_file)
# Config does not contain frontmatter keys
assert "author" not in config
assert "labels" not in config
assert config["esphome"]["name"] == "my_node"
# Frontmatter is stored on CORE keyed by resolved path
frontmatter = core.CORE.frontmatter[yaml_file.resolve()]
assert frontmatter["author"] == "Jesse"
assert frontmatter["labels"] == ["office", "climate"]
def test_frontmatter_absent_when_single_document(tmp_path: Path) -> None:
"""A YAML file with a single document does not populate CORE.frontmatter."""
yaml_file = tmp_path / "main.yaml"
yaml_file.write_text("esphome:\n name: my_node\n")
yaml_util.load_yaml(yaml_file)
assert yaml_file.resolve() not in core.CORE.frontmatter
def test_frontmatter_absent_when_leading_doc_separator(tmp_path: Path) -> None:
"""A leading `---` with no content above it is just a document start marker,
not frontmatter, and must not populate CORE.frontmatter."""
yaml_file = tmp_path / "main.yaml"
yaml_file.write_text("---\nesphome:\n name: my_node\n")
config = yaml_util.load_yaml(yaml_file)
assert config["esphome"]["name"] == "my_node"
assert yaml_file.resolve() not in core.CORE.frontmatter
def test_frontmatter_supports_arbitrary_keys(tmp_path: Path) -> None:
"""Frontmatter keys are not validated — any structure is accepted."""
yaml_file = tmp_path / "main.yaml"
yaml_file.write_text(
"any_key: any_value\n"
"nested:\n"
" count: 42\n"
" items:\n"
" - a\n"
" - b\n"
"---\n"
"esphome:\n"
" name: t\n"
)
yaml_util.load_yaml(yaml_file)
frontmatter = core.CORE.frontmatter[yaml_file.resolve()]
assert frontmatter["any_key"] == "any_value"
assert frontmatter["nested"]["count"] == 42
assert frontmatter["nested"]["items"] == ["a", "b"]
def test_frontmatter_supports_deeply_nested_paths(tmp_path: Path) -> None:
"""Frontmatter preserves deeply nested dict/list structures intact."""
yaml_file = tmp_path / "main.yaml"
yaml_file.write_text(
"device:\n"
" metadata:\n"
" location:\n"
" building: HQ\n"
" floor: 3\n"
" room:\n"
" number: 302\n"
" occupants:\n"
" - name: Jesse\n"
" role:\n"
" title: maintainer\n"
" since: 2021\n"
" - name: Alice\n"
" role:\n"
" title: contributor\n"
" since: 2024\n"
"---\n"
"esphome:\n"
" name: t\n"
)
yaml_util.load_yaml(yaml_file)
fm = core.CORE.frontmatter[yaml_file.resolve()]
room = fm["device"]["metadata"]["location"]["room"]
assert room["number"] == 302
assert room["occupants"][0]["name"] == "Jesse"
assert room["occupants"][0]["role"]["title"] == "maintainer"
assert room["occupants"][0]["role"]["since"] == 2021
assert room["occupants"][1]["role"]["title"] == "contributor"
def test_frontmatter_more_than_two_documents_raises(tmp_path: Path) -> None:
"""Three or more YAML documents is unsupported and must raise."""
yaml_file = tmp_path / "main.yaml"
yaml_file.write_text("a: 1\n---\nb: 2\n---\nc: 3\n")
with pytest.raises(EsphomeError, match="at most two are supported"):
yaml_util.load_yaml(yaml_file)
def test_frontmatter_empty_frontmatter_doc_not_stored(tmp_path: Path) -> None:
"""An empty (null) frontmatter document is treated as no frontmatter."""
yaml_file = tmp_path / "main.yaml"
yaml_file.write_text("---\n---\nesphome:\n name: t\n")
config = yaml_util.load_yaml(yaml_file)
assert config["esphome"]["name"] == "t"
assert yaml_file.resolve() not in core.CORE.frontmatter
def test_frontmatter_empty_config_doc(tmp_path: Path) -> None:
"""An empty config document after a frontmatter document yields an empty config."""
yaml_file = tmp_path / "main.yaml"
yaml_file.write_text("only: frontmatter\n---\n")
config = yaml_util.load_yaml(yaml_file)
assert config == {}
assert core.CORE.frontmatter[yaml_file.resolve()]["only"] == "frontmatter"
def test_frontmatter_included_file_stored(tmp_path: Path) -> None:
"""Frontmatter on an !include'd file is also captured on CORE, keyed by
that file's resolved path."""
inc = tmp_path / "child.yaml"
inc.write_text("child_meta: hello\n---\nchild_key: value\n")
main = tmp_path / "main.yaml"
main.write_text("esphome:\n name: t\nchild: !include child.yaml\n")
config = yaml_util.load_yaml(main)
# !include is deferred; force resolution so the child file actually loads
force_load_include_files(config)
assert config["child"].load()["child_key"] == "value"
# Main file has no frontmatter
assert main.resolve() not in core.CORE.frontmatter
# Included file's frontmatter is captured
assert core.CORE.frontmatter[inc.resolve()]["child_meta"] == "hello"