Compare commits

...

134 Commits

Author SHA1 Message Date
Jesse Hills 9c0ffee020 Merge pull request #16760 from esphome/bump-2026.5.2
2026.5.2
2026-06-02 15:39:40 +12:00
Jesse Hills 070c14b04a Bump version to 2026.5.2 2026-06-02 14:33:41 +12:00
J. Nick Koston 559cfd1555 [api] Fix crash loop on VoiceAssistantConfigurationRequest (#16757) 2026-06-02 14:33:41 +12:00
Jonathan Swoboda 571a12ffe5 [core] Clean build when the toolchain changes (#16744) 2026-06-02 14:33:41 +12:00
J. Nick Koston a4d247fa0a [core] Persist esphome.area in StorageJSON (#16710) 2026-06-02 14:33:41 +12:00
Fyleo 8e57894af7 [sx126x] fix a typo in image calibration on 863 - 870 Mhz frequency (#16731) 2026-06-02 14:33:41 +12:00
J. Nick Koston f9aba18f8e [libretiny] Fix RTL8710B IRAM_ATTR section being dropped from flashed image (#16616) 2026-06-02 14:33:41 +12:00
Jesse Hills a04f6da814 [packages] Resolve git symlinks on Windows when materialized as text (#16657) 2026-06-02 14:33:41 +12:00
Jonathan Swoboda 3f57117efd [esp32] Decode crash PCs via IDF toolchain on IDF builds (#16626) 2026-06-02 14:33:41 +12:00
J. Nick Koston d7f809181a [writer] Mark storage_should_clean as public API for device-builder (#16443) 2026-06-02 14:33:41 +12:00
Jesse Hills 3d1a614e55 Merge pull request #16610 from esphome/bump-2026.5.1
2026.5.1
2026-05-25 10:42:20 +12:00
Jesse Hills 03e2eb4b4a Bump version to 2026.5.1 2026-05-25 09:28:49 +12:00
Jonathan Swoboda ddd353d105 [esp32] Disable IDF's COMPILER_DISABLE_DEFAULT_ERRORS so -Wno-error actually undoes -Werror (#16604) 2026-05-25 09:28:49 +12:00
Jonathan Swoboda 9a34a6aabb [esp32] Replace per-class -Wno-error=X demotes with blanket -Wno-error for ESP-IDF toolchain (#16599) 2026-05-25 09:28:49 +12:00
J. Nick Koston 0babc52472 [bluetooth_proxy] Recover slot stuck in DISCONNECTING when CLOSE_EVT is dropped (#16588) 2026-05-25 09:28:49 +12:00
Jonathan Swoboda adde7681e8 [esp32] Demote IDF #warning deprecations from error under ESP-IDF toolchain (#16584) 2026-05-25 09:28:49 +12:00
J. Nick Koston 8f6ea62628 [uart] Wake main loop on ESP8266 software serial RX (#16562) 2026-05-25 09:28:49 +12:00
J. Nick Koston 4e7bc92061 [esp8266] Use os_timer-based esp_delay() in delay() (#16563) 2026-05-25 09:28:49 +12:00
Edvard Filistovič 1f4a061572 [libretiny] Fix LN882H IRAM_ATTR injection point in patch_linker.py (#16570) 2026-05-25 09:28:49 +12:00
J. Nick Koston 59db9a4673 [dashboard] Fix flaky test_websocket_refresh_command on Windows CI (#16565) 2026-05-25 09:28:49 +12:00
Kevin Ahrendt 7ae5566472 [sendspin] Bump sendspin-cpp to v0.6.1 (#16553) 2026-05-25 09:28:49 +12:00
J. Nick Koston f247def4ac [core] Refresh compiled config cache after upload/logs fallback (#16548) 2026-05-25 09:28:49 +12:00
Jonathan Swoboda 27d53ec117 [sx126x] Assert NSS before wait_busy so commands wake the chip from sleep (#16546) 2026-05-25 09:28:49 +12:00
J. Nick Koston 0c94a173b6 [api] Break api_connection/api_server include cycle to drop custom unique_ptr deleter (#16542) 2026-05-25 09:28:49 +12:00
Jonathan Swoboda ae2e372762 [tuya] Restore null guard on status_pin lost in #16353 (#16539) 2026-05-25 09:28:48 +12:00
Jonathan Swoboda e6ed275746 [esp32] Defer esp_panic_handler wrap so arduino-esp32 IDF component skips it (#16538) 2026-05-25 09:28:48 +12:00
Jonathan Swoboda 878027ff50 [espidf] Honor the dict shorthand for library.json dependencies (#16537) 2026-05-25 09:28:48 +12:00
Jonathan Swoboda 858cfd5b94 [espidf] Default to remote HEAD when cg.add_library URL has no #ref (#16535) 2026-05-25 09:28:48 +12:00
Jonathan Swoboda 5225416347 [espidf] Backport ninja linux-arm64 entry into tools.json on aarch64 hosts (#16527) 2026-05-25 09:28:48 +12:00
Jonathan Swoboda 615d5aa827 [core] Persist & restore CORE.toolchain through StorageJSON (#16531) 2026-05-25 09:28:48 +12:00
Jonathan Swoboda e92a4c9472 [espidf] Write version.txt after extract so bootloader shows the real version (#16532) 2026-05-25 09:28:48 +12:00
Jonathan Swoboda 32fa856bf0 [espidf] Fix tarfile extract crashing on Python 3.11 with None mode (#16530) 2026-05-25 09:28:48 +12:00
Jonathan Swoboda cc88456ce7 [espidf] Filter noisy 'git rev-parse' errors when .git is stripped (#16521) 2026-05-25 09:28:48 +12:00
dependabot[bot] 79539cb85d Bump zeroconf from 0.149.13 to 0.149.16 (#16533)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-25 09:28:48 +12:00
dependabot[bot] 16b6509a03 Bump zeroconf from 0.149.12 to 0.149.13 (#16520)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-25 09:28:48 +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 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
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 Ahrendt 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
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 Swoboda 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 Burzinski 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 Burzinski 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 59847d56e8 Merge pull request #16411 from esphome/bump-2026.5.0b1
2026.5.0b1
2026-05-14 15:24:58 +12:00
Jesse Hills 09a926fa13 Bump version to 2026.5.0b1 2026-05-14 12:33:43 +12:00
Jonathan Swoboda a3b6f92433 [espidf] Regenerate bundled CMakeLists; auto-REQUIRE via IDF build properties (#16406) 2026-05-13 23:58:48 +00:00
dependabot[bot] 06786da7dd Bump actions/create-github-app-token from 3.1.1 to 3.2.0 (#16409)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-13 16:28:47 -07:00
Jonathan Swoboda 910cc38dd7 [writer] Clean ESP-IDF build artifacts in clean_build (#16410) 2026-05-13 19:25:35 -04:00
dependabot[bot] c8aba6913b Bump requests from 2.34.0 to 2.34.1 (#16408)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-13 15:38:13 -05:00
Boris Krivonog ce8810bc42 [mitsubishi_cn105] Add vane and wide-vane support (#16405) 2026-05-13 13:25:32 -05:00
Jonathan Swoboda 1c6966b761 [espidf] Run PIO extraScript with SCons-env shim (#16404) 2026-05-13 13:07:59 -04:00
J. Nick Koston 03f5e4775c [tests] Add CodSpeed benchmark for compiled-config cache fast path (#16402)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-05-13 12:06:20 -05:00
Boris Krivonog 445d841229 [mitsubishi_cn105] Simplified protocol lookups (#16399) 2026-05-13 16:49:32 +00:00
Jonathan Swoboda d7b00047bd [espidf] Emit -W warning flags at project scope so managed components also see them (#16403) 2026-05-13 12:27:06 -04:00
Jonathan Swoboda 3fee97ae5a [espidf] Partition pio_components cache by framework (#16401) 2026-05-13 12:08:51 -04:00
J. Nick Koston cb520cda6b [core] Retry PlatformIO downloads on transport-layer errors (#16397) 2026-05-13 10:01:42 -05:00
J. Nick Koston 8bce32ec35 [tests] Cover top-level !include failure path in track_yaml_loads (#16396) 2026-05-13 10:01:26 -05:00
J. Nick Koston b866525437 [ci] Skip native ESP-IDF compile test when no relevant files changed (#16395) 2026-05-13 10:01:11 -05:00
J. Nick Koston 0e4922a340 [core] Cache validated config to skip re-validation on upload/logs (#16381) 2026-05-13 22:14:19 +12:00
Boris Krivonog 45a4811bb4 [mitsubishi_cn105] Unified timeout handling (#16385) 2026-05-13 01:40:19 -05:00
Dmitrii Kuminov 65ea29b44a [core] Fix !include vars not being substituted in !lambda values (#16320)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-05-12 22:41:30 -05:00
Keith Burzinski 480c23012c [radio_frequency] Add on_control trigger; ir_rf_proxy driver-agnostic (#16368)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:13:29 -05:00
Jonathan Swoboda 1dfd3fe9c2 [esp32] Print PlatformIO-format RAM/Flash summary after native ESP-IDF builds (#16394)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-12 22:52:11 -04:00
Michael Hansen f94735dc62 [api][voice_assistant] Add second audio channel for voice_assistant (#16265)
Co-authored-by: Kevin Ahrendt <kevin.ahrendt@openhomefoundation.org>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-05-13 13:38:39 +12:00
Kevin Ahrendt 65b53692bd [i2s_audio] Properly track DMA input/output (#16317) 2026-05-12 20:36:26 -05:00
Kevin Ahrendt 3df0527c1f [audio] Document ring buffer source thread safety (#16393) 2026-05-13 00:10:32 +00:00
Mat931 dc95b22c76 [safe_mode] Allow recovering soft-bricked devices via reboot to recovery partition (#16339)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-05-13 12:07:49 +12:00
Jonathan Swoboda 1c2043e054 [esp32] Relax -Werror=reorder and -Werror=maybe-uninitialized on native ESP-IDF (#16392) 2026-05-12 20:04:54 -04:00
Kevin Ahrendt 7f37ee3c53 [mixer] Use RingBufferAudioSource (#16316) 2026-05-12 23:58:32 +00:00
Kevin Ahrendt 8b6cbc9f2b [i2s_audio] Speaker uses new RingBufferAudioSource (#16315)
Thanks!
2026-05-12 23:58:03 +00:00
George Galt 531367d7e1 [micro_wake_word] Increase INFERENCE_TASK_STACK_SIZE to 8192 for P4 chip (#16390) 2026-05-12 19:47:54 -04:00
Jesse Hills cb2dbcd70d [ci] Add validate.*.yaml for config-only component tests (#16384) 2026-05-13 11:37:33 +12:00
Kevin Ahrendt 45a8bd49c3 [audio] Add a RingBufferAudioSource (#16314) 2026-05-12 19:33:03 -04:00
Jonathan Swoboda 9195b9898e [ms8607] Pin humidity i2c_id in test fixture (#16386) 2026-05-12 18:20:09 -05:00
dependabot[bot] aec48cf231 Bump aioesphomeapi from 44.24.2 to 45.0.0 (#16391)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-12 23:19:07 +00:00
Jonathan Swoboda 907ae46aba [zigbee] Fix init-order and missing-field warnings on native ESP-IDF (#16389) 2026-05-12 19:13:04 -04:00
Jesse Hills 057fc4c1a8 Move AI instructions to AGENTS.md (#16382) 2026-05-13 09:19:27 +12:00
Cody Cutrer 76d3433425 [cli] Add config-hash command (#15548)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-05-12 20:43:48 +00:00
Kevin Ahrendt b512cc42a8 [sendspin] Updates sendspin-cpp to v0.5.0 (#16380) 2026-05-12 16:42:44 -04:00
Boris Krivonog 66e4a1dfa8 [mitsubishi_cn105] Add C++ API for setting/clearing remote room temperature (#15558)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-05-12 15:39:21 -05:00
Kevin Ahrendt ee72efa760 [sendspin] Fix client_id MAC mismatch with ethernet (#16331) 2026-05-12 16:27:25 -04:00
J. Nick Koston f54480ec48 [rp2040] Bump arduino-pico framework to 5.6.0 (#16375) 2026-05-12 15:20:25 -05:00
J. Nick Koston 34f69e0d6e [ci] Comment on PRs that touch the legacy dashboard (#16378) 2026-05-12 14:42:23 -05:00
dependabot[bot] 57893a8eb1 Bump aioesphomeapi from 44.23.0 to 44.24.2 (#16376)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-12 19:37:31 +00:00
tomaszduda23 c511dddf2a [core] allow defining run_compile in external_components (#16179)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-12 14:59:54 -04:00
Jesse Hills 76ce45c59e [script] Preserve source order of enum options in language schema (#16371) 2026-05-13 06:28:39 +12:00
J. Nick Koston 727c74da3f [script] Fix array-type parameters in script.execute (#16374) 2026-05-12 12:17:23 -05:00
Jonathan Swoboda 365ed19319 [core] Fix PROGMEM_STRING_TABLE placement on ESP8266 without flash log strings (#16373) 2026-05-12 11:19:10 -04:00
Clyde Stubbs 4ff946ac15 [cli] Add --no-states flag to run command (#16366) 2026-05-12 07:52:07 -04:00
Mat931 b5e50144e3 [ota] Improve OTA error messages (#16327)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick+github@koston.org>
2026-05-12 02:32:58 +00:00
dependabot[bot] 49df1bd30e Bump actions/cache from 5.0.3 to 5.0.5 (#16365)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-12 02:18:06 +00:00
dependabot[bot] 7dce58c58d Bump requests from 2.33.1 to 2.34.0 (#16364)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-12 02:17:42 +00:00
Clyde Stubbs a232aedebd [lvgl] Check for user defined LV_USE items (#16362) 2026-05-12 12:09:42 +10:00
Clyde Stubbs 4e31b71304 [lvgl] Add new trigger on_update and new number option (#16312) 2026-05-12 08:52:33 +10:00
J. Nick Koston 55ef66cc26 [helpers] Re-enable ProgressBar under --dashboard mode (#16357) 2026-05-11 13:12:55 -05:00
luar123 e479e8b641 [zigbee] Add power_source option to esp32 (#16062)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-05-11 14:08:52 -04:00
luar123 ee8ca2a3bf [zigbee] add on_join trigger for esp32 (#16060)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-05-11 11:54:35 -04:00
213 changed files with 7399 additions and 1528 deletions
+1 -1
View File
@@ -1 +1 @@
96c95feaa60831da5f43e3c6a7c7a3a237e17c5d12995a730dbc3884c8dcd11c
593fd53fa09944a59af3f38521e31d87fe10b60326b8d82bb76413c5149b312c
+1 -1
View File
@@ -1 +1 @@
../.ai/instructions.md
../AGENTS.md
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
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 }}
+52 -7
View File
@@ -252,6 +252,8 @@ jobs:
python-linters: ${{ steps.determine.outputs.python-linters }}
import-time: ${{ steps.determine.outputs.import-time }}
device-builder: ${{ steps.determine.outputs.device-builder }}
native-idf: ${{ steps.determine.outputs.native-idf }}
native-idf-components: ${{ steps.determine.outputs.native-idf-components }}
changed-components: ${{ steps.determine.outputs.changed-components }}
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }}
@@ -261,6 +263,7 @@ jobs:
cpp-unit-tests-run-all: ${{ steps.determine.outputs.cpp-unit-tests-run-all }}
cpp-unit-tests-components: ${{ steps.determine.outputs.cpp-unit-tests-components }}
component-test-batches: ${{ steps.determine.outputs.component-test-batches }}
validate-only-components: ${{ steps.determine.outputs.validate-only-components }}
benchmarks: ${{ steps.determine.outputs.benchmarks }}
steps:
- name: Check out code from GitHub
@@ -296,6 +299,8 @@ jobs:
echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT
echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT
echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT
echo "native-idf=$(echo "$output" | jq -r '.native_idf')" >> $GITHUB_OUTPUT
echo "native-idf-components=$(echo "$output" | jq -r '.native_idf_components')" >> $GITHUB_OUTPUT
echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT
echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT
echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT
@@ -305,6 +310,7 @@ jobs:
echo "cpp-unit-tests-run-all=$(echo "$output" | jq -r '.cpp_unit_tests_run_all')" >> $GITHUB_OUTPUT
echo "cpp-unit-tests-components=$(echo "$output" | jq -c '.cpp_unit_tests_components')" >> $GITHUB_OUTPUT
echo "component-test-batches=$(echo "$output" | jq -c '.component_test_batches')" >> $GITHUB_OUTPUT
echo "validate-only-components=$(echo "$output" | jq -c '.validate_only_components')" >> $GITHUB_OUTPUT
echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT
- name: Save components graph cache
if: github.ref == 'refs/heads/dev'
@@ -417,7 +423,10 @@ jobs:
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # v4.15.1
with:
run: ${{ steps.build.outputs.binary }}
run: |
. venv/bin/activate
${{ steps.build.outputs.binary }}
pytest tests/benchmarks/python/ --codspeed --no-cov
mode: simulation
clang-tidy-single:
@@ -775,13 +784,45 @@ jobs:
echo "Config validation passed! Starting compilation..."
echo ""
# Compute the compile-stage component list. Components whose only
# changes are validate.*.yaml files are config-only -- their source
# and test fixtures didn't move, so rebuilding firmware adds no
# signal. Subtract them from this batch before invoking compile.
validate_only_json='${{ needs.determine-jobs.outputs.validate-only-components }}'
if [ -z "$validate_only_json" ]; then
validate_only_json='[]'
fi
if ! validate_only_csv=$(echo "$validate_only_json" | jq -r 'join(",")'); then
echo "::error::Failed to render validate-only-components as CSV from: $validate_only_json"
exit 1
fi
if [ -z "$validate_only_csv" ]; then
compile_csv="$components_csv"
else
components_sorted=$(echo "$components_csv" | tr ',' '\n' | sort -u)
validate_sorted=$(echo "$validate_only_csv" | tr ',' '\n' | sort -u)
if ! diff_out=$(comm -23 <(echo "$components_sorted") <(echo "$validate_sorted")); then
echo "::error::Failed to compute compile component subset."
exit 1
fi
compile_csv=$(echo "$diff_out" | paste -sd ',' -)
skipped=$(comm -12 <(echo "$components_sorted") <(echo "$validate_sorted") | paste -sd ',' -)
if [ -n "$skipped" ]; then
echo "Validate-only components in this batch (skipping compile): $skipped"
fi
fi
# Show disk space before compilation
echo "Disk space before compilation:"
df -h
echo ""
# Run compilation with grouping and isolation
python3 script/test_build_components.py -e compile -c "$components_csv" -f --isolate "$directly_changed_csv"
if [ -n "$compile_csv" ]; then
# Run compilation with grouping and isolation
python3 script/test_build_components.py -e compile -c "$compile_csv" -f --isolate "$directly_changed_csv"
else
echo "All components in this batch are validate-only -- skipping compile stage."
fi
test-native-idf:
name: Test components with native ESP-IDF
@@ -789,10 +830,14 @@ jobs:
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.native-idf == 'true'
env:
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
TEST_COMPONENTS: esp32,api,heatpumpir,bme280_i2c,bh1750,aht10,esp32_ble,esp32_ble_beacon,esp32_ble_client,esp32_ble_server,esp32_ble_tracker,ble_client,ble_presence,ble_rssi,ble_scanner
# Comma-joined subset of the native-IDF representative component list,
# computed by script/determine-jobs.py (native_idf_components_to_test).
# Single source of truth -- the full list lives in
# script/determine-jobs.py::NATIVE_IDF_TEST_COMPONENTS.
TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.native-idf-components }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -804,7 +849,7 @@ jobs:
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache ESPHome
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.esphome-idf
key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }}
@@ -863,7 +908,7 @@ jobs:
- name: Save ESPHome cache
if: github.ref == 'refs/heads/dev'
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.esphome-idf
key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }}
@@ -35,7 +35,7 @@ jobs:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
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 }}
@@ -0,0 +1,113 @@
name: Add Dashboard Deprecation Comment
on:
pull_request_target:
types: [opened, synchronize]
# All API calls (pulls.listFiles + issues.{list,create,update}Comment) are performed with
# the App token minted below, so the workflow's GITHUB_TOKEN does not need any scopes.
permissions: {}
jobs:
dashboard-deprecation-comment:
name: Dashboard deprecation comment
runs-on: ubuntu-latest
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 }}
# pulls.listFiles + issues.{list,create,update}Comment on PRs. For PR resources
# the issues.*Comment APIs require the pull-requests scope, not issues.
permission-pull-requests: write
- name: Add dashboard deprecation comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const commentMarker = "<!-- This comment was generated automatically by the dashboard-deprecation-comment workflow. -->";
const commentBody = `Thanks for opening this PR!
Heads up: the legacy ESPHome dashboard (\`esphome/dashboard/\` and \`tests/dashboard/\`) is **deprecated** and is being replaced by [ESPHome Device Builder](https://github.com/esphome/device-builder). We are not adding new features to the legacy dashboard and it will eventually be removed from this repository.
What this means for your PR:
- **New features / enhancements**: please port the change to [esphome/device-builder](https://github.com/esphome/device-builder) instead. We are unlikely to review or merge new dashboard features here.
- **Bug fixes**: small fixes may still be considered, but please check first whether the same issue exists in Device Builder, where the fix will have a longer life.
- **Security issues**: please do not file a public PR. Report privately via [GitHub security advisories](https://github.com/esphome/esphome/security/advisories/new) so we can coordinate a fix.
We appreciate the contribution and apologize for the friction; flagging this early so your time isn't spent on a change that may not land.
---
(Added by the PR bot)
${commentMarker}`;
async function getDashboardChanges(github, owner, repo, prNumber) {
const changedFiles = await github.paginate(
github.rest.pulls.listFiles,
{
owner: owner,
repo: repo,
pull_number: prNumber,
per_page: 100,
}
);
return changedFiles.filter(file =>
file.filename.startsWith('esphome/dashboard/') ||
file.filename.startsWith('tests/dashboard/')
);
}
async function findBotComment(github, owner, repo, prNumber) {
const comments = await github.paginate(
github.rest.issues.listComments,
{
owner: owner,
repo: repo,
issue_number: prNumber,
per_page: 100,
}
);
return comments.find(comment =>
comment.body.includes(commentMarker) && comment.user.type === "Bot"
);
}
const prNumber = context.payload.pull_request.number;
const { owner, repo } = context.repo;
const dashboardChanges = await getDashboardChanges(github, owner, repo, prNumber);
const existingComment = await findBotComment(github, owner, repo, prNumber);
if (dashboardChanges.length === 0) {
// PR doesn't (or no longer) touches the legacy dashboard. If we previously
// commented (e.g. files were removed in a later push), leave the comment in
// place for history rather than thrash on edit/delete.
return;
}
if (existingComment) {
if (existingComment.body === commentBody) {
return;
}
await github.rest.issues.updateComment({
owner: owner,
repo: repo,
comment_id: existingComment.id,
body: commentBody,
});
} else {
await github.rest.issues.createComment({
owner: owner,
repo: repo,
issue_number: prNumber,
body: commentBody,
});
}
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
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 }}
+2 -70
View File
@@ -212,74 +212,6 @@ jobs:
docker buildx imagetools create $(jq -Rcnr 'inputs | . / "," | map("-t " + .) | join(" ")' <<< "${{ steps.tags.outputs.tags}}") \
$(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@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
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@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
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:
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
runs-on: ubuntu-latest
@@ -289,7 +221,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
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 }}
@@ -302,7 +234,7 @@ jobs:
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
github.rest.actions.createWorkflowDispatch({
await github.rest.actions.createWorkflowDispatch({
owner: "esphome",
repo: "version-notifier",
workflow_id: "notify.yml",
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
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 }}
+14 -4
View File
@@ -398,13 +398,23 @@ This document provides essential context for AI models interacting with this pro
│ ├── i2c/ # I2C bus
│ └── spi/ # SPI bus
└── components/[component]/
├── common.yaml # Component-only config (no bus definitions)
├── test.esp32-idf.yaml
├── test.esp8266-ard.yaml
── test.rp2040-ard.yaml
├── common.yaml # Component-only config (no bus definitions)
├── test.esp32-idf.yaml # config + compile
├── test.esp8266-ard.yaml # config + compile
── test-variant.esp32-idf.yaml # variant test, config + compile
├── validate.esp32-idf.yaml # config-only (never compiled)
└── validate-legacy.esp32-idf.yaml # config-only variant
```
Run them using `script/test_build_components`. Use `-c <component>` to test specific components and `-t <target>` for specific platforms.
* **Config-only test files (`validate.*.yaml`):** Use this prefix when a YAML file only needs to exercise schema/validation paths and does not need to be compiled. CI runs `validate.*.yaml` files with `esphome config` only and skips them during compile. The grammar mirrors `test.*.yaml`:
- `validate.<platform>.yaml` — base config-only test
- `validate-<variant>.<platform>.yaml` — config-only variant
Use this for things like deprecated-syntax migration tests, schema edge cases, or platform-specific validation branches where building firmware adds no signal. A component may have any mix of `test.*.yaml` and `validate.*.yaml` files. Validate files never participate in bus-grouping; each one runs as its own `esphome config` invocation.
When a PR's only edits to a component are `validate.*.yaml` files (no source changes, no `test.*.yaml` changes, and the component isn't pulled in as a dependency of another changed component), CI skips the compile stage for that component entirely and only runs config validation. This is decided in `script/determine-jobs.py` via `_component_change_is_validate_only` and surfaced as the `validate_only_components` output that the `test-build-components-split` job consumes.
* **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`:
```yaml
# test.esp32-idf.yaml — use packages for buses
+1 -1
View File
@@ -1 +1 @@
.ai/instructions.md
AGENTS.md
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.5.0-dev
PROJECT_NUMBER = 2026.5.2
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
+1 -1
View File
@@ -1 +1 @@
.ai/instructions.md
AGENTS.md
+7 -3
View File
@@ -13,12 +13,16 @@ RUN git config --system --add safe.directory "*" \
&& git config --system advice.detachedHead false
# Install build tools for Python packages that require compilation
# (e.g., ruamel.yaml.clibz used by ESP-IDF's idf-component-manager)
# (e.g., ruamel.yaml.clib used by ESP-IDF's idf-component-manager).
# Also install libusb-1.0 at runtime so the ESP-IDF tools installer can
# validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without
# it idf_tools.py rejects the openocd install with exit 127 and aborts
# the whole framework setup.
RUN if command -v apk > /dev/null; then \
apk add --no-cache build-base; \
apk add --no-cache build-base libusb; \
else \
apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \
&& rm -rf /var/lib/apt/lists/*; \
fi
+71 -4
View File
@@ -50,6 +50,7 @@ from esphome.const import (
CONF_TOPIC,
CONF_USERNAME,
CONF_WEB_SERVER,
CONF_WIFI,
ENV_NOGITIGNORE,
KEY_CORE,
KEY_TARGET_PLATFORM,
@@ -733,11 +734,22 @@ def write_cpp_file() -> 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
# If you change this format, update the regex in that script as well
_LOGGER.info("Compiling app... Build path: %s", CORE.build_path)
if CORE.using_toolchain_esp_idf:
module = importlib.import_module("esphome.components." + CORE.target_platform)
platform_run_compile = getattr(module, "run_compile", None)
if platform_run_compile is not None and platform_run_compile(args, config):
pass
elif CORE.using_toolchain_esp_idf:
from esphome.espidf import toolchain
rc = toolchain.run_compile(config, CORE.verbose)
@@ -1411,6 +1423,15 @@ def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
return 0
def command_config_hash(args: ArgsProtocol, config: ConfigType) -> int | None:
# generating code might modify config, so it must be done in order to generate
# a hash that will match what was generated when compiling and then running
# on the device
generate_cpp_contents(config)
safe_print(f"0x{CORE.config_hash:08x}")
return 0
def command_vscode(args: ArgsProtocol) -> int | None:
from esphome import vscode
@@ -1946,6 +1967,7 @@ PRE_CONFIG_ACTIONS = {
POST_CONFIG_ACTIONS = {
"config": command_config,
"config-hash": command_config_hash,
"compile": command_compile,
"upload": command_upload,
"logs": command_logs,
@@ -2059,6 +2081,13 @@ def parse_args(argv):
"--show-secrets", help="Show secrets in output.", action="store_true"
)
parser_config_hash = subparsers.add_parser(
"config-hash", help="Calculate the hash of the configuration."
)
parser_config_hash.add_argument(
"configuration", help="Your YAML configuration file(s).", nargs="+"
)
parser_compile = subparsers.add_parser(
"compile", help="Read the configuration and compile a program."
)
@@ -2170,6 +2199,13 @@ def parse_args(argv):
parser_run.add_argument(
"--no-logs", help="Disable starting logs.", action="store_true"
)
parser_run.add_argument(
"--no-states",
action="store_true",
help="Do not show entity state changes in log output.",
)
parser_run.add_argument(
"--reset",
"-r",
@@ -2406,10 +2442,41 @@ def run_esphome(argv):
# Commands that don't need fresh external components: logs just connects
# to the device, and clean is about to delete the build directory.
skip_external = args.command in ("logs", "clean")
config = read_config(
dict(args.substitution) if args.substitution else {},
skip_external_update=skip_external,
command_line_substitutions = dict(args.substitution) if args.substitution else {}
# Fast path for upload/logs: reuse the validated-config cache the
# last compile wrote. Falls back to read_config when missing/stale.
# Skipped when -s overrides are passed, since the cache was written
# against the previous substitution set.
config: ConfigType | None = None
cache_eligible = (
args.command in ("upload", "logs") and not command_line_substitutions
)
if cache_eligible:
from esphome.compiled_config import load_compiled_config
config = load_compiled_config(conf_path)
if config is not None:
_LOGGER.info(
"Loaded validated config cache for %s, skipping validation.",
conf_path.name,
)
if config is None:
config = read_config(
command_line_substitutions,
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:
return 2
CORE.config = config
+111 -39
View File
@@ -3,18 +3,22 @@
import json
from pathlib import Path
from esphome.components.esp32 import get_esp32_variant
from esphome.components.esp32 import get_esp32_variant, idf_version
import esphome.config_validation as cv
from esphome.core import CORE
from esphome.helpers import mkdir_p, write_file_if_changed
from esphome.writer import update_storage_json
def get_available_components() -> list[str] | None:
"""Get list of available ESP-IDF components from project_description.json.
"""Get list of built-in ESP-IDF components from project_description.json.
Returns only internal ESP-IDF components, excluding external/managed
components (from idf_component.yml).
Excludes ``src``, IDF-managed components (``managed_components/``), and
converted PIO libs (``pio_components/``). Returns ``None`` if the build
dir or ``project_description.json`` isn't ready yet.
"""
if CORE.build_path is None:
return None
project_desc = Path(CORE.build_path) / "build" / "project_description.json"
if not project_desc.exists():
return None
@@ -31,9 +35,9 @@ def get_available_components() -> list[str] | None:
if name == "src":
continue
# Exclude managed/external components
# Exclude IDF-managed and converted-PIO components (external).
comp_dir = info.get("dir", "")
if "managed_components" in comp_dir:
if "managed_components" in comp_dir or "pio_components" in comp_dir:
continue
result.append(name)
@@ -48,17 +52,68 @@ def has_discovered_components() -> bool:
return get_available_components() is not None
def get_project_cmakelists() -> str:
"""Generate the top-level CMakeLists.txt for ESP-IDF project."""
def get_project_cmakelists(minimal: bool = False) -> str:
"""Generate the top-level CMakeLists.txt for ESP-IDF project.
When ``minimal`` is true, omit ``ESPHOME_PROJECT_BUILTIN_COMPONENTS``
since ``project_description.json`` may be stale on the first write.
"""
# Get IDF target from ESP32 variant (e.g., ESP32S3 -> esp32s3)
variant = get_esp32_variant()
idf_target = variant.lower().replace("-", "")
# Extract compile definitions from build flags (-DXXX -> XXX)
compile_defs = [flag for flag in sorted(CORE.build_flags) if flag.startswith("-D")]
# esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and
# removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get
# --format=raw because the legacy mode doesn't support it.
size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else ""
# Project-wide compile options: -D defines and -W warning flags (skip
# -Wl, linker flags — those go on the src component via
# target_link_options below). Emitted via idf_build_set_property so the
# flags propagate to every IDF component (including managed ones like
# esphome__micro-mp3) rather than just src/. Required so suppressions
# like ``-Wno-error=maybe-uninitialized`` actually silence warnings in
# third-party components we don't author.
project_compile_opts = [
flag
for flag in sorted(CORE.build_flags)
if flag.startswith("-D")
or (flag.startswith("-W") and not flag.startswith("-Wl,"))
]
extra_compile_options = "\n".join(
f'idf_build_set_property(COMPILE_OPTIONS "{compile_def}" APPEND)'
for compile_def in compile_defs
f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)'
for flag in project_compile_opts
)
# Per-project list exposed as a CMake variable so converted PIO libs
# can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking
# project-specific names into their cached CMakeLists.
#
# Emit via idf_build_set_property (not plain set()) so the value is
# serialised into build_properties.temp.cmake and visible to IDF's
# early requirements-expansion pass (component_get_requirements.cmake
# runs as a separate CMake script invocation that doesn't load the
# project's top-level CMakeLists; without this, ${ESPHOME_PROJECT_
# MANAGED_COMPONENTS} in a converted-lib REQUIRES expands to empty).
from esphome.components.esp32 import get_managed_component_require_names
managed_components_property = "\n".join(
f"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS {name} APPEND)"
for name in get_managed_component_require_names()
)
# Built-in IDF components exposed via our own property (not IDF's
# __COMPONENT_REQUIRES_COMMON, which would append them to every
# component's REQUIRES including real IDF components). Referenced by
# src/CMakeLists and by each converted PIO lib's CMakeLists. Skipped
# on minimal writes because project_description.json may be stale.
builtin_components_property = (
""
if minimal
else "\n".join(
f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)"
for name in sorted(get_available_components() or [])
)
)
return f"""\
@@ -88,50 +143,67 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
{extra_compile_options}
{managed_components_property}
{builtin_components_property}
project({CORE.name})
# Emit raw JSON size data for ESPHome to read post-build.
add_custom_command(
TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD
COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=raw
-o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json
${{CMAKE_PROJECT_NAME}}.map
WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}}
VERBATIM
)
"""
def get_component_cmakelists(minimal: bool = False) -> str:
"""Generate the main component CMakeLists.txt."""
idf_requires = [] if minimal else (get_available_components() or [])
requires_str = " ".join(idf_requires)
def get_component_cmakelists() -> str:
"""Generate the main component CMakeLists.txt.
# Extract compile options (-W flags, excluding linker flags)
compile_opts = [
flag
for flag in CORE.build_flags
if flag.startswith("-W") and not flag.startswith("-Wl,")
]
compile_opts_str = "\n ".join(sorted(compile_opts)) if compile_opts else ""
# Extract linker options (-Wl, flags)
REQUIRES pulls in the discovered built-in IDF components via the
project-level variables set in the top-level CMakeLists.
"""
# Extract linker options (-Wl, flags). Compile flags (-D, -W) are
# emitted project-wide via idf_build_set_property in
# get_project_cmakelists so they reach every component, not just src/.
link_opts = [flag for flag in CORE.build_flags if flag.startswith("-Wl,")]
link_opts_str = "\n ".join(sorted(link_opts)) if link_opts else ""
return f"""\
# Auto-generated by ESPHome
file(GLOB_RECURSE app_sources
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
)
# CONFIGURE_DEPENDS asks CMake to re-check the glob each build so test
# runs that reuse the build dir don't compile stale source paths. It's
# invalid in script mode (cmake -P), which is how IDF's
# component_get_requirements.cmake includes us, so skip it there.
if(CMAKE_SCRIPT_MODE_FILE)
file(GLOB_RECURSE app_sources
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
)
else()
file(GLOB_RECURSE app_sources CONFIGURE_DEPENDS
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
)
endif()
idf_component_register(
SRCS ${{app_sources}}
INCLUDE_DIRS "." "esphome"
REQUIRES {requires_str}
REQUIRES ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}}
)
# Apply C++ standard
target_compile_features(${{COMPONENT_LIB}} PUBLIC cxx_std_20)
# ESPHome compile options
target_compile_options(${{COMPONENT_LIB}} PUBLIC
{compile_opts_str}
)
# ESPHome linker options
target_link_options(${{COMPONENT_LIB}} PUBLIC
{link_opts_str}
@@ -152,11 +224,11 @@ def write_project(minimal: bool = False) -> None:
# Write top-level CMakeLists.txt
write_file_if_changed(
CORE.relative_build_path("CMakeLists.txt"),
get_project_cmakelists(),
get_project_cmakelists(minimal=minimal),
)
# Write component CMakeLists.txt in src/
write_file_if_changed(
CORE.relative_src_path("CMakeLists.txt"),
get_component_cmakelists(minimal=minimal),
get_component_cmakelists(),
)
+76
View File
@@ -0,0 +1,76 @@
"""Validated-config cache for the upload/logs fast path.
compile dumps the validated config to <data_dir>/storage/<file>.validated.yaml;
the next upload/logs for that YAML reuses it instead of running the full
read_config pipeline. YAML round-trip (yaml_util.dump/load_yaml) keeps
!lambda/!include/IDs/paths intact; mtime gates staleness.
"""
from __future__ import annotations
import logging
from pathlib import Path
from esphome.core import CORE
from esphome.helpers import write_file
from esphome.storage_json import StorageJSON, ext_storage_path
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
def compiled_config_path(config_filename: str) -> Path:
"""Path to the cached validated config alongside the storage sidecar."""
return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml"
def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool:
"""True iff the cache file exists and isn't older than the source."""
try:
return cache_path.stat().st_mtime >= source_path.stat().st_mtime
except OSError:
return False
def save_compiled_config(config: ConfigType) -> None:
"""Write the validated-config cache. Always-write so mtime stays fresh.
Mode 0600 because show_secrets=True resolves !secret inline.
Failures are non-fatal: the fast path falls back to read_config.
"""
from esphome import yaml_util
try:
rendered = yaml_util.dump(config, show_secrets=True)
write_file(compiled_config_path(CORE.config_filename), rendered, private=True)
except Exception as err: # pylint: disable=broad-except
_LOGGER.debug("Skipping compiled config cache write: %s", err)
def load_compiled_config(conf_path: Path) -> ConfigType | None:
"""Load the cached validated config and apply storage metadata to CORE.
Returns None (caller falls back to read_config) when the cache is
missing, older than the source YAML, unparseable, or the sidecar
is incomplete.
"""
cache_path = compiled_config_path(conf_path.name)
if not _cache_is_fresh(cache_path, conf_path):
return None
from esphome import yaml_util
try:
config = yaml_util.load_yaml(cache_path, clear_secrets=False)
except Exception: # pylint: disable=broad-except
return None
storage = StorageJSON.load(ext_storage_path(conf_path.name))
if storage is None:
return None
# apply_to_core assumes a real compile wrote the sidecar; wizard-only
# sidecars leave both of these unset and can't drive upload/logs.
if not storage.core_platform and not storage.target_platform:
return None
storage.apply_to_core()
return config
+1
View File
@@ -2026,6 +2026,7 @@ message VoiceAssistantAudio {
bytes data = 1 [(pointer_to_buffer) = true];
bool end = 2;
bytes data2 = 3 [(pointer_to_buffer) = true];
}
enum VoiceAssistantTimerEvent {
@@ -1,5 +1,6 @@
#include "api_connection.h"
#ifdef USE_API
#include "api_connection_buffer.h" // for encode_to_buffer / get_batch_delay_ms_ inlines
#ifdef USE_API_NOISE
#include "api_frame_helper_noise.h"
#endif
@@ -1305,6 +1306,9 @@ void APIConnection::on_voice_assistant_announce_request(const VoiceAssistantAnno
bool APIConnection::send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg) {
VoiceAssistantConfigurationResponse resp;
if (!this->check_voice_assistant_api_connection_()) {
// send_message encodes synchronously, so this stack local outlives the encode
const std::vector<std::string> empty_wake_words;
resp.active_wake_words = &empty_wake_words;
return this->send_message(resp);
}
+11 -40
View File
@@ -11,7 +11,8 @@
#endif
#include "api_pb2.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/component.h"
#ifdef USE_ESP32_CRASH_HANDLER
@@ -36,6 +37,9 @@ class ComponentIterator;
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
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
// 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
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.
// ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites.
static inline uint16_t ESPHOME_ALWAYS_INLINE 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;
}
// Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites.
// Defined in api_connection_buffer.h (needs APIServer complete).
static uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
const void *msg, APIConnection *conn, uint32_t remaining_size);
// 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.
@@ -792,7 +762,8 @@ class APIConnection final : public APIServerConnectionBase {
// Read by process_batch_multi_ to pass into MessageInfo.
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
// MTU is 1500. Sometimes users will see as low as 1460 MTU.
// 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
+7
View File
@@ -2893,6 +2893,11 @@ bool VoiceAssistantAudio::decode_length(uint32_t field_id, ProtoLengthDelimited
this->data_len = value.size();
break;
}
case 3: {
this->data2 = value.data();
this->data2_len = value.size();
break;
}
default:
return false;
}
@@ -2902,12 +2907,14 @@ uint8_t *VoiceAssistantAudio::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len);
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->end);
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->data2, this->data2_len);
return pos;
}
uint32_t VoiceAssistantAudio::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_length(1, this->data_len);
size += ProtoSize::calc_bool(1, this->end);
size += ProtoSize::calc_length(1, this->data2_len);
return size;
}
bool VoiceAssistantTimerEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) {
+3 -1
View File
@@ -2436,13 +2436,15 @@ class VoiceAssistantEventResponse final : public ProtoDecodableMessage {
class VoiceAssistantAudio final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 106;
static constexpr uint8_t ESTIMATED_SIZE = 21;
static constexpr uint8_t ESTIMATED_SIZE = 40;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("voice_assistant_audio"); }
#endif
const uint8_t *data{nullptr};
uint16_t data_len{0};
bool end{false};
const uint8_t *data2{nullptr};
uint16_t data2_len{0};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
+1
View File
@@ -2174,6 +2174,7 @@ const char *VoiceAssistantAudio::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("VoiceAssistantAudio"));
dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len);
dump_field(out, ESPHOME_PSTR("end"), this->end);
dump_bytes_field(out, ESPHOME_PSTR("data2"), this->data2, this->data2_len);
return out.c_str();
}
const char *VoiceAssistantTimerEventResponse::dump_to(DumpBuffer &out) const {
-5
View File
@@ -30,11 +30,6 @@ APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-c
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) {
ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno);
this->destroy_socket_();
+4 -10
View File
@@ -3,6 +3,8 @@
#include "esphome/core/defines.h"
#ifdef USE_API
#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_pb2.h"
#include "api_pb2_service.h"
@@ -12,8 +14,6 @@
#include "esphome/core/controller.h"
#include "esphome/core/log.h"
#include "esphome/core/string_ref.h"
#include "list_entities.h"
#include "subscribe_state.h"
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
#endif
@@ -191,15 +191,9 @@ class APIServer final : public Component,
bool is_connected_with_state_subscription() const;
// 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.
// Custom deleter is defined out-of-line in api_server.cpp so libc++ does not
// 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>;
using APIConnectionPtr = std::unique_ptr<APIConnection>;
class ActiveClientsView {
const APIConnectionPtr *begin_;
const APIConnectionPtr *end_;
+1 -1
View File
@@ -395,7 +395,7 @@ async def to_code(config):
)
if data.mp3_support:
cg.add_define("USE_AUDIO_MP3_SUPPORT")
add_idf_component(name="esphome/micro-mp3", ref="0.2.0")
add_idf_component(name="esphome/micro-mp3", ref="0.2.1")
_emit_memory_pair(
data.mp3.buffer_memory,
"CONFIG_MP3_DECODER_PREFER_PSRAM",
@@ -207,6 +207,137 @@ void ConstAudioSourceBuffer::consume(size_t bytes) {
this->data_start_ += bytes;
}
std::unique_ptr<RingBufferAudioSource> RingBufferAudioSource::create(
std::shared_ptr<ring_buffer::RingBuffer> ring_buffer, size_t max_fill_bytes, uint8_t alignment_bytes) {
if (ring_buffer == nullptr || max_fill_bytes == 0 || alignment_bytes == 0 || alignment_bytes > MAX_ALIGNMENT_BYTES) {
return nullptr;
}
return std::unique_ptr<RingBufferAudioSource>(
new RingBufferAudioSource(std::move(ring_buffer), max_fill_bytes, alignment_bytes));
}
RingBufferAudioSource::~RingBufferAudioSource() {
if (this->acquired_item_ != nullptr) {
this->ring_buffer_->receive_release(this->acquired_item_);
this->acquired_item_ = nullptr;
}
}
void RingBufferAudioSource::release_item_() {
if (this->acquired_item_ == nullptr) {
return;
}
if (this->item_trailing_length_ > 0) {
// Copy the trailing sub-frame bytes into the splice buffer before returning the item; the next
// fill() will complete the frame from the head of the next chunk.
std::memcpy(this->splice_buffer_, this->item_trailing_ptr_, this->item_trailing_length_);
this->splice_length_ = this->item_trailing_length_;
this->item_trailing_ptr_ = nullptr;
this->item_trailing_length_ = 0;
}
this->ring_buffer_->receive_release(this->acquired_item_);
this->acquired_item_ = nullptr;
}
void RingBufferAudioSource::consume(size_t bytes) {
bytes = std::min(bytes, this->current_available_);
this->current_data_ += bytes;
this->current_available_ -= bytes;
// Promotion of queued data is deferred to fill() so callers see new data as a fresh return value
// rather than appearing silently after consume(). When the held item has nothing left depending
// on it (no exposed bytes and no queued region), release it now so the ring buffer can be
// reclaimed by writers even if fill() is never called again.
if (this->current_available_ == 0 && this->queued_length_ == 0) {
this->release_item_();
}
}
bool RingBufferAudioSource::has_buffered_data() const {
// splice_length_ is deliberately not considered here. It holds an incomplete frame whose completion
// bytes must still arrive through the ring buffer, which ring_buffer_->available() already reports.
// Counting it separately would strand a drain loop when a stream ends mid-frame and those completion
// bytes never come.
return (this->current_available_ > 0) || (this->queued_length_ > 0) || (this->ring_buffer_->available() > 0);
}
size_t RingBufferAudioSource::fill(TickType_t ticks_to_wait, bool /*pre_shift*/) {
if (this->current_available_ > 0) {
// Caller has not finished consuming the current exposure
return 0;
}
// If a queued region (the aligned remainder of the new chunk after a splice frame) is waiting,
// promote it to the exposed region and report its size as fresh data.
if (this->queued_length_ > 0) {
this->current_data_ = this->queued_data_;
this->current_available_ = this->queued_length_;
this->queued_data_ = nullptr;
this->queued_length_ = 0;
return this->current_available_;
}
// Nothing exposed and nothing queued: release the previously held item (saving any sub-frame tail
// to splice_buffer_) and acquire a new chunk.
this->release_item_();
size_t chunk_length = 0;
void *item = this->ring_buffer_->receive_acquire(chunk_length, this->max_fill_bytes_, ticks_to_wait);
if (item == nullptr) {
return 0;
}
uint8_t *chunk_data = static_cast<uint8_t *>(item);
bool exposing_splice_frame = false;
// Complete any pending splice frame from the head of the new chunk.
if (this->splice_length_ > 0) {
const size_t needed = static_cast<size_t>(this->alignment_bytes_) - this->splice_length_;
if (chunk_length < needed) {
// Not enough data to complete the spliced frame yet; absorb everything and wait for more.
std::memcpy(this->splice_buffer_ + this->splice_length_, chunk_data, chunk_length);
this->splice_length_ += chunk_length;
this->ring_buffer_->receive_release(item);
return 0;
}
std::memcpy(this->splice_buffer_ + this->splice_length_, chunk_data, needed);
chunk_data += needed;
chunk_length -= needed;
this->splice_length_ = 0;
exposing_splice_frame = true;
}
this->acquired_item_ = item;
// Split the remaining chunk into its aligned region and a (possibly zero) sub-frame trailing tail.
const size_t trailing = (this->alignment_bytes_ > 1) ? (chunk_length % this->alignment_bytes_) : 0;
const size_t aligned_bytes = chunk_length - trailing;
if (trailing > 0) {
this->item_trailing_ptr_ = chunk_data + aligned_bytes;
this->item_trailing_length_ = trailing;
}
if (exposing_splice_frame) {
// Expose the spliced frame from splice_buffer_, queuing the chunk's aligned region for the next
// fill() call.
this->current_data_ = this->splice_buffer_;
this->current_available_ = this->alignment_bytes_;
this->queued_data_ = chunk_data;
this->queued_length_ = aligned_bytes;
return this->alignment_bytes_;
}
if (aligned_bytes == 0) {
// The entire chunk is a sub-frame tail (only possible when alignment exceeds chunk size). Save it
// to the splice buffer and release the item so the next fill() can complete the frame.
this->release_item_();
return 0;
}
this->current_data_ = chunk_data;
this->current_available_ = aligned_bytes;
return aligned_bytes;
}
} // namespace esphome::audio
#endif
@@ -214,6 +214,86 @@ class ConstAudioSourceBuffer : public AudioReadableBuffer {
size_t length_{0};
};
/// @brief Zero-copy audio source that reads directly from a ring buffer's internal storage.
///
/// Optionally enforces a minimum read alignment (e.g. one audio frame). When alignment_bytes > 1, the
/// source transparently stitches frames that straddle the ring buffer's wrap boundary by buffering the
/// trailing partial frame from one chunk and joining it with the head of the next chunk in a small
/// internal splice buffer, so callers always see frame-aligned data.
///
/// Not thread-safe. The underlying ring_buffer::RingBuffer supports one producer and one consumer
/// running concurrently, but a given RingBufferAudioSource (its acquired item, splice buffer, and
/// queued region) must be used by only one thread, and that thread is the ring buffer's consumer.
class RingBufferAudioSource : public AudioReadableBuffer {
public:
/// Maximum supported alignment. Sized to cover 32-bit samples across up to 2 channels (8 bytes).
static constexpr size_t MAX_ALIGNMENT_BYTES = 8;
/// @brief Creates a new ring-buffer-backed audio source after validating its parameters.
/// @param ring_buffer The ring buffer to read from. Must be non-null.
/// @param max_fill_bytes Soft cap on bytes acquired per fill() call. Must be > 0.
/// @param alignment_bytes Minimum exposed-region alignment in bytes (defaults to 1, i.e. byte-aligned).
/// Pass bytes_per_frame to make every exposed region a whole number of frames. Must be in
/// [1, MAX_ALIGNMENT_BYTES].
/// @return unique_ptr if parameters are valid, nullptr otherwise
static std::unique_ptr<RingBufferAudioSource> create(std::shared_ptr<ring_buffer::RingBuffer> ring_buffer,
size_t max_fill_bytes, uint8_t alignment_bytes = 1);
~RingBufferAudioSource() override;
// AudioReadableBuffer interface
const uint8_t *data() const override { return this->current_data_; }
size_t available() const override { return this->current_available_; }
void consume(size_t bytes) override;
bool has_buffered_data() const override;
/// pre_shift is ignored: there is no intermediate transfer buffer to compact, so an unconsumed
/// exposure stays in place and fill() returns 0 until it is fully consumed.
size_t fill(TickType_t ticks_to_wait, bool pre_shift) override;
/// @brief Returns a mutable pointer to the currently exposed audio data.
/// The pointer may reference the ring buffer's internal storage or, when exposing a stitched frame
/// across a wrap boundary, an internal splice buffer. In either case mutations are safe but data
/// should be discarded after use, since the underlying storage will be reused on the next fill().
/// Use only when the caller is the sole consumer of this source.
uint8_t *mutable_data() { return this->current_data_; }
protected:
/// @brief Constructs a new ring-buffer-backed audio source. Use create() instead, which validates
/// arguments before construction.
explicit RingBufferAudioSource(std::shared_ptr<ring_buffer::RingBuffer> ring_buffer, size_t max_fill_bytes,
uint8_t alignment_bytes)
: ring_buffer_(std::move(ring_buffer)), max_fill_bytes_(max_fill_bytes), alignment_bytes_(alignment_bytes) {}
/// @brief Releases the currently held ring buffer item, first copying any trailing sub-frame bytes
/// into the splice buffer so they can be stitched with the next chunk.
void release_item_();
std::shared_ptr<ring_buffer::RingBuffer> ring_buffer_;
size_t max_fill_bytes_;
void *acquired_item_{nullptr};
uint8_t *current_data_{nullptr};
// Sub-frame trailing bytes inside the held item that will be copied to splice_buffer_ on release.
uint8_t *item_trailing_ptr_{nullptr};
// After the currently-exposed splice frame is consumed, fill() will promote this region (the aligned
// remainder of the new chunk) to the exposed region. queued_length_ == 0 when nothing is queued.
uint8_t *queued_data_{nullptr};
// Splice buffer holds the start of a partial frame whose remainder lives at the head of the next
// chunk. While splice_length_ > 0, the buffer is incomplete and waiting for completion bytes.
uint8_t splice_buffer_[MAX_ALIGNMENT_BYTES];
size_t current_available_{0};
size_t queued_length_{0};
// item_trailing_length_ and splice_length_ are bounded by MAX_ALIGNMENT_BYTES.
uint8_t alignment_bytes_;
uint8_t item_trailing_length_{0};
uint8_t splice_length_{0};
};
} // namespace esphome::audio
#endif
@@ -135,12 +135,26 @@ void BluetoothConnection::loop() {
// - For V3_WITH_CACHE: Services are never sent, disable after INIT state
// - For V3_WITHOUT_CACHE: Disable only after service discovery is complete
// (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent)
if (this->state() != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
this->send_service_ == DONE_SENDING_SERVICES)) {
// Never disable while DISCONNECTING — BLEClientBase::loop() needs to keep running so the
// 10s safety timeout can force IDLE if CLOSE_EVT is never delivered.
if (this->state() != espbt::ClientState::INIT && this->state() != espbt::ClientState::DISCONNECTING &&
(this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
this->send_service_ == DONE_SENDING_SERVICES)) {
this->disable_loop();
}
}
void BluetoothConnection::on_disconnect_complete(esp_err_t reason) {
// Called from both the CLOSE_EVT handler and the DISCONNECTING safety timeout in the
// base class. Free the proxy slot, notify the API client, and reset send_service_.
// address_ may already be 0 if reset_connection_ ran earlier on this teardown.
if (this->address_ == 0) {
return;
}
ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, reason);
this->reset_connection_(reason);
}
void BluetoothConnection::reset_connection_(esp_err_t reason) {
// Send disconnection notification
this->proxy_->send_device_connection(this->address_, false, 0, reason);
@@ -372,14 +386,6 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason);
break;
}
case ESP_GATTC_CLOSE_EVT: {
ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_,
param->close.reason);
// Now the GATT connection is fully closed and controller resources are freed
// Safe to mark the connection slot as available
this->reset_connection_(param->close.reason);
break;
}
case ESP_GATTC_OPEN_EVT: {
if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) {
this->reset_connection_(param->open.status);
@@ -33,6 +33,8 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase {
protected:
friend class BluetoothProxy;
void on_disconnect_complete(esp_err_t reason) override;
bool supports_efficient_uuids_() const;
void send_service_for_discovery_();
void reset_connection_(esp_err_t reason);
@@ -1,5 +1,6 @@
#include "bluetooth_proxy.h"
#include "esphome/components/api/api_server.h"
#include "esphome/core/log.h"
#include "esphome/core/macros.h"
#include "esphome/core/application.h"
@@ -161,7 +161,7 @@ void BME680BSECComponent::dump_config() {
" IAQ Mode: %s\n"
" Supply Voltage: %sV\n"
" Sample Rate: %s\n"
" State Save Interval: %ims",
" State Save Interval: %" PRIu32 "ms",
this->temperature_offset_, this->iaq_mode_ == IAQ_MODE_STATIC ? "Static" : "Mobile",
this->supply_voltage_ == SUPPLY_VOLTAGE_3V3 ? "3.3" : "1.8",
BME680_BSEC_SAMPLE_RATE_LOG(this->sample_rate_), this->state_save_interval_ms_);
@@ -461,7 +461,7 @@ int8_t BME680BSECComponent::write_bytes_wrapper(uint8_t devid, uint8_t a_registe
}
void BME680BSECComponent::delay_ms(uint32_t period) {
ESP_LOGV(TAG, "Delaying for %ums", period);
ESP_LOGV(TAG, "Delaying for %" PRIu32 "ms", period);
delay(period);
}
+139 -48
View File
@@ -46,7 +46,7 @@ from esphome.const import (
Toolchain,
__version__,
)
from esphome.core import CORE, HexInt, Library
from esphome.core import CORE, EsphomeError, HexInt, Library
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.coroutine import CoroPriority, coroutine_with_priority
from esphome.espidf.component import generate_idf_component
@@ -113,6 +113,7 @@ ARDUINO_FRAMEWORK_NAME = "framework-arduinoespressif32"
ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}"
ARDUINO_LIBS_NAME = f"{ARDUINO_FRAMEWORK_NAME}-libs"
ARDUINO_LIBS_PKG = f"pioarduino/{ARDUINO_LIBS_NAME}"
ARDUINO_ESP32_COMPONENT_NAME = "espressif/arduino-esp32"
LOG_LEVELS_IDF = [
"NONE",
@@ -588,6 +589,18 @@ def add_idf_component(
}
def get_managed_component_require_names() -> list[str]:
"""Return sorted IDF require names for components added via
``add_idf_component`` (``owner/name`` -> ``owner__name``).
The build_gen layer (``build_gen.espidf.get_project_cmakelists``)
feeds this list into ``ESPHOME_PROJECT_MANAGED_COMPONENTS`` so
converted PIO libraries can REQUIRE them by name at configure time.
"""
components_registry = CORE.data.get(KEY_ESP32, {}).get(KEY_COMPONENTS, {})
return sorted(name.replace("/", "__") for name in components_registry)
def exclude_builtin_idf_component(name: str) -> None:
"""Exclude an ESP-IDF component from the build.
@@ -780,19 +793,15 @@ PLATFORM_VERSION_LOOKUP = {
}
def _check_pio_versions(config):
config = config.copy()
value = config[CONF_FRAMEWORK]
def _resolve_framework_version(value: ConfigType) -> cv.Version:
"""Resolve a named or raw framework version and validate the minimum.
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 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:
version = ARDUINO_FRAMEWORK_VERSION_LOOKUP[value[CONF_VERSION]]
else:
@@ -805,7 +814,38 @@ def _check_pio_versions(config):
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
if version < cv.Version(3, 0, 0):
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)
value[CONF_SOURCE] = value.get(
CONF_SOURCE, _format_framework_arduino_version(version)
@@ -813,9 +853,6 @@ def _check_pio_versions(config):
if _is_framework_url(value[CONF_SOURCE]):
value[CONF_SOURCE] = f"{ARDUINO_FRAMEWORK_PKG}@{value[CONF_SOURCE]}"
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)
value[CONF_SOURCE] = value.get(
CONF_SOURCE,
@@ -831,12 +868,6 @@ def _check_pio_versions(config):
)
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(
str(PLATFORM_VERSION_LOOKUP["recommended"])
):
@@ -848,19 +879,26 @@ def _check_pio_versions(config):
return config
def _check_esp_idf_versions(config):
config = _check_pio_versions(config)
def _check_esp_idf_versions(config: ConfigType) -> ConfigType:
config = config.copy()
value = config[CONF_FRAMEWORK]
# Remove unwanted keys if present
for key in (CONF_SOURCE, CONF_PLATFORM_VERSION):
value.pop(key, None)
# platform_version is a PlatformIO concept; drop it if a user carried it
# over from a PIO-style config. CONF_SOURCE, on the other hand, is kept:
# 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 = cv.Version.parse(value[CONF_VERSION])
version = cv.Version(version.major, version.minor, version.patch)
version = _resolve_framework_version(value)
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
@@ -1706,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):
framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
conf = config[CONF_FRAMEWORK]
@@ -1753,19 +1816,21 @@ async def to_code(config):
Path(__file__).parent / "iram_fix.py.script",
)
else:
cg.add_build_flag("-Wno-error=format")
cg.add_build_flag("-Wno-error=missing-field-initializers")
cg.add_build_flag("-Wno-error=volatile")
# Demote IDF's blanket -Werror to warnings so third-party libs
# and user lambdas don't need a -Wno-error=<class> per warning.
# The sdkconfig knob disables IDF's rewrite to -Werror=all (which
# can't be globally undone); -Wno-error then handles the demotion.
add_idf_sdkconfig_option("CONFIG_COMPILER_DISABLE_DEFAULT_ERRORS", False)
cg.add_build_flag("-Wno-error")
# -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates
cg.add_build_flag("-Wno-missing-field-initializers")
cg.set_cpp_standard("gnu++20")
cg.add_build_flag("-DUSE_ESP32")
cg.add_define("USE_NATIVE_64BIT_TIME")
cg.add_build_flag("-Wl,-z,noexecstack")
# Arduino already wraps esp_panic_handler for its own backtrace handler,
# so only add our wrap when using ESP-IDF framework to avoid linker conflicts.
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
cg.add_build_flag("-Wl,--wrap=esp_panic_handler")
cg.add_define("USE_ESP32_CRASH_HANDLER")
# Deferred so KEY_COMPONENTS is fully populated -- see the coroutine.
CORE.add_job(_finalize_arduino_aware_flags)
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
variant = config[CONF_VARIANT]
cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}")
@@ -2450,8 +2515,14 @@ def _write_sdkconfig():
)
want_opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
# Include the resolved framework version as a Kconfig comment so a
# version switch that happens to leave the option set unchanged still
# bumps this file's content -- which is what has_outdated_files()
# uses to decide whether to reconfigure.
framework_version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
contents = (
"\n".join(
f"# ESPHOME_IDF_VERSION={framework_version}\n"
+ "\n".join(
f"{name}={_format_sdkconfig_val(value)}"
for name, value in sorted(want_opts.items())
)
@@ -2466,9 +2537,8 @@ def _write_sdkconfig():
def _platformio_library_to_dependency(library: Library) -> tuple[str, 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["version"] = version
return name, dependency
@@ -2495,7 +2565,12 @@ def _write_idf_component_yml():
stubs_dir = CORE.relative_build_path("component_stubs")
stubs_dir.mkdir(exist_ok=True)
for component_name in components_to_stub:
# Sort so the dict insertion order (and thus the generated
# src/idf_component.yml) is deterministic across runs; otherwise
# the manifest content shuffles every build, write_file_if_changed
# always writes, and ninja keeps triggering CMake re-runs on
# otherwise-cached rebuilds.
for component_name in sorted(components_to_stub):
# Create stub directory with minimal CMakeLists.txt
stub_path = stubs_dir / _idf_component_stub_name(component_name)
stub_path.mkdir(exist_ok=True)
@@ -2515,7 +2590,7 @@ def _write_idf_component_yml():
if CORE.using_toolchain_esp_idf:
add_idf_component(
name="espressif/arduino-esp32",
name=ARDUINO_ESP32_COMPONENT_NAME,
ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]),
)
@@ -2582,13 +2657,29 @@ def copy_files():
def _decode_pc(config, addr):
from esphome.platformio import toolchain
# _decode_pc runs from the api log processor's asyncio callback, which
# only catches EsphomeError. Any other exception escaping here tears down
# the protocol and triggers an infinite reconnect/replay loop. Convert
# toolchain-resolution errors (e.g. missing build dir / cmake cache) into
# EsphomeError so the caller can disable decoding cleanly.
if CORE.using_toolchain_esp_idf:
from esphome.espidf import toolchain as idf_toolchain
idedata = toolchain.get_idedata(config)
if not idedata.addr2line_path or not idedata.firmware_elf_path:
try:
addr2line_path = idf_toolchain.get_addr2line_path()
firmware_elf_path = idf_toolchain.get_elf_path()
except RuntimeError as err:
raise EsphomeError(f"ESP-IDF toolchain not available: {err}") from err
else:
from esphome.platformio import toolchain
idedata = toolchain.get_idedata(config)
addr2line_path = idedata.addr2line_path
firmware_elf_path = idedata.firmware_elf_path
if not addr2line_path or not firmware_elf_path:
_LOGGER.debug("decode_pc no addr2line")
return
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
command = [str(addr2line_path), "-pfiaC", "-e", str(firmware_elf_path), addr]
try:
translation = subprocess.check_output(command, close_fds=False).decode().strip()
except Exception: # pylint: disable=broad-except
@@ -72,6 +72,7 @@ void BLEClientBase::loop() {
// never delivered CLOSE_EVT/DISCONNECT_EVT, services would leak without this call.
this->release_services();
this->set_idle_();
this->on_disconnect_complete(ESP_GATT_CONN_TIMEOUT);
}
}
@@ -418,6 +419,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
this->log_gattc_lifecycle_event_("CLOSE");
this->release_services();
this->set_idle_();
this->on_disconnect_complete(param->close.reason);
break;
}
case ESP_GATTC_SEARCH_RES_EVT: {
@@ -140,6 +140,12 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
void log_gattc_warning_(const char *operation, esp_err_t err);
void log_connection_params_(const char *param_type);
void handle_connection_result_(esp_err_t ret);
/// Hook called once a connection has been fully torn down (after release_services() and
/// set_idle_()), from both the CLOSE_EVT handler and the DISCONNECTING safety timeout.
/// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state)
/// override this to release that state. `reason` is the controller reason code, or
/// ESP_GATT_CONN_TIMEOUT for the safety-timeout path.
virtual void on_disconnect_complete(esp_err_t reason) {}
/// Transition to IDLE and reset conn_id — call when the connection is fully dead.
void set_idle_() {
this->set_state(espbt::ClientState::IDLE);
@@ -149,6 +155,10 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
void set_disconnecting_() {
this->disconnecting_started_ = millis();
this->set_state(espbt::ClientState::DISCONNECTING);
// BluetoothConnection::loop() disables the component loop after service discovery
// completes, so the DISCONNECTING timeout check in loop() would never run if CLOSE_EVT
// gets lost. Re-enable the loop so the 10s safety timeout can force IDLE.
this->enable_loop();
}
// Compact error logging helpers to reduce flash usage
void log_error_(const char *message);
+1 -1
View File
@@ -249,7 +249,7 @@ async def to_code(config):
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1")
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2")
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.6")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.7")
else:
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0")
esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0")
@@ -92,7 +92,7 @@ void Esp32HostedUpdate::setup() {
if (esp_hosted_get_coprocessor_fwversion(&ver_info) == ESP_OK) {
// 16 bytes: "255.255.255" (11 chars) + null + safety margin
char buf[16];
snprintf(buf, sizeof(buf), "%d.%d.%d", ver_info.major1, ver_info.minor1, ver_info.patch1);
snprintf(buf, sizeof(buf), "%" PRIu32 ".%" PRIu32 ".%" PRIu32, ver_info.major1, ver_info.minor1, ver_info.patch1);
this->update_info_.current_version = buf;
} else {
this->update_info_.current_version = "unknown";
@@ -120,8 +120,8 @@ void Esp32HostedUpdate::setup() {
this->state_ = update::UPDATE_STATE_NO_UPDATE;
}
} else {
ESP_LOGW(TAG, "Invalid app description magic word: 0x%08x (expected 0x%08x)", app_desc->magic_word,
ESP_APP_DESC_MAGIC_WORD);
ESP_LOGW(TAG, "Invalid app description magic word: 0x%08" PRIx32 " (expected 0x%08" PRIx32 ")",
app_desc->magic_word, static_cast<uint32_t>(ESP_APP_DESC_MAGIC_WORD));
this->state_ = update::UPDATE_STATE_NO_UPDATE;
}
} else {
+12 -12
View File
@@ -5,6 +5,7 @@
#include <Arduino.h>
#include <core_esp8266_features.h>
#include <coredecls.h>
extern "C" {
#include <user_interface.h>
@@ -71,23 +72,22 @@ uint32_t IRAM_ATTR HOT millis() {
return result;
}
// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object
// call to the original millis() that --wrap can't intercept, so calling ::delay()
// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still
// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and
// WiFi run correctly. Theoretically less power-efficient than Arduino's
// os_timer-based delay() for long waits, but nearly all ESPHome delays are short
// (sensor/I²C/SPI settling in the 1100 ms range) where the difference is
// negligible.
// Delegate to Arduino's 1-arg esp_delay(), which uses os_timer + esp_suspend to
// suspend the cont task for `ms` milliseconds without polling millis(). This
// matches pre-2026.5.0 behavior (when esphome::delay() forwarded to ::delay())
// and lets the SDK run freely while we wait, which timing-sensitive
// interrupt-driven code (e.g. ESP8266 software-serial RX in components like
// fingerprint_grow) depends on. The poll-based busy-wait that this replaced
// rarely yielded inside short waits like delay(1), starving WiFi/SDK tasks and
// 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) {
if (ms == 0) {
optimistic_yield(1000);
return;
}
uint32_t start = millis();
while (millis() - start < ms) {
optimistic_yield(1000);
}
esp_delay(ms);
}
void arch_restart() {
@@ -108,8 +108,8 @@ void ESPHomeOTAComponent::dump_config() {
ESP_LOGCONFIG(TAG,
" Partition access allowed\n"
" Running app:\n"
" Partition address: 0x%X\n"
" Used size: %zu bytes (0x%X)",
" Partition address: 0x%" PRIX32 "\n"
" Used size: %zu bytes (0x%zX)",
this->running_app_offset_, this->running_app_size_, this->running_app_size_);
#ifdef USE_ESP32
@@ -378,7 +378,7 @@ void ESPHomeOTAComponent::handle_data_() {
}
ota_size = (static_cast<size_t>(buf[0]) << 24) | (static_cast<size_t>(buf[1]) << 16) |
(static_cast<size_t>(buf[2]) << 8) | buf[3];
ESP_LOGV(TAG, "Size is %u bytes", ota_size);
ESP_LOGV(TAG, "Size is %zu bytes", ota_size);
#ifndef USE_OTA_PARTITIONS
if (ota_type != ota::OTA_TYPE_UPDATE_APP) {
@@ -749,7 +749,7 @@ bool ESPHomeOTAComponent::handle_auth_send_() {
this->auth_buf_[0] = this->auth_type_;
hasher.get_hex(buf);
ESP_LOGV(TAG, "Auth: Nonce is %.*s", hex_size, buf);
ESP_LOGV(TAG, "Auth: Nonce is %.*s", (int) hex_size, buf);
}
// Try to write auth_type + nonce
@@ -809,13 +809,13 @@ bool ESPHomeOTAComponent::handle_auth_read_() {
hasher.add(nonce, hex_size * 2); // Add both nonce and cnonce (contiguous in buffer)
hasher.calculate();
ESP_LOGV(TAG, "Auth: CNonce is %.*s", hex_size, cnonce);
ESP_LOGV(TAG, "Auth: CNonce is %.*s", (int) hex_size, cnonce);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char computed_hash[SHA256_HEX_SIZE + 1]; // Buffer for hex-encoded hash (max expected length + null terminator)
hasher.get_hex(computed_hash);
ESP_LOGV(TAG, "Auth: Result is %.*s", hex_size, computed_hash);
ESP_LOGV(TAG, "Auth: Result is %.*s", (int) hex_size, computed_hash);
#endif
ESP_LOGV(TAG, "Auth: Response is %.*s", hex_size, response);
ESP_LOGV(TAG, "Auth: Response is %.*s", (int) hex_size, response);
// Compare response
bool matches = hasher.equals_hex(response);
@@ -19,7 +19,7 @@ void FastLEDLightOutput::dump_config() {
ESP_LOGCONFIG(TAG,
"FastLED light:\n"
" Num LEDs: %u\n"
" Max refresh rate: %u",
" Max refresh rate: %" PRIu32,
this->num_leds_, this->max_refresh_rate_.value_or(0));
}
void FastLEDLightOutput::write_state(light::LightState *state) {
@@ -206,6 +206,7 @@ uint8_t FingerprintGrowComponent::save_fingerprint_() {
break;
case ENROLL_MISMATCH:
ESP_LOGE(TAG, "Scans do not match");
[[fallthrough]];
default:
return this->data_[0];
}
@@ -15,6 +15,16 @@ void FT5x06Touchscreen::setup() {
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.
this->set_timeout(200, [this] { this->continue_setup_(); });
}
@@ -39,15 +49,6 @@ void FT5x06Touchscreen::continue_setup_() {
this->mark_failed();
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() {
@@ -71,7 +72,7 @@ void FT5x06Touchscreen::update_touches() {
uint16_t x = encode_uint16(data[i][0] & 0x0F, data[i][1]);
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) {
this->add_raw_touch_position_(id, x, y);
}
+1 -1
View File
@@ -22,7 +22,7 @@ static constexpr uint8_t MEAS_CONF_HUM = 0x04; // Bits 2:1 = 10: humidity only
void HDC2080Component::setup() {
const uint8_t data = 0x00; // automatic measurement mode disabled, heater off
if (this->write_register(REG_RESET_DRDY_INT_CONF, &data, 1) != i2c::ERROR_OK) {
this->mark_failed(ESP_LOG_MSG_COMM_FAIL);
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
}
-1
View File
@@ -125,7 +125,6 @@ async def to_code(config):
cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT]))
cg.add(var.set_max_temperature(config[CONF_MAX_TEMPERATURE]))
cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE]))
cg.add_build_flag("-Wno-error=overloaded-virtual")
cg.add_library("tonia/HeatpumpIR", "1.0.41")
if CORE.is_libretiny or CORE.is_esp32:
@@ -89,10 +89,10 @@ def _set_num_channels_from_config(config):
def _set_stream_limits(config):
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(
min_bits_per_sample=16,
max_bits_per_sample=16,
max_bits_per_sample=32,
min_channels=2,
max_channels=2,
min_sample_rate=config.get(CONF_SAMPLE_RATE),
@@ -213,9 +213,6 @@ def _final_validate(config):
)
if config[CONF_CHANNEL] != CONF_STEREO:
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]:
raise cv.Invalid(
"SPDIF mode requires 'use_apll: true' for accurate clock generation"
@@ -138,26 +138,30 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
// Reset lockstep records queue so it starts paired with the (also-reset) i2s_event_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
const size_t ring_buffer_size = this->current_stream_info_.ms_to_bytes(ring_buffer_duration);
const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1);
// 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 size_t bytes_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;
std::unique_ptr<audio::AudioSourceTransferBuffer> transfer_buffer =
audio::AudioSourceTransferBuffer::create(bytes_to_fill_single_dma_buffer);
std::unique_ptr<audio::RingBufferAudioSource> audio_source;
if (transfer_buffer != nullptr) {
{
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
if (temp_ring_buffer.use_count() == 1) {
transfer_buffer->set_source(temp_ring_buffer);
audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, bytes_to_fill_single_dma_buffer,
static_cast<uint8_t>(bytes_per_frame));
if (audio_source != nullptr) {
this->audio_ring_buffer_ = temp_ring_buffer;
successful_setup = true;
}
@@ -173,7 +177,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
// on_sent events drain in lockstep without crediting any audio frames.
this->spdif_encoder_->set_preload_mode(true);
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) {
break; // DMA preload buffer full or error
}
@@ -297,24 +302,24 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
if (!this->pause_state_) {
while (real_frames_in_block < SPDIF_BLOCK_SAMPLES) {
if (transfer_buffer->available() == 0) {
size_t bytes_read = transfer_buffer->transfer_data_from_source(read_timeout_ticks);
if (audio_source->available() == 0) {
size_t bytes_read = audio_source->fill(read_timeout_ticks, false);
if (bytes_read == 0) {
break; // No upstream data within the read budget; silence-pad the remainder.
}
uint8_t *new_data = transfer_buffer->get_buffer_end() - bytes_read;
uint8_t *new_data = audio_source->mutable_data();
this->apply_software_volume_(new_data, bytes_read);
this->swap_esp32_mono_samples_(new_data, bytes_read);
}
const uint32_t frames_still_needed = SPDIF_BLOCK_SAMPLES - real_frames_in_block;
const size_t bytes_still_needed = this->current_stream_info_.frames_to_bytes(frames_still_needed);
const size_t bytes_to_feed = std::min(transfer_buffer->available(), bytes_still_needed);
const size_t bytes_to_feed = std::min(audio_source->available(), bytes_still_needed);
uint32_t blocks_sent = 0;
size_t pcm_consumed = 0;
esp_err_t err = this->spdif_encoder_->write(transfer_buffer->get_buffer_start(), bytes_to_feed,
write_timeout_ticks, &blocks_sent, &pcm_consumed);
esp_err_t err = this->spdif_encoder_->write(audio_source->data(), bytes_to_feed, write_timeout_ticks,
&blocks_sent, &pcm_consumed);
if (err != ESP_OK) {
// A failed (or timed-out) send leaves an unsent block in the encoder's stitch buffer;
// resuming would credit the next iteration's bytes against an old block. Bail and
@@ -325,7 +330,7 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
}
if (pcm_consumed > 0) {
transfer_buffer->decrease_buffer_length(pcm_consumed);
audio_source->consume(pcm_consumed);
real_frames_in_block += this->current_stream_info_.bytes_to_frames(pcm_consumed);
}
if (blocks_sent > 0) {
@@ -387,9 +392,7 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
this->spdif_encoder_->reset();
}
if (transfer_buffer != nullptr) {
transfer_buffer.reset();
}
audio_source.reset();
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPED);
@@ -408,8 +411,9 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s
this->sample_rate_, audio_stream_info.get_sample_rate());
return ESP_ERR_NOT_SUPPORTED;
}
if (audio_stream_info.get_bits_per_sample() != 16) {
ESP_LOGE(TAG, "Only supports 16 bits per sample");
const uint8_t bits_per_sample = audio_stream_info.get_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;
}
if (audio_stream_info.get_channels() != 2) {
@@ -417,11 +421,8 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s
return ESP_ERR_NOT_SUPPORTED;
}
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO &&
(i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) {
ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration");
return ESP_ERR_NOT_SUPPORTED;
}
// Tell the encoder what input width to expect. 32-bit input is truncated to 24-bit on the wire.
this->spdif_encoder_->set_bytes_per_sample(bits_per_sample / 8);
if (!this->parent_->try_lock()) {
ESP_LOGE(TAG, "Parent bus is busy");
@@ -99,7 +99,7 @@ void I2SAudioSpeakerBase::loop() {
}
if (event_group_bits & SpeakerEventGroupBits::ERR_ESP_NO_MEM) {
ESP_LOGE(TAG, "Not enough memory");
ESP_LOGE(TAG, "Speaker task setup failed (allocation, preload, or channel enable)");
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM);
}
@@ -19,7 +19,6 @@
namespace esphome::i2s_audio {
// 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 ssize_t TASK_PRIORITY = 19;
@@ -36,9 +35,7 @@ enum SpeakerEventGroupBits : uint32_t {
ERR_ESP_NO_MEM = (1 << 19),
ERR_DROPPED_EVENT = (1 << 20), // ISR overflowed the event queue, dropping a completion event
ERR_PARTIAL_WRITE = (1 << 21), // a DMA write returned fewer bytes than requested (or the encoder
// failed to commit a complete block), which breaks the lockstep
// invariant for every subsequent event
ERR_PARTIAL_WRITE = (1 << 21), // i2s_channel_write returned fewer bytes than requested
ERR_LOCKSTEP_DESYNC = (1 << 22), // i2s_event_queue_ and write_records_queue_ fell out of sync
ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits
@@ -16,8 +16,16 @@ namespace esphome::i2s_audio {
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 I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT + 1;
// 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
// invariant between i2s_event_queue_ and write_records_queue_).
static constexpr size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT * 2;
// Generous timeout for ``i2s_channel_write`` blocking. A buffer frees roughly every
// DMA_BUFFER_DURATION_MS, so a multiple of that gives plenty of slack against scheduling jitter
// without masking real failures.
static constexpr TickType_t WRITE_TIMEOUT_TICKS = pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS * (DMA_BUFFERS_COUNT + 1));
void I2SAudioSpeaker::dump_config() {
I2SAudioSpeakerBase::dump_config();
@@ -44,31 +52,78 @@ void I2SAudioSpeaker::run_speaker_task() {
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
const size_t ring_buffer_size = this->current_stream_info_.ms_to_bytes(ring_buffer_duration);
const uint32_t frames_to_fill_single_dma_buffer = this->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS);
const size_t bytes_to_fill_single_dma_buffer =
this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer);
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;
const uint32_t frames_per_dma_buffer = this->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS);
const size_t dma_buffer_bytes = this->current_stream_info_.frames_to_bytes(frames_per_dma_buffer);
bool successful_setup = false;
std::unique_ptr<audio::AudioSourceTransferBuffer> transfer_buffer =
audio::AudioSourceTransferBuffer::create(bytes_to_fill_single_dma_buffer);
if (transfer_buffer != nullptr) {
std::unique_ptr<audio::RingBufferAudioSource> audio_source;
// Pre-zeroed buffer used to silence-pad each DMA descriptor whenever real audio doesn't fully fill it.
RAMAllocator<uint8_t> silence_allocator;
uint8_t *silence_buffer = silence_allocator.allocate(dma_buffer_bytes);
if (silence_buffer != nullptr) {
memset(silence_buffer, 0, dma_buffer_bytes);
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
if (temp_ring_buffer.use_count() == 1) {
transfer_buffer->set_source(temp_ring_buffer);
audio_source =
audio::RingBufferAudioSource::create(temp_ring_buffer, dma_buffer_bytes, static_cast<uint8_t>(bytes_per_frame));
if (audio_source != nullptr) {
// audio_source is nullptr if the ring buffer fails to allocate
this->audio_ring_buffer_ = temp_ring_buffer;
successful_setup = true;
}
}
if (successful_setup) {
// Preload every DMA descriptor with silence and push a matching zero-real-frames record per buffer.
// This guarantees that every on_sent event has a corresponding write record from the start, so
// ``i2s_event_queue_`` and ``write_records_queue_`` stay in lockstep for the entire task lifetime.
for (size_t i = 0; i < DMA_BUFFERS_COUNT; i++) {
size_t bytes_loaded = 0;
esp_err_t err = i2s_channel_preload_data(this->tx_handle_, silence_buffer, dma_buffer_bytes, &bytes_loaded);
if (err != ESP_OK || bytes_loaded != dma_buffer_bytes) {
ESP_LOGV(TAG, "Failed to preload silence into DMA buffer %u (err=%d, loaded=%u)", (unsigned) i, (int) err,
(unsigned) bytes_loaded);
successful_setup = false;
break;
}
uint32_t zero_real_frames = 0;
if (xQueueSend(this->write_records_queue_, &zero_real_frames, 0) != pdTRUE) {
// Should never happen: the queue was just reset and is sized for DMA_BUFFERS_COUNT * 2 entries.
ESP_LOGV(TAG, "Failed to push preload write record");
successful_setup = false;
break;
}
}
}
if (successful_setup) {
// Register the on_sent callback BEFORE enabling the channel so the very first transmitted buffer
// generates a queued event that pairs with the first preloaded silence record.
const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb};
i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this);
if (i2s_channel_enable(this->tx_handle_) != ESP_OK) {
ESP_LOGV(TAG, "Failed to enable I2S channel");
successful_setup = false;
}
}
if (!successful_setup) {
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM);
} else {
bool stop_gracefully = false;
bool tx_dma_underflow = true;
uint32_t frames_written = 0;
// Number of records currently in ``write_records_queue_`` that carry real audio. Used by graceful
// stop to wait until every real-audio buffer has been confirmed played by an ISR event.
uint32_t pending_real_buffers = 0;
uint32_t last_data_received_time = millis();
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING);
@@ -77,11 +132,21 @@ void I2SAudioSpeaker::run_speaker_task() {
// - Paused, OR
// - No timeout configured, OR
// - Timeout hasn't elapsed since last data
//
// Always-fill model: every iteration writes exactly one DMA buffer's worth, mixing real audio
// and silence padding as needed. The blocking ``i2s_channel_write`` paces the loop at the DMA
// consumption rate, and every buffer write is matched 1:1 with a record on ``write_records_queue_``.
//
// While paused, the real-audio fill is skipped and the entire DMA buffer is filled with silence;
// the same blocking ``i2s_channel_write`` provides natural pacing (one buffer per ~DMA_BUFFER_DURATION_MS),
// so the lockstep invariant is preserved without burning CPU.
while (this->pause_state_ || !this->timeout_.has_value() ||
(millis() - last_data_received_time) <= this->timeout_.value()) {
uint32_t event_group_bits = xEventGroupGetBits(this->event_group_);
if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) {
// COMMAND_STOP is set both by user-initiated stop() and by the ISR when it drops a completion
// event (paired with ERR_DROPPED_EVENT so loop() can distinguish the two cases).
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP);
ESP_LOGV(TAG, "Exiting: COMMAND_STOP received");
break;
@@ -97,89 +162,126 @@ void I2SAudioSpeaker::run_speaker_task() {
break;
}
// Drain ISR-stamped completion events. Each event corresponds 1:1 with a write_records_queue_
// entry by construction (preloaded records at startup, plus exactly one record pushed per
// iteration alongside exactly one DMA-buffer-sized write).
int64_t write_timestamp;
bool lockstep_broken = false;
while (xQueueReceive(this->i2s_event_queue_, &write_timestamp, 0)) {
// Receives timing events from the I2S on_sent callback. If actual audio data was sent in this event, it passes
// on the timing info via the audio_output_callback.
uint32_t frames_sent = frames_to_fill_single_dma_buffer;
if (frames_to_fill_single_dma_buffer > frames_written) {
tx_dma_underflow = true;
frames_sent = frames_written;
const uint32_t frames_zeroed = frames_to_fill_single_dma_buffer - frames_written;
write_timestamp -= this->current_stream_info_.frames_to_microseconds(frames_zeroed);
} else {
tx_dma_underflow = false;
}
frames_written -= frames_sent;
// Standard I2S mode: fire callback immediately for each event
if (frames_sent > 0) {
this->audio_output_callback_(frames_sent, write_timestamp);
}
}
if (this->pause_state_) {
// Pause state is accessed atomically, so thread safe
// Delay so the task yields, then skip transferring audio data
vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS));
continue;
}
// Wait half the duration of the data already written to the DMA buffers for new audio data
// The millisecond helper modifies the frames_written variable, so use the microsecond helper and divide by 1000
uint32_t read_delay = (this->current_stream_info_.frames_to_microseconds(frames_written) / 1000) / 2;
size_t bytes_read = transfer_buffer->transfer_data_from_source(pdMS_TO_TICKS(read_delay));
uint8_t *new_data = transfer_buffer->get_buffer_end() - bytes_read;
if (bytes_read > 0) {
this->apply_software_volume_(new_data, bytes_read);
this->swap_esp32_mono_samples_(new_data, bytes_read);
}
if (transfer_buffer->available() == 0) {
if (stop_gracefully && tx_dma_underflow) {
uint32_t real_frames = 0;
if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) {
// Should never happen: would indicate the lockstep invariant is broken.
ESP_LOGV(TAG, "Event without matching write record");
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC);
lockstep_broken = true;
break;
}
vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS / 2));
} else {
size_t bytes_written = 0;
if (tx_dma_underflow) {
// Temporarily disable channel and callback to reset the I2S driver's internal DMA buffer queue
i2s_channel_disable(this->tx_handle_);
const i2s_event_callbacks_t null_callbacks = {.on_sent = nullptr};
i2s_channel_register_event_callback(this->tx_handle_, &null_callbacks, this);
i2s_channel_preload_data(this->tx_handle_, transfer_buffer->get_buffer_start(), transfer_buffer->available(),
&bytes_written);
} else {
// Audio is already playing, use regular write to add to the DMA buffers
i2s_channel_write(this->tx_handle_, transfer_buffer->get_buffer_start(), transfer_buffer->available(),
&bytes_written, DMA_BUFFER_DURATION_MS);
if (real_frames > 0) {
pending_real_buffers--;
// Real audio is packed at the start of each DMA buffer with any silence padding on the
// tail, so the real audio finished playing earlier than the buffer-completion timestamp
// by the duration of the trailing zeros.
const uint32_t silence_frames = frames_per_dma_buffer - real_frames;
const int64_t adjusted_ts =
write_timestamp - this->current_stream_info_.frames_to_microseconds(silence_frames);
this->audio_output_callback_(real_frames, adjusted_ts);
}
}
if (lockstep_broken) {
break;
}
if (bytes_written > 0) {
last_data_received_time = millis();
frames_written += this->current_stream_info_.bytes_to_frames(bytes_written);
transfer_buffer->decrease_buffer_length(bytes_written);
// Graceful stop: exit only after the source's exposed chunk is drained, the underlying ring
// buffer has nothing left to hand over, and every real-audio buffer we submitted has been
// confirmed played. ``has_buffered_data()`` returns bytes still sitting in the ring buffer
// awaiting fill().
if (stop_gracefully && audio_source->available() == 0 && !this->has_buffered_data() &&
pending_real_buffers == 0) {
ESP_LOGV(TAG, "Exiting: graceful stop complete");
break;
}
if (tx_dma_underflow) {
tx_dma_underflow = false;
// Enable the on_sent callback and channel after preload
xQueueReset(this->i2s_event_queue_);
const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb};
i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this);
i2s_channel_enable(this->tx_handle_);
// Compose exactly one DMA buffer's worth: drain as much real audio as the source currently
// exposes (may take multiple fill() calls when crossing a ring buffer wrap), then pad any
// remainder with silence. All writes pack into the next free DMA descriptor in order, so the
// descriptor ends up holding [real audio][silence padding].
size_t bytes_written_total = 0;
size_t real_bytes_total = 0;
bool partial_write_failure = false;
if (!this->pause_state_) {
while (bytes_written_total < dma_buffer_bytes) {
size_t bytes_read = audio_source->fill(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS) / 2, false);
if (bytes_read > 0) {
uint8_t *new_data = audio_source->mutable_data() + audio_source->available() - bytes_read;
this->apply_software_volume_(new_data, bytes_read);
this->swap_esp32_mono_samples_(new_data, bytes_read);
}
const size_t to_write = std::min(audio_source->available(), dma_buffer_bytes - bytes_written_total);
if (to_write == 0) {
// Ring buffer has nothing more to hand over right now; pad the rest of this DMA buffer
// with silence so the lockstep invariant (one write per iteration) is preserved.
break;
}
size_t bw = 0;
i2s_channel_write(this->tx_handle_, audio_source->data(), to_write, &bw, WRITE_TIMEOUT_TICKS);
if (bw != to_write) {
// A short real-audio write breaks DMA descriptor alignment for every subsequent event;
// the only safe recovery is to restart the task.
ESP_LOGV(TAG, "Partial real audio write: %u of %u bytes", (unsigned) bw, (unsigned) to_write);
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE);
partial_write_failure = true;
break;
}
audio_source->consume(bw);
bytes_written_total += bw;
real_bytes_total += bw;
}
if (real_bytes_total > 0) {
last_data_received_time = millis();
}
}
if (partial_write_failure) {
break;
}
const size_t silence_bytes = dma_buffer_bytes - bytes_written_total;
if (silence_bytes > 0) {
size_t bw = 0;
i2s_channel_write(this->tx_handle_, silence_buffer, silence_bytes, &bw, WRITE_TIMEOUT_TICKS);
if (bw != silence_bytes) {
// Same descriptor-alignment hazard as a partial real-audio write.
ESP_LOGV(TAG, "Partial silence write: %u of %u bytes", (unsigned) bw, (unsigned) silence_bytes);
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE);
break;
}
}
const uint32_t real_frames_in_buffer = this->current_stream_info_.bytes_to_frames(real_bytes_total);
// Push the matching write record. Capacity headroom in I2S_EVENT_QUEUE_COUNT guarantees this
// succeeds even with a transient backlog of unprocessed events; if it ever fails the lockstep
// invariant is broken and every subsequent timestamp would be silently wrong, so bail.
if (xQueueSend(this->write_records_queue_, &real_frames_in_buffer, 0) != pdTRUE) {
ESP_LOGV(TAG, "Exiting: write records queue full");
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC);
break;
}
if (real_frames_in_buffer > 0) {
pending_real_buffers++;
}
}
}
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING);
if (transfer_buffer != nullptr) {
transfer_buffer.reset();
audio_source.reset();
if (silence_buffer != nullptr) {
silence_allocator.deallocate(silence_buffer, dma_buffer_bytes);
silence_buffer = nullptr;
}
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPED);
@@ -300,7 +402,7 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream
return err;
}
i2s_channel_enable(this->tx_handle_);
// The speaker task will enable the channel after preloading.
return ESP_OK;
}
@@ -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
// 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;
// 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;
}
// 4-bit BMC lookup table: 16 entries (16 bytes in flash)
// Index: 4-bit data value (0-15), always phase=true start
// Compile-time parity helper (constexpr-friendly, runs only at LUT build time).
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 = [] {
std::array<uint8_t, 16> t{};
for (uint32_t i = 0; i < 16; i++)
t[i] = static_cast<uint8_t>(bmc_lut_encode(i, 4));
std::array<uint32_t, 16> t{};
for (uint32_t i = 0; i < 16; i++) {
uint32_t bmc = bmc_lut_encode(i, 4);
uint32_t delta = bmc_lut_parity(i, 4) ? 0xFFFF0000u : 0u;
t[i] = bmc | delta;
}
return t;
}();
// 8-bit BMC lookup table: 256 entries (512 bytes in flash)
// Index: 8-bit data value (0-255), always phase=true start
// 8-bit BMC lookup table: 256 entries x uint32_t = 1024 bytes in flash.
// 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 = [] {
std::array<uint16_t, 256> t{};
for (uint32_t i = 0; i < 256; i++)
t[i] = bmc_lut_encode(i, 8);
std::array<uint32_t, 256> t{};
for (uint32_t i = 0; i < 256; i++) {
uint32_t bmc = bmc_lut_encode(i, 8);
uint32_t delta = bmc_lut_parity(i, 8) ? 0xFFFF0000u : 0u;
t[i] = bmc | delta;
}
return t;
}();
@@ -63,7 +85,7 @@ bool SPDIFEncoder::setup() {
}
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->reset();
@@ -73,7 +95,7 @@ bool SPDIFEncoder::setup() {
void SPDIFEncoder::reset() {
this->spdif_block_ptr_ = this->spdif_block_buf_.get();
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) {
@@ -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_() {
// IEC 60958-3 Consumer Channel Status Block (192 bits = 24 bytes)
// Transmitted LSB-first within each byte, one bit per frame via C bit
//
// Byte 0: Control bits
// Bit 0: 0 = Consumer format (not professional AES3)
// 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)
// Transmitted LSB-first within each byte, one bit per frame via C bit.
// Any cached silence block was built for the previous channel status; it is now stale.
this->block_buf_is_silence_block_ = false;
// Clear all bytes first
this->channel_status_.fill(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
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) {
// ============================================================================
// Build raw 32-bit subframe (IEC 60958 format)
// ============================================================================
// Bit layout:
// 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
// ============================================================================
// 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.
ESPHOME_ALWAYS_INLINE static inline uint32_t c_bit_for_frame(const std::array<uint8_t, 24> &channel_status,
uint32_t frame) {
return static_cast<uint32_t>((channel_status[frame >> 3] >> (frame & 7)) & 1u) << 30;
}
// 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)
// C = channel status bit for current frame (same bit used for both L and R subframes)
bool c_bit = this->get_channel_status_bit_(this->frame_in_block_);
if (c_bit) {
raw_subframe |= (1U << 30);
// Build a raw IEC 60958 subframe from PCM little-endian input of width Bps bytes.
// Caller is responsible for OR-ing in the C bit and parity.
template<uint8_t Bps> ESPHOME_ALWAYS_INLINE static inline uint32_t build_raw_subframe(const uint8_t *pcm_sample) {
static_assert(Bps == 2 || Bps == 3 || Bps == 4, "Unsupported bytes per sample");
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
// This ensures consistent BMC ending phase regardless of audio content
uint32_t bits_4_30 = (raw_subframe >> 4) & 0x07FFFFFF; // 27 bits (4-30)
uint32_t ones_count = __builtin_popcount(bits_4_30);
uint32_t parity = ones_count & 1; // 1 if odd count, 0 if even
raw_subframe |= parity << 31; // Set P bit to make total even
// BMC-encode a subframe and write the two output uint32 words to dst. Caller passes
// raw_subframe with the C bit set (bit 30) and the P bit cleared (bit 31 = 0). P is
// derived from the cumulative parity-mask delta of the per-byte LUT lookups.
//
// I2S halfword swap means word[0] transmits as: bits 24-31, 16-23, 8-15, 0-7.
// 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
// ============================================================================
// Select preamble based on position in block and channel
// ============================================================================
// B = block start (left channel, frame 0 of 192-frame block)
// M = left channel (frames 1-191)
// W = right channel (all frames)
uint8_t preamble;
if (this->is_left_channel_) {
preamble = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M;
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;
// 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 {
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);
}
}
// ============================================================================
// BMC encode the data portion (bits 4-31) using lookup tables
// ============================================================================
// The I2S uses 16-bit halfword swap: bits 16-31 transmit before bits 0-15.
// This applies to BOTH word[0] and word[1].
//
// word[0] transmission order: [16-23] → [24-31] → [0-7] → [8-15]
// For correct S/PDIF subframe order (preamble → aux → audio):
// - bits 16-23: preamble (8 BMC bits)
// - 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;
}
template<uint8_t Bps> void SPDIFEncoder::encode_silence_frame_() {
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_);
uint8_t preamble_l = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M;
bmc_encode_subframe<Bps>(raw, preamble_l, this->spdif_block_ptr_);
bmc_encode_subframe<Bps>(raw, PREAMBLE_W, this->spdif_block_ptr_ + 2);
this->spdif_block_ptr_ += 4;
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) {
@@ -295,79 +329,162 @@ esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) {
return err;
}
size_t SPDIFEncoder::get_pending_pcm_bytes() const {
if (this->spdif_block_ptr_ == nullptr || this->spdif_block_buf_ == nullptr) {
return 0;
template<uint8_t Bps>
HOT esp_err_t SPDIFEncoder::write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait,
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
size_t pending_uint32s = this->spdif_block_ptr_ - this->spdif_block_buf_.get();
size_t pending_samples = pending_uint32s / 2;
return pending_samples * 2; // 2 bytes per sample
// Send any complete block that was just finished.
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;
}
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,
size_t *bytes_consumed) {
const uint8_t *pcm_data = src;
const uint8_t *pcm_end = src + size;
uint32_t block_count = 0;
if (size > 0) {
// Real PCM is about to be encoded into the buffer, so it is no longer a full-silence block.
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) {
// Check if there's a pending complete block from a previous failed send
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;
template<uint8_t Bps> esp_err_t SPDIFEncoder::flush_with_silence_typed_(TickType_t ticks_to_wait) {
// If a complete block is already pending (from a previous failed send), emit just that block.
// Otherwise pad the partial block with silence (or generate a full silence block if empty) and
// send. Always emits exactly one block on success.
if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
const bool was_empty = (this->spdif_block_ptr_ == this->spdif_block_buf_.get());
// 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 (was_empty && this->block_buf_is_silence_block_) {
return this->send_block_(ticks_to_wait);
}
// Encode one 16-bit sample
this->encode_sample_(pcm_data);
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;
// Pad with silence frames at the configured width.
while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
this->encode_silence_frame_<Bps>();
}
++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;
}
if (blocks_sent != nullptr) {
*blocks_sent = block_count;
}
if (bytes_consumed != nullptr) {
*bytes_consumed = size;
}
return ESP_OK;
return this->send_block_(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.
// Otherwise pad the partial block with silence (or generate a full silence block if empty)
// and send. Always emits exactly one block on success.
if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
static const uint8_t SILENCE[2] = {0, 0};
while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
this->encode_sample_(SILENCE);
}
switch (this->bytes_per_sample_) {
case 2:
return this->flush_with_silence_typed_<2>(ticks_to_wait);
case 3:
return this->flush_with_silence_typed_<3>(ticks_to_wait);
case 4:
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
@@ -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
// 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
// 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)
/// @param user_ctx User context pointer passed during callback registration
@@ -64,8 +62,16 @@ class SPDIFEncoder {
/// @brief Check if currently in 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
/// @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 ticks_to_wait Timeout for blocking writes
/// @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,
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,
/// 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
@@ -95,7 +90,7 @@ class SPDIFEncoder {
void reset();
/// @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.
void set_sample_rate(uint32_t sample_rate);
@@ -103,8 +98,19 @@ class SPDIFEncoder {
uint32_t get_sample_rate() const { return this->sample_rate_; }
protected:
/// @brief Encode a single 16-bit PCM sample into the current block position
HOT void encode_sample_(const uint8_t *pcm_sample);
/// @brief Encode a single stereo silence frame at the current block position.
/// @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
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
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)
// 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
// 1-byte aligned members (grouped together to avoid internal padding)
uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block
bool is_left_channel_{true}; // Alternates L/R for stereo samples
bool preload_mode_{false}; // Whether to use preload callback vs write callback
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.
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
// 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)
// Placed last since std::array<uint8_t> has 1-byte alignment
+1 -1
View File
@@ -319,7 +319,7 @@ void Inkplate::fill(Color color) {
memset(this->partial_buffer_, fill, this->get_buffer_length_());
}
ESP_LOGV(TAG, "Fill finished (%ums)", millis() - start_time);
ESP_LOGV(TAG, "Fill finished (%" PRIu32 "ms)", millis() - start_time);
}
void Inkplate::display() {
@@ -106,7 +106,6 @@ void RfProxy::setup() {
void RfProxy::dump_config() {
ESP_LOGCONFIG(TAG,
"RF Proxy '%s'\n"
" Backend: remote_transmitter/receiver\n"
" Supports Transmitter: %s\n"
" Supports Receiver: %s",
this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()),
@@ -124,7 +123,9 @@ void RfProxy::dump_config() {
}
void RfProxy::control(const radio_frequency::RadioFrequencyCall &call) {
// RF: no IR carrier modulation
// RF: no IR carrier modulation. Any RF front-end coordination (state turnaround, retuning)
// happens via the radio_frequency entity's on_control trigger and remote_transmitter's
// on_transmit/on_complete triggers — wired up in user YAML.
transmit_raw_timings(this->transmitter_, 0, call);
}
+4 -1
View File
@@ -43,7 +43,10 @@ class IrRfProxy : public infrared::Infrared {
#endif // USE_IR_RF
#ifdef USE_RADIO_FREQUENCY
/// RfProxy - Radio Frequency platform implementation using remote_transmitter/receiver as backend
/// RfProxy - Radio Frequency platform implementation using remote_transmitter/receiver as backend.
/// Driver-agnostic: integration with specific RF front-end chips (CC1101, RFM69, etc.) is done
/// in YAML by wiring their actions to `remote_transmitter`'s on_transmit/on_complete triggers and
/// to this entity's on_control trigger (see radio_frequency component docs).
class RfProxy : public radio_frequency::RadioFrequency {
public:
RfProxy() = default;
@@ -35,17 +35,19 @@ def _final_validate(config: ConfigType) -> None:
if CONF_REMOTE_TRANSMITTER_ID not in config:
return
transmitter_id = config[CONF_REMOTE_TRANSMITTER_ID]
full_config = fv.full_config.get()
transmitter_path = full_config.get_path_for_id(transmitter_id)[:-1]
transmitter_path = full_config.get_path_for_id(config[CONF_REMOTE_TRANSMITTER_ID])[
:-1
]
transmitter_config = full_config.get_config_for_path(transmitter_path)
duty_percent = transmitter_config.get(CONF_CARRIER_DUTY_PERCENT)
if duty_percent is not None and duty_percent != 100:
raise cv.Invalid(
f"Transmitter '{transmitter_id}' must have '{CONF_CARRIER_DUTY_PERCENT}' "
"set to 100% for RF transmission. Dedicated RF hardware handles modulation; "
"applying a carrier duty cycle would corrupt the signal"
f"Transmitter '{config[CONF_REMOTE_TRANSMITTER_ID]}' must have "
f"'{CONF_CARRIER_DUTY_PERCENT}' set to 100% for RF transmission. "
"Dedicated RF hardware handles modulation; applying a carrier duty cycle "
"would corrupt the signal"
)
+13 -11
View File
@@ -11,11 +11,19 @@
#include "esphome/core/time_64.h"
// IRAM_ATTR places a function in executable RAM so it is callable from an
// ISR even while flash is busy (XIP stall, OTA, logger flash write).
// Each family uses a section its stock linker already routes to RAM:
// RTL8710B → .image2.ram.text, RTL8720C → .sram.text. LN882H is the
// exception: its stock linker has no matching glob, so patch_linker.py
// injects KEEP(*(.sram.text*)) into .flash_copysection at pre-link.
// ISR even while flash is busy (XIP stall, OTA, logger flash write). All
// LibreTiny families that need it share the same .sram.text input section
// name; how that section is routed into RAM differs per family:
// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
// RTL8710B: patch_linker.py.script injects KEEP(*(.sram.text*)) at the
// top of .ram_image2.data (which IS in ltchiptool's
// sections_ram). The stock linker has KEEP(*(.image2.ram.text*))
// in .ram_image2.text but that output section is NOT in
// ltchiptool's AmebaZ elf2bin sections_ram list, so code routed
// there is dropped from the flashed binary.
// LN882H: patch_linker.py.script injects KEEP(*(.sram.text*)) into
// .flash_copysection (> RAM0 AT> FLASH), after KEEP(*(.vectors))
// so the Cortex-M4 vector table stays 512-byte-aligned for VTOR.
//
// BK72xx (all variants) are left as a no-op: their SDK wraps flash
// operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for
@@ -26,13 +34,7 @@
// layer.
#if defined(USE_BK72XX)
#define IRAM_ATTR
#elif defined(USE_LIBRETINY_VARIANT_RTL8710B)
// Stock linker consumes *(.image2.ram.text*) into .ram_image2.text (> BD_RAM).
#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text")))
#else
// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
// LN882H: patch_linker.py.script injects *(.sram.text*) into
// .flash_copysection (> RAM0 AT> FLASH).
#define IRAM_ATTR __attribute__((noinline, section(".sram.text")))
#endif
#define PROGMEM
@@ -6,14 +6,22 @@ import re
import subprocess
# ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a per-family
# section routed into RAM-executable memory (see esphome/core/hal.h).
# section routed into RAM-executable memory (see esphome/core/hal.h). The
# input section name is always .sram.text; only the output section it lands
# in differs per family.
#
# This script is NOT loaded on BK72xx (IRAM_ATTR is a no-op there; the SDK
# masks FIQ+IRQ around flash writes). On the remaining families:
# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it.
# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it.
# - RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
# - RTL8710B: stock linker has KEEP(*(.image2.ram.text*)) in .ram_image2.text,
# but ltchiptool's AmebaZ elf2bin (soc/ambz/binary.py) does NOT list
# .ram_image2.text in sections_ram, so code there is silently dropped from
# the flashed image. Inject KEEP(*(.sram.text*)) at the top of
# .ram_image2.data (which IS extracted) instead.
# - 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.
@@ -27,7 +35,25 @@ _KEEP_LINE = (
"__esphome_sram_text_end = .; "
+ _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)")
# Inject at the top of .ram_image2.data, before __data_start__ so our code
# does not fall inside the data range markers. .ram_image2.data is one of the
# sections ltchiptool's AmebaZ elf2bin extracts; BD_RAM is rwx so the code is
# executable. AmbZ has no C runtime .data copy loop (the bootloader loads
# image2 into BD_RAM whole) so the inline code is not clobbered after boot.
#
# The regex is intentionally strict (no attribute / ALIGN between the section
# name and the opening brace, brace on its own line). If a future AmbZ SDK
# linker template changes this format, _pre_link raises RuntimeError on the
# unpatched .ld file(s), and the RTL8710B CI compile job in
# tests/test_build_components fails on the PR, surfacing the mismatch loudly
# rather than silently shipping a binary with IRAM_ATTR code dropped from
# one or both OTA slots.
_AMBZ_DATA = re.compile(r"(\.ram_image2\.data\s*:\s*\n?\s*\{\s*\n)")
def _detect(env):
@@ -56,7 +82,7 @@ KNOWN_VARIANTS = frozenset({
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):
if _MARKER in content:
return content
@@ -65,12 +91,11 @@ def _inject_keep(host_section):
# Variants not listed here intentionally have no .ld patcher:
# - RTL8710B: hal.h uses section(".image2.ram.text") which the stock linker
# already routes into .ram_image2.text (> BD_RAM).
# - RTL8720C: stock linker already consumes *(.sram.text*).
# - RTL8720C: stock linker already consumes *(.sram.text*) into .ram.code_text.
# - BK72xx (all): SDK masks FIQ+IRQ around flash writes, IRAM_ATTR is no-op.
_PATCHERS_BY_VARIANT = {
"LN882H": (_inject_keep(_LN_COPY),),
"RTL8710B": (_inject_keep(_AMBZ_DATA),),
}
@@ -81,13 +106,14 @@ def _patchers_for(variant):
def _pre_link(target, source, env):
build_dir = env.subst("$BUILD_DIR")
ld_files = [f for f in os.listdir(build_dir) if f.endswith(".ld")]
patched = 0
patched = []
unpatched = []
for name in ld_files:
path = os.path.join(build_dir, name)
with open(path, "r", encoding="utf-8") as fh:
original = fh.read()
if _MARKER in original:
patched += 1
patched.append(name)
continue
content = original
for fn in _patchers:
@@ -96,7 +122,9 @@ def _pre_link(target, source, env):
with open(path, "w", encoding="utf-8") as fh:
fh.write(content)
print("ESPHome: patched {} for IRAM_ATTR placement".format(name))
patched += 1
patched.append(name)
else:
unpatched.append(name)
if not patched:
raise RuntimeError(
"ESPHome: no .ld in {} was patched for IRAM_ATTR. Update the "
@@ -104,6 +132,20 @@ def _pre_link(target, source, env):
build_dir
)
)
# Every .ld in the build must be patched. RTL8710B generates one .ld per
# OTA slot (xip1, xip2); if only one matches, the unpatched slot would
# ship with IRAM_ATTR code dropped to zeros and brick the device on the
# boot after an OTA into that slot.
if unpatched:
raise RuntimeError(
"ESPHome: {} of {} .ld file(s) in {} were not patched for "
"IRAM_ATTR: {}. The regex in patch_linker.py.script "
"(_PATCHERS_BY_VARIANT[{!r}]) matched the others but not "
"these. Update the regex to cover all linker scripts.".format(
len(unpatched), len(ld_files), build_dir,
", ".join(unpatched), _variant,
)
)
# Substrings matched against demangled names as a fallback on RTL8720C,
+7 -7
View File
@@ -506,13 +506,13 @@ async def _late_logger_init(config: ConfigType) -> None:
def validate_printf(value):
# https://stackoverflow.com/questions/30011379/how-can-i-parse-a-c-format-string-in-python
cfmt = r"""
( # start of capture group 1
% # literal "%"
(?:[-+0 #]{0,5}) # optional flags
(?:\d+|\*)? # width
(?:\.(?:\d+|\*))? # precision
(?:h|l|ll|w|I|I32|I64)? # size
[cCdiouxXeEfgGaAnpsSZ] # type
( # start of capture group 1
% # literal "%"
(?:[-+0 #]{0,5}) # optional flags
(?:\d+|\*)? # width
(?:\.(?:\d+|\*))? # precision
(?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size
[cCdiouxXeEfgGaAnpsSZ] # type
)
""" # noqa
matches = re.findall(cfmt, value[CONF_FORMAT], flags=re.VERBOSE)
+30 -2
View File
@@ -1,6 +1,7 @@
import importlib
from pathlib import Path
import pkgutil
import re
from esphome.automation import Trigger, build_automation, validate_automation
import esphome.codegen as cg
@@ -30,12 +31,14 @@ import esphome.config_validation as cv
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_BUFFER_SIZE,
CONF_ESPHOME,
CONF_GROUP,
CONF_ID,
CONF_LAMBDA,
CONF_LOG_LEVEL,
CONF_ON_IDLE,
CONF_PAGES,
CONF_PLATFORMIO_OPTIONS,
CONF_ROTATION,
CONF_TIMEOUT,
CONF_TRIGGER_ID,
@@ -51,6 +54,8 @@ from . import defines as df, lv_validation as lvalid, widgets
from .automation import layers_to_code, lvgl_update
from .defines import (
CONF_ALIGN_TO_LAMBDA_ID,
LOGGER,
add_lv_use,
get_focused_widgets,
get_lv_images_used,
get_refreshed_widgets,
@@ -67,6 +72,7 @@ from .keypads import KEYPADS_CONFIG, keypads_to_code
from .lv_validation import lv_bool
from .lvcode import LvContext, LvglComponent, lv_event_t_ptr, lvgl_static
from .schemas import (
BASE_PROPS,
DISP_BG_SCHEMA,
FULL_STYLE_SCHEMA,
STYLE_REMAP,
@@ -96,6 +102,7 @@ from .widgets import (
get_screen_active,
set_obj_properties,
)
from .widgets.img import CONF_IMAGE
# Import only what we actually use directly in this file
from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code
@@ -148,9 +155,28 @@ def generate_lv_conf_h():
all_defines = set(
df.LV_DEFINES + tuple(f"LV_USE_{w.upper()}" for w in WIDGET_TYPES)
)
# Get the defines that are actually used based on the config
build_flags = (
CORE.config[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS).get("build_flags", [])
)
if not isinstance(build_flags, list):
build_flags = [build_flags]
# Extract define names from build flags like '-DLV_USE_CHART=1', '-D LV_USE_CHART',
# or multiple defines in one string.
define_pattern = r'-D\s*([A-Z_][A-Z0-9_]*)(?:=[^\s\'"\]]*)?'
defines_from_flags = {
m.group(1) for flag in build_flags for m in re.finditer(define_pattern, flag)
}
# Get the defines that are actually used based on the config,
lv_defines = df.get_defines()
unused_defines = all_defines - set(lv_defines)
clashes = defines_from_flags & lv_defines.keys()
if clashes:
LOGGER.warning(
"Some defines are set both by ESPHome build flags and by LVGL configuration which may lead to unexpected behavior: %s",
sorted(list(clashes)),
)
unused_defines = all_defines - lv_defines.keys() - defines_from_flags
# Create the content of lv_conf.h with the used defines set to their value, and the unused defines disabled
definitions = [as_macro(m, v) for m, v in lv_defines.items()] + [
as_macro(m, "0") for m in unused_defines
@@ -410,6 +436,8 @@ async def to_code(configs):
# This must be done after all widgets are created
styles_used = df.get_styles_used()
if any(BASE_PROPS.get(x) is lvalid.lv_image for x in styles_used):
add_lv_use(CONF_IMAGE)
for use in df.get_lv_uses():
df.add_define(f"LV_USE_{use.upper()}")
cg.add_define(f"USE_LVGL_{use.upper()}")
+9 -2
View File
@@ -20,7 +20,6 @@ from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import Expression, SafeExpType
LOGGER = logging.getLogger(__name__)
lvgl_ns = cg.esphome_ns.namespace("lvgl")
DOMAIN = "lvgl"
KEY_COLOR_FORMATS = "color_formats"
@@ -400,6 +399,13 @@ LV_EVENT_MAP = {
LV_PRESS_EVENTS = ("PRESS", "PRESSING", "RELEASE")
VALUE_ON_CHANGE = "on_change"
VALUE_ON_UPDATE = "on_update"
VALUE_ON_VALUE = "on_value"
VALUE_ON_RELEASE = "on_release"
LV_VALUE_EVENTS = (VALUE_ON_CHANGE, VALUE_ON_UPDATE, VALUE_ON_VALUE, VALUE_ON_RELEASE)
def is_press_event(event: str) -> bool:
return event.removeprefix("on_").upper() in LV_PRESS_EVENTS
@@ -788,6 +794,7 @@ CONF_SKIP = "skip"
CONF_SYMBOL = "symbol"
CONF_TAB_ID = "tab_id"
CONF_TABS = "tabs"
CONF_THEME = "theme"
CONF_TICK_STYLE = "tick_style"
CONF_TIME_FORMAT = "time_format"
CONF_TILE = "tile"
@@ -799,7 +806,7 @@ CONF_TOUCHSCREENS = "touchscreens"
CONF_TRANSFORM_ROTATION = "transform_rotation"
CONF_TRANSFORM_SCALE = "transform_scale"
CONF_TRANSPARENCY_KEY = "transparency_key"
CONF_THEME = "theme"
CONF_TRIGGER = "trigger"
CONF_UPDATE_ON_RELEASE = "update_on_release"
CONF_UPDATE_WHEN_DISPLAY_IDLE = "update_when_display_idle"
CONF_VISIBLE_ROW_COUNT = "visible_row_count"
+14 -14
View File
@@ -9,13 +9,13 @@ CONF_IF_NAN = "if_nan"
# noqa
f_regex = re.compile(
r"""
( # start of capture group 1
% # literal "%"
[-+0 #]{0,5} # optional flags
(?:\d+|\*)? # width
(?:\.(?:\d+|\*))? # precision
(?:h|l|ll|w|I|I32|I64)? # size
f # type
( # start of capture group 1
% # literal "%"
[-+0 #]{0,5} # optional flags
(?:\d+|\*)? # width
(?:\.(?:\d+|\*))? # precision
(?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size
f # type
)
""",
flags=re.VERBOSE,
@@ -23,13 +23,13 @@ f_regex = re.compile(
# noqa
c_regex = re.compile(
r"""
( # start of capture group 1
% # literal "%"
[-+0 #]{0,5} # optional flags
(?:\d+|\*)? # width
(?:\.(?:\d+|\*))? # precision
(?:h|l|ll|w|I|I32|I64)? # size
[cCdiouxXeEfgGaAnpsSZ] # type
( # start of capture group 1
% # literal "%"
[-+0 #]{0,5} # optional flags
(?:\d+|\*)? # width
(?:\.(?:\d+|\*))? # precision
(?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size
[cCdiouxXeEfgGaAnpsSZ] # type
)
""",
flags=re.VERBOSE,
+2 -1
View File
@@ -20,7 +20,8 @@ from esphome.cpp_generator import (
)
from esphome.yaml_util import ESPHomeDataBase
from .defines import literal, lvgl_ns
from .defines import literal
from .types import lvgl_ns
LVGL_COMP = "lv_component" # used as a lambda argument in lvgl_comp()
+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());
}
#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.
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); }
#endif // LV_USE_IMAGE
inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) {
::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector);
@@ -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) {
::lv_style_set_bitmap_mask_src(style, image->get_lv_image_dsc());
}
#endif // USE_LVGL_IMAGE
#endif
#ifdef USE_LVGL_ANIMIMG
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));
@@ -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());
}
#endif // USE_LVGL_ANIMIMG
#endif // USE_IMAGE
#ifdef USE_LVGL_METER
int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value);
+24 -13
View File
@@ -1,10 +1,16 @@
import esphome.codegen as cg
from esphome.components import number
import esphome.config_validation as cv
from esphome.const import CONF_RESTORE_VALUE
from esphome.const import CONF_ON_RELEASE, CONF_RESTORE_VALUE
from esphome.cpp_generator import MockObj
from ..defines import CONF_ANIMATED, CONF_UPDATE_ON_RELEASE, CONF_WIDGET
from ..defines import (
CONF_ANIMATED,
CONF_TRIGGER,
CONF_UPDATE_ON_RELEASE,
CONF_WIDGET,
LOGGER,
)
from ..lv_validation import animated
from ..lvcode import (
EVENT_ARG,
@@ -14,7 +20,8 @@ from ..lvcode import (
lv_obj,
lvgl_static,
)
from ..types import LV_EVENT, LvNumber, lvgl_ns
from ..schemas import TRIGGER_EVENT_MAP, VALUE_TRIGGER_SCHEMA
from ..types import LvNumber, lvgl_ns
from ..widgets import get_widgets, wait_for_widgets
LVGLNumber = lvgl_ns.class_("LVGLNumber", number.Number, cg.Component)
@@ -22,14 +29,22 @@ LVGLNumber = lvgl_ns.class_("LVGLNumber", number.Number, cg.Component)
CONFIG_SCHEMA = number.number_schema(LVGLNumber).extend(
{
cv.Required(CONF_WIDGET): cv.use_id(LvNumber),
**VALUE_TRIGGER_SCHEMA,
cv.Optional(CONF_ANIMATED, default=True): animated,
cv.Optional(CONF_UPDATE_ON_RELEASE, default=False): cv.boolean,
cv.Optional(CONF_UPDATE_ON_RELEASE): cv.boolean,
cv.Optional(CONF_RESTORE_VALUE, default=False): cv.boolean,
}
)
async def to_code(config):
trigger = config[CONF_TRIGGER]
if CONF_UPDATE_ON_RELEASE in config:
LOGGER.warning(
"Option 'update_on_release' is deprecated and will be removed in 2026.11.0 - use 'trigger: on_release' instead"
)
if config[CONF_UPDATE_ON_RELEASE]:
trigger = CONF_ON_RELEASE
widget = await get_widgets(config, CONF_WIDGET)
widget = widget[0]
await wait_for_widgets()
@@ -40,19 +55,13 @@ async def to_code(config):
"value", MockObj("v") * MockObj(widget.get_scale()), config[CONF_ANIMATED]
)
lv_obj.send_event(widget.obj, UPDATE_EVENT, cg.nullptr)
event_code = (
LV_EVENT.VALUE_CHANGED
if not config[CONF_UPDATE_ON_RELEASE]
else LV_EVENT.RELEASED
)
var = await number.new_number(
config,
await control.get_lambda(),
await value.get_lambda(),
event_code,
config[CONF_RESTORE_VALUE],
max_value=widget.type.get_max(widget.config),
min_value=widget.type.get_min(widget.config),
max_value=await widget.type.get_max(widget.config),
min_value=await widget.type.get_min(widget.config),
step=widget.type.get_step(widget.config),
)
async with LambdaContext(EVENT_ARG) as event:
@@ -60,6 +69,8 @@ async def to_code(config):
await cg.register_component(var, config)
cg.add(
lvgl_static.add_event_cb(
widget.obj, await event.get_lambda(), UPDATE_EVENT, event_code
widget.obj,
await event.get_lambda(),
*TRIGGER_EVENT_MAP[trigger],
)
)
+2 -7
View File
@@ -10,12 +10,8 @@ namespace esphome::lvgl {
class LVGLNumber : public number::Number, public Component {
public:
LVGLNumber(std::function<void(float)> control_lambda, std::function<float()> value_lambda, lv_event_code_t event,
bool restore)
: control_lambda_(std::move(control_lambda)),
value_lambda_(std::move(value_lambda)),
event_(event),
restore_(restore) {}
LVGLNumber(std::function<void(float)> control_lambda, std::function<float()> value_lambda, bool restore)
: control_lambda_(std::move(control_lambda)), value_lambda_(std::move(value_lambda)), restore_(restore) {}
void setup() override {
float value = this->value_lambda_();
@@ -42,7 +38,6 @@ class LVGLNumber : public number::Number, public Component {
}
std::function<void(float)> control_lambda_;
std::function<float()> value_lambda_;
lv_event_code_t event_;
bool restore_;
ESPPreferenceObject pref_{};
};
+23 -2
View File
@@ -10,6 +10,7 @@ from esphome.const import (
CONF_GROUP,
CONF_ID,
CONF_ON_BOOT,
CONF_ON_UPDATE,
CONF_ON_VALUE,
CONF_STATE,
CONF_TEXT,
@@ -29,7 +30,13 @@ from .defines import (
CONF_SCROLL_SNAP_Y,
CONF_SCROLLBAR_MODE,
CONF_TIME_FORMAT,
CONF_TRIGGER,
LV_GRAD_DIR,
LV_VALUE_EVENTS,
VALUE_ON_CHANGE,
VALUE_ON_RELEASE,
VALUE_ON_UPDATE,
VALUE_ON_VALUE,
get_remapped_uses,
is_press_event,
)
@@ -41,8 +48,9 @@ from .layout import (
grid_alignments,
)
from .lv_validation import lv_color, lv_font, lv_gradient, lv_image, opacity
from .lvcode import LvglComponent, lv_event_t_ptr
from .lvcode import UPDATE_EVENT, LvglComponent, lv_event_t_ptr
from .types import (
LV_EVENT,
LVEncoderListener,
LvType,
lv_group_t,
@@ -355,6 +363,19 @@ SET_STATE_SCHEMA = cv.Schema(
FLAG_SCHEMA = cv.Schema({cv.Optional(flag): lvalid.lv_bool for flag in df.OBJ_FLAGS})
FLAG_LIST = cv.ensure_list(df.LV_OBJ_FLAG.one_of)
VALUE_TRIGGER_SCHEMA = {
cv.Optional(CONF_TRIGGER, default=CONF_ON_VALUE): cv.one_of(
*LV_VALUE_EVENTS, lower=True
),
}
TRIGGER_EVENT_MAP = {
VALUE_ON_CHANGE: (LV_EVENT.VALUE_CHANGED,),
VALUE_ON_UPDATE: (UPDATE_EVENT,),
VALUE_ON_VALUE: (LV_EVENT.VALUE_CHANGED, UPDATE_EVENT),
VALUE_ON_RELEASE: (LV_EVENT.RELEASED,),
}
def part_schema(parts):
"""
@@ -370,7 +391,7 @@ def part_schema(parts):
def automation_schema(typ: LvType):
events = df.LV_EVENT_TRIGGERS + df.SWIPE_TRIGGERS
if typ.has_on_value:
events = events + (CONF_ON_VALUE,)
events = events + (CONF_ON_VALUE, CONF_ON_UPDATE)
args = typ.get_arg_type()
def get_trigger_args(event):
+6 -12
View File
@@ -1,21 +1,16 @@
from esphome.components.sensor import Sensor, new_sensor, sensor_schema
import esphome.config_validation as cv
from ..defines import CONF_WIDGET
from ..lvcode import (
EVENT_ARG,
UPDATE_EVENT,
LambdaContext,
LvContext,
lv_add,
lvgl_static,
)
from ..types import LV_EVENT, LvNumber
from ..defines import CONF_TRIGGER, CONF_WIDGET
from ..lvcode import EVENT_ARG, LambdaContext, LvContext, lv_add, lvgl_static
from ..schemas import TRIGGER_EVENT_MAP, VALUE_TRIGGER_SCHEMA
from ..types import LvNumber
from ..widgets import Widget, get_widgets, wait_for_widgets
CONFIG_SCHEMA = sensor_schema(Sensor).extend(
{
cv.Required(CONF_WIDGET): cv.use_id(LvNumber),
**VALUE_TRIGGER_SCHEMA,
}
)
@@ -33,7 +28,6 @@ async def to_code(config):
lvgl_static.add_event_cb(
widget.obj,
await lamb.get_lambda(),
LV_EVENT.VALUE_CHANGED,
UPDATE_EVENT,
*TRIGGER_EVENT_MAP[config[CONF_TRIGGER]],
)
)
+2
View File
@@ -9,6 +9,7 @@ from .defines import (
CONF_THEME,
LValidator,
add_lv_use,
get_styles_used,
get_theme_widget_map,
literal,
)
@@ -25,6 +26,7 @@ def has_style_props(config) -> bool:
async def style_set(svar, style):
for prop, validator in ALL_STYLES.items():
if (value := style.get(prop)) is not None:
get_styles_used().add(prop)
if isinstance(validator, LValidator):
value = await validator.process(value)
if isinstance(value, list):
+8
View File
@@ -3,6 +3,7 @@ import esphome.codegen as cg
from esphome.const import (
CONF_ID,
CONF_ON_BOOT,
CONF_ON_UPDATE,
CONF_ON_VALUE,
CONF_TRIGGER_ID,
CONF_X,
@@ -92,6 +93,13 @@ async def generate_triggers():
UPDATE_EVENT,
)
for conf in config.get(CONF_ON_UPDATE, ()):
await add_trigger(
conf,
w,
UPDATE_EVENT,
)
await add_on_boot_triggers(config.get(CONF_ON_BOOT, ()))
+1 -2
View File
@@ -3,8 +3,6 @@ from esphome.const import CONF_TEXT, CONF_VALUE
from esphome.cpp_generator import MockObj
from esphome.cpp_types import Component, esphome_ns
from .defines import lvgl_ns
class LvType(cg.MockObjClass):
def __init__(self, *args, **kwargs):
@@ -47,6 +45,7 @@ PlainTrigger = esphome_ns.class_("Trigger<>", automation.Trigger.template())
DrawEndTrigger = esphome_ns.class_(
"Trigger<uint32_t, uint32_t>", automation.Trigger.template(cg.uint32, cg.uint32)
)
lvgl_ns = cg.esphome_ns.namespace("lvgl")
IdleTrigger = lvgl_ns.class_("IdleTrigger", automation.Trigger.template())
ObjUpdateAction = lvgl_ns.class_("ObjUpdateAction", automation.Action)
LvglCondition = lvgl_ns.class_("LvglCondition", automation.Condition)
+7 -6
View File
@@ -48,6 +48,7 @@ from ..defines import (
join_enums,
literal,
)
from ..lv_validation import lv_int
from ..lvcode import (
LvConditional,
add_line_marks,
@@ -207,10 +208,10 @@ class WidgetType:
"""
return ()
def get_max(self, config: dict):
async def get_max(self, config: dict):
return sys.maxsize
def get_min(self, config: dict):
async def get_min(self, config: dict):
return -sys.maxsize
def get_step(self, config: dict):
@@ -637,8 +638,8 @@ async def widget_to_code(w_cnfig, w_type: WidgetType | str, parent) -> Widget:
class NumberType(WidgetType):
def get_max(self, config: dict):
return int(config.get(CONF_MAX_VALUE, 100))
async def get_max(self, config: dict):
return await lv_int.process(config.get(CONF_MAX_VALUE, 100))
def get_min(self, config: dict):
return int(config.get(CONF_MIN_VALUE, 0))
async def get_min(self, config: dict):
return await lv_int.process(config.get(CONF_MIN_VALUE, 0))
+2 -2
View File
@@ -125,10 +125,10 @@ class SpinboxType(WidgetType):
def get_uses(self):
return CONF_TEXTAREA, CONF_LABEL
def get_max(self, config: dict):
async def get_max(self, config: dict):
return config[CONF_RANGE_TO]
def get_min(self, config: dict):
async def get_min(self, config: dict):
return config[CONF_RANGE_FROM]
def get_step(self, config: dict):
@@ -23,7 +23,13 @@ static const size_t DATA_TIMEOUT_MS = 50;
static const uint32_t RING_BUFFER_DURATION_MS = 120;
#ifdef CONFIG_IDF_TARGET_ESP32P4
// ESP32-P4 PIE-optimized esp-nn kernels (e.g. depthwise_conv_s8_ch1_pie) require
// significantly more stack than other variants, causing stack protection faults at 3072.
static const uint32_t INFERENCE_TASK_STACK_SIZE = 8192;
#else
static const uint32_t INFERENCE_TASK_STACK_SIZE = 3072;
#endif
static const UBaseType_t INFERENCE_TASK_PRIORITY = 3;
enum EventGroupBits : uint32_t {
+2 -2
View File
@@ -130,8 +130,8 @@ ClimateTraits AirConditioner::traits() {
void AirConditioner::dump_config() {
ESP_LOGCONFIG(Constants::TAG,
"MideaDongle:\n"
" [x] Period: %dms\n"
" [x] Response timeout: %dms\n"
" [x] Period: %" PRIu32 "ms\n"
" [x] Response timeout: %" PRIu32 "ms\n"
" [x] Request attempts: %d",
this->base_.getPeriod(), this->base_.getTimeout(), this->base_.getNumAttempts());
#ifdef USE_REMOTE_TRANSMITTER
+69 -2
View File
@@ -1,8 +1,11 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import climate, uart
import esphome.config_validation as cv
from esphome.const import CONF_UPDATE_INTERVAL
from esphome.types import ConfigType
from esphome.const import CONF_ID, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL
from esphome.core import ID
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType, TemplateArgsType
DEPENDENCIES = ["uart"]
AUTO_LOAD = ["climate"]
@@ -19,6 +22,18 @@ MitsubishiCN105Climate = mitsubishi_ns.class_(
uart.UARTDevice,
)
SetRemoteTemperatureAction = mitsubishi_ns.class_(
"SetRemoteTemperatureAction",
automation.Action,
cg.Parented.template(MitsubishiCN105Climate),
)
ClearRemoteTemperatureAction = mitsubishi_ns.class_(
"ClearRemoteTemperatureAction",
automation.Action,
cg.Parented.template(MitsubishiCN105Climate),
)
CONFIG_SCHEMA = (
climate.climate_schema(MitsubishiCN105Climate)
.extend(uart.UART_DEVICE_SCHEMA)
@@ -53,3 +68,55 @@ async def to_code(config: ConfigType) -> None:
config[CONF_CURRENT_TEMPERATURE_MIN_INTERVAL]
)
)
@automation.register_action(
"climate.mitsubishi_cn105.set_remote_temperature",
SetRemoteTemperatureAction,
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate),
cv.Required(CONF_TEMPERATURE): cv.templatable(
cv.All(
cv.temperature,
cv.Range(min=8.0, max=39.5),
)
),
}
),
synchronous=True,
)
async def set_remote_temperature_action_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
temperature = await cg.templatable(config[CONF_TEMPERATURE], args, float)
cg.add(var.set_temperature(temperature))
return var
@automation.register_action(
"climate.mitsubishi_cn105.clear_remote_temperature",
ClearRemoteTemperatureAction,
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate),
}
),
synchronous=True,
)
async def clear_remote_temperature_action_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
return var
@@ -1,3 +1,4 @@
#include <algorithm>
#include <array>
#include <cmath>
#include <numeric>
@@ -7,7 +8,7 @@ namespace esphome::mitsubishi_cn105 {
static const char *const TAG = "mitsubishi_cn105.driver";
static constexpr uint32_t WRITE_TIMEOUT_MS = 2000;
static constexpr uint32_t RESPONSE_TIMEOUT_MS = 2000;
static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31;
@@ -29,44 +30,85 @@ static constexpr uint8_t STATUS_MSG_ROOM_TEMP = 0x03;
static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41;
static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61;
static constexpr std::array<std::optional<MitsubishiCN105::Mode>, 9> PROTOCOL_MODE_MAP = {
std::nullopt, // 0x00
template<auto Unknown, size_t N> struct LookupMap {
using value_type = decltype(Unknown);
static constexpr auto UNKNOWN_VALUE = Unknown;
const std::array<value_type, N> table;
constexpr value_type lookup(uint8_t raw) const { return (raw < N) ? this->table[raw] : UNKNOWN_VALUE; }
constexpr bool reverse_lookup(value_type value, uint8_t &out) const {
static_assert(N <= std::numeric_limits<uint8_t>::max());
if (value == UNKNOWN_VALUE) {
return false;
}
for (uint8_t i = 0; i < static_cast<uint8_t>(N); ++i) {
if (this->table[i] == value) {
out = i;
return true;
}
}
return false;
}
constexpr bool is_valid(value_type value) const {
uint8_t raw;
return reverse_lookup(value, raw);
}
};
template<auto Unknown, class T, std::size_t N> static constexpr auto make_map(const T (&values)[N]) {
return LookupMap<Unknown, N>{std::to_array(values)};
}
static constexpr auto PROTOCOL_MODE_MAP = make_map<MitsubishiCN105::Mode::UNKNOWN>({
MitsubishiCN105::Mode::UNKNOWN, // 0x00
MitsubishiCN105::Mode::HEAT, // 0x01
MitsubishiCN105::Mode::DRY, // 0x02
MitsubishiCN105::Mode::COOL, // 0x03
std::nullopt, // 0x04
std::nullopt, // 0x05
std::nullopt, // 0x06
MitsubishiCN105::Mode::UNKNOWN, // 0x04
MitsubishiCN105::Mode::UNKNOWN, // 0x05
MitsubishiCN105::Mode::UNKNOWN, // 0x06
MitsubishiCN105::Mode::FAN_ONLY, // 0x07
MitsubishiCN105::Mode::AUTO // 0x08
};
});
static constexpr std::array<std::optional<MitsubishiCN105::FanMode>, 7> PROTOCOL_FAN_MODE_MAP = {
static constexpr auto PROTOCOL_FAN_MODE_MAP = make_map<MitsubishiCN105::FanMode::UNKNOWN>({
MitsubishiCN105::FanMode::AUTO, // 0x00
MitsubishiCN105::FanMode::QUIET, // 0x01
MitsubishiCN105::FanMode::SPEED_1, // 0x02
MitsubishiCN105::FanMode::SPEED_2, // 0x03
std::nullopt, // 0x04
MitsubishiCN105::FanMode::UNKNOWN, // 0x04
MitsubishiCN105::FanMode::SPEED_3, // 0x05
MitsubishiCN105::FanMode::SPEED_4 // 0x06
};
});
template<typename T, size_t N>
static constexpr std::optional<T> lookup(const std::array<std::optional<T>, N> &table, uint8_t value) {
return (value < N) ? table[value] : std::nullopt;
}
static constexpr auto PROTOCOL_VANE_MODE_MAP = make_map<MitsubishiCN105::VaneMode::UNKNOWN>({
MitsubishiCN105::VaneMode::AUTO, // 0x00
MitsubishiCN105::VaneMode::POSITION_1, // 0x01
MitsubishiCN105::VaneMode::POSITION_2, // 0x02
MitsubishiCN105::VaneMode::POSITION_3, // 0x03
MitsubishiCN105::VaneMode::POSITION_4, // 0x04
MitsubishiCN105::VaneMode::POSITION_5, // 0x05
MitsubishiCN105::VaneMode::UNKNOWN, // 0x06
MitsubishiCN105::VaneMode::SWING // 0x07
});
template<typename T, size_t N>
static constexpr bool reverse_lookup(const std::array<std::optional<T>, N> &table, T value, uint8_t &placeholder) {
for (size_t i = 0; i < N; ++i) {
const auto &table_value = table[i];
if (table_value.has_value() && table_value == value) {
placeholder = i;
return true;
}
}
return false;
}
static constexpr auto PROTOCOL_WIDE_VANE_MODE_MAP = make_map<MitsubishiCN105::WideVaneMode::UNKNOWN>({
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x00
MitsubishiCN105::WideVaneMode::FAR_LEFT, // 0x01
MitsubishiCN105::WideVaneMode::LEFT, // 0x02
MitsubishiCN105::WideVaneMode::CENTER, // 0x03
MitsubishiCN105::WideVaneMode::RIGHT, // 0x04
MitsubishiCN105::WideVaneMode::FAR_RIGHT, // 0x05
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x06
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x07
MitsubishiCN105::WideVaneMode::LEFT_RIGHT, // 0x08
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x09
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0A
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0B
MitsubishiCN105::WideVaneMode::SWING // 0x0C
});
static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) {
return static_cast<uint8_t>(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0}));
@@ -81,7 +123,7 @@ static constexpr auto make_packet(uint8_t type, const std::array<uint8_t, Payloa
return packet;
}
static float decode_temperature(int temp_a, int temp_b, int delta) {
static constexpr float decode_temperature(int temp_a, int temp_b, int delta) {
return temp_b != 0 ? (temp_b - 128) / 2.0f : delta + temp_a;
}
@@ -90,23 +132,31 @@ static constexpr auto CONNECT_PACKET = make_packet(PACKET_TYPE_CONNECT_REQUEST,
void MitsubishiCN105::initialize() { this->set_state_(State::CONNECTING); }
bool MitsubishiCN105::update() {
if (const auto start = this->status_update_start_ms_) {
if (this->pending_updates_.any()) {
this->cancel_waiting_and_transition_to_(State::APPLYING_SETTINGS);
return false;
}
switch (this->state_) {
case State::WAITING_FOR_SCHEDULED_STATUS_UPDATE:
if (this->pending_updates_.any()) {
this->status_update_wait_credit_ms_ =
std::min(this->update_interval_ms_, get_loop_time_ms() - this->operation_start_ms_);
this->set_state_(State::APPLYING_SETTINGS);
return false;
}
if (this->has_timed_out_(this->update_interval_ms_)) {
this->set_state_(State::UPDATING_STATUS);
return false;
}
break;
if ((get_loop_time_ms() - *start) >= this->update_interval_ms_) {
this->cancel_waiting_and_transition_to_(State::UPDATING_STATUS);
return false;
}
}
case State::CONNECTING:
case State::UPDATING_STATUS:
case State::APPLYING_SETTINGS:
if (this->has_timed_out_(RESPONSE_TIMEOUT_MS)) {
this->set_state_(State::READ_TIMEOUT);
return false;
}
break;
if (const auto start = this->write_timeout_start_ms_; start && (get_loop_time_ms() - *start) >= WRITE_TIMEOUT_MS) {
this->write_timeout_start_ms_.reset();
this->frame_parser_.reset();
this->set_state_(State::READ_TIMEOUT);
return false;
default:
break;
}
return this->frame_parser_.read_and_parse(this->device_, [this](uint8_t type, const uint8_t *payload, size_t len) {
@@ -168,7 +218,6 @@ void MitsubishiCN105::did_transition_(State to) {
break;
case State::CONNECTED:
this->write_timeout_start_ms_.reset();
this->current_status_msg_type_ = STATUS_MSG_SETTINGS;
this->set_state_(State::UPDATING_STATUS);
break;
@@ -178,7 +227,6 @@ void MitsubishiCN105::did_transition_(State to) {
break;
case State::STATUS_UPDATED: {
this->write_timeout_start_ms_.reset();
if (this->pending_updates_.any() && this->is_status_initialized()) {
this->set_state_(State::APPLYING_SETTINGS);
} else if (this->current_status_msg_type_ == STATUS_MSG_SETTINGS && this->should_request_room_temperature_()) {
@@ -191,22 +239,23 @@ void MitsubishiCN105::did_transition_(State to) {
}
case State::SCHEDULE_NEXT_STATUS_UPDATE:
this->status_update_start_ms_ = get_loop_time_ms();
this->operation_start_ms_ = get_loop_time_ms() - this->status_update_wait_credit_ms_;
this->status_update_wait_credit_ms_ = 0;
this->current_status_msg_type_ = STATUS_MSG_SETTINGS;
this->set_state_(State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
break;
case State::APPLYING_SETTINGS:
this->apply_settings_();
this->pending_updates_.clear();
break;
case State::SETTINGS_APPLIED:
this->write_timeout_start_ms_.reset();
this->set_state_(State::SCHEDULE_NEXT_STATUS_UPDATE);
break;
case State::READ_TIMEOUT:
this->frame_parser_.reset();
this->status_update_wait_credit_ms_ = 0;
this->set_state_(State::CONNECTING);
break;
@@ -230,7 +279,7 @@ bool MitsubishiCN105::should_request_room_temperature_() const {
void MitsubishiCN105::send_packet_(const uint8_t *packet, size_t len) {
FrameParser::dump_buffer_vv("TX", packet, len);
this->device_.write_array(packet, len);
this->write_timeout_start_ms_ = get_loop_time_ms();
this->operation_start_ms_ = get_loop_time_ms();
}
void MitsubishiCN105::update_status_() {
@@ -238,11 +287,6 @@ void MitsubishiCN105::update_status_() {
this->send_packet_(make_packet(PACKET_TYPE_STATUS_REQUEST, payload));
}
void MitsubishiCN105::cancel_waiting_and_transition_to_(State state) {
this->status_update_start_ms_.reset();
this->set_state_(state);
}
bool MitsubishiCN105::process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len) {
switch (type) {
case PACKET_TYPE_CONNECT_RESPONSE:
@@ -278,9 +322,10 @@ bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len)
this->set_state_(State::STATUS_UPDATED);
}
bool changed = previous.power_on != this->status_.power_on || previous.mode != this->status_.mode ||
previous.fan_mode != this->status_.fan_mode ||
previous.target_temperature != this->status_.target_temperature;
bool changed =
previous.power_on != this->status_.power_on || previous.mode != this->status_.mode ||
previous.fan_mode != this->status_.fan_mode || previous.target_temperature != this->status_.target_temperature ||
previous.vane_mode != this->status_.vane_mode || previous.wide_vane_mode != this->status_.wide_vane_mode;
if (this->is_room_temperature_enabled()) {
changed |= previous.room_temperature != this->status_.room_temperature;
@@ -309,22 +354,31 @@ bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len)
return false;
}
if (!this->pending_updates_.has(UpdateFlag::POWER)) {
if (!this->pending_updates_.contains(UpdateFlag::POWER)) {
this->status_.power_on = payload[2] != 0;
}
this->use_temperature_encoding_b_ = payload[10] != 0;
if (!this->pending_updates_.has(UpdateFlag::TEMPERATURE)) {
if (!this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) {
this->status_.target_temperature = decode_temperature(-payload[4], payload[10], TARGET_TEMPERATURE_ENC_A_OFFSET);
}
if (!this->pending_updates_.has(UpdateFlag::MODE)) {
if (!this->pending_updates_.contains(UpdateFlag::MODE)) {
const bool i_see = payload[3] > 0x08;
this->status_.mode = lookup(PROTOCOL_MODE_MAP, payload[3] - (i_see ? 0x08 : 0)).value_or(Mode::UNKNOWN);
this->status_.mode = PROTOCOL_MODE_MAP.lookup(payload[3] - (i_see ? 0x08 : 0));
}
if (!this->pending_updates_.has(UpdateFlag::FAN)) {
this->status_.fan_mode = lookup(PROTOCOL_FAN_MODE_MAP, payload[5]).value_or(FanMode::UNKNOWN);
if (!this->pending_updates_.contains(UpdateFlag::FAN)) {
this->status_.fan_mode = PROTOCOL_FAN_MODE_MAP.lookup(payload[5]);
}
if (!this->pending_updates_.contains(UpdateFlag::VANE)) {
this->status_.vane_mode = PROTOCOL_VANE_MODE_MAP.lookup(payload[6]);
}
this->set_wide_vane_high_bit_ = (payload[9] & 0xF0) == 0x80;
if (!this->pending_updates_.contains(UpdateFlag::WIDE_VANE)) {
this->status_.wide_vane_mode = PROTOCOL_WIDE_VANE_MODE_MAP.lookup(payload[9] & 0x0F);
}
return true;
@@ -342,6 +396,27 @@ bool MitsubishiCN105::parse_status_room_temperature_(const uint8_t *payload, siz
return true;
}
void MitsubishiCN105::set_remote_temperature(float temperature) {
if (std::isnan(temperature)) {
ESP_LOGD(TAG, "Ignoring NaN remote temperature");
return;
}
if (temperature < 8.0f || temperature > 39.5f) {
ESP_LOGD(TAG, "Ignoring out-of-range remote temperature: %.1f", temperature);
return;
}
this->set_remote_temperature_half_deg_(static_cast<uint8_t>(std::round(temperature * 2.0f)));
}
void MitsubishiCN105::clear_remote_temperature() {
this->set_remote_temperature_half_deg_(REMOTE_TEMPERATURE_DISABLED);
}
void MitsubishiCN105::set_remote_temperature_half_deg_(uint8_t temperature_half_deg) {
this->remote_temperature_half_deg_ = temperature_half_deg;
this->pending_updates_.set(UpdateFlag::REMOTE_TEMPERATURE);
}
void MitsubishiCN105::set_power(bool power_on) {
this->status_.power_on = power_on;
this->pending_updates_.set(UpdateFlag::POWER);
@@ -357,8 +432,7 @@ void MitsubishiCN105::set_target_temperature(float target_temperature) {
}
void MitsubishiCN105::set_mode(Mode mode) {
uint8_t placeholder;
if (!reverse_lookup(PROTOCOL_MODE_MAP, mode, placeholder)) {
if (!PROTOCOL_MODE_MAP.is_valid(mode)) {
ESP_LOGD(TAG, "Setting invalid mode: %u", static_cast<uint8_t>(mode));
return;
}
@@ -367,8 +441,7 @@ void MitsubishiCN105::set_mode(Mode mode) {
}
void MitsubishiCN105::set_fan_mode(FanMode fan_mode) {
uint8_t placeholder;
if (!reverse_lookup(PROTOCOL_FAN_MODE_MAP, fan_mode, placeholder)) {
if (!PROTOCOL_FAN_MODE_MAP.is_valid(fan_mode)) {
ESP_LOGD(TAG, "Setting invalid fan mode: %u", static_cast<uint8_t>(fan_mode));
return;
}
@@ -376,31 +449,80 @@ void MitsubishiCN105::set_fan_mode(FanMode fan_mode) {
this->pending_updates_.set(UpdateFlag::FAN);
}
void MitsubishiCN105::set_vane_mode(VaneMode vane_mode) {
if (!PROTOCOL_VANE_MODE_MAP.is_valid(vane_mode)) {
ESP_LOGD(TAG, "Setting invalid vane mode: %u", static_cast<uint8_t>(vane_mode));
return;
}
this->status_.vane_mode = vane_mode;
this->pending_updates_.set(UpdateFlag::VANE);
}
void MitsubishiCN105::set_wide_vane_mode(WideVaneMode wide_vane_mode) {
if (!PROTOCOL_WIDE_VANE_MODE_MAP.is_valid(wide_vane_mode)) {
ESP_LOGD(TAG, "Setting invalid wide vane mode: %u", static_cast<uint8_t>(wide_vane_mode));
return;
}
this->status_.wide_vane_mode = wide_vane_mode;
this->pending_updates_.set(UpdateFlag::WIDE_VANE);
}
void MitsubishiCN105::apply_settings_() {
std::array<uint8_t, REQUEST_PAYLOAD_LEN> payload = {0x01};
std::array<uint8_t, REQUEST_PAYLOAD_LEN> payload{};
if (this->pending_updates_.has(UpdateFlag::POWER)) {
payload[1] |= 0x01;
payload[3] = this->status_.power_on ? 0x01 : 0x00;
}
if (this->pending_updates_.has(UpdateFlag::TEMPERATURE)) {
payload[1] |= 0x04;
if (this->use_temperature_encoding_b_) {
payload[14] = static_cast<uint8_t>(std::round(this->status_.target_temperature * 2.0f) + 128);
// Apply all other pending settings first; handle REMOTE_TEMPERATURE last
if (this->pending_updates_.contains_only(UpdateFlag::REMOTE_TEMPERATURE)) {
payload[0] = 0x07;
if (this->remote_temperature_half_deg_ == REMOTE_TEMPERATURE_DISABLED) {
payload[3] = 0x80;
} else {
payload[5] = static_cast<uint8_t>(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(this->status_.target_temperature));
payload[1] = 0x01;
payload[2] = static_cast<uint8_t>(this->remote_temperature_half_deg_ - 16);
payload[3] = static_cast<uint8_t>(this->remote_temperature_half_deg_ + 128);
}
this->pending_updates_.clear(UpdateFlag::REMOTE_TEMPERATURE);
} else {
payload[0] = 0x01;
if (this->pending_updates_.contains(UpdateFlag::POWER)) {
payload[1] |= 0x01;
payload[3] = this->status_.power_on ? 0x01 : 0x00;
}
}
if (this->pending_updates_.has(UpdateFlag::MODE) &&
reverse_lookup(PROTOCOL_MODE_MAP, this->status_.mode, payload[4])) {
payload[1] |= 0x02;
}
if (this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) {
payload[1] |= 0x04;
if (this->use_temperature_encoding_b_) {
payload[14] = static_cast<uint8_t>(std::round(this->status_.target_temperature * 2.0f) + 128);
} else {
payload[5] =
static_cast<uint8_t>(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(this->status_.target_temperature));
}
}
if (this->pending_updates_.has(UpdateFlag::FAN) &&
reverse_lookup(PROTOCOL_FAN_MODE_MAP, this->status_.fan_mode, payload[6])) {
payload[1] |= 0x08;
if (this->pending_updates_.contains(UpdateFlag::MODE) &&
PROTOCOL_MODE_MAP.reverse_lookup(this->status_.mode, payload[4])) {
payload[1] |= 0x02;
}
if (this->pending_updates_.contains(UpdateFlag::FAN) &&
PROTOCOL_FAN_MODE_MAP.reverse_lookup(this->status_.fan_mode, payload[6])) {
payload[1] |= 0x08;
}
if (this->pending_updates_.contains(UpdateFlag::VANE) &&
PROTOCOL_VANE_MODE_MAP.reverse_lookup(this->status_.vane_mode, payload[7])) {
payload[1] |= 0x10;
}
if (this->pending_updates_.contains(UpdateFlag::WIDE_VANE) &&
PROTOCOL_WIDE_VANE_MODE_MAP.reverse_lookup(this->status_.wide_vane_mode, payload[13])) {
payload[2] |= 0x01;
if (this->set_wide_vane_high_bit_) {
payload[13] |= 0x80;
}
}
this->pending_updates_.clear(UpdateFlag::POWER, UpdateFlag::TEMPERATURE, UpdateFlag::MODE, UpdateFlag::FAN,
UpdateFlag::VANE, UpdateFlag::WIDE_VANE);
}
this->send_packet_(make_packet(PACKET_TYPE_WRITE_SETTINGS_REQUEST, payload));
@@ -2,6 +2,7 @@
#include <optional>
#include "esphome/components/uart/uart.h"
#include "esphome/core/finite_set_mask.h"
namespace esphome::mitsubishi_cn105 {
@@ -28,12 +29,36 @@ class MitsubishiCN105 {
UNKNOWN,
};
enum class VaneMode : uint8_t {
AUTO,
POSITION_1,
POSITION_2,
POSITION_3,
POSITION_4,
POSITION_5,
SWING,
UNKNOWN,
};
enum class WideVaneMode : uint8_t {
FAR_LEFT,
LEFT,
CENTER,
RIGHT,
FAR_RIGHT,
LEFT_RIGHT,
SWING,
UNKNOWN,
};
struct Status {
bool power_on{false};
float target_temperature{NAN};
float room_temperature{NAN};
bool power_on{false};
Mode mode{Mode::UNKNOWN};
FanMode fan_mode{FanMode::UNKNOWN};
float room_temperature{NAN};
VaneMode vane_mode{VaneMode::UNKNOWN};
WideVaneMode wide_vane_mode{WideVaneMode::UNKNOWN};
};
explicit MitsubishiCN105(uart::UARTDevice &device) : device_(device) {}
@@ -60,6 +85,10 @@ class MitsubishiCN105 {
void set_target_temperature(float target_temperature);
void set_mode(Mode mode);
void set_fan_mode(FanMode fan_mode);
void set_vane_mode(VaneMode vane_mode);
void set_wide_vane_mode(WideVaneMode mode);
void set_remote_temperature(float temperature);
void clear_remote_temperature();
protected:
enum class State : uint8_t {
@@ -91,20 +120,27 @@ class MitsubishiCN105 {
};
enum class UpdateFlag : uint8_t {
TEMPERATURE = 1 << 0,
POWER = 1 << 1,
MODE = 1 << 2,
FAN = 1 << 3,
TEMPERATURE = 0,
POWER = 1,
MODE = 2,
FAN = 3,
VANE = 4,
WIDE_VANE = 5,
REMOTE_TEMPERATURE = 6,
};
struct UpdateFlags {
void set(UpdateFlag f) { flags_ |= static_cast<uint8_t>(f); }
void clear() { flags_ = 0; }
bool any() const { return flags_ != 0; }
bool has(UpdateFlag f) const { return (flags_ & static_cast<uint8_t>(f)) != 0; }
template<typename... Flags> void set(Flags... flags) { (this->mask_.insert(flags), ...); }
template<typename... Flags> void clear(Flags... flags) { (this->mask_.erase(flags), ...); }
bool any() const { return !this->mask_.empty(); }
bool contains(UpdateFlag flag) const { return this->mask_.count(flag); }
bool contains_only(UpdateFlag flag) const { return this->mask_.get_mask() == Mask{flag}.get_mask(); }
protected:
uint8_t flags_{0};
using Mask =
FiniteSetMask<UpdateFlag, DefaultBitPolicy<UpdateFlag, static_cast<int>(UpdateFlag::REMOTE_TEMPERATURE) + 1>>;
Mask mask_;
};
void set_state_(State new_state);
@@ -116,25 +152,30 @@ class MitsubishiCN105 {
bool parse_status_room_temperature_(const uint8_t *payload, size_t len);
void send_packet_(const uint8_t *packet, size_t len);
void update_status_();
void cancel_waiting_and_transition_to_(State state);
bool should_request_room_temperature_() const;
void apply_settings_();
bool has_timed_out_(uint32_t timeout) const { return ((get_loop_time_ms() - this->operation_start_ms_) >= timeout); }
void set_remote_temperature_half_deg_(uint8_t temperature_half_deg);
template<typename T> void send_packet_(const T &packet) { this->send_packet_(packet.data(), packet.size()); }
static bool should_transition(State from, State to);
static const LogString *state_to_string(State state);
uart::UARTDevice &device_;
uint32_t update_interval_ms_{1000};
uint32_t status_update_wait_credit_ms_{0};
uint32_t operation_start_ms_{0};
uint32_t room_temperature_min_interval_ms_{60000};
std::optional<uint32_t> write_timeout_start_ms_;
std::optional<uint32_t> status_update_start_ms_;
std::optional<uint32_t> last_room_temperature_update_ms_;
Status status_{};
State state_{State::NOT_CONNECTED};
UpdateFlags pending_updates_;
bool use_temperature_encoding_b_{false};
uint8_t current_status_msg_type_{0};
bool set_wide_vane_high_bit_{false};
FrameParser frame_parser_;
uint8_t current_status_msg_type_{0};
static constexpr uint8_t REMOTE_TEMPERATURE_DISABLED = 0;
uint8_t remote_temperature_half_deg_{REMOTE_TEMPERATURE_DISABLED};
};
} // namespace esphome::mitsubishi_cn105
@@ -56,7 +56,7 @@ void MitsubishiCN105Climate::dump_config() {
ESP_LOGCONFIG(TAG, " Current temperature min interval: %" PRIu32 " ms",
this->hp_.get_room_temperature_min_interval());
} else {
ESP_LOGCONFIG(TAG, " Current temperature: disabled");
ESP_LOGCONFIG(TAG, " Current temperature: DISABLED");
}
ESP_LOGCONFIG(TAG,
" Update interval: %" PRIu32 " ms\n"
@@ -1,5 +1,6 @@
#pragma once
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/components/climate/climate.h"
#include "esphome/components/uart/uart.h"
@@ -18,8 +19,11 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public
climate::ClimateTraits traits() override;
void control(const climate::ClimateCall &call) override;
void set_update_interval(uint32_t ms) { hp_.set_update_interval(ms); }
void set_current_temperature_min_interval(uint32_t ms) { hp_.set_room_temperature_min_interval(ms); }
void set_update_interval(uint32_t ms) { this->hp_.set_update_interval(ms); }
void set_current_temperature_min_interval(uint32_t ms) { this->hp_.set_room_temperature_min_interval(ms); }
void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); }
void clear_remote_temperature() { this->hp_.clear_remote_temperature(); }
protected:
void apply_values_();
@@ -27,4 +31,18 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public
MitsubishiCN105 hp_;
};
template<typename... Ts>
class SetRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
public:
TEMPLATABLE_VALUE(float, temperature)
void play(const Ts &...x) override { this->parent_->set_remote_temperature(this->temperature_.value(x...)); }
};
template<typename... Ts>
class ClearRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
public:
void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); }
};
} // namespace esphome::mitsubishi_cn105
@@ -182,7 +182,7 @@ void SourceSpeaker::loop() {
break;
}
case speaker::STATE_RUNNING:
if (!this->transfer_buffer_->has_buffered_data() &&
if (!this->audio_source_->has_buffered_data() &&
(this->pending_playback_frames_.load(std::memory_order_acquire) == 0)) {
// No audio data in buffer waiting to get mixed and no frames are pending playback
if ((this->timeout_ms_.has_value() && ((millis() - this->last_seen_data_ms_) > this->timeout_ms_.value())) ||
@@ -254,15 +254,12 @@ void SourceSpeaker::send_command_(uint32_t command_bit, bool wake_loop) {
void SourceSpeaker::start() { this->send_command_(SOURCE_SPEAKER_COMMAND_START, true); }
esp_err_t SourceSpeaker::start_() {
const size_t ring_buffer_size = this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_);
if (this->transfer_buffer_.use_count() == 0) {
this->transfer_buffer_ =
audio::AudioSourceTransferBuffer::create(this->audio_stream_info_.ms_to_bytes(TRANSFER_BUFFER_DURATION_MS));
if (this->transfer_buffer_ == nullptr) {
return ESP_ERR_NO_MEM;
}
const size_t bytes_per_frame = this->audio_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->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame;
if (this->audio_source_.use_count() == 0) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (!temp_ring_buffer) {
temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
@@ -271,9 +268,15 @@ esp_err_t SourceSpeaker::start_() {
if (!temp_ring_buffer) {
return ESP_ERR_NO_MEM;
} else {
this->transfer_buffer_->set_source(temp_ring_buffer);
}
std::unique_ptr<audio::RingBufferAudioSource> source = audio::RingBufferAudioSource::create(
temp_ring_buffer, this->audio_stream_info_.ms_to_bytes(TRANSFER_BUFFER_DURATION_MS),
static_cast<uint8_t>(bytes_per_frame));
if (source == nullptr) {
return ESP_ERR_NO_MEM;
}
this->audio_source_ = std::move(source);
}
return this->parent_->start(this->audio_stream_info_);
@@ -284,7 +287,7 @@ void SourceSpeaker::stop() { this->send_command_(SOURCE_SPEAKER_COMMAND_STOP); }
void SourceSpeaker::finish() { this->send_command_(SOURCE_SPEAKER_COMMAND_FINISH); }
bool SourceSpeaker::has_buffered_data() const {
return ((this->transfer_buffer_.use_count() > 0) && this->transfer_buffer_->has_buffered_data());
return ((this->audio_source_.use_count() > 0) && this->audio_source_->has_buffered_data());
}
void SourceSpeaker::set_mute_state(bool mute_state) {
@@ -301,16 +304,18 @@ void SourceSpeaker::set_volume(float volume) {
float SourceSpeaker::get_volume() { return this->parent_->get_output_speaker()->get_volume(); }
size_t SourceSpeaker::process_data_from_source(std::shared_ptr<audio::AudioSourceTransferBuffer> &transfer_buffer,
size_t SourceSpeaker::process_data_from_source(std::shared_ptr<audio::RingBufferAudioSource> &audio_source,
TickType_t ticks_to_wait) {
// Store current offset, as these samples are already ducked
const size_t current_length = transfer_buffer->available();
if (audio_source->available() > 0) {
// Existing exposure was ducked when fill() promoted it; do not re-duck on partial-consume re-entry.
return 0;
}
size_t bytes_read = transfer_buffer->transfer_data_from_source(ticks_to_wait);
size_t bytes_read = audio_source->fill(ticks_to_wait, false);
uint32_t samples_to_duck = this->audio_stream_info_.bytes_to_samples(bytes_read);
if (samples_to_duck > 0) {
int16_t *current_buffer = reinterpret_cast<int16_t *>(transfer_buffer->get_buffer_start() + current_length);
int16_t *current_buffer = reinterpret_cast<int16_t *>(audio_source->mutable_data());
duck_samples(current_buffer, samples_to_duck, &this->current_ducking_db_reduction_,
&this->ducking_transition_samples_remaining_, this->samples_per_ducking_step_,
@@ -406,7 +411,7 @@ void SourceSpeaker::duck_samples(int16_t *input_buffer, uint32_t input_samples_t
void SourceSpeaker::enter_stopping_state_() {
this->state_ = speaker::STATE_STOPPING;
this->stopping_start_ms_ = millis();
this->transfer_buffer_.reset();
this->audio_source_.reset();
}
void MixerSpeaker::dump_config() {
@@ -612,9 +617,9 @@ void MixerSpeaker::audio_mixer_task(void *params) {
// Pre-allocate vectors to avoid heap allocation in the loop (max 8 source speakers per schema)
FixedVector<SourceSpeaker *> speakers_with_data;
FixedVector<std::shared_ptr<audio::AudioSourceTransferBuffer>> transfer_buffers_with_data;
FixedVector<std::shared_ptr<audio::RingBufferAudioSource>> audio_sources_with_data;
speakers_with_data.init(this_mixer->source_speakers_.size());
transfer_buffers_with_data.init(this_mixer->source_speakers_.size());
audio_sources_with_data.init(this_mixer->source_speakers_.size());
while (true) {
uint32_t event_group_bits = xEventGroupGetBits(this_mixer->event_group_);
@@ -629,27 +634,27 @@ void MixerSpeaker::audio_mixer_task(void *params) {
this_mixer->audio_stream_info_.value().bytes_to_frames(output_transfer_buffer->free());
speakers_with_data.clear();
transfer_buffers_with_data.clear();
audio_sources_with_data.clear();
for (auto &speaker : this_mixer->source_speakers_) {
if (speaker->is_running() && !speaker->get_pause_state()) {
// Speaker is running and not paused, so it possibly can provide audio data
std::shared_ptr<audio::AudioSourceTransferBuffer> transfer_buffer = speaker->get_transfer_buffer().lock();
if (transfer_buffer.use_count() == 0) {
// No transfer buffer allocated, so skip processing this speaker
std::shared_ptr<audio::RingBufferAudioSource> audio_source = speaker->get_audio_source().lock();
if (audio_source.use_count() == 0) {
// No audio source allocated, so skip processing this speaker
continue;
}
speaker->process_data_from_source(transfer_buffer, 0); // Transfers and ducks audio from source ring buffers
speaker->process_data_from_source(audio_source, 0); // Exposes and ducks audio from source ring buffers
if (transfer_buffer->available() > 0) {
// Store the locked transfer buffers in their own vector to avoid releasing ownership until after the loop
transfer_buffers_with_data.push_back(transfer_buffer);
if (audio_source->available() > 0) {
// Retain shared ownership across the mixing pass so the source isn't released mid-mix
audio_sources_with_data.push_back(audio_source);
speakers_with_data.push_back(speaker);
}
}
}
if (transfer_buffers_with_data.empty()) {
if (audio_sources_with_data.empty()) {
// No audio available for transferring, block task temporarily
delay(TASK_DELAY_MS);
continue;
@@ -657,7 +662,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
uint32_t frames_to_mix = output_frames_free;
if ((transfer_buffers_with_data.size() == 1) || this_mixer->queue_mode_) {
if ((audio_sources_with_data.size() == 1) || this_mixer->queue_mode_) {
// Only one speaker has audio data, just copy samples over
audio::AudioStreamInfo active_stream_info = speakers_with_data[0]->get_audio_stream_info();
@@ -667,10 +672,10 @@ void MixerSpeaker::audio_mixer_task(void *params) {
// Speaker's sample rate matches the output speaker's, copy directly
const uint32_t frames_available_in_buffer =
active_stream_info.bytes_to_frames(transfer_buffers_with_data[0]->available());
active_stream_info.bytes_to_frames(audio_sources_with_data[0]->available());
frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer);
copy_frames(reinterpret_cast<int16_t *>(transfer_buffers_with_data[0]->get_buffer_start()),
active_stream_info, reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end()),
copy_frames(reinterpret_cast<const int16_t *>(audio_sources_with_data[0]->data()), active_stream_info,
reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end()),
this_mixer->audio_stream_info_.value(), frames_to_mix);
// Set playback delay for newly contributing source
@@ -682,7 +687,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
// Update source speaker pending frames
speakers_with_data[0]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release);
transfer_buffers_with_data[0]->decrease_buffer_length(active_stream_info.frames_to_bytes(frames_to_mix));
audio_sources_with_data[0]->consume(active_stream_info.frames_to_bytes(frames_to_mix));
// Update output transfer buffer length and pipeline frame count
output_transfer_buffer->increase_buffer_length(
@@ -709,25 +714,25 @@ void MixerSpeaker::audio_mixer_task(void *params) {
}
} else {
// Determine how many frames to mix
for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) {
const uint32_t frames_available_in_buffer = speakers_with_data[i]->get_audio_stream_info().bytes_to_frames(
transfer_buffers_with_data[i]->available());
for (size_t i = 0; i < audio_sources_with_data.size(); ++i) {
const uint32_t frames_available_in_buffer =
speakers_with_data[i]->get_audio_stream_info().bytes_to_frames(audio_sources_with_data[i]->available());
frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer);
}
int16_t *primary_buffer = reinterpret_cast<int16_t *>(transfer_buffers_with_data[0]->get_buffer_start());
const int16_t *primary_buffer = reinterpret_cast<const int16_t *>(audio_sources_with_data[0]->data());
audio::AudioStreamInfo primary_stream_info = speakers_with_data[0]->get_audio_stream_info();
// Mix two streams together
for (size_t i = 1; i < transfer_buffers_with_data.size(); ++i) {
for (size_t i = 1; i < audio_sources_with_data.size(); ++i) {
mix_audio_samples(primary_buffer, primary_stream_info,
reinterpret_cast<int16_t *>(transfer_buffers_with_data[i]->get_buffer_start()),
reinterpret_cast<const int16_t *>(audio_sources_with_data[i]->data()),
speakers_with_data[i]->get_audio_stream_info(),
reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end()),
this_mixer->audio_stream_info_.value(), frames_to_mix);
if (i != transfer_buffers_with_data.size() - 1) {
if (i != audio_sources_with_data.size() - 1) {
// Need to mix more streams together, point primary buffer and stream info to the already mixed output
primary_buffer = reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end());
primary_buffer = reinterpret_cast<const int16_t *>(output_transfer_buffer->get_buffer_end());
primary_stream_info = this_mixer->audio_stream_info_.value();
}
}
@@ -735,8 +740,8 @@ void MixerSpeaker::audio_mixer_task(void *params) {
// Get current pipeline depth for delay calculation (before incrementing)
uint32_t current_pipeline_frames = this_mixer->frames_in_pipeline_.load(std::memory_order_acquire);
// Update source transfer buffer lengths and add new audio durations to the source speaker pending playbacks
for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) {
// Update source audio source consumption and add new audio durations to the source speaker pending playbacks
for (size_t i = 0; i < audio_sources_with_data.size(); ++i) {
// Set playback delay for newly contributing sources
if (!speakers_with_data[i]->has_contributed_.load(std::memory_order_acquire)) {
speakers_with_data[i]->playback_delay_frames_.store(current_pipeline_frames, std::memory_order_release);
@@ -744,7 +749,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
}
speakers_with_data[i]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release);
transfer_buffers_with_data[i]->decrease_buffer_length(
audio_sources_with_data[i]->consume(
speakers_with_data[i]->get_audio_stream_info().frames_to_bytes(frames_to_mix));
}
@@ -67,11 +67,13 @@ class SourceSpeaker : public speaker::Speaker, public Component {
void set_pause_state(bool pause_state) override { this->pause_state_ = pause_state; }
bool get_pause_state() const override { return this->pause_state_; }
/// @brief Transfers audio from the ring buffer into the transfer buffer. Ducks audio while transferring.
/// @param transfer_buffer Locked shared_ptr to the transfer buffer (must be valid, not null)
/// @brief Exposes the next ring buffer chunk (zero-copy) and ducks the freshly exposed bytes in place.
/// If the source still has bytes from a prior partial consume, this is a no-op (those bytes were already
/// ducked on the fill that exposed them).
/// @param audio_source Locked shared_ptr to the audio source (must be valid, not null)
/// @param ticks_to_wait FreeRTOS ticks to wait while waiting to read from the ring buffer.
/// @return Number of bytes transferred from the ring buffer.
size_t process_data_from_source(std::shared_ptr<audio::AudioSourceTransferBuffer> &transfer_buffer,
/// @return Number of bytes newly exposed from the ring buffer.
size_t process_data_from_source(std::shared_ptr<audio::RingBufferAudioSource> &audio_source,
TickType_t ticks_to_wait);
/// @brief Sets the ducking level for the source speaker.
@@ -83,7 +85,7 @@ class SourceSpeaker : public speaker::Speaker, public Component {
void set_parent(MixerSpeaker *parent) { this->parent_ = parent; }
void set_timeout(uint32_t ms) { this->timeout_ms_ = ms; }
std::weak_ptr<audio::AudioSourceTransferBuffer> get_transfer_buffer() { return this->transfer_buffer_; }
std::weak_ptr<audio::RingBufferAudioSource> get_audio_source() { return this->audio_source_; }
protected:
friend class MixerSpeaker;
@@ -106,7 +108,7 @@ class SourceSpeaker : public speaker::Speaker, public Component {
MixerSpeaker *parent_;
std::shared_ptr<audio::AudioSourceTransferBuffer> transfer_buffer_;
std::shared_ptr<audio::RingBufferAudioSource> audio_source_;
std::weak_ptr<ring_buffer::RingBuffer> ring_buffer_;
uint32_t buffer_duration_ms_;
@@ -68,6 +68,9 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type)
return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE;
} else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) {
return OTA_RESPONSE_ERROR_WRITING_FLASH;
} else if (err == ESP_ERR_OTA_PARTITION_CONFLICT) {
// This error appears with 1 factory and 1 ota partition
return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION;
}
return OTA_RESPONSE_ERROR_UNKNOWN;
}
@@ -210,7 +210,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() {
ESP_LOGE(TAG, "Cannot resolve running app partition at address 0x%" PRIX32, running_app_offset);
return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE;
}
ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address,
ESP_LOGD(TAG, "Copying running app from 0x%" PRIX32 " to 0x%" PRIX32 " (size: 0x%zX)", running_app_part->address,
plan.copy_dest_part->address, running_app_size);
err = esp_partition_copy(plan.copy_dest_part, 0, running_app_part, 0, running_app_size);
if (err != ESP_OK) {
@@ -261,7 +261,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() {
ESP_LOGE(TAG, "Selected app partition not found after partition table update");
return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE;
}
ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address);
ESP_LOGD(TAG, "Setting next boot partition to 0x%" PRIX32, new_boot_partition->address);
err = esp_ota_set_boot_partition(new_boot_partition);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X)", err);
+35 -7
View File
@@ -215,7 +215,7 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
If loading fails after cloning, attempts a revert and retry in case
a prior cached checkout is stale.
"""
repo_dir, revert = git.clone_or_update(
repo_root, revert = git.clone_or_update(
url=config[CONF_URL],
ref=config.get(CONF_REF),
refresh=config[CONF_REFRESH],
@@ -225,6 +225,10 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
)
files: list[dict[str, Any]] = []
# ``repo_root`` is the directory containing ``.git`` and must be passed
# to git for symlink-stub resolution. ``repo_dir`` may be narrowed to a
# subdirectory via the user's CONF_PATH and is used for file lookups.
repo_dir = repo_root
if base_path := config.get(CONF_PATH):
repo_dir = repo_dir / base_path
@@ -236,13 +240,37 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
def _load_package_yaml(yaml_file: Path, filename: str) -> dict:
"""Load a YAML file from a remote package, validating min_version."""
try:
new_yaml = yaml_util.load_yaml(yaml_file)
except EsphomeError as e:
def _load(path: Path) -> dict | str | None:
try:
return yaml_util.load_yaml(path)
except EsphomeError as e:
raise cv.Invalid(
f"{filename} is not a valid YAML file."
f" Please check the file contents.\n{e}"
) from e
new_yaml = _load(yaml_file)
if not isinstance(new_yaml, dict):
# On Windows, git defaults to core.symlinks=false unless the user
# has Developer Mode enabled or is running elevated. Files stored
# in the repo as symlinks (tree mode 120000) are then checked out
# as plain text files containing the symlink target path, so
# parsing them as YAML yields a bare scalar instead of a mapping.
# Best-effort: follow the symlink target ourselves and re-load.
target = git.resolve_symlink_stub(repo_root, yaml_file)
if target is not None:
new_yaml = _load(target)
if not isinstance(new_yaml, dict):
raise cv.Invalid(
f"{filename} is not a valid YAML file."
f" Please check the file contents.\n{e}"
) from e
f"{filename} does not contain a YAML mapping at the top level "
f"(got {type(new_yaml).__name__}). "
f"If this file is a git symlink in the source repository, it "
f"may not have been materialized correctly on your platform "
f"(this is a known issue with git on Windows without Developer "
f"Mode enabled). Try pointing your package at the real file "
f"path instead."
)
esphome_config = new_yaml.get(CONF_ESPHOME) or {}
min_version = esphome_config.get(CONF_MIN_VERSION)
if min_version is not None and cv.Version.parse(min_version) > cv.Version.parse(
@@ -150,7 +150,7 @@ void IRAM_ATTR PulseMeterSensor::edge_intr(PulseMeterSensor *sensor) {
edge_state.last_sent_edge_us_ = now;
state.last_detected_edge_us_ = now;
state.last_rising_edge_us_ = now;
state.count_++; // NOLINT(clang-diagnostic-deprecated-volatile)
state.count_ += 1;
}
// This ISR is bound to rising edges, so the pin is high
@@ -173,7 +173,7 @@ void IRAM_ATTR PulseMeterSensor::pulse_intr(PulseMeterSensor *sensor) {
} else if (length && !pulse_state.latched_ && sensor->last_pin_val_) { // Long enough high edge
pulse_state.latched_ = true;
state.last_detected_edge_us_ = pulse_state.last_intr_;
state.count_++; // NOLINT(clang-diagnostic-deprecated-volatile)
state.count_ += 1;
}
// Due to order of operations this includes
@@ -8,9 +8,10 @@ breaking changes policy. Use at your own risk.
Once the API is considered stable, this warning will be removed.
"""
from esphome import automation
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.const import CONF_ID, CONF_ON_CONTROL
from esphome.core import CORE, coroutine_with_priority
from esphome.core.entity_helpers import queue_entity_register, setup_entity
from esphome.coroutine import CoroPriority
@@ -42,6 +43,7 @@ def radio_frequency_schema(class_: type[cg.MockObjClass]) -> cv.Schema:
return entity_schema.extend(
{
cv.GenerateID(): cv.declare_id(class_),
cv.Optional(CONF_ON_CONTROL): automation.validate_automation({}),
}
)
@@ -59,6 +61,11 @@ async def register_radio_frequency(var: cg.Pvariable, config: ConfigType) -> Non
await setup_radio_frequency_core_(var, config)
CORE.register_platform_component("radio_frequency", var)
for conf in config.get(CONF_ON_CONTROL, []):
await automation.build_callback_automation(
var, "add_on_control_callback", [(RadioFrequencyCall, "x")], conf
)
async def new_radio_frequency(config: ConfigType, *args) -> cg.Pvariable:
"""Create a new RadioFrequency instance.
@@ -54,6 +54,10 @@ RadioFrequencyCall &RadioFrequencyCall::set_repeat_count(uint32_t count) {
void RadioFrequencyCall::perform() {
if (this->parent_ != nullptr) {
// Fire any on_control hooks (user-wired automations) before handing off to
// the platform-specific control() — gives users a chance to react to call
// parameters (e.g. retune an external RF front-end based on call.get_frequency()).
this->parent_->control_callback_.call(*this);
this->parent_->control(*this);
}
}
@@ -170,6 +170,15 @@ class RadioFrequency : public Component, public EntityBase, public remote_base::
this->receive_callback_.add(std::forward<F>(callback));
}
/// Add a callback to invoke when a transmit call is made on this entity.
/// Fires before the platform-specific control() runs, with the call object
/// (containing frequency, modulation, repeat count, etc.). Used by the
/// `on_control` YAML trigger so users can wire any RF front-end driver
/// (CC1101, RFM69, custom) to react to per-call parameters.
template<typename F> void add_on_control_callback(F &&callback) {
this->control_callback_.add(std::forward<F>(callback));
}
protected:
friend class RadioFrequencyCall;
@@ -182,6 +191,8 @@ class RadioFrequency : public Component, public EntityBase, public remote_base::
// Callback manager for receive events (lazy: saves memory when no callbacks registered)
LazyCallbackManager<void(remote_base::RemoteReceiveData)> receive_callback_;
// Callback manager for on_control trigger (lazy: same memory savings)
LazyCallbackManager<void(const RadioFrequencyCall &)> control_callback_;
};
} // namespace esphome::radio_frequency
@@ -78,10 +78,10 @@ void RemoteReceiverComponent::setup() {
void RemoteReceiverComponent::dump_config() {
ESP_LOGCONFIG(TAG,
"Remote Receiver:\n"
" Buffer Size: %u\n"
" Tolerance: %u%s\n"
" Filter out pulses shorter than: %u us\n"
" Signal is done after %u us of no changes",
" Buffer Size: %" PRIu32 "\n"
" Tolerance: %" PRIu32 "%s\n"
" Filter out pulses shorter than: %" PRIu32 " us\n"
" Signal is done after %" PRIu32 " us of no changes",
this->buffer_size_, this->tolerance_,
(this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? " us" : "%", this->filter_us_,
this->idle_us_);
+3 -3
View File
@@ -139,7 +139,7 @@ def _parse_platform_version(value):
# The default/recommended arduino framework version
# - https://github.com/earlephilhower/arduino-pico/releases
# - https://api.registry.platformio.org/v3/packages/earlephilhower/tool/framework-arduinopico
RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 5, 1)
RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 6, 0)
# The raspberrypi platform version to use for arduino frameworks
# - https://github.com/maxgerhardt/platform-raspberrypi/tags
@@ -149,8 +149,8 @@ RECOMMENDED_ARDUINO_PLATFORM_VERSION = "v1.4.0-gcc14-arduinopico460"
def _arduino_check_versions(value):
value = value.copy()
lookups = {
"dev": (cv.Version(5, 5, 1), "https://github.com/earlephilhower/arduino-pico"),
"latest": (cv.Version(5, 5, 1), None),
"dev": (cv.Version(5, 6, 0), "https://github.com/earlephilhower/arduino-pico"),
"latest": (cv.Version(5, 6, 0), None),
"recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None),
}
+18
View File
@@ -457,6 +457,19 @@ RP2040_BOARD_PINS = {
"SS": 17,
"TX": 12,
},
"challenger_2350_nbiot": {
"LED": 15,
"MISO": 16,
"MOSI": 19,
"RX": 13,
"SCK": 18,
"SCL": 21,
"SCL1": 31,
"SDA": 20,
"SDA1": 31,
"SS": 17,
"TX": 12,
},
"challenger_2350_wifi6_ble5": {
"LED": 7,
"MISO": 16,
@@ -1711,6 +1724,11 @@ BOARDS = {
"mcu": "rp2350",
"max_pin": 47,
},
"challenger_2350_nbiot": {
"name": "iLabs Challenger 2350 NB-IoT",
"mcu": "rp2350",
"max_pin": 47,
},
"challenger_2350_wifi6_ble5": {
"name": "iLabs Challenger 2350 WiFi/BLE",
"mcu": "rp2350",
+74 -2
View File
@@ -15,6 +15,7 @@
#elif defined(USE_ESP32)
#include <esp_ota_ops.h>
#include <esp_system.h>
#include <esp_image_format.h>
#endif
#endif
@@ -22,6 +23,37 @@ namespace esphome::safe_mode {
static const char *const TAG = "safe_mode";
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK) && !defined(USE_OTA_PARTITIONS)
// Find a non-running app partition. If verify is true, only returns a partition
// whose image passes verification (expensive: reads flash). Returns nullptr if none found.
static const esp_partition_t *find_alternate_app_partition(bool verify) {
const esp_partition_t *running = esp_ota_get_running_partition();
const esp_partition_t *result = nullptr;
esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr);
while (it != nullptr) {
const esp_partition_t *p = esp_partition_get(it);
if (p->address != running->address) {
if (!verify) {
result = p;
break;
}
esp_image_metadata_t data = {};
const esp_partition_pos_t part_pos = {
.offset = p->address,
.size = p->size,
};
if (esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &part_pos, &data) == ESP_OK) {
result = p;
break;
}
}
it = esp_partition_next(it);
}
esp_partition_iterator_release(it);
return result;
}
#endif
void SafeModeComponent::dump_config() {
ESP_LOGCONFIG(TAG,
"Safe Mode:\n"
@@ -34,7 +66,11 @@ void SafeModeComponent::dump_config() {
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
const char *state_str;
if (this->ota_state_ == ESP_OTA_IMG_NEW) {
#ifdef USE_OTA_PARTITIONS
state_str = "support unknown";
#else
state_str = "not supported";
#endif
} else if (this->ota_state_ == ESP_OTA_IMG_PENDING_VERIFY) {
state_str = "supported";
} else {
@@ -64,6 +100,18 @@ void SafeModeComponent::dump_config() {
" See https://esphome.io/guides/faq.html#brownout-detector-was-triggered");
}
}
if (!this->app_ota_possible_) {
ESP_LOGW(TAG, "OTA updates are impossible.");
#ifdef USE_OTA_PARTITIONS
ESP_LOGW(TAG, " OTA partition table update or serial flashing is required.");
#else
if (find_alternate_app_partition(false) != nullptr) {
ESP_LOGW(TAG, " Activate safe mode to reboot to the recovery partition.");
} else {
ESP_LOGE(TAG, " No recovery partition available; serial flashing is required.");
}
#endif
}
#endif
}
@@ -124,8 +172,10 @@ bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t en
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
// Check partition state to detect if bootloader supports rollback
const esp_partition_t *running = esp_ota_get_running_partition();
esp_ota_get_state_partition(running, &this->ota_state_);
const esp_partition_t *running_part = esp_ota_get_running_partition();
esp_ota_get_state_partition(running_part, &this->ota_state_);
const esp_partition_t *next_part = esp_ota_get_next_update_partition(nullptr);
this->app_ota_possible_ = (next_part != nullptr && next_part != running_part);
#endif
uint32_t rtc_val = this->read_rtc_();
@@ -151,6 +201,28 @@ bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t en
ESP_LOGE(TAG, "Boot loop detected");
}
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK) && !defined(USE_OTA_PARTITIONS)
// Allow recovery of soft-bricked devices
// Instead of starting safe_mode, reboot to the other app partition if all conditions are met:
// - app OTA is impossible (for example because the other app partition has type 'factory')
// - the other app partition contains a valid app (for example Tasmota safeboot image or ESPHome)
// - allow_partition_access is not configured making recovery via partition table update impossible
// Image verification is deferred until here so the cost is only paid when entering safe mode,
// not on every boot.
if (!this->app_ota_possible_) {
const esp_partition_t *rollback_part = find_alternate_app_partition(true);
if (rollback_part != nullptr) {
esp_err_t err = esp_ota_set_boot_partition(rollback_part);
if (err == ESP_OK) {
ESP_LOGW(TAG, "OTA updates are impossible. Rebooting to recovery app.");
App.reboot();
} else {
ESP_LOGE(TAG, "Failed to set recovery boot partition: %s", esp_err_to_name(err));
}
}
}
#endif
this->status_set_error();
this->set_timeout(enable_time, []() {
ESP_LOGW(TAG, "Timeout, restarting");
+4 -1
View File
@@ -48,11 +48,14 @@ class SafeModeComponent final : public Component {
uint32_t safe_mode_enable_time_{60000}; ///< The time safe mode should remain active for
uint32_t safe_mode_rtc_value_{0};
uint32_t safe_mode_start_time_{0}; ///< stores when safe mode was enabled
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
esp_ota_img_states_t ota_state_{ESP_OTA_IMG_UNDEFINED}; // 4-byte enum
#endif
// Group 1-byte members together to minimize padding
bool boot_successful_{false}; ///< set to true after boot is considered successful
uint8_t safe_mode_num_attempts_{0};
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
esp_ota_img_states_t ota_state_{ESP_OTA_IMG_UNDEFINED};
bool app_ota_possible_{true};
#endif
// Larger objects at the end
ESPPreferenceObject rtc_;
+2
View File
@@ -169,6 +169,8 @@ async def script_execute_action_to_code(config, action_id, template_arg, args):
return value
if type == "bool":
return cg.RawExpression(str(value).lower())
if isinstance(value, (list, tuple)):
return cg.ArrayInitializer(*value)
return cg.RawExpression(str(value))
return converter
-2
View File
@@ -25,7 +25,6 @@ from esphome.const import (
CONF_TEMPERATURE_COMPENSATION,
CONF_TIME_CONSTANT,
CONF_VOC,
DEVICE_CLASS_AQI,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PM1,
DEVICE_CLASS_PM10,
@@ -77,7 +76,6 @@ def _gas_sensor(
return sensor.sensor_schema(
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
).extend(
{
-3
View File
@@ -14,7 +14,6 @@ from esphome.const import (
CONF_TEMPERATURE,
CONF_TYPE,
CONF_VOC,
DEVICE_CLASS_AQI,
DEVICE_CLASS_CARBON_DIOXIDE,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PM1,
@@ -93,13 +92,11 @@ CONFIG_SCHEMA = (
cv.Optional(CONF_VOC): sensor.sensor_schema(
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_NOX): sensor.sensor_schema(
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_CO2): sensor.sensor_schema(
+5 -2
View File
@@ -206,12 +206,15 @@ async def to_code(config: ConfigType) -> None:
)
# sendspin-cpp library
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.4.0")
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1")
cg.add_define("USE_SENDSPIN", True) # for MDNS
data = _get_data()
# The color role is not yet wired up in ESPHome; disable it in the library for now.
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_COLOR", False)
# Configure Sendspin roles based on requested features (ESPHome internally via USE_SENDSPIN_*)
# and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*).
if data.artwork_support:
@@ -264,7 +267,7 @@ async def to_code(config: ConfigType) -> None:
# Library defaults: priority 18 (one above httpd_priority 17 so the decoder is not
# starved by the HTTP server during the initial encoded-audio burst at stream start),
# interpolation/decode buffer locations PREFER_EXTERNAL.
# decode buffer location PREFER_EXTERNAL.
player_struct_fields = [
("audio_formats", audio_format_structs),
("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]),
@@ -188,14 +188,6 @@ void SendspinMediaSource::on_stream_end() {
}
}
// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback)
void SendspinMediaSource::on_stream_clear() {
if (this->get_state() != media_source::MediaSourceState::IDLE) {
// Only set to IDLE if we were previously in a non-IDLE state, to avoid duplicate state changes
this->set_state_(media_source::MediaSourceState::IDLE);
}
}
// THREAD CONTEXT: Main loop (PlayerRoleListener callback)
void SendspinMediaSource::on_volume_changed(uint8_t volume) { this->request_volume_(volume / 100.0f); }
@@ -49,9 +49,6 @@ class SendspinMediaSource : public SendspinChild,
/// @brief Called when the audio stream ends (main loop thread).
void on_stream_end() override;
/// @brief Called when the audio stream is cleared (main loop thread).
void on_stream_clear() override;
/// @brief Called when volume changes (main loop thread).
void on_volume_changed(uint8_t volume) override;

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