Compare commits

...

158 Commits

Author SHA1 Message Date
J. Nick Koston 8666f1b3ed [core] Outline Scheduler::call cleanup slow path into cold combined helper
Fold the to_remove_empty check, cleanup_slow_path_ call, and
MAX_LOGICALLY_DELETED_ITEMS threshold check into a single hot-path
branch + cold outlined helper.

Before this change, the cleanup block in Scheduler::call compiled to
two independent memw + l32i sequences (one for to_remove_empty_() inside
cleanup_(), one for the separate to_remove_count_() check) because GCC
cannot CSE across the memw barriers that std::atomic<uint32_t>::load
emits on Xtensa.

A previous attempt at collapsing these into a single inline branch
(#15985) had the right assembly for the memw count but grew
Scheduler::call by a handful of bytes and rearranged the control flow
enough to nudge sched up ~0.5 us/iter on gatetrigger in practice.

This version goes further: cleanup_slow_combined_ is annotated
noinline + cold so the entire slow path (both reads, both calls) is
pulled out of Scheduler::call entirely. The hot path becomes:

    memw; l32i a8, [to_remove_]; beqz skip
    call8 cleanup_slow_combined_   ; unlikely, cold

and Scheduler::call's body shrinks 344 B -> 332 B (-12 B, below the dev
baseline). Adjacent code (feed_wdt_slow_, etc.) stays in the same flash
region, avoiding the cache-layout side-effects that made earlier
attempts a net loss on busier configs.

The [[unlikely]] attribute on the branch plus the cold attribute on the
helper give the compiler permission to keep the skip path straight and
push the call out-of-line.
2026-04-24 17:11:36 -05:00
dependabot[bot] f62972c2c6 Bump ruff from 0.15.11 to 0.15.12 (#15981)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-04-24 19:34:00 +00:00
dependabot[bot] f36efbc762 Update tzdata requirement from >=2026.1 to >=2026.2 (#15980)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 19:27:12 +00:00
Kevin Ahrendt 9caf9ee023 [sendspin] Bumps sendspin-cpp library for a bugfix (#15976) 2026-04-24 11:53:03 -05:00
Kevin Ahrendt 94e300389c [sendspin] remove year and track number text sensors and refactor (#15975) 2026-04-24 15:35:32 +00:00
Kevin Ahrendt 55bcf33446 [sendspin] Add metadata sensor component (#15971) 2026-04-24 14:32:47 +00:00
Kevin Ahrendt f132b7dc07 [media_player][speaker][speaker_source] Centralize preferred format codegen (#14771) 2026-04-24 14:09:03 +00:00
J. Nick Koston baa6d5f96b [web_server_idf] Fix cross-thread race on SSE session state (#15967) 2026-04-24 08:11:47 -05:00
J. Nick Koston 773b4d887b [core] Scheduler: don't sleep while defer queue is non-empty (#15968) 2026-04-24 08:11:29 -05:00
Kevin Ahrendt ac7f0f0b74 [sendspin] Add a metadata text sensor component (#15969) 2026-04-24 11:07:00 +00:00
Kevin Ahrendt bc7f35b569 [sendspin] Add a Sendspin media source component for playing audio (PR4) (#15950)
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-04-24 10:00:22 +00:00
J. Nick Koston ae02ab3865 [wifi] Fix stale wifi.connected after state transition (#15966) 2026-04-24 03:42:36 -05:00
J. Nick Koston eceb534895 [deep_sleep] Fix sleep_duration codegen type to uint32_t (#15965) 2026-04-24 07:19:59 +00:00
tomaszduda23 404620b99c [deep_sleep][logger][zephyr][zigbee] add deep sleep support with zigbee wakeup (#13950)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-23 22:31:46 -04:00
Kevin Ahrendt 3ccaa771a7 [sendspin] Add a group media player controller (PR3) (#15948)
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-04-24 01:46:25 +00:00
Kevin Ahrendt b4a86e46b2 [sendspin] Add controller role and sendspin.switch action (PR2) (#15929)
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 20:22:47 -05:00
Kevin Ahrendt ddf1426f86 [sendspin] Add initial Sendspin hub component (PR1) (#15924)
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 22:09:36 +00:00
J. Nick Koston 90d7bfe02e [ci] Auto-close PRs opened from a fork's default branch (#15957) 2026-04-23 16:36:32 -05:00
Kevin Ahrendt d759f1a567 [audio_http] Add a media source for playing audio from HTTP URLs (#15741)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-23 15:53:52 -05:00
luar123 f757cd1210 [zigbee][core] Add support for Zigbee binary sensors on ESP32 H2 and C6 (#11553)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-23 12:46:56 -04:00
Paulus Schoutsen 9b45b046a8 [core] Allow finding all devices as target that match mac suffix (#13135) 2026-04-23 08:43:32 -05:00
J. Nick Koston 70ae614abd [api] Fall back to plaintext for logger connections (#15938) 2026-04-23 08:23:38 -05:00
J. Nick Koston 8f9b91eece [wifi] Avoid BDK 3.0.78 wifi_event_sta_disconnected_t collision on BK72xx (#15942) 2026-04-23 08:22:17 -05:00
J. Nick Koston 3ca86fc3fc [core] Raise WDT_FEED_INTERVAL_MS to 2000ms on BK72xx (#15943) 2026-04-23 08:21:46 -05:00
J. Nick Koston b38db617a2 [core] Clean up stale includes and inline yield_with_select_ in application (#15945) 2026-04-23 08:21:05 -05:00
J. Nick Koston 13fe881f70 [scheduler][core] Lock-free fast-path on ESPHOME_THREAD_MULTI_NO_ATOMICS via __atomic builtins (#15947) 2026-04-23 08:20:31 -05:00
J. Nick Koston 50c181671c [ci] Better explain too-big bot review message (#15939) 2026-04-23 06:47:16 -05:00
PolarGoose 43a371caab [dsmr] Small refactoring: Move Aes128GcmDecryptorImpl type inside esphome::dsmr namespace. (#15940) 2026-04-23 04:08:49 -05:00
dependabot[bot] 64290d32a1 Bump aioesphomeapi from 44.20.0 to 44.21.0 (#15941)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 03:32:12 -05:00
J. Nick Koston 9685d4eb0b [core] feed_wdt wraps feed_wdt_with_time (#15932) 2026-04-23 01:15:44 -05:00
Keith Burzinski 4c2efd4165 [radio_frequency] Add experimental radio_frequency entity type (base component + API) (#15556) 2026-04-23 01:15:25 -05:00
J. Nick Koston 6f00ea1457 [core] Move host socket-select wake mechanism into wake.h/wake.cpp (#15931) 2026-04-23 15:53:10 +12:00
Jonathan Swoboda a881121110 [ota] Make set_auth_password() lambda-callable via empty-password opt-in (#15928) 2026-04-22 23:06:31 -04:00
dependabot[bot] f8167c9a70 Bump aioesphomeapi from 44.19.0 to 44.20.0 (#15936)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 02:40:19 +00:00
Jesse Hills e1d629f0d2 [time] Handle Windows EINVAL when validating POSIX TZ strings (#15934) 2026-04-23 14:35:13 +12:00
Clyde Stubbs 224cc7b419 [lvgl] Triggers on tabview tabs fix (#15935) 2026-04-23 14:35:00 +12:00
Jesse Hills 4d4347d33a Merge branch 'release' into dev 2026-04-23 14:10:54 +12:00
Jesse Hills 6ca5b31fab Merge pull request #15933 from esphome/bump-2026.4.2
2026.4.2
2026-04-23 14:10:10 +12:00
dependabot[bot] 17f9269841 Update wheel requirement from <0.47,>=0.43 to >=0.43,<0.48 (#15926)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 19:12:15 -05:00
dependabot[bot] 6253947311 Bump click from 8.3.2 to 8.3.3 (#15927)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 19:12:02 -05:00
Jesse Hills 00b71208a6 Bump version to 2026.4.2 2026-04-23 11:18:39 +12:00
Keith Burzinski 76eb8f697f [usb_uart] Derive TX output chunk count from buffer_size config (#15909) 2026-04-23 11:18:39 +12:00
Jonathan Swoboda 2a3bd8bc85 [io_expanders] Self-heal interrupt-driven expanders when INT stays asserted across the read (#15923) 2026-04-23 11:18:39 +12:00
Keith Burzinski 629da4d878 [esp32] Add Secure Boot V1 ECDSA signing scheme for pre-rev-3.0 ESP32 (#15882) 2026-04-23 11:18:39 +12:00
Jonathan Swoboda 5c2ceb63e0 [ld2412] Fix null deref in set_basic_config when entities unconfigured (#15893) 2026-04-23 11:18:39 +12:00
Jonathan Swoboda 92cb6dd7fd [core] Fix Pvariable placement new losing subclass identity (#15881) 2026-04-23 11:18:39 +12:00
Jonathan Swoboda 06e5931ad7 [image] Fix rodata bloat for multi-frame RGB565+alpha animations (#15873) 2026-04-23 11:18:39 +12:00
Clyde Stubbs dc5b06285d [lvgl] Fix update of textarea attached to keyboard (#15866) 2026-04-23 11:18:38 +12:00
Clyde Stubbs 3d0a2421a6 [lvgl] Fix overloads for setting images on styles (#15864) 2026-04-23 11:18:38 +12:00
Clyde Stubbs 22f6791dea [lvgl] Fix format of hello world page (#15868) 2026-04-23 11:18:38 +12:00
Keith Burzinski 70b1d9a087 [api_protobuf] Support compound ifdef conditions in proto generator (#15930) 2026-04-22 17:57:15 -05:00
Keith Burzinski 36720c8495 [usb_uart] Derive TX output chunk count from buffer_size config (#15909) 2026-04-23 09:16:14 +12:00
Jonathan Swoboda c48ab2ef92 [io_expanders] Self-heal interrupt-driven expanders when INT stays asserted across the read (#15923) 2026-04-23 09:05:15 +12:00
Keith Burzinski 162ee2ecaf [i2s_audio] Split speaker into base class and standard subclass (#15404) 2026-04-22 14:40:18 -05:00
Asela Fernando a73bac0b5f [ac_dimmer] Zero-crossing interrupt type (#15862)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-04-22 14:57:53 -04:00
Rishab Mehta 4e84611ae7 [internal_temperature] Fix internal Temperature discrepancy on BK7231T (#15771)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-04-22 17:50:59 +00:00
PolarGoose ea2e36e55a [dsmr] Improve performance. Add missing sensors. Remove Crypto-no-arduino. (#15875) 2026-04-22 13:49:14 -04:00
Michael Turner fcbc4d64fe [one_wire] Reset bus before SKIP ROM command (#14669)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-04-22 13:20:02 -04:00
Timothy dcd103cec0 [cse7761] bidirectional active power (#15162)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-04-22 13:11:18 -04:00
Ludovic BOUÉ 5e715692d6 [network] Reorder IPv6 configuration for network components (#11694)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-04-22 17:01:20 +00:00
rwrozelle d5263cd46e [esp32] add watchdog_timeout configuration variable (#15908)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-04-22 13:01:23 +00:00
J. Nick Koston c399cd2fa2 [core] RAII guard for component loop phase (#15897) 2026-04-22 14:04:29 +02:00
J. Nick Koston f6bf6dc8e5 [core] Dedupe yield() fast path in wakeable_delay and always-inline (#15915) 2026-04-22 13:52:40 +02:00
J. Nick Koston e35b435f02 [libretiny] Inline xTaskGetTickCount() for millis() fast path (#15918) 2026-04-22 13:52:27 +02:00
J. Nick Koston 886cd7ab72 [core] Collapse adjacent USE_HOST ifdef blocks in Application (#15914) 2026-04-22 07:47:01 -04:00
dependabot[bot] 73714dc489 Bump aioesphomeapi from 44.18.0 to 44.19.0 (#15920)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 12:26:25 +02:00
dependabot[bot] 5218bbd791 Update argcomplete requirement from >=2.0.0 to >=3.6.3 (#15921)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 12:19:47 +02:00
J. Nick Koston 23ad30cb4c [esp32] Use xTaskGetTickCount() for millis() when tick rate is 1kHz (#15661) 2026-04-22 06:44:53 +02:00
J. Nick Koston a3b49d1ed9 [core] Use MAC_ADDRESS_BUFFER_SIZE constant instead of duplicated literal (#15913) 2026-04-22 04:43:33 +00:00
J. Nick Koston 9c80cbf19c [light] Reduce validate_ clamp code size and speed up unit-range clamps (#15728) 2026-04-22 16:34:26 +12:00
J. Nick Koston 699cf9690a [core] Optimize value_accuracy_to_buf to avoid snprintf (#15596) 2026-04-22 16:31:34 +12:00
J. Nick Koston 67576d4879 [rp2040] Tune oversized lwIP defaults for ESPHome (#14843) 2026-04-22 06:29:13 +02:00
J. Nick Koston edcf96d057 [wifi] Use queue abstraction for LibreTiny WiFi events (#15343) 2026-04-22 06:24:09 +02:00
dependabot[bot] bb81c91d0c Update tzdata requirement from >=2021.1 to >=2026.1 (#15911)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 03:08:58 +00:00
dependabot[bot] 78f1467be4 Bump aioesphomeapi from 44.17.0 to 44.18.0 (#15912)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 03:08:42 +00:00
dependabot[bot] da44d43981 Update pyparsing requirement from >=3.0 to >=3.3.2 (#15910)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 05:07:48 +02:00
Javier Peletier 9cebce1b6e [substitutions] Improve error messages with include stack trace (#15874)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-04-22 03:19:01 +02:00
Jesse Hills b20fedd806 [bl0906] Disable loop when idle and introduce BL0906Stage enum (#15884)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-04-22 03:18:21 +02:00
Keith Burzinski ee91ad8f06 [esp32] Add Secure Boot V1 ECDSA signing scheme for pre-rev-3.0 ESP32 (#15882) 2026-04-22 11:25:05 +12:00
dependabot[bot] 7560112144 Bump aioesphomeapi from 44.16.1 to 44.17.0 (#15906)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-21 22:08:41 +02:00
Geoff 43c6b839cd [sensor] Filter to round to significant digits (#11157)
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick+github@koston.org>
2026-04-21 14:00:03 +00:00
Edward Firmo 0c9d443a5c [esp32_ble] Add use_psram option to offload BT memory allocation to SPIRAM (#15644) 2026-04-21 15:18:46 +02:00
J. Nick Koston 14defb69b6 [template] Use placement new for template text restore saver (#15883) 2026-04-21 13:04:13 +00:00
Egor Vorontsov 3a6f3dfb94 [lock] Implemented open states support (#15120)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-04-21 13:03:07 +00:00
J. Nick Koston 7bd36e0c8d [debug] Migrate trivial buf_append_printf sites to buf_append_str (#15885) 2026-04-21 12:51:45 +00:00
J. Nick Koston e4f413adad [core] decouple main loop cadence from scheduler wake timing (#15792) 2026-04-21 14:48:21 +02:00
J. Nick Koston 1504ac3d19 [core] Replace strnlen in buf_append_str for Zephyr compatibility (#15892) 2026-04-21 12:32:29 +00:00
J. Nick Koston 947c714f89 [core] Inline api_is_connected() for hot-path callers (#15888) 2026-04-21 13:48:33 +02:00
J. Nick Koston e4d5886383 [zwave_proxy] Inline loop() hot-path fast-paths for response_handler_ and process_uart_ (#15887) 2026-04-21 13:48:16 +02:00
J. Nick Koston f504099485 [api] Replace clients_ std::vector with compile-time std::array + uint8_t count (#15889) 2026-04-21 13:47:37 +02:00
Jonathan Swoboda cb56f9a9bf [qmc5883l] Use GPIO interrupt when DRDY pin is configured (#15876) 2026-04-21 07:47:16 -04:00
Jonathan Swoboda 26a656af29 [ld2412] Fix null deref in set_basic_config when entities unconfigured (#15893) 2026-04-21 07:47:07 -04:00
dependabot[bot] a8bd035b62 Bump CodSpeedHQ/action from 4.13.1 to 4.14.0 (#15880)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-21 04:44:25 +02:00
J. Nick Koston f05fa45747 [sensor] Specialize throttle_with_priority NaN-only case (#15823) 2026-04-21 04:41:13 +02:00
J. Nick Koston 78875abee4 [core] Make buf_append_str PROGMEM-aware on ESP8266 (#15738)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-21 04:40:40 +02:00
J. Nick Koston 37608c2656 [ltr390] Reduce data polling delay and timeout (#15507) 2026-04-21 04:40:24 +02:00
J. Nick Koston a5b1f3eece [core] Remove pre-sleep socket scan from fast select path (#15639) 2026-04-21 04:40:03 +02:00
J. Nick Koston 0d3a3552da [core] Move heap-allocating helpers to alloc_helpers.h/cpp (#15623) 2026-04-21 04:39:49 +02:00
J. Nick Koston 0a0176d600 [core] raise WDT_FEED_INTERVAL_MS from 3 ms to 300 ms (#15846) 2026-04-21 04:38:12 +02:00
J. Nick Koston 4cb7ea2584 [light] Force-inline LightCall::set_flag_/clear_flag_ (#15729) 2026-04-21 04:37:56 +02:00
Jonathan Swoboda a43ee15b56 [core] Fix Pvariable placement new losing subclass identity (#15881) 2026-04-20 22:33:48 -04:00
Jonathan Swoboda 213ab312d2 [image] Fix rodata bloat for multi-frame RGB565+alpha animations (#15873) 2026-04-20 16:27:34 -04:00
Kevin Ahrendt 94f30d5950 [micro_wake_word] Use ESPMicroSpeechFeatures from Espressif registry (v1.2.3) (#15879) 2026-04-20 16:26:47 -04:00
Elvin Luff 6af341bb5b [epaper_spi] Support SSD1683 and GDEY042T81 4.2 inch display (#13910) 2026-04-20 09:34:31 -04:00
Clyde Stubbs 82656cb0cf [mipi_dsi] Add Seeed reTerminal d1001 display (#15867) 2026-04-20 09:28:52 -04:00
Rui Marinho b72f5447c3 [modbus] Simplify payload size validation in modbus_helpers (#15838) 2026-04-20 09:24:07 -04:00
Clyde Stubbs 73b8e8ac09 [lvgl] Fix update of textarea attached to keyboard (#15866) 2026-04-20 09:15:51 -04:00
Clyde Stubbs 9459f0426d [lvgl] Fix overloads for setting images on styles (#15864) 2026-04-20 09:14:15 -04:00
Clyde Stubbs 0dae41aa22 [lvgl] Fix format of hello world page (#15868) 2026-04-20 09:13:42 -04:00
Thomas Rupprecht 7321e6e52f [rtttl] allow any control parameters order and default value fallback (#14438)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-04-20 09:10:05 -04:00
guillempages f0c21520aa [mipi_rgb] Add definitions for sunton displays (#15858) 2026-04-20 21:56:56 +10:00
Jesse Hills b0c133201f Merge branch 'release' into dev 2026-04-20 13:53:30 +12:00
Jesse Hills 572fb83015 Merge pull request #15859 from esphome/bump-2026.4.1
2026.4.1
2026-04-20 13:52:45 +12:00
Clyde Stubbs 0d3db2b670 [lvgl] Fix angles for arc (#15860) 2026-04-20 12:08:35 +12:00
Clyde Stubbs e5f6a734ba [lvgl] Fix angles for arc (#15860) 2026-04-20 12:08:07 +12:00
J. Nick Koston bab9cd3e7a [runtime_stats] Track main loop active time and report overhead (#15743) 2026-04-20 11:20:39 +12:00
Jesse Hills 36812591eb Bump version to 2026.4.1 2026-04-20 10:20:56 +12:00
Javier Peletier 1862c6115f [packages] Improve error messages with include stack and fix missing path propagation (#15844)
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>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-04-20 10:20:56 +12:00
J. Nick Koston ef780886c3 [substitutions] Fix substitutions: !include file.yaml regression (#15850) 2026-04-20 10:20:56 +12:00
J. Nick Koston 602305b20d [core] Default PollingComponent() to 1ms when codegen is bypassed (#15831) 2026-04-20 10:20:56 +12:00
dependabot[bot] 78701debec Bump aioesphomeapi from 44.16.0 to 44.16.1 (#15836)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 10:20:56 +12:00
J. Nick Koston 08ac61ae94 [core] Feed WDT unconditionally in main loop to fix empty-config panic (#15830) 2026-04-20 10:20:16 +12:00
Clyde Stubbs 6d5340f253 [lvgl] Fix crash with snow on rotated display (#15822) 2026-04-20 10:18:05 +12:00
Clyde Stubbs e2dfef5ddc [runtime_image] Fix RGB order (#15813) 2026-04-20 10:18:05 +12:00
Jonathan Swoboda 1d88027618 [esp32] Downgrade unneeded ignore_pin_validation_error to a warning (#15811) 2026-04-20 10:18:05 +12:00
J. Nick Koston 9841deec31 [core] Fix DelayAction compile error with non-const reference args (#15814) 2026-04-20 10:18:05 +12:00
Jonathan Swoboda ed5852c2d6 [ethernet] Fix SPI3_HOST default breaking compile on variants without SPI3 (#15809)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-04-20 10:18:05 +12:00
J. Nick Koston b26601a3dc [core] coerce set_interval(0) / update_interval: 0ms to 1ms (#15799) 2026-04-20 10:18:05 +12:00
Clyde Stubbs f5806818cd [image] Fix byte order handling (#15800) 2026-04-20 10:18:05 +12:00
Clyde Stubbs c3e739eba9 [mipi_spi] Drawing fixes for native display (#15802) 2026-04-20 10:18:05 +12:00
Jonathan Swoboda b167b64f06 [lvgl] Guard lv_image_set_src wrapper with LV_USE_IMAGE (#15789) 2026-04-20 10:18:05 +12:00
Jonathan Swoboda 722cfae04c [esp32] Accept unquoted minimum_chip_revision values (#15785) 2026-04-20 10:18:05 +12:00
J. Nick Koston 9cb2b562b9 [ili9xxx] Guard against null buffer in display_() when allocation fails (#15786) 2026-04-20 10:18:05 +12:00
J. Nick Koston 81fb6712fe [bundle] Force-resolve nested IncludeFile during file discovery (#15762) 2026-04-20 10:18:05 +12:00
Jonathan Swoboda 227dfa3730 [qmc5883l] Move per-update log line from DEBUG to VERBOSE (#15781) 2026-04-20 10:18:05 +12:00
J. Nick Koston aa80bdbbc6 [time] Fix RTC is_valid() rejecting valid times after day_of_year cleanup (#15763) 2026-04-20 10:18:05 +12:00
J. Nick Koston 914ed10bcc [core] Diagnose missing cg.templatable in codegen for TEMPLATABLE_VALUE fields (#15758) 2026-04-20 10:18:05 +12:00
Boris Krivonog 92c99a7d41 [mitsubishi_cn105] use HEAT_COOL mode to enable temperature slider (#15748) 2026-04-20 10:18:05 +12:00
Clyde Stubbs af1aaba547 [lvgl] Clean the build if lv_conf.h changes (#15777) 2026-04-20 10:18:05 +12:00
dependabot[bot] 5a2b7546f6 Bump aioesphomeapi from 44.15.0 to 44.16.0 (#15757)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 10:18:05 +12:00
Jonathan Swoboda 4047d5af5f [sx126x][sx127x] Fix frequency precision loss from float32 codegen (#15753) 2026-04-20 10:18:05 +12:00
Jonathan Swoboda 6857e1ceb4 [st7789v] Fix swapped offset_width/offset_height in model presets (#15755) 2026-04-20 10:18:04 +12:00
J. Nick Koston 4479212008 [core] Inline feed_wdt hot path with out-of-line slow path (#15656)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-20 10:18:04 +12:00
J. Nick Koston cb90ac45c3 [core] Fix app_state_ status bits clobbered for non-looping components (#15658) 2026-04-20 10:18:04 +12:00
J. Nick Koston 1847666e75 [core] Default PollingComponent() to not run when codegen is bypassed (#15832) 2026-04-19 17:05:27 -05:00
Javier Peletier aad1318b4a [packages] Improve error messages with include stack and fix missing path propagation (#15844)
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>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-04-19 21:09:14 +00:00
J. Nick Koston 7a23a339e9 [substitutions] Fix substitutions: !include file.yaml regression (#15850) 2026-04-20 09:00:31 +12:00
J. Nick Koston 38d894dfe7 [ld2412] Fix flaky integration test race condition (#15833) 2026-04-18 08:17:22 -05:00
J. Nick Koston b293be23b0 [ci] Honor CONFLICTS_WITH when grouping component tests (#15834)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-18 08:11:38 -05:00
J. Nick Koston ccb53e34ca [core] Default PollingComponent() to 1ms when codegen is bypassed (#15831) 2026-04-18 09:04:51 -04:00
dependabot[bot] ec9d59f3dc Bump aioesphomeapi from 44.16.0 to 44.16.1 (#15836)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-18 12:32:36 +00:00
J. Nick Koston df72aa26c0 [core] Feed WDT unconditionally in main loop to fix empty-config panic (#15830) 2026-04-18 11:58:54 +00:00
Clyde Stubbs d3691c7ca5 [lvgl] Fix crash with snow on rotated display (#15822) 2026-04-18 09:17:28 +10:00
J. Nick Koston 562ce541a0 [bme680_bsec] [bme68x_bsec2] Mark the two BSEC variants as mutually exclusive (#15826) 2026-04-17 17:54:24 -05:00
J. Nick Koston 6ebe1e92eb [ci] Scope local pylint pre-commit hook to esphome/ (#15818) 2026-04-17 17:54:12 -05:00
Clyde Stubbs 1bf455cfbb [runtime_image] Fix RGB order (#15813) 2026-04-18 06:42:45 +10:00
Clyde Stubbs 290e213cd0 [mipi_spi] Add Sunton ESP32-2424S012 (#15812) 2026-04-18 06:41:33 +10:00
Jonathan Swoboda b1b0005574 [esp32] Downgrade unneeded ignore_pin_validation_error to a warning (#15811) 2026-04-17 16:14:54 -04:00
308 changed files with 12054 additions and 2741 deletions
+1 -1
View File
@@ -1 +1 @@
075ed2142432dc59883bb52db8ac11270f952851d6400deae080f5468c7cb592
1b1ce6324c50c4595703c7df0a8a479b4fe84b71ff1a8793cce1a16f17a33324
+25 -5
View File
@@ -41,16 +41,36 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo,
let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`;
message +=
`Hey @${prAuthor}, thanks for the contribution! Just a heads up, ` +
`this PR is on the large side `;
if (tooManyLabels && tooManyChanges) {
message += `This PR is too large with ${nonTestChanges} line changes (excluding tests) and affects ${originalLabelCount} different components/areas.`;
message +=
`(${nonTestChanges} line changes excluding tests, across ` +
`${originalLabelCount} different components/areas)`;
} else if (tooManyLabels) {
message += `This PR affects ${originalLabelCount} different components/areas.`;
message +=
`(it touches ${originalLabelCount} different components/areas)`;
} else {
message += `This PR is too large with ${nonTestChanges} line changes (excluding tests).`;
message += `(${nonTestChanges} line changes excluding tests)`;
}
message += ` Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.\n\n`;
message += `For guidance on breaking down large PRs, see: https://developers.esphome.io/contributing/submitting-your-work/#how-to-approach-large-submissions`;
message += `, which makes it harder for maintainers to review.\n\n`;
message +=
`Smaller, focused PRs tend to be reviewed much faster since they ` +
`fit into the short gaps between other maintainer work; large ones ` +
`often have to wait for a rare long uninterrupted block of time. ` +
`If you can break this up into smaller pieces that can be reviewed ` +
`independently, it will almost certainly land faster overall.\n\n`;
message +=
`Before putting more time in, it's also worth popping into ` +
`\`#devs\` on [Discord](https://esphome.io/chat) so we can help ` +
`you scope things and flag anything already in flight.\n\n`;
message +=
`For more details (including how to split the work up), see: ` +
`https://developers.esphome.io/contributing/submitting-your-work/` +
`#how-to-approach-large-submissions`;
messages.push(message);
}
+1 -1
View File
@@ -339,7 +339,7 @@ jobs:
echo "binary=$BINARY" >> $GITHUB_OUTPUT
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@db35df748deb45fdef0960669f57d627c1956c30 # v4
uses: CodSpeedHQ/action@658a901452bb54c799643e060733b7afe9121b8d # v4.14.0
with:
run: ${{ steps.build.outputs.binary }}
mode: simulation
@@ -0,0 +1,72 @@
name: Close PR From Fork Default Branch
on:
# pull_request_target is required so we have permission to comment and close PRs from forks.
pull_request_target:
types: [opened, reopened]
permissions:
pull-requests: write
issues: write
jobs:
close:
name: Close PR opened from fork's default branch
runs-on: ubuntu-latest
if: >-
github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
&& github.event.pull_request.head.ref == github.event.repository.default_branch
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.pull_request.number;
const author = context.payload.pull_request.user.login;
const defaultBranch = context.payload.repository.default_branch;
const headRepo = context.payload.pull_request.head.repo.full_name;
const body = [
`Hi @${author}, thanks for opening a pull request! :tada:`,
``,
`It looks like this PR was opened from the \`${defaultBranch}\` branch of your fork (\`${headRepo}\`), which is the same name as this repository's default branch. Working directly on \`${defaultBranch}\` in your fork causes a few problems:`,
``,
`- Your fork's \`${defaultBranch}\` branch will permanently diverge from \`esphome/esphome:${defaultBranch}\`, making it hard to keep your fork up to date.`,
`- Any additional commits you push to \`${defaultBranch}\` will be added to this PR, so you can't easily work on multiple changes at once.`,
`- Pushing maintainer fixes to your branch is awkward, since it means committing directly to your fork's default branch.`,
`- It makes local collaboration painful — \`${defaultBranch}\` in a checkout becomes ambiguous between upstream and your fork, and maintainers end up with naming collisions when fetching your branch.`,
``,
`Please re-open this as a new PR from a dedicated feature branch. The usual flow looks like:`,
``,
`\`\`\`bash`,
`# Make sure your fork's ${defaultBranch} is up to date with upstream`,
`git remote add upstream https://github.com/${owner}/${repo}.git # if you haven't already`,
`git fetch upstream`,
`git checkout ${defaultBranch}`,
`git reset --hard upstream/${defaultBranch}`,
`git push --force-with-lease origin ${defaultBranch}`,
``,
`# Create a new branch for your change and cherry-pick / re-apply your commits there`,
`git checkout -b my-feature-branch upstream/${defaultBranch}`,
`# ...re-apply your changes, then:`,
`git push origin my-feature-branch`,
`\`\`\``,
``,
`Then open a new pull request from \`my-feature-branch\` into \`${owner}/${repo}:${defaultBranch}\`.`,
``,
`Closing this PR for now — sorry for the friction, and thanks again for contributing! :heart:`,
].join('\n');
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body,
});
await github.rest.pulls.update({
owner,
repo,
pull_number: prNumber,
state: 'closed',
});
+2 -1
View File
@@ -11,7 +11,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.11
rev: v0.15.12
hooks:
# Run the linter.
- id: ruff
@@ -58,6 +58,7 @@ repos:
entry: python3 script/run-in-env.py pylint
language: system
types: [python]
files: ^esphome/.+\.py$
- id: clang-tidy-hash
name: Update clang-tidy hash
entry: python script/clang_tidy_hash.py --update-if-changed
+8 -1
View File
@@ -56,6 +56,7 @@ esphome/components/audio_adc/* @kbx81
esphome/components/audio_dac/* @kbx81
esphome/components/audio_file/* @kahrendt
esphome/components/audio_file/media_source/* @kahrendt
esphome/components/audio_http/* @kahrendt
esphome/components/axs15231/* @clydebarrow
esphome/components/b_parasite/* @rbaron
esphome/components/ballu/* @bazuchan
@@ -403,6 +404,7 @@ esphome/components/qmp6988/* @andrewpc
esphome/components/qr_code/* @wjtje
esphome/components/qspi_dbi/* @clydebarrow
esphome/components/qwiic_pir/* @kahrendt
esphome/components/radio_frequency/* @kbx81
esphome/components/radon_eye_ble/* @jeffeb3
esphome/components/radon_eye_rd200/* @jeffeb3
esphome/components/rc522/* @glmnet
@@ -438,6 +440,11 @@ esphome/components/sen0321/* @notjj
esphome/components/sen21231/* @shreyaskarnik
esphome/components/sen5x/* @martgras
esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct
esphome/components/sendspin/* @kahrendt
esphome/components/sendspin/media_player/* @kahrendt
esphome/components/sendspin/media_source/* @kahrendt
esphome/components/sendspin/sensor/* @kahrendt
esphome/components/sendspin/text_sensor/* @kahrendt
esphome/components/sensirion_common/* @martgras
esphome/components/sensor/* @esphome/core
esphome/components/serial_proxy/* @kbx81
@@ -599,6 +606,6 @@ esphome/components/xxtea/* @clydebarrow
esphome/components/zephyr/* @tomaszduda23
esphome/components/zephyr_mcumgr/ota/* @tomaszduda23
esphome/components/zhlt01/* @cfeenstra1024
esphome/components/zigbee/* @tomaszduda23
esphome/components/zigbee/* @luar123 @tomaszduda23
esphome/components/zio_ultrasonic/* @kahrendt
esphome/components/zwave_proxy/* @kbx81
+1
View File
@@ -4,4 +4,5 @@ include requirements.txt
recursive-include esphome *.yaml
recursive-include esphome *.cpp *.h *.tcc *.c
recursive-include esphome *.py.script
recursive-include esphome *.jinja
recursive-include esphome LICENSE.txt
+108 -12
View File
@@ -39,6 +39,7 @@ from esphome.const import (
CONF_MDNS,
CONF_MQTT,
CONF_NAME,
CONF_NAME_ADD_MAC_SUFFIX,
CONF_OTA,
CONF_PASSWORD,
CONF_PLATFORM,
@@ -71,6 +72,7 @@ from esphome.util import (
run_external_process,
safe_print,
)
from esphome.zeroconf import discover_mdns_devices
_LOGGER = logging.getLogger(__name__)
@@ -204,6 +206,64 @@ def _resolve_with_cache(address: str, purpose: Purpose) -> list[str]:
return [address]
def _populate_mdns_cache(hosts_to_addresses: dict[str, list[str]]) -> None:
"""Store discovered ``host -> [ips]`` entries in ``CORE.address_cache``.
Ensures ``CORE.address_cache`` exists, then records each mDNS hostname so
the downstream resolution path (``resolve_ip_address``) can skip opening a
second Zeroconf client.
"""
from esphome.address_cache import AddressCache
if CORE.address_cache is None:
CORE.address_cache = AddressCache()
for host, addresses in hosts_to_addresses.items():
if addresses:
_LOGGER.debug("Caching mDNS result %s -> %s", host, addresses)
CORE.address_cache.add_mdns_addresses(host, addresses)
def _discover_mac_suffix_devices() -> list[str] | None:
"""Discover ``<name>-<mac>.local`` devices and cache their IPs.
Returns:
- ``None`` when discovery isn't applicable (``name_add_mac_suffix`` off,
mDNS disabled, or ``CORE.address`` is already an IP). Callers should
then fall back to whatever default OTA address they normally use.
- ``[]`` when discovery ran but found nothing. Callers should NOT fall
back to the base name: with ``name_add_mac_suffix`` enabled, the base
name by definition doesn't exist on the network.
- A non-empty sorted list of ``.local`` hostnames on success.
Populates ``CORE.address_cache`` so downstream resolution (``espota2`` or
``aioesphomeapi`` via :func:`_resolve_network_devices`) reuses the IPs we
already have without opening a second Zeroconf client.
"""
if not (has_name_add_mac_suffix() and has_mdns() and has_non_ip_address()):
return None
_LOGGER.info("Discovering devices...")
if not (discovered := discover_mdns_devices(CORE.name)):
_LOGGER.warning(
"No devices matching '%s-<mac>.local' were discovered.", CORE.name
)
return []
_populate_mdns_cache(discovered)
return list(discovered)
def _ota_hostnames_for_default(purpose: Purpose) -> list[str]:
"""Return OTA hostname(s) for the ``--device OTA`` / default-resolve path.
When ``name_add_mac_suffix`` is enabled, returns discovered
``<name>-<mac>.local`` hostnames (possibly empty — in which case the
caller should not fall back to the base name). Otherwise falls back to
the cache-resolved ``CORE.address``.
"""
if (discovered := _discover_mac_suffix_devices()) is not None:
return discovered
return _resolve_with_cache(CORE.address, purpose)
def choose_upload_log_host(
default: list[str] | str | None,
check_default: str | None,
@@ -242,14 +302,14 @@ def choose_upload_log_host(
resolved.append("MQTT")
if has_api() and has_non_ip_address() and has_resolvable_address():
resolved.extend(_resolve_with_cache(CORE.address, purpose))
resolved.extend(_ota_hostnames_for_default(purpose))
elif purpose == Purpose.UPLOADING:
if has_ota() and has_mqtt_ip_lookup():
resolved.append("MQTTIP")
if has_ota() and has_non_ip_address() and has_resolvable_address():
resolved.extend(_resolve_with_cache(CORE.address, purpose))
resolved.extend(_ota_hostnames_for_default(purpose))
else:
resolved.append(device)
if not resolved:
@@ -281,22 +341,29 @@ def choose_upload_log_host(
elif bootsel.permission_error:
bootsel_permission_error = True
def add_ota_options() -> None:
"""Add OTA options, using mDNS discovery if name_add_mac_suffix is enabled."""
if (discovered := _discover_mac_suffix_devices()) is not None:
# Discovery was applicable. Use whatever we found — on empty,
# intentionally skip the base-name fallback since with
# name_add_mac_suffix on, the base name doesn't exist on the net.
for host in discovered:
options.append((f"Over The Air ({host})", host))
elif has_resolvable_address():
options.append((f"Over The Air ({CORE.address})", CORE.address))
if has_mqtt_ip_lookup():
options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
if purpose == Purpose.LOGGING:
if has_mqtt_logging():
mqtt_config = CORE.config[CONF_MQTT]
options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT"))
if has_api():
if has_resolvable_address():
options.append((f"Over The Air ({CORE.address})", CORE.address))
if has_mqtt_ip_lookup():
options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
add_ota_options()
elif purpose == Purpose.UPLOADING and has_ota():
if has_resolvable_address():
options.append((f"Over The Air ({CORE.address})", CORE.address))
if has_mqtt_ip_lookup():
options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
add_ota_options()
# Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found
if (
@@ -407,7 +474,17 @@ def has_resolvable_address() -> bool:
return not CORE.address.endswith(".local")
def mqtt_get_ip(config: ConfigType, username: str, password: str, client_id: str):
def has_name_add_mac_suffix() -> bool:
"""Check if name_add_mac_suffix is enabled in the config."""
if CORE.config is None:
return False
esphome_config = CORE.config.get(CONF_ESPHOME, {})
return esphome_config.get(CONF_NAME_ADD_MAC_SUFFIX, False)
def mqtt_get_ip(
config: ConfigType, username: str, password: str, client_id: str
) -> list[str]:
from esphome import mqtt
return mqtt.get_esphome_device_ip(config, username, password, client_id)
@@ -420,6 +497,9 @@ def _resolve_network_devices(
This function filters the devices list to:
- Replace MQTT/MQTTIP magic strings with actual IP addresses via MQTT lookup
- Expand hostnames that are already in ``CORE.address_cache`` to their
cached IPs so downstream code (e.g. aioesphomeapi) doesn't open a second
Zeroconf client to resolve them
- Deduplicate addresses while preserving order
- Only resolve MQTT once even if multiple MQTT strings are present
- If MQTT resolution fails, log a warning and continue with other devices
@@ -444,13 +524,29 @@ def _resolve_network_devices(
mqtt_ips = mqtt_get_ip(
config, args.username, args.password, args.client_id
)
network_devices.extend(mqtt_ips)
# pylint can't infer mqtt_get_ip's return through its
# lazy ``from esphome import mqtt`` import, so it flags
# the genexpr below.
network_devices.extend(
addr
for addr in mqtt_ips # pylint: disable=not-an-iterable
if addr not in network_devices
)
except EsphomeError as err:
_LOGGER.warning(
"MQTT IP discovery failed (%s), will try other devices if available",
err,
)
mqtt_resolved = True
continue
# If the hostname is already in the address cache (e.g. populated by
# mDNS discovery), substitute the cached IPs so aioesphomeapi doesn't
# open its own Zeroconf to re-resolve it.
if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)):
network_devices.extend(
addr for addr in cached if addr not in network_devices
)
elif device not in network_devices:
# Regular network address or IP - add if not already present
network_devices.append(device)
+11
View File
@@ -101,6 +101,17 @@ class AddressCache:
"""Check if any cache entries exist."""
return bool(self.mdns_cache or self.dns_cache)
def add_mdns_addresses(self, hostname: str, addresses: list[str]) -> None:
"""Store resolved mDNS addresses for ``hostname`` in the cache.
Callers that discover ``.local`` hosts (e.g. via mDNS browse) can use
this to avoid a second resolution round-trip during the upload path.
No-op when ``addresses`` is empty.
"""
if not addresses:
return
self.mdns_cache[normalize_hostname(hostname)] = addresses
@classmethod
def from_cli_args(
cls, mdns_args: Iterable[str], dns_args: Iterable[str]
+56
View File
@@ -0,0 +1,56 @@
"""Helpers for running an async coroutine from sync code via a daemon thread.
``asyncio.run(coro())`` in the main thread blocks until the loop's cleanup
cycle finishes, which can add hundreds of milliseconds before the caller
receives the result. Running the loop in a daemon thread lets the caller
observe the result as soon as the coroutine completes while cleanup finishes
in the background.
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
import threading
from typing import Generic, TypeVar
_T = TypeVar("_T")
class AsyncThreadRunner(threading.Thread, Generic[_T]):
"""Run an async coroutine in a daemon thread and expose its result.
The runner catches all exceptions from the coroutine and stores them in
``exception`` so ``event`` is always set — this prevents callers waiting
on ``event`` from hanging forever when the coroutine crashes.
Typical usage::
runner = AsyncThreadRunner(lambda: my_coro(arg))
runner.start()
if not runner.event.wait(timeout=5.0):
... # timed out
if runner.exception is not None:
raise runner.exception
result = runner.result
"""
def __init__(self, coro_factory: Callable[[], Awaitable[_T]]) -> None:
super().__init__(daemon=True)
self._coro_factory = coro_factory
self.result: _T | None = None
self.exception: BaseException | None = None
self.event = threading.Event()
async def _runner(self) -> None:
try:
self.result = await self._coro_factory()
except Exception as exc: # pylint: disable=broad-except
# Capture all exceptions so ``event`` is always set — otherwise a
# crash would hang the waiter forever.
self.exception = exc
finally:
self.event.set()
def run(self) -> None:
asyncio.run(self._runner())
+15 -9
View File
@@ -190,7 +190,7 @@ void AcDimmer::setup() {
this->zero_cross_pin_->setup();
this->store_.zero_cross_pin = this->zero_cross_pin_->to_isr();
this->zero_cross_pin_->attach_interrupt(&AcDimmerDataStore::s_gpio_intr, &this->store_,
gpio::INTERRUPT_FALLING_EDGE);
this->zero_cross_interrupt_type_);
}
#ifdef USE_ESP8266
@@ -226,19 +226,25 @@ void AcDimmer::write_state(float state) {
void AcDimmer::dump_config() {
ESP_LOGCONFIG(TAG,
"AcDimmer:\n"
" Min Power: %.1f%%\n"
" Init with half cycle: %s",
" Min Power: %.1f%%\n"
" Init with half cycle: %s",
this->store_.min_power / 10.0f, YESNO(this->init_with_half_cycle_));
LOG_PIN(" Output Pin: ", this->gate_pin_);
LOG_PIN(" Zero-Cross Pin: ", this->zero_cross_pin_);
if (method_ == DIM_METHOD_LEADING_PULSE) {
ESP_LOGCONFIG(TAG, " Method: leading pulse");
} else if (method_ == DIM_METHOD_LEADING) {
ESP_LOGCONFIG(TAG, " Method: leading");
if (this->zero_cross_interrupt_type_ == gpio::INTERRUPT_RISING_EDGE) {
ESP_LOGCONFIG(TAG, " Interrupt Type: rising");
} else if (this->zero_cross_interrupt_type_ == gpio::INTERRUPT_FALLING_EDGE) {
ESP_LOGCONFIG(TAG, " Interrupt Type: falling");
} else {
ESP_LOGCONFIG(TAG, " Method: trailing");
ESP_LOGCONFIG(TAG, " Interrupt Type: any");
}
if (method_ == DIM_METHOD_LEADING_PULSE) {
ESP_LOGCONFIG(TAG, " Method: leading pulse");
} else if (method_ == DIM_METHOD_LEADING) {
ESP_LOGCONFIG(TAG, " Method: leading");
} else {
ESP_LOGCONFIG(TAG, " Method: trailing");
}
LOG_FLOAT_OUTPUT(this);
ESP_LOGV(TAG, " Estimated Frequency: %.3fHz", 1e6f / this->store_.cycle_time_us / 2);
}
+2
View File
@@ -48,6 +48,7 @@ class AcDimmer : public output::FloatOutput, public Component {
void dump_config() override;
void set_gate_pin(InternalGPIOPin *gate_pin) { gate_pin_ = gate_pin; }
void set_zero_cross_pin(InternalGPIOPin *zero_cross_pin) { zero_cross_pin_ = zero_cross_pin; }
void set_zero_cross_interrupt_type(gpio::InterruptType type) { zero_cross_interrupt_type_ = type; }
void set_init_with_half_cycle(bool init_with_half_cycle) { init_with_half_cycle_ = init_with_half_cycle; }
void set_method(DimMethod method) { method_ = method; }
@@ -56,6 +57,7 @@ class AcDimmer : public output::FloatOutput, public Component {
InternalGPIOPin *gate_pin_;
InternalGPIOPin *zero_cross_pin_;
gpio::InterruptType zero_cross_interrupt_type_;
AcDimmerDataStore store_;
bool init_with_half_cycle_;
DimMethod method_;
+14
View File
@@ -7,6 +7,8 @@ from esphome.core import CORE
CODEOWNERS = ["@glmnet"]
gpio_ns = cg.esphome_ns.namespace("gpio")
ac_dimmer_ns = cg.esphome_ns.namespace("ac_dimmer")
AcDimmer = ac_dimmer_ns.class_("AcDimmer", output.FloatOutput, cg.Component)
@@ -17,15 +19,26 @@ DIM_METHODS = {
"TRAILING": DimMethod.DIM_METHOD_TRAILING,
}
ZC_INTERRUPT_TYPES = {
"RISING": gpio_ns.INTERRUPT_RISING_EDGE,
"FALLING": gpio_ns.INTERRUPT_FALLING_EDGE,
"ANY": gpio_ns.INTERRUPT_ANY_EDGE,
}
CONF_GATE_PIN = "gate_pin"
CONF_ZERO_CROSS_PIN = "zero_cross_pin"
CONF_INIT_WITH_HALF_CYCLE = "init_with_half_cycle"
CONF_ZERO_CROSS_INTERRUPT_TYPE = "zero_cross_interrupt_type"
CONFIG_SCHEMA = cv.All(
output.FLOAT_OUTPUT_SCHEMA.extend(
{
cv.Required(CONF_ID): cv.declare_id(AcDimmer),
cv.Required(CONF_GATE_PIN): pins.internal_gpio_output_pin_schema,
cv.Required(CONF_ZERO_CROSS_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_ZERO_CROSS_INTERRUPT_TYPE, default="FALLING"): cv.enum(
ZC_INTERRUPT_TYPES, upper=True, space="_"
),
cv.Optional(CONF_INIT_WITH_HALF_CYCLE, default=True): cv.boolean,
cv.Optional(CONF_METHOD, default="leading pulse"): cv.enum(
DIM_METHODS, upper=True, space="_"
@@ -54,5 +67,6 @@ async def to_code(config):
cg.add(var.set_gate_pin(pin))
pin = await cg.gpio_pin_expression(config[CONF_ZERO_CROSS_PIN])
cg.add(var.set_zero_cross_pin(pin))
cg.add(var.set_zero_cross_interrupt_type(config[CONF_ZERO_CROSS_INTERRUPT_TYPE]))
cg.add(var.set_init_with_half_cycle(config[CONF_INIT_WITH_HALF_CYCLE]))
cg.add(var.set_method(config[CONF_METHOD]))
+4 -2
View File
@@ -2,6 +2,8 @@
#include <cstdio>
#include <cstring>
#include "esphome/core/alloc_helpers.h"
namespace esphome {
namespace anova {
@@ -105,14 +107,14 @@ void AnovaCodec::decode(const uint8_t *data, uint16_t length) {
}
case READ_TARGET_TEMPERATURE:
case SET_TARGET_TEMPERATURE: {
this->target_temp_ = parse_number<float>(str_until(buf, '\r')).value_or(0.0f);
this->target_temp_ = parse_number<float>(str_until(buf, '\r')).value_or(0.0f); // NOLINT
if (this->fahrenheit_)
this->target_temp_ = ftoc(this->target_temp_);
this->has_target_temp_ = true;
break;
}
case READ_CURRENT_TEMPERATURE: {
this->current_temp_ = parse_number<float>(str_until(buf, '\r')).value_or(0.0f);
this->current_temp_ = parse_number<float>(str_until(buf, '\r')).value_or(0.0f); // NOLINT
if (this->fahrenheit_)
this->current_temp_ = ftoc(this->current_temp_);
this->has_current_temp_ = true;
+5 -6
View File
@@ -291,12 +291,12 @@ CONFIG_SCHEMA = cv.All(
cv.SplitDefault(
CONF_MAX_CONNECTIONS,
esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes
esp32=8, # 520KB RAM available
esp32=5, # 520KB RAM available
rp2040=4, # 264KB RAM but LWIP constraints
bk72xx=8, # Moderate RAM
rtl87xx=8, # Moderate RAM
bk72xx=5, # Moderate RAM
rtl87xx=5, # Moderate RAM
host=8, # Abundant resources
ln882x=8, # Moderate RAM
ln882x=5, # Moderate RAM
): cv.int_range(min=1, max=20),
# Maximum queued send buffers per connection before dropping connection
# Each buffer uses ~8-12 bytes overhead plus actual message size
@@ -336,8 +336,7 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_batch_delay(config[CONF_BATCH_DELAY]))
if CONF_LISTEN_BACKLOG in config:
cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG]))
if CONF_MAX_CONNECTIONS in config:
cg.add(var.set_max_connections(config[CONF_MAX_CONNECTIONS]))
cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS])
cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE])
# Set USE_API_USER_DEFINED_ACTIONS if any services are enabled
+29 -6
View File
@@ -2544,27 +2544,50 @@ message ListEntitiesInfraredResponse {
message InfraredRFTransmitRawTimingsRequest {
option (id) = 136;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_IR_RF";
option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY";
uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"];
fixed32 key = 2 [(force) = true]; // Key identifying the transmitter instance
uint32 carrier_frequency = 3; // Carrier frequency in Hz
uint32 repeat_count = 4; // Number of times to transmit (1 = once, 2 = twice, etc.)
fixed32 key = 2 [(force) = true]; // Key identifying the transmitter instance
uint32 carrier_frequency = 3; // Carrier frequency in Hz
uint32 repeat_count = 4; // Number of times to transmit (1 = once, 2 = twice, etc.)
repeated sint32 timings = 5 [packed = true, (packed_buffer) = true]; // Raw timings in microseconds (zigzag-encoded): positive = mark (LED/TX on), negative = space (LED/TX off)
uint32 modulation = 6; // RadioFrequencyModulation enum value (0 = OOK; ignored for IR entities)
}
// Event message for received infrared/RF data
message InfraredRFReceiveEvent {
option (id) = 137;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_IR_RF";
option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY";
option (no_delay) = true;
uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"];
fixed32 key = 2 [(force) = true]; // Key identifying the receiver instance
fixed32 key = 2 [(force) = true]; // Key identifying the receiver instance
repeated sint32 timings = 3 [packed = true, (container_pointer_no_template) = "std::vector<int32_t>"]; // Raw timings in microseconds (zigzag-encoded): alternating mark/space periods
}
// ==================== RADIO FREQUENCY ====================
// Lists available radio frequency entity instances
message ListEntitiesRadioFrequencyResponse {
option (id) = 148;
option (base_class) = "InfoResponseProtoMessage";
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_RADIO_FREQUENCY";
string object_id = 1 [(max_data_length) = 120, (force) = true];
fixed32 key = 2 [(force) = true];
string name = 3 [(max_data_length) = 120, (force) = true];
string icon = 4 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
bool disabled_by_default = 5;
EntityCategory entity_category = 6;
uint32 device_id = 7 [(field_ifdef) = "USE_DEVICES"];
uint32 capabilities = 8; // Bitmask of RadioFrequencyCapabilityFlags: bit 0 = transmitter, bit 1 = receiver
uint32 frequency_min = 9; // Minimum tunable frequency in Hz; if min == max (non-zero): fixed frequency; 0 = unspecified
uint32 frequency_max = 10; // Maximum tunable frequency in Hz; 0 = unspecified
uint32 supported_modulations = 11; // Bitmask of supported RadioFrequencyModulation values (bit N = modulation N supported)
}
// ==================== SERIAL PROXY ====================
enum SerialProxyParity {
+56 -8
View File
@@ -49,6 +49,9 @@
#ifdef USE_INFRARED
#include "esphome/components/infrared/infrared.h"
#endif
#ifdef USE_RADIO_FREQUENCY
#include "esphome/components/radio_frequency/radio_frequency.h"
#endif
namespace esphome::api {
@@ -100,6 +103,12 @@ static const int CAMERA_STOP_STREAM = 5000;
entity_type *entity_var = App.get_##getter_name##_by_key(msg.key, msg.device_id); \
if ((entity_var) == nullptr) \
return;
// Helper macro for multi-entity dispatch: looks up an entity by key and device_id without early return or make_call().
// Use when multiple entity types must be checked in sequence (at most one will match).
#define ENTITY_COMMAND_LOOKUP(entity_type, entity_var, getter_name) \
entity_type *entity_var = App.get_##getter_name##_by_key(msg.key, msg.device_id)
#else // No device support, use simpler macros
// Helper macro for entity command handlers - gets entity by key, returns if not found, and creates call
// object
@@ -115,6 +124,12 @@ static const int CAMERA_STOP_STREAM = 5000;
entity_type *entity_var = App.get_##getter_name##_by_key(msg.key); \
if ((entity_var) == nullptr) \
return;
// Helper macro for multi-entity dispatch: looks up an entity by key without early return or make_call().
// Use when multiple entity types must be checked in sequence (at most one will match).
#define ENTITY_COMMAND_LOOKUP(entity_type, entity_var, getter_name) \
entity_type *entity_var = App.get_##getter_name##_by_key(msg.key)
#endif // USE_DEVICES
APIConnection::APIConnection(std::unique_ptr<socket::Socket> sock, APIServer *parent) : parent_(parent) {
@@ -1471,19 +1486,36 @@ uint16_t APIConnection::try_send_event_info(EntityBase *entity, APIConnection *c
}
#endif
#ifdef USE_IR_RF
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg) {
// TODO: When RF is implemented, add a field to the message to distinguish IR vs RF
// and dispatch to the appropriate entity type based on that field.
// Dispatch by key: infrared entities are checked first, then radio frequency entities.
// The key is unique across all entity instances on a device, so at most one lookup will succeed.
#ifdef USE_INFRARED
ENTITY_COMMAND_MAKE_CALL(infrared::Infrared, infrared, infrared)
call.set_carrier_frequency(msg.carrier_frequency);
call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_);
call.set_repeat_count(msg.repeat_count);
call.perform();
ENTITY_COMMAND_LOOKUP(infrared::Infrared, infrared, infrared);
if (infrared != nullptr) {
auto call = infrared->make_call();
call.set_carrier_frequency(msg.carrier_frequency);
call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_);
call.set_repeat_count(msg.repeat_count);
call.perform();
return;
}
#endif
#ifdef USE_RADIO_FREQUENCY
ENTITY_COMMAND_LOOKUP(radio_frequency::RadioFrequency, radio_frequency, radio_frequency);
if (radio_frequency != nullptr) {
auto call = radio_frequency->make_call();
call.set_frequency(msg.carrier_frequency);
call.set_modulation(static_cast<radio_frequency::RadioFrequencyModulation>(msg.modulation));
call.set_repeat_count(msg.repeat_count);
call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_);
call.perform();
}
#endif
}
#endif
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { this->send_message(msg); }
#endif
@@ -1580,6 +1612,19 @@ uint16_t APIConnection::try_send_infrared_info(EntityBase *entity, APIConnection
}
#endif
#ifdef USE_RADIO_FREQUENCY
uint16_t APIConnection::try_send_radio_frequency_info(EntityBase *entity, APIConnection *conn,
uint32_t remaining_size) {
auto *rf = static_cast<radio_frequency::RadioFrequency *>(entity);
ListEntitiesRadioFrequencyResponse msg;
msg.capabilities = rf->get_capability_flags();
msg.frequency_min = rf->get_traits().get_frequency_min_hz();
msg.frequency_max = rf->get_traits().get_frequency_max_hz();
msg.supported_modulations = rf->get_traits().get_supported_modulations();
return fill_and_encode_entity_info(rf, msg, conn, remaining_size);
}
#endif
#ifdef USE_UPDATE
bool APIConnection::send_update_state(update::UpdateEntity *update) {
return this->send_message_smart_(update, UpdateStateResponse::MESSAGE_TYPE, UpdateStateResponse::ESTIMATED_SIZE);
@@ -2341,6 +2386,9 @@ uint16_t APIConnection::dispatch_message_(const DeferredBatch::BatchItem &item,
#ifdef USE_INFRARED
CASE_INFO_ONLY(infrared, ListEntitiesInfraredResponse)
#endif
#ifdef USE_RADIO_FREQUENCY
CASE_INFO_ONLY(radio_frequency, ListEntitiesRadioFrequencyResponse)
#endif
#ifdef USE_EVENT
CASE_INFO_ONLY(event, ListEntitiesEventResponse)
#endif
+4 -1
View File
@@ -223,7 +223,7 @@ class APIConnection final : public APIServerConnectionBase {
void on_water_heater_command_request(const WaterHeaterCommandRequest &msg);
#endif
#ifdef USE_IR_RF
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg);
void send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg);
#endif
@@ -612,6 +612,9 @@ class APIConnection final : public APIServerConnectionBase {
#ifdef USE_INFRARED
static uint16_t try_send_infrared_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size);
#endif
#ifdef USE_RADIO_FREQUENCY
static uint16_t try_send_radio_frequency_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size);
#endif
#ifdef USE_EVENT
static uint16_t try_send_event_response(event::Event *event, StringRef event_type, APIConnection *conn,
uint32_t remaining_size);
+44 -1
View File
@@ -3861,7 +3861,7 @@ uint32_t ListEntitiesInfraredResponse::calculate_size() const {
return size;
}
#endif
#ifdef USE_IR_RF
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) {
#ifdef USE_DEVICES
@@ -3875,6 +3875,9 @@ bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, proto
case 4:
this->repeat_count = value;
break;
case 6:
this->modulation = value;
break;
default:
return false;
}
@@ -3928,6 +3931,46 @@ uint32_t InfraredRFReceiveEvent::calculate_size() const {
return size;
}
#endif
#ifdef USE_RADIO_FREQUENCY
uint8_t *ListEntitiesRadioFrequencyResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id);
ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key);
ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name);
#ifdef USE_ENTITY_ICON
ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->icon);
#endif
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast<uint32_t>(this->entity_category));
#ifdef USE_DEVICES
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id);
#endif
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->capabilities);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->frequency_min);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->frequency_max);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->supported_modulations);
return pos;
}
uint32_t ListEntitiesRadioFrequencyResponse::calculate_size() const {
uint32_t size = 0;
size += 2 + this->object_id.size();
size += 5;
size += 2 + this->name.size();
#ifdef USE_ENTITY_ICON
size += !this->icon.empty() ? 2 + this->icon.size() : 0;
#endif
size += ProtoSize::calc_bool(1, this->disabled_by_default);
size += this->entity_category ? 2 : 0;
#ifdef USE_DEVICES
size += ProtoSize::calc_uint32(1, this->device_id);
#endif
size += ProtoSize::calc_uint32(1, this->capabilities);
size += ProtoSize::calc_uint32(1, this->frequency_min);
size += ProtoSize::calc_uint32(1, this->frequency_max);
size += ProtoSize::calc_uint32(1, this->supported_modulations);
return size;
}
#endif
#ifdef USE_SERIAL_PROXY
bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) {
+24 -2
View File
@@ -3054,11 +3054,11 @@ class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage {
protected:
};
#endif
#ifdef USE_IR_RF
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 136;
static constexpr uint8_t ESTIMATED_SIZE = 220;
static constexpr uint8_t ESTIMATED_SIZE = 224;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("infrared_rf_transmit_raw_timings_request"); }
#endif
@@ -3071,6 +3071,7 @@ class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage {
const uint8_t *timings_data_{nullptr};
uint16_t timings_length_{0};
uint16_t timings_count_{0};
uint32_t modulation{0};
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
@@ -3101,6 +3102,27 @@ class InfraredRFReceiveEvent final : public ProtoMessage {
protected:
};
#endif
#ifdef USE_RADIO_FREQUENCY
class ListEntitiesRadioFrequencyResponse final : public InfoResponseProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 148;
static constexpr uint8_t ESTIMATED_SIZE = 56;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_radio_frequency_response"); }
#endif
uint32_t capabilities{0};
uint32_t frequency_min{0};
uint32_t frequency_max{0};
uint32_t supported_modulations{0};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
#endif
#ifdef USE_SERIAL_PROXY
class SerialProxyConfigureRequest final : public ProtoDecodableMessage {
public:
+23 -1
View File
@@ -2576,7 +2576,7 @@ const char *ListEntitiesInfraredResponse::dump_to(DumpBuffer &out) const {
return out.c_str();
}
#endif
#ifdef USE_IR_RF
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
const char *InfraredRFTransmitRawTimingsRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("InfraredRFTransmitRawTimingsRequest"));
#ifdef USE_DEVICES
@@ -2591,6 +2591,7 @@ const char *InfraredRFTransmitRawTimingsRequest::dump_to(DumpBuffer &out) const
out.append_p(ESPHOME_PSTR(" values, "));
append_uint(out, this->timings_length_);
out.append_p(ESPHOME_PSTR(" bytes]\n"));
dump_field(out, ESPHOME_PSTR("modulation"), this->modulation);
return out.c_str();
}
const char *InfraredRFReceiveEvent::dump_to(DumpBuffer &out) const {
@@ -2605,6 +2606,27 @@ const char *InfraredRFReceiveEvent::dump_to(DumpBuffer &out) const {
return out.c_str();
}
#endif
#ifdef USE_RADIO_FREQUENCY
const char *ListEntitiesRadioFrequencyResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesRadioFrequencyResponse"));
dump_field(out, ESPHOME_PSTR("object_id"), this->object_id);
dump_field(out, ESPHOME_PSTR("key"), this->key);
dump_field(out, ESPHOME_PSTR("name"), this->name);
#ifdef USE_ENTITY_ICON
dump_field(out, ESPHOME_PSTR("icon"), this->icon);
#endif
dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default);
dump_field(out, ESPHOME_PSTR("entity_category"), static_cast<enums::EntityCategory>(this->entity_category));
#ifdef USE_DEVICES
dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
#endif
dump_field(out, ESPHOME_PSTR("capabilities"), this->capabilities);
dump_field(out, ESPHOME_PSTR("frequency_min"), this->frequency_min);
dump_field(out, ESPHOME_PSTR("frequency_max"), this->frequency_max);
dump_field(out, ESPHOME_PSTR("supported_modulations"), this->supported_modulations);
return out.c_str();
}
#endif
#ifdef USE_SERIAL_PROXY
const char *SerialProxyConfigureRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyConfigureRequest"));
+1 -1
View File
@@ -625,7 +625,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
#ifdef USE_IR_RF
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
case InfraredRFTransmitRawTimingsRequest::MESSAGE_TYPE: {
InfraredRFTransmitRawTimingsRequest msg;
msg.decode(msg_data, msg_size);
+1 -1
View File
@@ -211,7 +211,7 @@ class APIServerConnectionBase {
void on_z_wave_proxy_request(const ZWaveProxyRequest &value){};
#endif
#ifdef USE_IR_RF
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &value){};
#endif
+33 -30
View File
@@ -118,7 +118,7 @@ void APIServer::loop() {
this->accept_new_connections_();
}
if (this->clients_.empty()) {
if (this->api_connection_count_ == 0) {
// Check reboot timeout - done in loop to avoid scheduler heap churn
// (cancelled scheduler items sit in heap memory until their scheduled time)
if (this->reboot_timeout_ != 0) {
@@ -135,15 +135,15 @@ void APIServer::loop() {
// Check network connectivity once for all clients
if (!network::is_connected()) {
// Network is down - disconnect all clients
for (auto &client : this->clients_) {
for (auto &client : this->active_clients()) {
client->on_fatal_error();
client->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Network down; disconnect"));
}
// Continue to process and clean up the clients below
}
size_t client_index = 0;
while (client_index < this->clients_.size()) {
uint8_t client_index = 0;
while (client_index < this->api_connection_count_) {
auto &client = this->clients_[client_index];
// Common case: process active client
@@ -161,7 +161,7 @@ void APIServer::loop() {
}
}
void APIServer::remove_client_(size_t client_index) {
void APIServer::remove_client_(uint8_t client_index) {
auto &client = this->clients_[client_index];
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
@@ -179,14 +179,17 @@ void APIServer::remove_client_(size_t client_index) {
// Close socket now (was deferred from on_fatal_error to allow getpeername)
client->helper_->close();
// Swap with the last element and pop (avoids expensive vector shifts)
if (client_index < this->clients_.size() - 1) {
std::swap(this->clients_[client_index], this->clients_.back());
// Swap-and-reset: move the removed client to the trailing slot and null it out so slots
// [api_connection_count_, N) remain nullptr.
const uint8_t last_index = this->api_connection_count_ - 1;
if (client_index < last_index) {
std::swap(this->clients_[client_index], this->clients_[last_index]);
}
this->clients_.pop_back();
this->clients_[last_index].reset();
this->api_connection_count_--;
// Last client disconnected - set warning and start tracking for reboot timeout
if (this->clients_.empty() && this->reboot_timeout_ != 0) {
if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0) {
this->status_set_warning(LOG_STR("waiting for client connection"));
this->last_connected_ = App.get_loop_component_start_time();
}
@@ -210,8 +213,8 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
sock->getpeername_to(peername);
// Check if we're at the connection limit
if (this->clients_.size() >= this->max_connections_) {
ESP_LOGW(TAG, "Max connections (%d), rejecting %s", this->max_connections_, peername);
if (this->api_connection_count_ >= MAX_API_CONNECTIONS) {
ESP_LOGW(TAG, "Max connections (%d), rejecting %s", MAX_API_CONNECTIONS, peername);
// Immediately close - socket destructor will handle cleanup
sock.reset();
continue;
@@ -220,11 +223,11 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
ESP_LOGD(TAG, "Accept %s", peername);
auto *conn = new APIConnection(std::move(sock), this);
this->clients_.emplace_back(conn);
this->clients_[this->api_connection_count_++].reset(conn);
conn->start();
// First client connected - clear warning and update timestamp
if (this->clients_.size() == 1 && this->reboot_timeout_ != 0) {
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0) {
this->status_clear_warning();
this->last_connected_ = App.get_loop_component_start_time();
}
@@ -237,7 +240,7 @@ void APIServer::dump_config() {
" Address: %s:%u\n"
" Listen backlog: %u\n"
" Max connections: %u",
network::get_use_address(), this->port_, this->listen_backlog_, this->max_connections_);
network::get_use_address(), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS);
#ifdef USE_API_NOISE
ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_.has_psk()));
if (!this->noise_ctx_.has_psk()) {
@@ -255,7 +258,7 @@ void APIServer::handle_disconnect(APIConnection *conn) {}
void APIServer::on_##entity_name##_update(entity_type *obj) { /* NOLINT(bugprone-macro-parentheses) */ \
if (obj->is_internal()) \
return; \
for (auto &c : this->clients_) { \
for (auto &c : this->active_clients()) { \
if (c->flags_.state_subscription) \
c->send_##entity_name##_state(obj); \
} \
@@ -337,7 +340,7 @@ API_DISPATCH_UPDATE(water_heater::WaterHeater, water_heater)
void APIServer::on_event(event::Event *obj) {
if (obj->is_internal())
return;
for (auto &c : this->clients_) {
for (auto &c : this->active_clients()) {
if (c->flags_.state_subscription)
c->send_event(obj);
}
@@ -349,7 +352,7 @@ void APIServer::on_event(event::Event *obj) {
void APIServer::on_update(update::UpdateEntity *obj) {
if (obj->is_internal())
return;
for (auto &c : this->clients_) {
for (auto &c : this->active_clients()) {
if (c->flags_.state_subscription)
c->send_update_state(obj);
}
@@ -360,12 +363,12 @@ void APIServer::on_update(update::UpdateEntity *obj) {
void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) {
// We could add code to manage a second subscription type, but, since this message type is
// very infrequent and small, we simply send it to all clients
for (auto &c : this->clients_)
for (auto &c : this->active_clients())
c->send_message(msg);
}
#endif
#ifdef USE_IR_RF
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_id, uint32_t key,
const std::vector<int32_t> *timings) {
InfraredRFReceiveEvent resp{};
@@ -375,7 +378,7 @@ void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_
resp.key = key;
resp.timings = timings;
for (auto &c : this->clients_)
for (auto &c : this->active_clients())
c->send_infrared_rf_receive_event(resp);
}
#endif
@@ -392,7 +395,7 @@ void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = bat
#ifdef USE_API_HOMEASSISTANT_SERVICES
void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) {
for (auto &client : this->clients_) {
for (auto &client : this->active_clients()) {
client->send_homeassistant_action(call);
}
}
@@ -532,7 +535,7 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString
return;
}
ESP_LOGW(TAG, "Disconnecting all clients to reset PSK");
for (auto &c : this->clients_) {
for (auto &c : this->active_clients()) {
DisconnectRequest req;
c->send_message(req);
}
@@ -583,7 +586,7 @@ bool APIServer::clear_noise_psk(bool make_active) {
#ifdef USE_HOMEASSISTANT_TIME
void APIServer::request_time() {
for (auto &client : this->clients_) {
for (auto &client : this->active_clients()) {
if (!client->flags_.remove && client->is_authenticated()) {
client->send_time_request();
return; // Only request from one client to avoid clock conflicts
@@ -593,8 +596,8 @@ void APIServer::request_time() {
#endif
bool APIServer::is_connected_with_state_subscription() const {
for (const auto &client : this->clients_) {
if (client->flags_.state_subscription) {
for (uint8_t i = 0; i < this->api_connection_count_; i++) {
if (this->clients_[i]->flags_.state_subscription) {
return true;
}
}
@@ -609,7 +612,7 @@ void APIServer::on_log(uint8_t level, const char *tag, const char *message, size
// we would be filling a buffer we are trying to clear
return;
}
for (auto &c : this->clients_) {
for (auto &c : this->active_clients()) {
if (!c->flags_.remove && c->get_log_subscription_level() >= level)
c->try_send_log_message(level, tag, message, message_len);
}
@@ -618,7 +621,7 @@ void APIServer::on_log(uint8_t level, const char *tag, const char *message, size
#ifdef USE_CAMERA
void APIServer::on_camera_image(const std::shared_ptr<camera::CameraImage> &image) {
for (auto &c : this->clients_) {
for (auto &c : this->active_clients()) {
if (!c->flags_.remove)
c->set_camera_state(image);
}
@@ -635,7 +638,7 @@ void APIServer::on_shutdown() {
this->batch_delay_ = 5;
// Send disconnect requests to all connected clients
for (auto &c : this->clients_) {
for (auto &c : this->active_clients()) {
DisconnectRequest req;
if (!c->send_message(req)) {
// If we can't send the disconnect request directly (tx_buffer full),
@@ -653,7 +656,7 @@ bool APIServer::teardown() {
this->loop();
// Return true only when all clients have been torn down
return this->clients_.empty();
return this->api_connection_count_ == 0;
}
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
+27 -8
View File
@@ -21,6 +21,8 @@
#include "esphome/components/camera/camera.h"
#endif
#include <array>
#include <memory>
#include <vector>
namespace esphome::api {
@@ -63,7 +65,6 @@ class APIServer final : public Component,
void set_batch_delay(uint16_t batch_delay);
uint16_t get_batch_delay() const { return batch_delay_; }
void set_listen_backlog(uint8_t listen_backlog) { this->listen_backlog_ = listen_backlog; }
void set_max_connections(uint8_t max_connections) { this->max_connections_ = max_connections; }
// Get reference to shared buffer for API connections
APIBuffer &get_shared_buffer_ref() { return shared_write_buffer_; }
@@ -182,13 +183,30 @@ class APIServer final : public Component,
#ifdef USE_ZWAVE_PROXY
void on_zwave_proxy_request(const ZWaveProxyRequest &msg);
#endif
#ifdef USE_IR_RF
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector<int32_t> *timings);
#endif
bool is_connected() const { return !this->clients_.empty(); }
bool is_connected() const { return this->api_connection_count_ != 0; }
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
// APIConnection but cannot reset/move the slot and break the count invariant.
using APIConnectionPtr = std::unique_ptr<APIConnection>;
class ActiveClientsView {
const APIConnectionPtr *begin_;
const APIConnectionPtr *end_;
public:
ActiveClientsView(const APIConnectionPtr *b, const APIConnectionPtr *e) : begin_(b), end_(e) {}
const APIConnectionPtr *begin() const { return this->begin_; }
const APIConnectionPtr *end() const { return this->end_; }
};
ActiveClientsView active_clients() const {
return {this->clients_.data(), this->clients_.data() + this->api_connection_count_};
}
#ifdef USE_API_HOMEASSISTANT_STATES
struct HomeAssistantStateSubscription {
const char *entity_id; // Pointer to flash (internal) or heap (external)
@@ -234,8 +252,8 @@ class APIServer final : public Component,
protected:
// Accept incoming socket connections. Only called when socket has pending connections.
void __attribute__((noinline)) accept_new_connections_();
// Remove a disconnected client by index. Swaps with last element and pops.
void __attribute__((noinline)) remove_client_(size_t client_index);
// Remove a disconnected client by index. Swaps with the last populated slot and resets it.
void __attribute__((noinline)) remove_client_(uint8_t client_index);
#ifdef USE_API_NOISE
bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg,
@@ -273,8 +291,9 @@ class APIServer final : public Component,
uint32_t reboot_timeout_{300000};
uint32_t last_connected_{0};
// Slots [0, api_connection_count_) are populated; trailing slots are always nullptr.
std::array<std::unique_ptr<APIConnection>, MAX_API_CONNECTIONS> clients_{};
// Vectors and strings (12 bytes each on 32-bit)
std::vector<std::unique_ptr<APIConnection>> clients_;
// Shared proto write buffer for all connections.
// Not pre-allocated: all send paths call prepare_first_message_buffer() which
// reserves the exact needed size. Pre-allocating here would cause heap fragmentation
@@ -309,10 +328,10 @@ class APIServer final : public Component,
uint16_t port_{6053};
uint16_t batch_delay_{100};
// Connection limits - these defaults will be overridden by config values
// from cv.SplitDefault in __init__.py which sets platform-specific defaults
// from cv.SplitDefault in __init__.py which sets platform-specific defaults.
uint8_t listen_backlog_{4};
uint8_t max_connections_{8};
bool shutting_down_ = false;
uint8_t api_connection_count_{0};
// 7 bytes used, 1 byte padding
#ifdef USE_API_NOISE
+18 -1
View File
@@ -93,7 +93,24 @@ async def async_run_logs(
config, raw_line, backtrace_state=backtrace_state
)
stop = await async_run(cli, on_log, name=name, subscribe_states=subscribe_states)
# Safe to fall back to plaintext here only for this diagnostics use
# case: the stream is one-way from device to client, and this code
# never accepts commands or acts on any message the device sends.
# An on-path attacker could still both inject fabricated log lines
# and passively read the device's log output (and any state data
# delivered when subscribe_states is enabled), so this does lose
# confidentiality as well as authentication/integrity. That tradeoff
# is acceptable for operator-visible logs, which aioesphomeapi also
# warns may come from an unverified device. Never mirror this opt-in
# for any connection that sends data to the device or uses Home
# Assistant actions.
stop = await async_run(
cli,
on_log,
name=name,
subscribe_states=subscribe_states,
allow_plaintext_fallback=True,
)
try:
await asyncio.Event().wait()
finally:
+3
View File
@@ -79,6 +79,9 @@ LIST_ENTITIES_HANDLER(water_heater, water_heater::WaterHeater, ListEntitiesWater
#ifdef USE_INFRARED
LIST_ENTITIES_HANDLER(infrared, infrared::Infrared, ListEntitiesInfraredResponse)
#endif
#ifdef USE_RADIO_FREQUENCY
LIST_ENTITIES_HANDLER(radio_frequency, radio_frequency::RadioFrequency, ListEntitiesRadioFrequencyResponse)
#endif
#ifdef USE_EVENT
LIST_ENTITIES_HANDLER(event, event::Event, ListEntitiesEventResponse)
#endif
+3
View File
@@ -87,6 +87,9 @@ class ListEntitiesIterator final : public ComponentIterator {
#ifdef USE_INFRARED
bool on_infrared(infrared::Infrared *entity) override;
#endif
#ifdef USE_RADIO_FREQUENCY
bool on_radio_frequency(radio_frequency::RadioFrequency *entity) override;
#endif
#ifdef USE_EVENT
bool on_event(event::Event *entity) override;
#endif
+3
View File
@@ -82,6 +82,9 @@ class InitialStateIterator final : public ComponentIterator {
#ifdef USE_INFRARED
bool on_infrared(infrared::Infrared *infrared) override { return true; };
#endif
#ifdef USE_RADIO_FREQUENCY
bool on_radio_frequency(radio_frequency::RadioFrequency *radio_frequency) override { return true; };
#endif
#ifdef USE_EVENT
bool on_event(event::Event *event) override { return true; };
#endif
@@ -0,0 +1,163 @@
#include "audio_http_media_source.h"
#ifdef USE_ESP32
#include "esphome/core/log.h"
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <algorithm>
namespace esphome::audio_http {
static const char *const TAG = "audio_http_media_source";
// Decoder task / buffer tuning. Kept here as constants so the header stays free of magic numbers.
static constexpr size_t DEFAULT_TRANSFER_BUFFER_SIZE = 8 * 1024; // Staging buffer between HTTP reader and decoder
static constexpr uint32_t HTTP_TIMEOUT_MS = 5000; // HTTP connect/read timeout
static constexpr uint32_t AUDIO_WRITE_TIMEOUT_MS = 50; // Max blocking time per on_audio_write() call
static constexpr uint32_t READER_WRITE_TIMEOUT_MS = 50; // Max blocking time when writing into the ring buffer
static constexpr uint8_t READER_TASK_PRIORITY = 2;
static constexpr uint8_t DECODER_TASK_PRIORITY = 2;
static constexpr size_t READER_TASK_STACK_SIZE = 4096;
static constexpr size_t DECODER_TASK_STACK_SIZE = 5120;
static constexpr uint32_t PAUSE_POLL_DELAY_MS = 20;
static constexpr const char *const HTTP_URI_PREFIX = "http://";
static constexpr const char *const HTTPS_URI_PREFIX = "https://";
void AudioHTTPMediaSource::dump_config() {
ESP_LOGCONFIG(TAG,
"Audio HTTP Media Source:\n"
" Buffer Size: %zu bytes\n"
" Decoder Task Stack in PSRAM: %s",
this->buffer_size_, YESNO(this->decoder_task_stack_in_psram_));
}
void AudioHTTPMediaSource::setup() {
this->disable_loop();
micro_decoder::DecoderConfig config;
config.ring_buffer_size = this->buffer_size_;
// Keep the transfer buffer smaller than the ring buffer so the reader can top up the ring
// while the decoder is still draining it, instead of oscillating between empty and full.
config.transfer_buffer_size = std::min(DEFAULT_TRANSFER_BUFFER_SIZE, this->buffer_size_ / 2);
config.http_timeout_ms = HTTP_TIMEOUT_MS;
config.audio_write_timeout_ms = AUDIO_WRITE_TIMEOUT_MS;
config.reader_write_timeout_ms = READER_WRITE_TIMEOUT_MS;
config.reader_priority = READER_TASK_PRIORITY;
config.decoder_priority = DECODER_TASK_PRIORITY;
config.reader_stack_size = READER_TASK_STACK_SIZE;
config.decoder_stack_size = DECODER_TASK_STACK_SIZE;
config.decoder_stack_in_psram = this->decoder_task_stack_in_psram_;
this->decoder_ = std::make_unique<micro_decoder::DecoderSource>(config);
if (this->decoder_ == nullptr) {
ESP_LOGE(TAG, "Failed to allocate decoder");
this->mark_failed();
return;
}
this->decoder_->set_listener(this); // We inherit from micro_decoder::DecoderListener
}
void AudioHTTPMediaSource::loop() { this->decoder_->loop(); }
bool AudioHTTPMediaSource::can_handle(const std::string &uri) const {
return uri.starts_with(HTTP_URI_PREFIX) || uri.starts_with(HTTPS_URI_PREFIX);
}
// Called from the orchestrator's main loop, so no synchronization needed with loop()
bool AudioHTTPMediaSource::play_uri(const std::string &uri) {
if (!this->is_ready() || this->is_failed() || this->status_has_error() || !this->has_listener()) {
return false;
}
// Check if source is already playing
if (this->get_state() != media_source::MediaSourceState::IDLE) {
ESP_LOGE(TAG, "Cannot play '%s': source is busy", uri.c_str());
return false;
}
// Validate URI starts with "http://" or "https://"
if (!uri.starts_with(HTTP_URI_PREFIX) && !uri.starts_with(HTTPS_URI_PREFIX)) {
ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str());
return false;
}
if (this->decoder_->play_url(uri)) {
this->pause_.store(false, std::memory_order_relaxed);
this->enable_loop();
return true;
}
ESP_LOGE(TAG, "Failed to start playback of '%s'", uri.c_str());
return false;
}
// Called from the orchestrator's main loop, so no synchronization needed with loop()
void AudioHTTPMediaSource::handle_command(media_source::MediaSourceCommand command) {
switch (command) {
case media_source::MediaSourceCommand::STOP:
this->decoder_->stop();
break;
case media_source::MediaSourceCommand::PAUSE:
// Only valid while actively playing; ignoring from IDLE/ERROR/PAUSED prevents the state
// machine from getting stuck in PAUSED when no playback is active (which would block the
// next play_uri() call via its IDLE-state precondition).
if (this->get_state() != media_source::MediaSourceState::PLAYING)
break;
// PAUSE does not stop the decoder task. Instead, on_audio_write() returns 0 and temporarily
// yields, which fills the ring buffer and applies back pressure that effectively pauses both
// the decoder and HTTP reader tasks.
this->set_state_(media_source::MediaSourceState::PAUSED);
this->pause_.store(true, std::memory_order_relaxed);
break;
case media_source::MediaSourceCommand::PLAY:
// Only resume from PAUSED; don't fabricate a PLAYING state from IDLE/ERROR.
if (this->get_state() != media_source::MediaSourceState::PAUSED)
break;
this->set_state_(media_source::MediaSourceState::PLAYING);
this->pause_.store(false, std::memory_order_relaxed);
break;
default:
break;
}
}
// Called from the decoder task. Forwards to the orchestrator's listener, which is responsible for
// being thread-safe with respect to its own audio writer.
size_t AudioHTTPMediaSource::on_audio_write(const uint8_t *data, size_t length, uint32_t timeout_ms) {
if (this->pause_.load(std::memory_order_relaxed)) {
vTaskDelay(pdMS_TO_TICKS(PAUSE_POLL_DELAY_MS));
return 0;
}
return this->write_output(data, length, timeout_ms, this->stream_info_);
}
// Called from the decoder task before the first on_audio_write().
void AudioHTTPMediaSource::on_stream_info(const micro_decoder::AudioStreamInfo &info) {
this->stream_info_ = audio::AudioStreamInfo(info.get_bits_per_sample(), info.get_channels(), info.get_sample_rate());
}
// microDecoder invokes on_state_change() from inside decoder_->loop(), so this runs on the main
// loop thread and it's safe to call set_state_() directly.
void AudioHTTPMediaSource::on_state_change(micro_decoder::DecoderState state) {
switch (state) {
case micro_decoder::DecoderState::IDLE:
this->set_state_(media_source::MediaSourceState::IDLE);
this->disable_loop();
break;
case micro_decoder::DecoderState::PLAYING:
this->set_state_(media_source::MediaSourceState::PLAYING);
break;
case micro_decoder::DecoderState::FAILED:
this->set_state_(media_source::MediaSourceState::ERROR);
break;
default:
break;
}
}
} // namespace esphome::audio_http
#endif // USE_ESP32
@@ -0,0 +1,59 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#include "esphome/components/audio/audio.h"
#include "esphome/components/media_source/media_source.h"
#include "esphome/core/component.h"
#include <micro_decoder/decoder_source.h>
#include <micro_decoder/types.h>
#include <atomic>
#include <memory>
#include <string>
namespace esphome::audio_http {
// Inherits from two unrelated listener-style interfaces:
// - media_source::MediaSource: this source reports state and writes audio *to* an orchestrator
// (the orchestrator calls set_listener() on us with a MediaSourceListener*).
// - micro_decoder::DecoderListener: the underlying decoder calls back *into* us with decoded
// audio and state changes (we call decoder_->set_listener(this) in setup()).
// The two set_listener() methods live on different base classes and serve opposite directions.
class AudioHTTPMediaSource : public Component, public media_source::MediaSource, public micro_decoder::DecoderListener {
public:
void setup() override;
void loop() override;
void dump_config() override;
void set_buffer_size(size_t buffer_size) { this->buffer_size_ = buffer_size; }
void set_task_stack_in_psram(bool task_stack_in_psram) { this->decoder_task_stack_in_psram_ = task_stack_in_psram; }
// MediaSource interface implementation
bool play_uri(const std::string &uri) override;
void handle_command(media_source::MediaSourceCommand command) override;
bool can_handle(const std::string &uri) const override;
// DecoderListener interface implementation
size_t on_audio_write(const uint8_t *data, size_t length, uint32_t timeout_ms) override;
void on_stream_info(const micro_decoder::AudioStreamInfo &info) override;
void on_state_change(micro_decoder::DecoderState state) override;
protected:
std::unique_ptr<micro_decoder::DecoderSource> decoder_;
audio::AudioStreamInfo stream_info_;
size_t buffer_size_{50000};
// Written from the main loop in handle_command(), read from the decoder task in
// on_audio_write(). Must be atomic to avoid a data race.
std::atomic<bool> pause_{false};
bool decoder_task_stack_in_psram_{false};
};
} // namespace esphome::audio_http
#endif // USE_ESP32
@@ -0,0 +1,59 @@
from typing import Any
import esphome.codegen as cg
from esphome.components import audio, esp32, media_source, psram
import esphome.config_validation as cv
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM
from esphome.types import ConfigType
CODEOWNERS = ["@kahrendt"]
AUTO_LOAD = ["audio"]
audio_http_ns = cg.esphome_ns.namespace("audio_http")
AudioHTTPMediaSource = audio_http_ns.class_(
"AudioHTTPMediaSource", cg.Component, media_source.MediaSource
)
def _request_micro_decoder(config: ConfigType) -> ConfigType:
audio.request_micro_decoder_support()
return config
def _validate_task_stack_in_psram(value: Any) -> bool:
# Only require the psram component when actually enabling PSRAM stacks; validating
# the boolean first means `false` doesn't trigger the requires_component check.
if value := cv.boolean(value):
return cv.requires_component(psram.DOMAIN)(value)
return value
CONFIG_SCHEMA = cv.All(
media_source.media_source_schema(
AudioHTTPMediaSource,
)
.extend(
{
cv.Optional(CONF_BUFFER_SIZE, default=50000): cv.int_range(
min=5000, max=1000000
),
cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram,
}
)
.extend(cv.COMPONENT_SCHEMA),
cv.only_on_esp32,
_request_micro_decoder,
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await media_source.register_media_source(var, config)
if config.get(CONF_TASK_STACK_IN_PSRAM):
cg.add(var.set_task_stack_in_psram(True))
esp32.add_idf_sdkconfig_option(
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
)
cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE]))
+47 -20
View File
@@ -20,58 +20,77 @@ constexpr uint8_t bl0906_checksum(const uint8_t address, const DataPacket *data)
}
void BL0906::loop() {
if (this->current_channel_ == UINT8_MAX) {
return;
}
while (this->available())
this->flush();
if (this->current_channel_ == 0) {
if (this->current_stage_ == STAGE_IDLE) {
// Woken up between cycles to drain the action queue. Go back to sleep.
this->handle_actions_();
this->disable_loop();
return;
}
if (this->current_stage_ == STAGE_TEMP) {
// Temperature
this->read_data_(BL0906_TEMPERATURE, BL0906_TREF, this->temperature_sensor_);
} else if (this->current_channel_ == 1) {
} else if (this->current_stage_ == STAGE_CHANNEL_1) {
this->read_data_(BL0906_I_1_RMS, BL0906_IREF, this->current_1_sensor_);
this->read_data_(BL0906_WATT_1, BL0906_PREF, this->power_1_sensor_);
this->read_data_(BL0906_CF_1_CNT, BL0906_EREF, this->energy_1_sensor_);
} else if (this->current_channel_ == 2) {
} else if (this->current_stage_ == STAGE_CHANNEL_2) {
this->read_data_(BL0906_I_2_RMS, BL0906_IREF, this->current_2_sensor_);
this->read_data_(BL0906_WATT_2, BL0906_PREF, this->power_2_sensor_);
this->read_data_(BL0906_CF_2_CNT, BL0906_EREF, this->energy_2_sensor_);
} else if (this->current_channel_ == 3) {
} else if (this->current_stage_ == STAGE_CHANNEL_3) {
this->read_data_(BL0906_I_3_RMS, BL0906_IREF, this->current_3_sensor_);
this->read_data_(BL0906_WATT_3, BL0906_PREF, this->power_3_sensor_);
this->read_data_(BL0906_CF_3_CNT, BL0906_EREF, this->energy_3_sensor_);
} else if (this->current_channel_ == 4) {
} else if (this->current_stage_ == STAGE_CHANNEL_4) {
this->read_data_(BL0906_I_4_RMS, BL0906_IREF, this->current_4_sensor_);
this->read_data_(BL0906_WATT_4, BL0906_PREF, this->power_4_sensor_);
this->read_data_(BL0906_CF_4_CNT, BL0906_EREF, this->energy_4_sensor_);
} else if (this->current_channel_ == 5) {
} else if (this->current_stage_ == STAGE_CHANNEL_5) {
this->read_data_(BL0906_I_5_RMS, BL0906_IREF, this->current_5_sensor_);
this->read_data_(BL0906_WATT_5, BL0906_PREF, this->power_5_sensor_);
this->read_data_(BL0906_CF_5_CNT, BL0906_EREF, this->energy_5_sensor_);
} else if (this->current_channel_ == 6) {
} else if (this->current_stage_ == STAGE_CHANNEL_6) {
this->read_data_(BL0906_I_6_RMS, BL0906_IREF, this->current_6_sensor_);
this->read_data_(BL0906_WATT_6, BL0906_PREF, this->power_6_sensor_);
this->read_data_(BL0906_CF_6_CNT, BL0906_EREF, this->energy_6_sensor_);
} else if (this->current_channel_ == UINT8_MAX - 2) {
} else if (this->current_stage_ == STAGE_FREQ) {
// Frequency
this->read_data_(BL0906_FREQUENCY, BL0906_FREF, frequency_sensor_);
this->read_data_(BL0906_FREQUENCY, BL0906_FREF, this->frequency_sensor_);
// Voltage
this->read_data_(BL0906_V_RMS, BL0906_UREF, voltage_sensor_);
} else if (this->current_channel_ == UINT8_MAX - 1) {
this->read_data_(BL0906_V_RMS, BL0906_UREF, this->voltage_sensor_);
} else if (this->current_stage_ == STAGE_POWER) {
// Total power
this->read_data_(BL0906_WATT_SUM, BL0906_WATT, this->total_power_sensor_);
// Total Energy
this->read_data_(BL0906_CF_SUM_CNT, BL0906_CF, this->total_energy_sensor_);
} else {
this->current_channel_ = UINT8_MAX - 2; // Go to frequency and voltage
return;
}
this->current_channel_++;
this->advance_stage_();
this->handle_actions_();
}
void BL0906::advance_stage_() {
switch (this->current_stage_) {
case STAGE_CHANNEL_6:
this->current_stage_ = STAGE_FREQ;
break;
case STAGE_FREQ:
this->current_stage_ = STAGE_POWER;
break;
case STAGE_POWER:
// Cycle complete; sleep until the next update().
this->current_stage_ = STAGE_IDLE;
this->disable_loop();
break;
default:
this->current_stage_ = static_cast<BL0906Stage>(this->current_stage_ + 1);
break;
}
}
void BL0906::setup() {
while (this->available())
this->flush();
@@ -85,12 +104,20 @@ void BL0906::setup() {
this->bias_correction_(BL0906_RMSOS_6, 0.01200, 0); // Calibration current_6
this->write_array(USR_WRPROT_ONLYREAD, sizeof(USR_WRPROT_ONLYREAD));
// Loop stays idle until the first update() or enqueued action.
this->disable_loop();
}
void BL0906::update() { this->current_channel_ = 0; }
void BL0906::update() {
this->current_stage_ = STAGE_TEMP;
this->enable_loop();
}
size_t BL0906::enqueue_action_(ActionCallbackFuncPtr function) {
this->action_queue_.push_back(function);
// Ensure the queue is serviced even if the read cycle has already completed.
this->enable_loop();
return this->action_queue_.size();
}
+18 -1
View File
@@ -12,6 +12,22 @@
namespace esphome {
namespace bl0906 {
// Stage values for the read state machine. After STAGE_CHANNEL_6 the state machine
// jumps to the two sentinel stages below, then to STAGE_IDLE which marks the cycle
// as complete and disables the loop.
enum BL0906Stage : uint8_t {
STAGE_TEMP = 0, // chip temperature
STAGE_CHANNEL_1 = 1, // per-phase current + power + energy
STAGE_CHANNEL_2 = 2,
STAGE_CHANNEL_3 = 3,
STAGE_CHANNEL_4 = 4,
STAGE_CHANNEL_5 = 5,
STAGE_CHANNEL_6 = 6,
STAGE_FREQ = UINT8_MAX - 2, // frequency + voltage
STAGE_POWER = UINT8_MAX - 1, // total power + total energy
STAGE_IDLE = UINT8_MAX, // cycle complete
};
struct DataPacket { // NOLINT(altera-struct-pack-align)
uint8_t l{0};
uint8_t m{0};
@@ -79,7 +95,8 @@ class BL0906 : public PollingComponent, public uart::UARTDevice {
void bias_correction_(uint8_t address, float measurements, float correction);
uint8_t current_channel_{0};
BL0906Stage current_stage_{STAGE_IDLE};
void advance_stage_();
size_t enqueue_action_(ActionCallbackFuncPtr function);
void handle_actions_();
@@ -6,6 +6,7 @@ from esphome.const import CONF_ID, CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, Fr
CODEOWNERS = ["@trvrnrth"]
DEPENDENCIES = ["i2c"]
AUTO_LOAD = ["sensor", "text_sensor"]
CONFLICTS_WITH = ["bme68x_bsec2"]
MULTI_CONF = True
CONF_BME680_BSEC_ID = "bme680_bsec_id"
@@ -13,6 +13,7 @@ from esphome.const import (
)
CODEOWNERS = ["@neffs", "@kbx81"]
CONFLICTS_WITH = ["bme680_bsec"]
DOMAIN = "bme68x_bsec2"
+8 -5
View File
@@ -204,24 +204,27 @@ void CSE7761Component::get_data_() {
value = this->read_(CSE7761_REG_RMSIA, 3);
this->data_.current_rms[0] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; // No load threshold of 10mA
value = this->read_(CSE7761_REG_POWERPA, 4);
this->data_.active_power[0] = (0 == this->data_.current_rms[0]) ? 0 : ((uint32_t) abs((int) value));
// PowerPA is two's complement signed 32-bit per datasheet
this->data_.active_power[0] = (0 == this->data_.current_rms[0]) ? 0 : static_cast<int32_t>(value);
value = this->read_(CSE7761_REG_RMSIB, 3);
this->data_.current_rms[1] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; // No load threshold of 10mA
value = this->read_(CSE7761_REG_POWERPB, 4);
this->data_.active_power[1] = (0 == this->data_.current_rms[1]) ? 0 : ((uint32_t) abs((int) value));
// PowerPB is two's complement signed 32-bit per datasheet
this->data_.active_power[1] = (0 == this->data_.current_rms[1]) ? 0 : static_cast<int32_t>(value);
// convert values and publish to sensors
float voltage = (float) this->data_.voltage_rms / this->coefficient_by_unit_(RMS_UC);
float voltage = static_cast<float>(this->data_.voltage_rms) / this->coefficient_by_unit_(RMS_UC);
if (this->voltage_sensor_ != nullptr) {
this->voltage_sensor_->publish_state(voltage);
}
for (uint8_t channel = 0; channel < 2; channel++) {
// Active power = PowerPA * PowerPAC * 1000 / 0x80000000
float active_power = (float) this->data_.active_power[channel] / this->coefficient_by_unit_(POWER_PAC); // W
float amps = (float) this->data_.current_rms[channel] / this->coefficient_by_unit_(RMS_IAC); // A
float active_power =
static_cast<float>(this->data_.active_power[channel]) / this->coefficient_by_unit_(POWER_PAC); // W
float amps = static_cast<float>(this->data_.current_rms[channel]) / this->coefficient_by_unit_(RMS_IAC); // A
ESP_LOGD(TAG, "Channel %d power %f W, current %f A", channel + 1, active_power, amps);
if (channel == 0) {
if (this->power_sensor_1_ != nullptr) {
+1 -3
View File
@@ -11,10 +11,8 @@ struct CSE7761DataStruct {
uint32_t frequency = 0;
uint32_t voltage_rms = 0;
uint32_t current_rms[2] = {0};
uint32_t energy[2] = {0};
uint32_t active_power[2] = {0};
int32_t active_power[2] = {0};
uint16_t coefficient[8] = {0};
uint8_t energy_update = 0;
bool ready = false;
};
+1 -1
View File
@@ -30,7 +30,7 @@ void DebugComponent::dump_config() {
char device_info_buffer[DEVICE_INFO_BUFFER_SIZE];
ESP_LOGD(TAG, "ESPHome version %s", ESPHOME_VERSION);
size_t pos = buf_append_printf(device_info_buffer, DEVICE_INFO_BUFFER_SIZE, 0, "%s", ESPHOME_VERSION);
size_t pos = buf_append_str(device_info_buffer, DEVICE_INFO_BUFFER_SIZE, 0, ESPHOME_VERSION);
this->free_heap_ = get_free_heap_();
ESP_LOGD(TAG, "Free Heap Size: %" PRIu32 " bytes", this->free_heap_);
+15 -8
View File
@@ -224,17 +224,21 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
const char *model = ESPHOME_VARIANT;
// Build features string
pos = buf_append_printf(buf, size, pos, "|Chip: %s Features:", model);
pos = buf_append_str(buf, size, pos, "|Chip: ");
pos = buf_append_str(buf, size, pos, model);
pos = buf_append_str(buf, size, pos, " Features:");
bool first_feature = true;
for (const auto &feature : CHIP_FEATURES) {
if (info.features & feature.bit) {
pos = buf_append_printf(buf, size, pos, "%s%s", first_feature ? "" : ", ", feature.name);
pos = buf_append_str(buf, size, pos, first_feature ? "" : ", ");
pos = buf_append_str(buf, size, pos, feature.name);
first_feature = false;
info.features &= ~feature.bit;
}
}
if (info.features != 0) {
pos = buf_append_printf(buf, size, pos, "%sOther:0x%" PRIx32, first_feature ? "" : ", ", info.features);
pos = buf_append_str(buf, size, pos, first_feature ? "" : ", ");
pos = buf_append_printf(buf, size, pos, "Other:0x%" PRIx32, info.features);
}
pos = buf_append_printf(buf, size, pos, " Cores:%u Revision:%u", info.cores, info.revision);
@@ -267,17 +271,20 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
// Framework detection
#ifdef USE_ARDUINO
ESP_LOGD(TAG, " Framework: Arduino");
pos = buf_append_printf(buf, size, pos, "|Framework: Arduino");
pos = buf_append_str(buf, size, pos, "|Framework: Arduino");
#else
ESP_LOGD(TAG, " Framework: ESP-IDF");
pos = buf_append_printf(buf, size, pos, "|Framework: ESP-IDF");
pos = buf_append_str(buf, size, pos, "|Framework: ESP-IDF");
#endif
pos = buf_append_printf(buf, size, pos, "|ESP-IDF: %s", esp_get_idf_version());
pos = buf_append_str(buf, size, pos, "|ESP-IDF: ");
pos = buf_append_str(buf, size, pos, esp_get_idf_version());
pos = buf_append_printf(buf, size, pos, "|EFuse MAC: %02X:%02X:%02X:%02X:%02X:%02X", mac[0], mac[1], mac[2], mac[3],
mac[4], mac[5]);
pos = buf_append_printf(buf, size, pos, "|Reset: %s", reset_reason);
pos = buf_append_printf(buf, size, pos, "|Wakeup: %s", wakeup_cause);
pos = buf_append_str(buf, size, pos, "|Reset: ");
pos = buf_append_str(buf, size, pos, reset_reason);
pos = buf_append_str(buf, size, pos, "|Wakeup: ");
pos = buf_append_str(buf, size, pos, wakeup_cause);
return pos;
}
+6 -3
View File
@@ -38,9 +38,12 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
lt_get_version(), lt_cpu_get_model_name(), lt_cpu_get_model(), lt_cpu_get_freq_mhz(), mac_id,
lt_get_board_code(), flash_kib, ram_kib, reset_reason);
pos = buf_append_printf(buf, size, pos, "|Version: %s", LT_BANNER_STR + 10);
pos = buf_append_printf(buf, size, pos, "|Reset Reason: %s", reset_reason);
pos = buf_append_printf(buf, size, pos, "|Chip Name: %s", lt_cpu_get_model_name());
pos = buf_append_str(buf, size, pos, "|Version: ");
pos = buf_append_str(buf, size, pos, LT_BANNER_STR + 10);
pos = buf_append_str(buf, size, pos, "|Reset Reason: ");
pos = buf_append_str(buf, size, pos, reset_reason);
pos = buf_append_str(buf, size, pos, "|Chip Name: ");
pos = buf_append_str(buf, size, pos, lt_cpu_get_model_name());
pos = buf_append_printf(buf, size, pos, "|Chip ID: 0x%06" PRIX32, mac_id);
pos = buf_append_printf(buf, size, pos, "|Flash: %" PRIu32 " KiB", flash_kib);
pos = buf_append_printf(buf, size, pos, "|RAM: %" PRIu32 " KiB", ram_kib);
+18 -8
View File
@@ -162,14 +162,18 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
const char *supply_status =
(nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_NORMAL) ? "Normal voltage." : "High voltage.";
ESP_LOGD(TAG, "Main supply status: %s", supply_status);
pos = buf_append_printf(buf, size, pos, "|Main supply status: %s", supply_status);
pos = buf_append_str(buf, size, pos, "|Main supply status: ");
pos = buf_append_str(buf, size, pos, supply_status);
// Regulator stage 0
if (nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_HIGH) {
const char *reg0_type = nrf_power_dcdcen_vddh_get(NRF_POWER) ? "DC/DC" : "LDO";
const char *reg0_voltage = regout0_to_str((NRF_UICR->REGOUT0 & UICR_REGOUT0_VOUT_Msk) >> UICR_REGOUT0_VOUT_Pos);
ESP_LOGD(TAG, "Regulator stage 0: %s, %s", reg0_type, reg0_voltage);
pos = buf_append_printf(buf, size, pos, "|Regulator stage 0: %s, %s", reg0_type, reg0_voltage);
pos = buf_append_str(buf, size, pos, "|Regulator stage 0: ");
pos = buf_append_str(buf, size, pos, reg0_type);
pos = buf_append_str(buf, size, pos, ", ");
pos = buf_append_str(buf, size, pos, reg0_voltage);
#ifdef USE_NRF52_REG0_VOUT
if ((NRF_UICR->REGOUT0 & UICR_REGOUT0_VOUT_Msk) >> UICR_REGOUT0_VOUT_Pos != USE_NRF52_REG0_VOUT) {
ESP_LOGE(TAG, "Regulator stage 0: expected %s", regout0_to_str(USE_NRF52_REG0_VOUT));
@@ -177,13 +181,14 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
#endif
} else {
ESP_LOGD(TAG, "Regulator stage 0: disabled");
pos = buf_append_printf(buf, size, pos, "|Regulator stage 0: disabled");
pos = buf_append_str(buf, size, pos, "|Regulator stage 0: disabled");
}
// Regulator stage 1
const char *reg1_type = nrf_power_dcdcen_get(NRF_POWER) ? "DC/DC" : "LDO";
ESP_LOGD(TAG, "Regulator stage 1: %s", reg1_type);
pos = buf_append_printf(buf, size, pos, "|Regulator stage 1: %s", reg1_type);
pos = buf_append_str(buf, size, pos, "|Regulator stage 1: ");
pos = buf_append_str(buf, size, pos, reg1_type);
// USB power state
const char *usb_state;
@@ -197,7 +202,8 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
usb_state = "disconnected";
}
ESP_LOGD(TAG, "USB power state: %s", usb_state);
pos = buf_append_printf(buf, size, pos, "|USB power state: %s", usb_state);
pos = buf_append_str(buf, size, pos, "|USB power state: ");
pos = buf_append_str(buf, size, pos, usb_state);
// Power-fail comparator
bool enabled;
@@ -302,14 +308,18 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
break;
}
ESP_LOGD(TAG, "Power-fail comparator: %s, VDDH: %s", pof_voltage, vddh_voltage);
pos = buf_append_printf(buf, size, pos, "|Power-fail comparator: %s, VDDH: %s", pof_voltage, vddh_voltage);
pos = buf_append_str(buf, size, pos, "|Power-fail comparator: ");
pos = buf_append_str(buf, size, pos, pof_voltage);
pos = buf_append_str(buf, size, pos, ", VDDH: ");
pos = buf_append_str(buf, size, pos, vddh_voltage);
} else {
ESP_LOGD(TAG, "Power-fail comparator: %s", pof_voltage);
pos = buf_append_printf(buf, size, pos, "|Power-fail comparator: %s", pof_voltage);
pos = buf_append_str(buf, size, pos, "|Power-fail comparator: ");
pos = buf_append_str(buf, size, pos, pof_voltage);
}
} else {
ESP_LOGD(TAG, "Power-fail comparator: disabled");
pos = buf_append_printf(buf, size, pos, "|Power-fail comparator: disabled");
pos = buf_append_str(buf, size, pos, "|Power-fail comparator: disabled");
}
auto package = [](uint32_t value) {
+6 -2
View File
@@ -14,6 +14,7 @@ from esphome.components.esp32 import (
VARIANT_ESP32S3,
get_esp32_variant,
)
from esphome.components.zephyr import zephyr_add_prj_conf
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import (
@@ -33,6 +34,7 @@ from esphome.const import (
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_NRF52,
PlatformFramework,
)
from esphome.core import CORE
@@ -304,7 +306,7 @@ CONFIG_SCHEMA = cv.All(
),
}
).extend(cv.COMPONENT_SCHEMA),
cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX]),
cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_NRF52]),
validate_config,
)
@@ -369,6 +371,8 @@ async def to_code(config):
if CONF_TOUCH_WAKEUP in config:
cg.add(var.set_touch_wakeup(config[CONF_TOUCH_WAKEUP]))
if CORE.using_zephyr and "zigbee" not in CORE.loaded_integrations:
zephyr_add_prj_conf("POWEROFF", True)
cg.add_define("USE_DEEP_SLEEP")
@@ -413,7 +417,7 @@ async def deep_sleep_enter_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, paren)
if CONF_SLEEP_DURATION in config:
template_ = await cg.templatable(config[CONF_SLEEP_DURATION], args, cg.int32)
template_ = await cg.templatable(config[CONF_SLEEP_DURATION], args, cg.uint32)
cg.add(var.set_sleep_duration(template_))
if CONF_UNTIL in config:
@@ -59,6 +59,8 @@ void DeepSleepComponent::deep_sleep_() {
lt_deep_sleep_enter();
}
bool DeepSleepComponent::should_teardown_() { return true; }
} // namespace esphome::deep_sleep
#endif // USE_BK72XX
@@ -9,11 +9,22 @@ static const char *const TAG = "deep_sleep";
// 5 seconds for deep sleep to ensure clean disconnect from Home Assistant
static const uint32_t TEARDOWN_TIMEOUT_DEEP_SLEEP_MS = 5000;
bool global_has_deep_sleep = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
bool global_has_deep_sleep = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
std::atomic<DeepSleepComponent *> global_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
void DeepSleepComponent::setup() {
#ifdef USE_ZEPHYR
k_sem_init(&this->wakeup_sem_, 0, 1);
#endif
global_has_deep_sleep = true;
this->schedule_sleep_();
// It can be used from another thread for waking up the device.
// It should be called as last item in setup.
global_deep_sleep.store(this);
}
void DeepSleepComponent::schedule_sleep_() {
this->next_enter_deep_sleep_ = false;
const optional<uint32_t> run_duration = get_run_duration_();
if (run_duration.has_value()) {
ESP_LOGI(TAG, "Scheduling in %" PRIu32 " ms", *run_duration);
@@ -58,13 +69,17 @@ void DeepSleepComponent::begin_sleep(bool manual) {
if (this->sleep_duration_.has_value()) {
ESP_LOGI(TAG, "Sleeping for %" PRId64 "us", *this->sleep_duration_);
}
App.run_safe_shutdown_hooks();
// It's critical to teardown components cleanly for deep sleep to ensure
// Home Assistant sees a clean disconnect instead of marking the device unavailable
App.teardown_components(TEARDOWN_TIMEOUT_DEEP_SLEEP_MS);
App.run_powerdown_hooks();
if (this->should_teardown_()) {
App.run_safe_shutdown_hooks();
// It's critical to teardown components cleanly for deep sleep to ensure
// Home Assistant sees a clean disconnect instead of marking the device unavailable
App.teardown_components(TEARDOWN_TIMEOUT_DEEP_SLEEP_MS);
App.run_powerdown_hooks();
}
this->deep_sleep_();
this->schedule_sleep_();
}
float DeepSleepComponent::get_setup_priority() const { return setup_priority::LATE; }
@@ -4,6 +4,7 @@
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include <atomic>
#ifdef USE_ESP32
#include <esp_sleep.h>
@@ -14,6 +15,10 @@
#include "esphome/core/time.h"
#endif
#ifdef USE_ZEPHYR
#include <zephyr/kernel.h>
#endif
#include <cinttypes>
namespace esphome {
@@ -120,6 +125,9 @@ class DeepSleepComponent : public Component {
void prevent_deep_sleep();
void allow_deep_sleep();
#ifdef USE_ZEPHYR
void wakeup();
#endif
protected:
// Returns nullopt if no run duration is set. Otherwise, returns the run
@@ -129,6 +137,8 @@ class DeepSleepComponent : public Component {
void dump_config_platform_();
bool prepare_to_sleep_();
void deep_sleep_();
void schedule_sleep_();
bool should_teardown_();
#ifdef USE_BK72XX
bool pin_prevents_sleep_(WakeUpPinItem &pinItem) const;
@@ -157,6 +167,9 @@ class DeepSleepComponent : public Component {
optional<uint32_t> run_duration_;
bool next_enter_deep_sleep_{false};
bool prevent_{false};
#ifdef USE_ZEPHYR
k_sem wakeup_sem_;
#endif
};
extern bool global_has_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
@@ -243,5 +256,8 @@ template<typename... Ts> class AllowDeepSleepAction : public Action<Ts...>, publ
void play(const Ts &...x) override { this->parent_->allow_deep_sleep(); }
};
extern std::atomic<DeepSleepComponent *>
global_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
} // namespace deep_sleep
} // namespace esphome
@@ -165,6 +165,8 @@ void DeepSleepComponent::deep_sleep_() {
esp_deep_sleep_start();
}
bool DeepSleepComponent::should_teardown_() { return true; }
} // namespace deep_sleep
} // namespace esphome
#endif // USE_ESP32
@@ -18,6 +18,8 @@ void DeepSleepComponent::deep_sleep_() {
ESP.deepSleep(this->sleep_duration_.value_or(0)); // NOLINT(readability-static-accessed-through-instance)
}
bool DeepSleepComponent::should_teardown_() { return true; }
} // namespace deep_sleep
} // namespace esphome
#endif
@@ -0,0 +1,60 @@
#include "deep_sleep_component.h"
#ifdef USE_ZEPHYR
#include "esphome/core/log.h"
#include <zephyr/sys/poweroff.h>
#include <zephyr/kernel.h>
#include <zephyr/stats/stats.h>
#include <zephyr/pm/pm.h>
namespace esphome::deep_sleep {
static const char *const TAG = "deep_sleep";
void DeepSleepComponent::wakeup() { k_sem_give(&this->wakeup_sem_); }
optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; }
void DeepSleepComponent::dump_config_platform_() {}
bool DeepSleepComponent::prepare_to_sleep_() { return true; }
void DeepSleepComponent::deep_sleep_() {
k_timeout_t sleep_duration = K_FOREVER;
if (this->sleep_duration_.has_value()) {
sleep_duration = K_USEC(*this->sleep_duration_);
} else {
#ifndef USE_ZIGBEE
// the device can be woken up through one of the following signals:
// - The DETECT signal, optionally generated by the GPIO peripheral.
// - The ANADETECT signal, optionally generated by the LPCOMP module.
// - The SENSE signal, optionally generated by the NFC module to wake-on-field.
// - Detecting a valid USB voltage on the VBUS pin (VBUS,DETECT).
// - A reset.
//
// The system is reset when it wakes up from System OFF mode.
sys_poweroff();
#endif
}
// It might wake up immediately if k_sem_give was called again after wake up
int ret = k_sem_take(&this->wakeup_sem_, sleep_duration);
if (ret == 0) {
ESP_LOGD(TAG, "Woken up by another thread");
} else {
ESP_LOGD(TAG, "Timeout expired (normal sleep)");
}
}
bool DeepSleepComponent::should_teardown_() {
if (this->sleep_duration_.has_value()) {
return false;
}
#ifdef USE_ZIGBEE
return false;
#else
return true;
#endif
}
} // namespace esphome::deep_sleep
#endif
+49 -16
View File
@@ -1,8 +1,19 @@
import logging
from esphome import pins
import esphome.codegen as cg
from esphome.components import uart
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_RECEIVE_TIMEOUT, CONF_UART_ID
from esphome.const import (
CONF_ID,
CONF_RECEIVE_TIMEOUT,
CONF_RX_BUFFER_SIZE,
CONF_UART_ID,
)
import esphome.final_validate as fv
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@glmnet", "@PolarGoose"]
@@ -21,8 +32,7 @@ CONF_MAX_TELEGRAM_LENGTH = "max_telegram_length"
CONF_REQUEST_INTERVAL = "request_interval"
CONF_REQUEST_PIN = "request_pin"
# Hack to prevent compile error due to ambiguity with lib namespace
dsmr_ns = cg.esphome_ns.namespace("esphome::dsmr")
dsmr_ns = cg.esphome_ns.namespace("dsmr")
Dsmr = dsmr_ns.class_("Dsmr", cg.Component, uart.UARTDevice)
@@ -54,24 +64,47 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config):
uart_component = await cg.get_variable(config[CONF_UART_ID])
var = cg.new_Pvariable(config[CONF_ID], uart_component, config[CONF_CRC_CHECK])
cg.add(var.set_max_telegram_length(config[CONF_MAX_TELEGRAM_LENGTH]))
if CONF_DECRYPTION_KEY in config:
cg.add(var.set_decryption_key(config[CONF_DECRYPTION_KEY]))
await cg.register_component(var, config)
if CONF_REQUEST_PIN in config:
request_pin = await cg.gpio_pin_expression(config[CONF_REQUEST_PIN])
cg.add(var.set_request_pin(request_pin))
cg.add(var.set_request_interval(config[CONF_REQUEST_INTERVAL].total_milliseconds))
cg.add(var.set_receive_timeout(config[CONF_RECEIVE_TIMEOUT].total_milliseconds))
else:
request_pin = cg.nullptr
decryption_key = config.get(CONF_DECRYPTION_KEY)
if decryption_key is None:
decryption_key = cg.nullptr
var = cg.new_Pvariable(
config[CONF_ID],
uart_component,
config[CONF_CRC_CHECK],
config[CONF_MAX_TELEGRAM_LENGTH],
config[CONF_REQUEST_INTERVAL].total_milliseconds,
config[CONF_RECEIVE_TIMEOUT].total_milliseconds,
request_pin,
decryption_key,
)
await cg.register_component(var, config)
cg.add_build_flag("-DDSMR_GAS_MBUS_ID=" + str(config[CONF_GAS_MBUS_ID]))
cg.add_build_flag("-DDSMR_WATER_MBUS_ID=" + str(config[CONF_WATER_MBUS_ID]))
cg.add_build_flag("-DDSMR_THERMAL_MBUS_ID=" + str(config[CONF_THERMAL_MBUS_ID]))
# DSMR Parser
cg.add_library("esphome/dsmr_parser", "1.1.0")
cg.add_library("esphome/dsmr_parser", "1.4.0")
# Crypto
cg.add_library("polargoose/Crypto-no-arduino", "0.4.0")
def final_validate(config: ConfigType) -> ConfigType:
full_config = fv.full_config.get()
for uart_conf in full_config["uart"]:
if uart_conf[CONF_ID] == config[CONF_UART_ID]:
rx_buffer_size = uart_conf[CONF_RX_BUFFER_SIZE]
if rx_buffer_size < 1500:
_LOGGER.warning(
"UART '%s' rx_buffer_size should be bigger than 1500 bytes to avoid packet losses (currently %d bytes).",
config[CONF_UART_ID],
rx_buffer_size,
)
break
return config
FINAL_VALIDATE_SCHEMA = final_validate
+141 -266
View File
@@ -1,315 +1,183 @@
#include "dsmr.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
// Ignore Zephyr. It doesn't have any encryption library.
#if defined(USE_ESP32) || defined(USE_ARDUINO) || defined(USE_HOST)
#include <AES.h>
#include <Crypto.h>
#include <GCM.h>
#include "dsmr.h"
#include "esphome/core/log.h"
#include <dsmr_parser/util.h>
namespace esphome::dsmr {
static const char *const TAG = "dsmr";
static constexpr auto &TAG = "dsmr";
static void log_callback(dsmr_parser::LogLevel level, const char *fmt, va_list args) {
std::array<char, 256> buf;
vsnprintf(buf.data(), buf.size(), fmt, args);
switch (level) {
case dsmr_parser::LogLevel::ERROR:
ESP_LOGE(TAG, "%s", buf.data());
break;
case dsmr_parser::LogLevel::WARNING:
ESP_LOGW(TAG, "%s", buf.data());
break;
case dsmr_parser::LogLevel::INFO:
ESP_LOGI(TAG, "%s", buf.data());
break;
case dsmr_parser::LogLevel::VERBOSE:
ESP_LOGV(TAG, "%s", buf.data());
break;
case dsmr_parser::LogLevel::VERY_VERBOSE:
ESP_LOGVV(TAG, "%s", buf.data());
break;
case dsmr_parser::LogLevel::DEBUG:
ESP_LOGD(TAG, "%s", buf.data());
break;
}
}
void Dsmr::setup() {
this->telegram_ = new char[this->max_telegram_len_]; // NOLINT
dsmr_parser::Logger::set_log_function(log_callback);
if (this->request_pin_ != nullptr) {
this->request_pin_->setup();
}
}
void Dsmr::loop() {
if (this->ready_to_request_data_()) {
if (this->decryption_key_.empty()) {
this->receive_telegram_();
} else {
this->receive_encrypted_telegram_();
}
if (!this->ready_to_request_data_()) {
return;
}
if (this->encryption_enabled_) {
this->receive_encrypted_telegram_();
} else {
this->receive_telegram_();
}
}
bool Dsmr::ready_to_request_data_() {
// When using a request pin, then wait for the next request interval.
if (this->request_pin_ != nullptr) {
if (!this->requesting_data_ && this->request_interval_reached_()) {
this->start_requesting_data_();
}
}
// Otherwise, sink serial data until next request interval.
else {
if (this->request_interval_reached_()) {
this->start_requesting_data_();
}
if (!this->requesting_data_) {
this->drain_rx_buffer_();
}
if (!this->requesting_data_ && this->request_interval_reached_()) {
this->start_requesting_data_();
}
return this->requesting_data_;
}
bool Dsmr::request_interval_reached_() {
bool Dsmr::request_interval_reached_() const {
if (this->last_request_time_ == 0) {
return true;
}
return millis() - this->last_request_time_ > this->request_interval_;
}
bool Dsmr::receive_timeout_reached_() { return millis() - this->last_read_time_ > this->receive_timeout_; }
bool Dsmr::available_within_timeout_() {
// Data are available for reading on the UART bus?
// Then we can start reading right away.
if (this->available()) {
this->last_read_time_ = millis();
return true;
}
// When we're not in the process of reading a telegram, then there is
// no need to actively wait for new data to come in.
if (!header_found_) {
return false;
}
// A telegram is being read. The smart meter might not deliver a telegram
// in one go, but instead send it in chunks with small pauses in between.
// When the UART RX buffer cannot hold a full telegram, then make sure
// that the UART read buffer does not overflow while other components
// perform their work in their loop. Do this by not returning control to
// the main loop, until the read timeout is reached.
if (this->parent_->get_rx_buffer_size() < this->max_telegram_len_) {
while (!this->receive_timeout_reached_()) {
delay(5);
if (this->available()) {
this->last_read_time_ = millis();
return true;
}
}
}
// No new data has come in during the read timeout? Then stop reading the
// telegram and start waiting for the next one to arrive.
if (this->receive_timeout_reached_()) {
ESP_LOGW(TAG, "Timeout while reading data for telegram");
this->reset_telegram_();
}
return false;
}
void Dsmr::start_requesting_data_() {
if (!this->requesting_data_) {
if (this->request_pin_ != nullptr) {
ESP_LOGV(TAG, "Start requesting data from P1 port");
this->request_pin_->digital_write(true);
} else {
ESP_LOGV(TAG, "Start reading data from P1 port");
}
this->requesting_data_ = true;
this->last_request_time_ = millis();
if (this->requesting_data_) {
return;
}
ESP_LOGV(TAG, "Start reading data from P1 port");
this->flush_rx_buffer_();
if (this->request_pin_ != nullptr) {
ESP_LOGV(TAG, "Set request pin to 1");
this->request_pin_->digital_write(true);
}
this->requesting_data_ = true;
this->last_request_time_ = millis();
}
void Dsmr::stop_requesting_data_() {
if (this->requesting_data_) {
if (this->request_pin_ != nullptr) {
ESP_LOGV(TAG, "Stop requesting data from P1 port");
this->request_pin_->digital_write(false);
} else {
ESP_LOGV(TAG, "Stop reading data from P1 port");
}
this->drain_rx_buffer_();
this->requesting_data_ = false;
if (!this->requesting_data_) {
return;
}
ESP_LOGV(TAG, "Stop reading data from P1 port");
if (this->request_pin_ != nullptr) {
ESP_LOGV(TAG, "Set request pin to 0");
this->request_pin_->digital_write(false);
}
this->requesting_data_ = false;
}
void Dsmr::drain_rx_buffer_() {
uint8_t buf[64];
size_t avail;
while ((avail = this->available()) > 0) {
if (!this->read_array(buf, std::min(avail, sizeof(buf)))) {
break;
}
void Dsmr::flush_rx_buffer_() {
ESP_LOGV(TAG, "Flush UART RX buffer");
while (!this->uart_read_chunk_().empty()) {
}
}
void Dsmr::reset_telegram_() {
this->header_found_ = false;
this->footer_found_ = false;
this->bytes_read_ = 0;
this->crypt_bytes_read_ = 0;
this->crypt_telegram_len_ = 0;
}
void Dsmr::receive_telegram_() {
while (this->available_within_timeout_()) {
// Read all available bytes in batches to reduce UART call overhead.
uint8_t buf[64];
size_t avail = this->available();
while (avail > 0) {
size_t to_read = std::min(avail, sizeof(buf));
if (!this->read_array(buf, to_read))
for (auto data = this->uart_read_chunk_(); !data.empty(); data = this->uart_read_chunk_()) {
for (uint8_t byte : data) {
const auto telegram = this->packet_accumulator_.process_byte(byte);
if (!telegram) { // No full packet received yet
continue;
}
if (this->parse_telegram_(telegram.value())) {
return;
avail -= to_read;
for (size_t i = 0; i < to_read; i++) {
const char c = static_cast<char>(buf[i]);
// Find a new telegram header, i.e. forward slash.
if (c == '/') {
ESP_LOGV(TAG, "Header of telegram found");
this->reset_telegram_();
this->header_found_ = true;
}
if (!this->header_found_)
continue;
// Check for buffer overflow.
if (this->bytes_read_ >= this->max_telegram_len_) {
this->reset_telegram_();
ESP_LOGE(TAG, "Error: telegram larger than buffer (%d bytes)", this->max_telegram_len_);
return;
}
// Some v2.2 or v3 meters will send a new value which starts with '('
// in a new line, while the value belongs to the previous ObisId. For
// proper parsing, remove these new line characters.
if (c == '(') {
while (true) {
auto previous_char = this->telegram_[this->bytes_read_ - 1];
if (previous_char == '\n' || previous_char == '\r') {
this->bytes_read_--;
} else {
break;
}
}
}
// Store the byte in the buffer.
this->telegram_[this->bytes_read_] = c;
this->bytes_read_++;
// Check for a footer, i.e. exclamation mark, followed by a hex checksum.
if (c == '!') {
ESP_LOGV(TAG, "Footer of telegram found");
this->footer_found_ = true;
continue;
}
// Check for the end of the hex checksum, i.e. a newline.
if (this->footer_found_ && c == '\n') {
// Parse the telegram and publish sensor values.
this->parse_telegram();
this->reset_telegram_();
return;
}
}
}
}
}
void Dsmr::receive_encrypted_telegram_() {
while (this->available_within_timeout_()) {
// Read all available bytes in batches to reduce UART call overhead.
uint8_t buf[64];
size_t avail = this->available();
while (avail > 0) {
size_t to_read = std::min(avail, sizeof(buf));
if (!this->read_array(buf, to_read))
return;
avail -= to_read;
for (size_t i = 0; i < to_read; i++) {
const char c = static_cast<char>(buf[i]);
// Find a new telegram start byte.
if (!this->header_found_) {
if ((uint8_t) c != 0xDB) {
continue;
}
ESP_LOGV(TAG, "Start byte 0xDB of encrypted telegram found");
this->reset_telegram_();
this->header_found_ = true;
}
// Check for buffer overflow.
if (this->crypt_bytes_read_ >= this->max_telegram_len_) {
this->reset_telegram_();
ESP_LOGE(TAG, "Error: encrypted telegram larger than buffer (%d bytes)", this->max_telegram_len_);
return;
}
// Store the byte in the buffer.
this->crypt_telegram_[this->crypt_bytes_read_] = c;
this->crypt_bytes_read_++;
// Read the length of the incoming encrypted telegram.
if (this->crypt_telegram_len_ == 0 && this->crypt_bytes_read_ > 20) {
// Complete header + data bytes
this->crypt_telegram_len_ = 13 + (this->crypt_telegram_[11] << 8 | this->crypt_telegram_[12]);
ESP_LOGV(TAG, "Encrypted telegram length: %d bytes", this->crypt_telegram_len_);
}
// Check for the end of the encrypted telegram.
if (this->crypt_telegram_len_ == 0 || this->crypt_bytes_read_ != this->crypt_telegram_len_) {
continue;
}
ESP_LOGV(TAG, "End of encrypted telegram found");
// Decrypt the encrypted telegram.
GCM<AES128> *gcmaes128{new GCM<AES128>()};
gcmaes128->setKey(this->decryption_key_.data(), gcmaes128->keySize());
// the iv is 8 bytes of the system title + 4 bytes frame counter
// system title is at byte 2 and frame counter at byte 15
for (int i = 10; i < 14; i++)
this->crypt_telegram_[i] = this->crypt_telegram_[i + 4];
constexpr uint16_t iv_size{12};
gcmaes128->setIV(&this->crypt_telegram_[2], iv_size);
gcmaes128->decrypt(reinterpret_cast<uint8_t *>(this->telegram_),
// the ciphertext start at byte 18
&this->crypt_telegram_[18],
// cipher size
this->crypt_bytes_read_ - 17);
delete gcmaes128; // NOLINT(cppcoreguidelines-owning-memory)
this->bytes_read_ = strnlen(this->telegram_, this->max_telegram_len_);
ESP_LOGV(TAG, "Decrypted telegram size: %d bytes", this->bytes_read_);
ESP_LOGVV(TAG, "Decrypted telegram: %s", this->telegram_);
// Parse the decrypted telegram and publish sensor values.
this->parse_telegram();
this->reset_telegram_();
return;
for (auto data = this->uart_read_chunk_(); !data.empty(); data = this->uart_read_chunk_()) {
for (uint8_t byte : data) {
if (this->buffer_pos_ >= this->buffer_.size()) { // Reset buffer if overflow
ESP_LOGW(TAG, "Encrypted buffer overflow, resetting");
this->buffer_pos_ = 0;
}
this->buffer_[this->buffer_pos_] = byte;
this->buffer_pos_++;
}
this->last_read_time_ = millis();
}
// Detect inter-frame delay. If no byte is received for more than receive_timeout, then the packet is complete.
if (millis() - this->last_read_time_ > this->receive_timeout_ && this->buffer_pos_ > 0) {
ESP_LOGV(TAG, "Encrypted telegram received (%zu bytes)", this->buffer_pos_);
const auto telegram = this->dlms_decryptor_.decrypt_inplace({this->buffer_.data(), this->buffer_pos_});
// Reset buffer position for the next packet
this->buffer_pos_ = 0;
this->last_read_time_ = 0;
if (!telegram) { // decryption failed
return;
}
// Parse and publish the telegram
this->parse_telegram_(telegram.value());
}
}
bool Dsmr::parse_telegram() {
MyData data;
ESP_LOGV(TAG, "Trying to parse telegram");
bool Dsmr::parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram) {
this->stop_requesting_data_();
const auto &res = dsmr_parser::P1Parser::parse(
data, this->telegram_, this->bytes_read_, false,
this->crc_check_); // Parse telegram according to data definition. Ignore unknown values.
if (res.err) {
// Parsing error, show it
auto err_str = res.fullError(this->telegram_, this->telegram_ + this->bytes_read_);
ESP_LOGE(TAG, "%s", err_str.c_str());
return false;
} else {
this->status_clear_warning();
this->publish_sensors(data);
ESP_LOGV(TAG, "Trying to parse telegram (%zu bytes)", telegram.content().size());
ESP_LOGVV(TAG, "Telegram content:\n %.*s", static_cast<int>(telegram.content().size()), telegram.content().data());
// publish the telegram, after publishing the sensors so it can also trigger action based on latest values
if (this->s_telegram_ != nullptr) {
this->s_telegram_->publish_state(this->telegram_, this->bytes_read_);
}
return true;
MyData data;
if (const bool res = dsmr_parser::DsmrParser::parse(data, telegram); !res) {
ESP_LOGE(TAG, "Failed to parse telegram");
return false;
}
this->status_clear_warning();
this->publish_sensors(data);
// Publish the telegram, after publishing the sensors so it can also trigger action based on latest values
if (this->s_telegram_ != nullptr) {
this->s_telegram_->publish_state(telegram.content().data(), telegram.content().size());
}
return true;
}
void Dsmr::dump_config() {
ESP_LOGCONFIG(TAG,
"DSMR:\n"
" Max telegram length: %d\n"
" Max telegram length: %zu\n"
" Receive timeout: %.1fs",
this->max_telegram_len_, this->receive_timeout_ / 1e3f);
this->buffer_.size(), this->receive_timeout_ / 1e3f);
if (this->request_pin_ != nullptr) {
LOG_PIN(" Request Pin: ", this->request_pin_);
}
@@ -324,30 +192,37 @@ void Dsmr::dump_config() {
DSMR_TEXT_SENSOR_LIST(DSMR_LOG_TEXT_SENSOR, )
}
void Dsmr::set_decryption_key(const char *decryption_key) {
void Dsmr::set_decryption_key_(const char *decryption_key) {
if (decryption_key == nullptr || decryption_key[0] == '\0') {
ESP_LOGI(TAG, "Disabling decryption");
this->decryption_key_.clear();
if (this->crypt_telegram_ != nullptr) {
delete[] this->crypt_telegram_;
this->crypt_telegram_ = nullptr;
}
this->encryption_enabled_ = false;
return;
}
if (!parse_hex(decryption_key, this->decryption_key_, 16)) {
ESP_LOGE(TAG, "Error, decryption key must be 32 hex characters");
this->decryption_key_.clear();
auto key = dsmr_parser::Aes128GcmDecryptionKey::from_hex(decryption_key);
if (!key) {
ESP_LOGE(TAG, "Error, decryption key has incorrect format");
this->encryption_enabled_ = false;
return;
}
ESP_LOGI(TAG, "Decryption key is set");
// Verbose level prints decryption key
ESP_LOGV(TAG, "Using decryption key: %s", decryption_key);
if (this->crypt_telegram_ == nullptr) {
this->crypt_telegram_ = new uint8_t[this->max_telegram_len_]; // NOLINT
this->gcm_decryptor_.set_encryption_key(key.value());
this->encryption_enabled_ = true;
}
std::span<uint8_t> Dsmr::uart_read_chunk_() {
const auto avail = this->available();
if (avail == 0) {
return {};
}
size_t to_read = std::min(avail, uart_chunk_reading_buf_.size());
if (!this->read_array(uart_chunk_reading_buf_.data(), to_read)) {
return {};
}
return {uart_chunk_reading_buf_.data(), to_read};
}
} // namespace esphome::dsmr
#endif
+69 -63
View File
@@ -1,31 +1,46 @@
#pragma once
// Ignore Zephyr. It doesn't have any encryption library.
#if defined(USE_ESP32) || defined(USE_ARDUINO) || defined(USE_HOST)
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/text_sensor/text_sensor.h"
#include "esphome/components/uart/uart.h"
#include "esphome/core/log.h"
#include <dsmr_parser/dlms_packet_decryptor.h>
#include <dsmr_parser/fields.h>
#include <dsmr_parser/packet_accumulator.h>
#include <dsmr_parser/parser.h>
#include <array>
#include <span>
#include <vector>
#if __has_include(<psa/crypto.h>)
#include <dsmr_parser/decryption/aes128gcm_tfpsa.h>
#elif __has_include(<mbedtls/gcm.h>)
#if __has_include(<mbedtls/esp_config.h>)
#include <mbedtls/esp_config.h>
#endif
#include <dsmr_parser/decryption/aes128gcm_mbedtls.h>
#elif __has_include(<bearssl/bearssl.h>)
#include <dsmr_parser/decryption/aes128gcm_bearssl.h>
#else
#error "The platform doesn't provide a compatible encryption library for dsmr_parser"
#endif
namespace esphome::dsmr {
using namespace dsmr_parser::fields;
// DSMR_**_LIST generated by ESPHome and written in esphome/core/defines
#if !defined(DSMR_SENSOR_LIST) && !defined(DSMR_TEXT_SENSOR_LIST)
// Neither set, set it to a dummy value to not break build
#define DSMR_TEXT_SENSOR_LIST(F, SEP) F(identification)
#endif
#if defined(DSMR_SENSOR_LIST) && defined(DSMR_TEXT_SENSOR_LIST)
#define DSMR_BOTH ,
#if __has_include(<psa/crypto.h>)
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmTfPsa;
#elif __has_include(<mbedtls/gcm.h>)
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmMbedTls;
#else
#define DSMR_BOTH
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmBearSsl;
#endif
using namespace dsmr_parser::fields;
#ifndef DSMR_SENSOR_LIST
#define DSMR_SENSOR_LIST(F, SEP)
#endif
@@ -34,21 +49,33 @@ using namespace dsmr_parser::fields;
#define DSMR_TEXT_SENSOR_LIST(F, SEP)
#endif
#define DSMR_DATA_SENSOR(s) s
#define DSMR_IDENTITY(s) s
#define DSMR_COMMA ,
#define DSMR_PREPEND_COMMA(...) __VA_OPT__(, ) __VA_ARGS__
using MyData = dsmr_parser::ParsedData<DSMR_TEXT_SENSOR_LIST(DSMR_DATA_SENSOR, DSMR_COMMA)
DSMR_BOTH DSMR_SENSOR_LIST(DSMR_DATA_SENSOR, DSMR_COMMA)>;
#ifdef DSMR_TEXT_SENSOR_LIST_DEFINED
using MyData = dsmr_parser::ParsedData<DSMR_TEXT_SENSOR_LIST(DSMR_IDENTITY, DSMR_COMMA)
DSMR_PREPEND_COMMA(DSMR_SENSOR_LIST(DSMR_IDENTITY, DSMR_COMMA))>;
#else
using MyData = dsmr_parser::ParsedData<DSMR_SENSOR_LIST(DSMR_IDENTITY, DSMR_COMMA)>;
#endif
class Dsmr : public Component, public uart::UARTDevice {
public:
Dsmr(uart::UARTComponent *uart, bool crc_check) : uart::UARTDevice(uart), crc_check_(crc_check) {}
Dsmr(uart::UARTComponent *uart, bool crc_check, size_t max_telegram_length, uint32_t request_interval,
uint32_t receive_timeout, GPIOPin *request_pin, const char *decryption_key)
: uart::UARTDevice(uart),
request_interval_(request_interval),
receive_timeout_(receive_timeout),
request_pin_(request_pin),
buffer_(max_telegram_length),
packet_accumulator_(buffer_, crc_check) {
this->set_decryption_key_(decryption_key);
}
void setup() override;
void loop() override;
bool parse_telegram();
void publish_sensors(MyData &data) {
#define DSMR_PUBLISH_SENSOR(s) \
if (data.s##_present && this->s_##s##_ != nullptr) \
@@ -57,20 +84,15 @@ class Dsmr : public Component, public uart::UARTDevice {
#define DSMR_PUBLISH_TEXT_SENSOR(s) \
if (data.s##_present && this->s_##s##_ != nullptr) \
s_##s##_->publish_state(data.s.c_str());
s_##s##_->publish_state(data.s.data(), data.s.size());
DSMR_TEXT_SENSOR_LIST(DSMR_PUBLISH_TEXT_SENSOR, )
};
void dump_config() override;
void set_decryption_key(const char *decryption_key);
// Remove before 2026.8.0
ESPDEPRECATED("Pass .c_str() - e.g. set_decryption_key(key.c_str()). Removed in 2026.8.0", "2026.2.0")
void set_decryption_key(const std::string &decryption_key) { this->set_decryption_key(decryption_key.c_str()); }
void set_max_telegram_length(size_t length) { this->max_telegram_len_ = length; }
void set_request_pin(GPIOPin *request_pin) { this->request_pin_ = request_pin; }
void set_request_interval(uint32_t interval) { this->request_interval_ = interval; }
void set_receive_timeout(uint32_t timeout) { this->receive_timeout_ = timeout; }
ESPDEPRECATED("Use 'decryption_key' configuration parameter. This method will be removed in 2026.8.0", "2026.2.0")
void set_decryption_key(const std::string &decryption_key) { this->set_decryption_key_(decryption_key.c_str()); }
// Sensor setters
#define DSMR_SET_SENSOR(s) \
@@ -85,56 +107,40 @@ class Dsmr : public Component, public uart::UARTDevice {
void set_telegram(text_sensor::TextSensor *sensor) { s_telegram_ = sensor; }
protected:
void set_decryption_key_(const char *decryption_key);
void receive_telegram_();
void receive_encrypted_telegram_();
void reset_telegram_();
void drain_rx_buffer_();
void flush_rx_buffer_();
/// Wait for UART data to become available within the read timeout.
///
/// The smart meter might provide data in chunks, causing available() to
/// return 0. When we're already reading a telegram, then we don't return
/// right away (to handle further data in an upcoming loop) but wait a
/// little while using this method to see if more data are incoming.
/// By not returning, we prevent other components from taking so much
/// time that the UART RX buffer overflows and bytes of the telegram get
/// lost in the process.
bool available_within_timeout_();
// Request telegram
uint32_t request_interval_;
bool request_interval_reached_();
GPIOPin *request_pin_{nullptr};
uint32_t last_request_time_{0};
bool requesting_data_{false};
bool parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram);
bool request_interval_reached_() const;
bool ready_to_request_data_();
void start_requesting_data_();
void stop_requesting_data_();
std::span<uint8_t> uart_read_chunk_();
// Read telegram
// Config
uint32_t request_interval_;
uint32_t receive_timeout_;
bool receive_timeout_reached_();
size_t max_telegram_len_;
char *telegram_{nullptr};
size_t bytes_read_{0};
uint8_t *crypt_telegram_{nullptr};
size_t crypt_telegram_len_{0};
size_t crypt_bytes_read_{0};
uint32_t last_read_time_{0};
bool header_found_{false};
bool footer_found_{false};
// handled outside dsmr
GPIOPin *request_pin_{nullptr};
text_sensor::TextSensor *s_telegram_{nullptr};
// Sensor member pointers
#define DSMR_DECLARE_SENSOR(s) sensor::Sensor *s_##s##_{nullptr};
DSMR_SENSOR_LIST(DSMR_DECLARE_SENSOR, )
#define DSMR_DECLARE_TEXT_SENSOR(s) text_sensor::TextSensor *s_##s##_{nullptr};
DSMR_TEXT_SENSOR_LIST(DSMR_DECLARE_TEXT_SENSOR, )
std::vector<uint8_t> decryption_key_{};
bool crc_check_;
// State
uint32_t last_request_time_{0};
uint32_t last_read_time_{0};
bool requesting_data_{false};
bool encryption_enabled_{false};
size_t buffer_pos_{0};
std::vector<uint8_t> buffer_;
dsmr_parser::PacketAccumulator packet_accumulator_;
Aes128GcmDecryptorImpl gcm_decryptor_;
dsmr_parser::DlmsPacketDecryptor dlms_decryptor_{gcm_decryptor_};
std::array<uint8_t, 256> uart_chunk_reading_buf_;
};
} // namespace esphome::dsmr
#endif
+81
View File
@@ -10,6 +10,7 @@ from esphome.const import (
DEVICE_CLASS_FREQUENCY,
DEVICE_CLASS_GAS,
DEVICE_CLASS_POWER,
DEVICE_CLASS_POWER_FACTOR,
DEVICE_CLASS_REACTIVE_POWER,
DEVICE_CLASS_VOLTAGE,
DEVICE_CLASS_WATER,
@@ -119,6 +120,42 @@ CONFIG_SCHEMA = cv.Schema(
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("energy_delivered_tariff1_il"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT_HOURS,
accuracy_decimals=3,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("energy_delivered_tariff2_il"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT_HOURS,
accuracy_decimals=3,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("energy_delivered_tariff3_il"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT_HOURS,
accuracy_decimals=3,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("energy_returned_tariff1_il"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT_HOURS,
accuracy_decimals=3,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("energy_returned_tariff2_il"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT_HOURS,
accuracy_decimals=3,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("energy_returned_tariff3_il"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT_HOURS,
accuracy_decimals=3,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("total_imported_energy"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
@@ -511,6 +548,12 @@ CONFIG_SCHEMA = cv.Schema(
device_class=DEVICE_CLASS_GAS,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("gas_delivered_gj"): sensor.sensor_schema(
unit_of_measurement=UNIT_GIGA_JOULE,
accuracy_decimals=3,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("water_delivered"): sensor.sensor_schema(
unit_of_measurement=UNIT_CUBIC_METER,
accuracy_decimals=3,
@@ -614,6 +657,12 @@ CONFIG_SCHEMA = cv.Schema(
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("active_demand_net"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("active_demand_abs"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT,
accuracy_decimals=3,
@@ -728,6 +777,37 @@ CONFIG_SCHEMA = cv.Schema(
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("power_factor"): sensor.sensor_schema(
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER_FACTOR,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("power_factor_l1"): sensor.sensor_schema(
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER_FACTOR,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("power_factor_l2"): sensor.sensor_schema(
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER_FACTOR,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("power_factor_l3"): sensor.sensor_schema(
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER_FACTOR,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("min_power_factor"): sensor.sensor_schema(
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER_FACTOR,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("period_3_for_instantaneous_values"): sensor.sensor_schema(
unit_of_measurement=UNIT_SECOND,
accuracy_decimals=0,
device_class=DEVICE_CLASS_DURATION,
state_class=STATE_CLASS_MEASUREMENT,
),
}
).extend(cv.COMPONENT_SCHEMA)
@@ -746,6 +826,7 @@ async def to_code(config):
sensors.append(f"F({key})")
if sensors:
cg.add_define("DSMR_SENSOR_LIST_DEFINED")
cg.add_define(
"DSMR_SENSOR_LIST(F, sep)", cg.RawExpression(" sep ".join(sensors))
)
+3
View File
@@ -15,7 +15,9 @@ CONFIG_SCHEMA = cv.Schema(
cv.Optional("p1_version_be"): text_sensor.text_sensor_schema(),
cv.Optional("timestamp"): text_sensor.text_sensor_schema(),
cv.Optional("electricity_tariff"): text_sensor.text_sensor_schema(),
cv.Optional("electricity_tariff_il"): text_sensor.text_sensor_schema(),
cv.Optional("electricity_failure_log"): text_sensor.text_sensor_schema(),
cv.Optional("electricity_failure_log_il"): text_sensor.text_sensor_schema(),
cv.Optional("message_short"): text_sensor.text_sensor_schema(),
cv.Optional("message_long"): text_sensor.text_sensor_schema(),
cv.Optional("equipment_id"): text_sensor.text_sensor_schema(),
@@ -52,6 +54,7 @@ async def to_code(config):
text_sensors.append(f"F({key})")
if text_sensors:
cg.add_define("DSMR_TEXT_SENSOR_LIST_DEFINED")
cg.add_define(
"DSMR_TEXT_SENSOR_LIST(F, sep)",
cg.RawExpression(" sep ".join(text_sensors)),
@@ -0,0 +1,97 @@
#include "epaper_spi_ssd1683.h"
#include <algorithm>
#include "esphome/core/log.h"
namespace esphome::epaper_spi {
static constexpr const char *const TAG = "epaper_spi.mono";
void EPaperSSD1683::refresh_screen(bool partial) {
ESP_LOGV(TAG, "Refresh screen");
this->cmd_data(0x3C, {partial ? (uint8_t) 0x80 : (uint8_t) 0x01});
// On partial update, set red RAM to inverse to remove BW ghosting
this->cmd_data(0x21, {partial ? (uint8_t) 0x80 : (uint8_t) 0x40, (uint8_t) 0x00});
// Set full update to 0xD7 for fast update, 0xF7 for normal
// Fast update flashes less and draws sooner but is in busy state for the same amount of time
// Manufacturer recommends not using fast update all the time, TODO expose this to the user
this->cmd_data(0x22, {partial ? (uint8_t) 0xFC : (uint8_t) 0xF7});
this->command(0x20);
}
// Puts the display into deep sleep mode 1, only way to get out is to reset the display
// Mode 1 retains RAM while sleeping, necessary for future partial and window updates
void EPaperSSD1683::deep_sleep() {
if (this->is_using_partial_update_()) {
ESP_LOGV(TAG, "Deep sleep mode 1");
this->cmd_data(0x10, {0x01}); // deep sleep, retain RAM
} else {
ESP_LOGV(TAG, "Deep sleep mode 2");
this->cmd_data(0x10, {0x03}); // deep sleep, lose RAM
}
}
void EPaperSSD1683::set_window() {
// if not using partial update, the display will go into deep sleep mode 2, so must rewrite entire
// buffer since the display RAM will not retain contents
if (!this->is_using_partial_update_()) {
this->x_low_ = 0;
this->x_high_ = this->width_;
this->y_low_ = 0;
this->y_high_ = this->height_;
}
// round x-coordinates to byte boundaries
this->x_low_ /= 8;
this->x_high_ += 7;
this->x_high_ /= 8;
this->cmd_data(0x44, {(uint8_t) this->x_low_, (uint8_t) (this->x_high_ - 1)});
this->cmd_data(0x45, {(uint8_t) this->y_low_, (uint8_t) (this->y_low_ / 256), (uint8_t) (this->y_high_ - 1),
(uint8_t) ((this->y_high_ - 1) / 256)});
this->cmd_data(0x4E, {(uint8_t) this->x_low_});
this->cmd_data(0x4F, {(uint8_t) this->y_low_, (uint8_t) (this->y_low_ / 256)});
}
bool HOT EPaperSSD1683::transfer_data() {
auto start_time = millis();
if (this->current_data_index_ == 0) {
if (this->send_red_) {
// round to byte boundaries
this->set_window();
}
// for monochrome, we need to send red on every refresh to prevent dirty pixels
// when doing a partial refresh
this->command(this->send_red_ ? 0x26 : 0x24);
this->current_data_index_ = this->y_low_; // actually current line
}
size_t row_length = this->x_high_ - this->x_low_;
FixedVector<uint8_t> bytes_to_send{};
bytes_to_send.init(row_length);
ESP_LOGV(TAG, "Writing %u bytes at line %zu at %ums", row_length, this->current_data_index_, (unsigned) millis());
this->start_data_();
while (this->current_data_index_ != this->y_high_) {
size_t data_idx = this->current_data_index_ * this->row_width_ + this->x_low_;
for (size_t i = 0; i != row_length; i++) {
bytes_to_send[i] = this->buffer_[data_idx++];
}
++this->current_data_index_;
this->write_array(&bytes_to_send.front(), row_length); // NOLINT
if (millis() - start_time > MAX_TRANSFER_TIME) {
// Let the main loop run and come back next loop
this->disable();
return false;
}
}
this->disable();
this->current_data_index_ = 0;
if (this->send_red_) {
this->send_red_ = false;
return false;
}
this->send_red_ = true;
return true;
}
} // namespace esphome::epaper_spi
@@ -0,0 +1,22 @@
#pragma once
#include "epaper_spi_mono.h"
namespace esphome::epaper_spi {
/**
* A class for Solomon SSD1683 epaper displays.
*/
class EPaperSSD1683 : public EPaperMono {
public:
EPaperSSD1683(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence,
size_t init_sequence_length)
: EPaperMono(name, width, height, init_sequence, init_sequence_length) {}
protected:
void refresh_screen(bool partial) override;
void deep_sleep() override;
void set_window() override;
bool transfer_data() override;
};
} // namespace esphome::epaper_spi
@@ -0,0 +1,27 @@
from esphome.const import CONF_DATA_RATE
from . import EpaperModel
class SSD1683(EpaperModel):
def __init__(self, name, class_name="EPaperSSD1683", data_rate="20MHz", **defaults):
defaults[CONF_DATA_RATE] = data_rate
super().__init__(name, class_name, **defaults)
# fmt: off
def get_init_sequence(self, config: dict):
_width, height = self.get_dimensions(config)
return (
(0x01, (height - 1) % 256, (height - 1) // 256, 0x00), # Set column gate limit
(0x18, 0x80), # Select internal Temp sensor
(0x11, 0x03), # Set transform
)
ssd1683 = SSD1683("ssd1683")
goodisplay_gdey042t81 = ssd1683.extend(
"goodisplay-gdey042t81-4.2",
width=400,
height=300,
)
+84 -20
View File
@@ -33,6 +33,7 @@ from esphome.const import (
CONF_TYPE,
CONF_VARIANT,
CONF_VERSION,
CONF_WATCHDOG_TIMEOUT,
KEY_CORE,
KEY_FRAMEWORK_VERSION,
KEY_NAME,
@@ -128,23 +129,30 @@ ASSERTION_LEVELS = {
SIGNING_SCHEMES = {
"rsa3072": "CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME",
"ecdsa256": "CONFIG_SECURE_SIGNED_APPS_ECDSA_V2_SCHEME",
"ecdsa_v1": "CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME",
}
# Chip variants that only support one signing scheme for Secure Boot V2.
# Chip variants that only support one V2 signing scheme.
# Based on SOC_SECURE_BOOT_V2_RSA / SOC_SECURE_BOOT_V2_ECC in soc_caps.h.
# Variants not listed in either set support both RSA and ECDSA
# Variants not listed in either set support both RSA and ECDSA V2
# (e.g. C5, C6, H2, P4). New variants should be added to the
# appropriate set if they only support one scheme.
SIGNED_OTA_RSA_ONLY_VARIANTS = {
VARIANT_ESP32,
# Note: VARIANT_ESP32 is not listed here because it supports V2 RSA only
# when minimum_chip_revision >= 3.0, which requires special handling.
SIGNED_OTA_V2_RSA_ONLY_VARIANTS = {
VARIANT_ESP32S2,
VARIANT_ESP32S3,
VARIANT_ESP32C3,
}
SIGNED_OTA_ECC_ONLY_VARIANTS = {
SIGNED_OTA_V2_ECC_ONLY_VARIANTS = {
VARIANT_ESP32C2,
VARIANT_ESP32C61,
}
# V1 ECDSA (Secure Boot V1) is only supported on the original ESP32.
# Based on SOC_SECURE_BOOT_V1 in soc_caps.h.
SIGNED_OTA_V1_ECDSA_VARIANTS = {
VARIANT_ESP32,
}
COMPILER_OPTIMIZATIONS = {
"DEBUG": "CONFIG_COMPILER_OPTIMIZATION_DEBUG",
@@ -991,25 +999,73 @@ def final_validate(config):
if signed_ota := advanced.get(CONF_SIGNED_OTA_VERIFICATION):
scheme = signed_ota[CONF_SIGNING_SCHEME]
variant = config[CONF_VARIANT]
scheme_variant_conflicts = {
"ecdsa256": (SIGNED_OTA_RSA_ONLY_VARIANTS, "rsa3072"),
"rsa3072": (SIGNED_OTA_ECC_ONLY_VARIANTS, "ecdsa256"),
}
if (conflict := scheme_variant_conflicts.get(scheme)) and variant in conflict[
0
]:
min_rev = advanced.get(CONF_MINIMUM_CHIP_REVISION)
scheme_path = [
CONF_FRAMEWORK,
CONF_ADVANCED,
CONF_SIGNED_OTA_VERIFICATION,
CONF_SIGNING_SCHEME,
]
# V1 ECDSA is only available on the original ESP32
if scheme == "ecdsa_v1" and variant not in SIGNED_OTA_V1_ECDSA_VARIANTS:
errs.append(
cv.Invalid(
f"Signing scheme '{scheme}' is not supported on "
f"{VARIANT_FRIENDLY[variant]}. Use '{conflict[1]}' instead.",
path=[
CONF_FRAMEWORK,
CONF_ADVANCED,
CONF_SIGNED_OTA_VERIFICATION,
CONF_SIGNING_SCHEME,
],
f"Signing scheme 'ecdsa_v1' is only supported on "
f"{VARIANT_FRIENDLY[VARIANT_ESP32]}. "
f"Use 'rsa3072' or 'ecdsa256' instead.",
path=scheme_path,
)
)
elif variant == VARIANT_ESP32:
# On ESP32, V2 RSA requires minimum_chip_revision >= 3.0
# Note: string comparison works here because cv.one_of constrains
# min_rev to known ESP32_CHIP_REVISIONS values ("0.0".."3.1").
if scheme == "rsa3072" and (min_rev is None or min_rev < "3.0"):
errs.append(
cv.Invalid(
f"Signing scheme 'rsa3072' on {VARIANT_FRIENDLY[variant]} "
f"requires minimum_chip_revision: '3.0' or higher "
f"(Secure Boot V2 RSA needs chip revision 3.0+). "
f"For older chip revisions, use 'ecdsa_v1' instead.",
path=scheme_path,
)
)
# ESP32 does not support V2 ECDSA (no SOC_SECURE_BOOT_V2_ECC)
elif scheme == "ecdsa256":
errs.append(
cv.Invalid(
f"Signing scheme 'ecdsa256' is not supported on "
f"{VARIANT_FRIENDLY[variant]}. Use 'rsa3072' (with "
f"minimum_chip_revision: '3.0') or 'ecdsa_v1' instead.",
path=scheme_path,
)
)
# V1 on rev 3.0+ -- suggest V2 RSA for stronger security
elif scheme == "ecdsa_v1" and min_rev is not None and min_rev >= "3.0":
_LOGGER.info(
"Using Secure Boot V1 ECDSA on %s rev %s. "
"Consider using 'rsa3072' (Secure Boot V2 RSA) for "
"stronger security on chip revision 3.0+.",
VARIANT_FRIENDLY[variant],
min_rev,
)
else:
# Non-ESP32 variants: check V2 scheme-variant compatibility
scheme_variant_conflicts = {
"ecdsa256": (SIGNED_OTA_V2_RSA_ONLY_VARIANTS, "rsa3072"),
"rsa3072": (SIGNED_OTA_V2_ECC_ONLY_VARIANTS, "ecdsa256"),
}
if (
conflict := scheme_variant_conflicts.get(scheme)
) and variant in conflict[0]:
errs.append(
cv.Invalid(
f"Signing scheme '{scheme}' is not supported on "
f"{VARIANT_FRIENDLY[variant]}. Use '{conflict[1]}' instead.",
path=scheme_path,
)
)
if CONF_OTA not in full_config:
_LOGGER.warning(
"Signed OTA verification is enabled but no OTA component is configured. "
@@ -1452,6 +1508,10 @@ CONFIG_SCHEMA = cv.All(
),
cv.Optional(CONF_VARIANT): cv.one_of(*VARIANTS, upper=True),
cv.Optional(CONF_FRAMEWORK): FRAMEWORK_SCHEMA,
cv.Optional(CONF_WATCHDOG_TIMEOUT, default="5s"): cv.All(
cv.positive_time_period_seconds,
cv.Range(min=cv.TimePeriod(seconds=5), max=cv.TimePeriod(seconds=60)),
),
}
),
_detect_variant,
@@ -1819,6 +1879,10 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True)
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0", False)
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1", False)
add_idf_sdkconfig_option(
"CONFIG_ESP_TASK_WDT_TIMEOUT_S",
config[CONF_WATCHDOG_TIMEOUT].total_seconds,
)
# Disable dynamic log level control to save memory
add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", False)
+20 -1
View File
@@ -23,7 +23,26 @@ extern "C" __attribute__((weak)) void initArduino() {}
namespace esphome {
void HOT yield() { vPortYield(); }
uint32_t IRAM_ATTR HOT millis() { return micros_to_millis(static_cast<uint64_t>(esp_timer_get_time())); }
// Use xTaskGetTickCount() when tick rate is 1 kHz (ESPHome's default via sdkconfig),
// falling back to esp_timer for non-standard rates. IRAM_ATTR is required because
// Wiegand and ZyAura call millis() from IRAM_ATTR ISR handlers on ESP32.
// xTaskGetTickCountFromISR() is used in ISR context to satisfy the FreeRTOS API contract.
uint32_t IRAM_ATTR HOT millis() {
#if CONFIG_FREERTOS_HZ == 1000
if (xPortInIsrContext()) [[unlikely]] {
return xTaskGetTickCountFromISR();
}
return xTaskGetTickCount();
#else
return micros_to_millis(static_cast<uint64_t>(esp_timer_get_time()));
#endif
}
// millis_64() stays on esp_timer — a different clock from xTaskGetTickCount(). This is
// safe because the two are never cross-compared: millis() values are only used for
// millis()-vs-millis() deltas (feed_wdt, warn_blocking, component start time), while
// millis_64() is used by the Scheduler and uptime sensors. On ESP32 (USE_NATIVE_64BIT_TIME),
// Scheduler::millis_64_from_(now) discards the 32-bit now and calls millis_64() directly,
// so the Scheduler is internally consistent on the esp_timer clock.
uint64_t HOT millis_64() { return micros_to_millis<uint64_t>(static_cast<uint64_t>(esp_timer_get_time())); }
void HOT delay(uint32_t ms) { vTaskDelay(ms / portTICK_PERIOD_MS); }
uint32_t IRAM_ATTR HOT micros() { return (uint32_t) esp_timer_get_time(); }
+9 -3
View File
@@ -172,10 +172,16 @@ def validate_gpio_pin(pin):
exc,
)
else:
# Throw an exception if used for a pin that would not have resulted
# in a validation error anyway!
# `ignore_pin_validation_error` only suppresses an error raised by the
# variant's pin_validation above (e.g. SPI flash/PSRAM pins, invalid pin
# numbers). If that didn't raise, the option is a no-op -- warn so the
# user can clean it up, but don't block the build.
if ignore_pin_validation_warning:
raise cv.Invalid(f"GPIO{pin[CONF_NUMBER]} is not a reserved pin")
_LOGGER.warning(
"GPIO%d has no validation errors to ignore; "
"remove `ignore_pin_validation_error: true` from this pin.",
pin[CONF_NUMBER],
)
return pin
+120 -3
View File
@@ -5,6 +5,7 @@ import json # noqa: E402
import os # noqa: E402
import pathlib # noqa: E402
import shutil # noqa: E402
import subprocess # noqa: E402
from glob import glob # noqa: E402
@@ -25,6 +26,114 @@ def _parse_sdkconfig(sdkconfig_path):
return options
def _generate_v1_verification_key(env):
"""Generate the V1 ECDSA verification key binary and assembly source file.
Secure Boot V1 embeds the public verification key directly in the app binary
as a compiled object (via a .S assembly file). The ESP-IDF CMake build generates
these files via custom commands, but PlatformIO's SCons bridge does not execute
them. This function replicates that logic:
1. Extracts the raw public key from the PEM signing key using espsecure.
2. Generates the .S assembly source that embeds the key bytes.
"""
build_dir = pathlib.Path(env.subst("$BUILD_DIR"))
project_dir = pathlib.Path(env.subst("$PROJECT_DIR"))
pioenv = env.subst("$PIOENV")
sdkconfig = _parse_sdkconfig(project_dir / f"sdkconfig.{pioenv}")
if sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME") != "y":
return
bin_path = build_dir / "signature_verification_key.bin"
asm_path = build_dir / "signature_verification_key.bin.S"
# Determine the source of the verification key
if sdkconfig.get("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES") == "y":
# Extract public key from the signing key
signing_key = sdkconfig.get("CONFIG_SECURE_BOOT_SIGNING_KEY")
if not signing_key:
return
signing_key_path = pathlib.Path(signing_key)
if not signing_key_path.exists():
print(f"Error: V1 ECDSA signing key not found: {signing_key_path}")
env.Exit(1)
return
if not bin_path.exists() or bin_path.stat().st_mtime < signing_key_path.stat().st_mtime:
python_exe = env.subst("$PYTHONEXE")
result = subprocess.run(
[python_exe, "-m", "espsecure", "extract_public_key",
"--keyfile", str(signing_key_path), str(bin_path)],
capture_output=True, text=True,
)
if result.returncode != 0:
print(f"Error extracting V1 verification key: {result.stderr}")
env.Exit(1)
return
print(f"Extracted V1 ECDSA verification key from {signing_key_path.name}")
else:
# User-provided verification key -- should already be a raw binary file
verification_key = sdkconfig.get("CONFIG_SECURE_BOOT_VERIFICATION_KEY")
if not verification_key:
return
verification_key_path = pathlib.Path(verification_key)
if not verification_key_path.exists():
print(f"Error: Verification key not found: {verification_key_path}")
env.Exit(1)
return
shutil.copyfile(str(verification_key_path), str(bin_path))
if not bin_path.exists():
return
# Generate the .S assembly file from the binary key data.
# Replicates ESP-IDF's data_file_embed_asm.cmake with RENAME_TO=signature_verification_key_bin.
# The file is needed in both the app build dir and the bootloader build dir, since
# the bootloader also embeds the verification key when CONFIG_SECURE_SIGNED_ON_BOOT_NO_SECURE_BOOT
# is enabled. PlatformIO's SCons bridge does not execute the CMake custom commands that
# normally generate these files.
data = bin_path.read_bytes()
varname = "signature_verification_key_bin"
lines = []
lines.append(f"/* Data converted from {bin_path.name} */")
lines.append(".data")
lines.append("#if !defined (__APPLE__) && !defined (__linux__)")
lines.append(".section .rodata.embedded")
lines.append("#endif")
lines.append(f"\n.global {varname}")
lines.append(f"{varname}:")
lines.append(f"\n.global _binary_{varname}_start")
lines.append(f"_binary_{varname}_start: /* for objcopy compatibility */")
# Format binary data as .byte lines (16 bytes per line)
for i in range(0, len(data), 16):
chunk = data[i:i + 16]
hex_bytes = ", ".join(f"0x{b:02x}" for b in chunk)
lines.append(f".byte {hex_bytes}")
lines.append(f"\n.global _binary_{varname}_end")
lines.append(f"_binary_{varname}_end: /* for objcopy compatibility */")
lines.append(f"\n.global {varname}_length")
lines.append(f"{varname}_length:")
lines.append(f".long {len(data)}")
lines.append("")
lines.append('#if defined (__linux__)')
lines.append('.section .note.GNU-stack,"",@progbits')
lines.append("#endif")
asm_content = "\n".join(lines) + "\n"
# Write to app build dir and bootloader build dir
asm_path.write_text(asm_content)
bootloader_dir = build_dir / "bootloader"
if bootloader_dir.is_dir():
bootloader_bin = bootloader_dir / "signature_verification_key.bin"
bootloader_asm = bootloader_dir / "signature_verification_key.bin.S"
shutil.copyfile(str(bin_path), str(bootloader_bin))
bootloader_asm.write_text(asm_content)
def sign_firmware(source, target, env):
"""
Sign the firmware binary using espsecure.py if signed OTA verification is enabled.
@@ -55,9 +164,12 @@ def sign_firmware(source, target, env):
env.Exit(1)
return
# ESPHome only exposes RSA3072 and ECDSA256 (both Secure Boot V2 schemes),
# so the espsecure signature version is always 2.
sign_version = "2"
# Determine espsecure signature version from the signing scheme:
# V1 ECDSA (Secure Boot V1) uses --version 1, V2 RSA/ECDSA use --version 2.
if sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME") == "y":
sign_version = "1"
else:
sign_version = "2"
firmware_name = os.path.basename(env.subst("$PROGNAME")) + ".bin"
firmware_path = build_dir / firmware_name
@@ -217,6 +329,11 @@ def esp32_copy_ota_bin(source, target, env):
print(f"Copied firmware to {new_file_name}")
# Generate V1 ECDSA verification key files before build starts.
# Workaround for PlatformIO not executing CMake custom commands that extract
# the public key and generate the .S assembly file for Secure Boot V1.
_generate_v1_verification_key(env) # noqa: F821
# Run signing first, then merge, then ota copy
env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", sign_firmware) # noqa: F821
env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", merge_factory_bin) # noqa: F821
+20
View File
@@ -7,6 +7,7 @@ from typing import Any
from esphome import automation
import esphome.codegen as cg
from esphome.components.const import CONF_USE_PSRAM
from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant
from esphome.components.esp32.const import VARIANT_ESP32C2
import esphome.config_validation as cv
@@ -342,6 +343,9 @@ CONFIG_SCHEMA = cv.Schema(
cv.Optional(CONF_MAX_CONNECTIONS, default=DEFAULT_MAX_CONNECTIONS): cv.All(
cv.positive_int, cv.Range(min=1, max=IDF_MAX_CONNECTIONS)
),
cv.Optional(CONF_USE_PSRAM): cv.All(
cv.only_on_esp32, cv.requires_component("psram"), cv.boolean
),
}
).extend(cv.COMPONENT_SCHEMA)
@@ -598,6 +602,22 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True)
add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True)
# When PSRAM and BT are used together, Bluedroid should prefer SPIRAM for
# heap allocations and use dynamic (heap-based) environment memory tables
# instead of large static DRAM arrays. This frees ~40 kB of internal RAM.
# Reference: Espressif ADF Design Considerations
# https://espressif-docs.readthedocs-hosted.com/projects/esp-adf/en/latest/
# design-guide/design-considerations.html
if config.get(CONF_USE_PSRAM, False):
cg.add_define("USE_ESP32_BLE_PSRAM")
# CONFIG_BT_ALLOCATION_FROM_SPIRAM_FIRST is only available on ESP32
# (BTDM dual-mode controller). BLE-only SoCs (C3, S3, C2, H2) do not
# expose this Kconfig symbol; applying it there would cause a build error.
if get_esp32_variant() == const.VARIANT_ESP32:
add_idf_sdkconfig_option("CONFIG_BT_ALLOCATION_FROM_SPIRAM_FIRST", True)
# CONFIG_BT_BLE_DYNAMIC_ENV_MEMORY applies to all Bluedroid-enabled variants.
add_idf_sdkconfig_option("CONFIG_BT_BLE_DYNAMIC_ENV_MEMORY", True)
# Register the core BLE loggers that are always needed
register_bt_logger(BTLoggers.GAP, BTLoggers.BTM, BTLoggers.HCI)
+4 -3
View File
@@ -257,11 +257,9 @@ bool ESP32BLE::ble_setup_() {
if (this->name_ != nullptr) {
if (App.is_name_add_mac_suffix_enabled()) {
// MAC address length: 12 hex chars + null terminator
constexpr size_t mac_address_len = 13;
// MAC address suffix length (last 6 characters of 12-char MAC address string)
constexpr size_t mac_address_suffix_len = 6;
char mac_addr[mac_address_len];
char mac_addr[MAC_ADDRESS_BUFFER_SIZE];
get_mac_address_into_buffer(mac_addr);
const char *mac_suffix_ptr = mac_addr + mac_address_suffix_len;
make_name_with_suffix_to(name_buffer, sizeof(name_buffer), this->name_, strlen(this->name_), '-', mac_suffix_ptr,
@@ -667,6 +665,9 @@ void ESP32BLE::dump_config() {
" MAC address: %s\n"
" IO Capability: %s",
mac_s, io_capability_s);
#ifdef USE_ESP32_BLE_PSRAM
ESP_LOGCONFIG(TAG, " PSRAM BLE allocation: enabled");
#endif
#ifdef ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS
const char *auth_req_mode_s = "<default>";
+7 -3
View File
@@ -150,10 +150,14 @@ async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
cg.add(var.set_port(config[CONF_PORT]))
# Password could be set to an empty string and we can assume that means no password
if config.get(CONF_PASSWORD):
cg.add(var.set_auth_password(config[CONF_PASSWORD]))
# Compile the auth path whenever `password:` is present in YAML, even if empty.
# An empty password opts in to the auth code path so set_auth_password() can be
# called at runtime (e.g. to rotate the password from a lambda). When `password:`
# is omitted entirely, the auth path is excluded to save flash on small devices.
if CONF_PASSWORD in config:
cg.add_define("USE_OTA_PASSWORD")
if config[CONF_PASSWORD]:
cg.add(var.set_auth_password(config[CONF_PASSWORD]))
cg.add_define("USE_OTA_VERSION", config[CONF_VERSION])
# Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it.
cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME")
@@ -28,6 +28,14 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
};
#ifdef USE_OTA_PASSWORD
void set_auth_password(const std::string &password) { password_ = password; }
#else
// Stub so lambdas referencing set_auth_password() produce a clear error instead of
// a cryptic "no member" diagnostic. Only fires if the stub is actually instantiated.
template<bool B = false> void set_auth_password(const std::string &) {
static_assert(B, "set_auth_password() requires the OTA auth path to be compiled. "
"Add 'password: \"\"' (empty string) to your 'ota: - platform: esphome' "
"config to enable runtime password rotation.");
}
#endif // USE_OTA_PASSWORD
/// Manually set the port OTA should listen on
@@ -22,7 +22,7 @@ void HttpRequestComponent::dump_config() {
}
std::string HttpContainer::get_response_header(const std::string &header_name) {
auto lower = str_lower_case(header_name);
auto lower = str_lower_case(header_name); // NOLINT
for (const auto &entry : this->response_headers_) {
if (entry.name == lower) {
ESP_LOGD(TAG, "Header with name %s found with value %s", lower.c_str(), entry.value.c_str());
@@ -11,6 +11,7 @@
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/alloc_helpers.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
@@ -400,7 +401,7 @@ class HttpRequestComponent : public Component {
std::vector<std::string> lower;
lower.reserve(collect_headers.size());
for (const auto &h : collect_headers) {
lower.push_back(str_lower_case(h));
lower.push_back(str_lower_case(h)); // NOLINT
}
return this->perform(url, method, body, request_headers, lower);
}
@@ -415,7 +416,7 @@ class HttpRequestComponent : public Component {
std::vector<std::string> lower;
lower.reserve(collect_headers.size());
for (const auto &h : collect_headers) {
lower.push_back(str_lower_case(h));
lower.push_back(str_lower_case(h)); // NOLINT
}
return this->perform(url, method, body, std::vector<Header>(request_headers.begin(), request_headers.end()), lower);
}
@@ -161,7 +161,7 @@ std::shared_ptr<HttpContainer> HttpRequestArduino::perform(const std::string &ur
container->response_headers_.clear();
auto header_count = container->client_.headers();
for (int i = 0; i < header_count; i++) {
const std::string header_name = str_lower_case(container->client_.headerName(i).c_str());
const std::string header_name = str_lower_case(container->client_.headerName(i).c_str()); // NOLINT
if (should_collect_header(lower_case_collect_headers, header_name)) {
std::string header_value = container->client_.header(i).c_str();
ESP_LOGD(TAG, "Received response header, name: %s, value: %s", header_name.c_str(), header_value.c_str());
@@ -115,7 +115,7 @@ std::shared_ptr<HttpContainer> HttpRequestHost::perform(const std::string &url,
container->content_length = container->response_body_.size();
for (auto header : response.headers) {
ESP_LOGD(TAG, "Header: %s: %s", header.first.c_str(), header.second.c_str());
auto lower_name = str_lower_case(header.first);
auto lower_name = str_lower_case(header.first); // NOLINT
if (should_collect_header(lower_case_collect_headers, lower_name)) {
container->response_headers_.push_back({lower_name, header.second});
}
@@ -38,7 +38,7 @@ esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) {
switch (evt->event_id) {
case HTTP_EVENT_ON_HEADER: {
const std::string header_name = str_lower_case(evt->header_key);
const std::string header_name = str_lower_case(evt->header_key); // NOLINT
if (should_collect_header(user_data->lower_case_collect_headers, header_name)) {
const std::string header_value = evt->header_value;
ESP_LOGD(TAG, "Received response header, name: %s, value: %s", header_name.c_str(), header_value.c_str());
@@ -33,13 +33,16 @@ AUTO_LOAD = ["audio"]
CODEOWNERS = ["@jesserockz", "@kahrendt"]
DEPENDENCIES = ["i2s_audio"]
I2SAudioSpeaker = i2s_audio_ns.class_(
"I2SAudioSpeaker", cg.Component, speaker.Speaker, I2SAudioOut
I2SAudioSpeakerBase = i2s_audio_ns.class_(
"I2SAudioSpeakerBase", cg.Component, speaker.Speaker, I2SAudioOut
)
I2SAudioSpeaker = i2s_audio_ns.class_("I2SAudioSpeaker", I2SAudioSpeakerBase)
CONF_DAC_TYPE = "dac_type"
CONF_I2S_COMM_FMT = "i2s_comm_fmt"
I2SCommFmt = i2s_audio_ns.enum("I2SCommFmt", is_class=True)
i2s_dac_mode_t = cg.global_ns.enum("i2s_dac_mode_t")
INTERNAL_DAC_OPTIONS = {
CONF_LEFT: i2s_dac_mode_t.I2S_DAC_CHANNEL_LEFT_EN,
@@ -183,11 +186,11 @@ async def to_code(config):
await speaker.register_speaker(var, config)
cg.add(var.set_dout_pin(config[CONF_I2S_DOUT_PIN]))
fmt = "std" # equals stand_i2s, stand_pcm_long, i2s_msb, pcm_long
fmt = I2SCommFmt.STANDARD # equals stand_i2s, stand_pcm_long, i2s_msb, pcm_long
if config[CONF_I2S_COMM_FMT] in ["stand_msb", "i2s_lsb"]:
fmt = "msb"
fmt = I2SCommFmt.MSB
elif config[CONF_I2S_COMM_FMT] in ["stand_pcm_short", "pcm_short", "pcm"]:
fmt = "pcm"
fmt = I2SCommFmt.PCM
cg.add(var.set_i2s_comm_fmt(fmt))
if config[CONF_TIMEOUT] != CONF_NEVER:
cg.add(var.set_timeout(config[CONF_TIMEOUT]))
@@ -13,36 +13,10 @@
#include "esp_timer.h"
namespace esphome {
namespace i2s_audio {
static const uint32_t DMA_BUFFER_DURATION_MS = 15;
static const size_t DMA_BUFFERS_COUNT = 4;
static const size_t TASK_STACK_SIZE = 4096;
static const ssize_t TASK_PRIORITY = 19;
static const size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT + 1;
namespace esphome::i2s_audio {
static const char *const TAG = "i2s_audio.speaker";
enum SpeakerEventGroupBits : uint32_t {
COMMAND_START = (1 << 0), // indicates loop should start speaker task
COMMAND_STOP = (1 << 1), // stops the speaker task
COMMAND_STOP_GRACEFULLY = (1 << 2), // Stops the speaker task once all data has been written
TASK_STARTING = (1 << 10),
TASK_RUNNING = (1 << 11),
TASK_STOPPING = (1 << 12),
TASK_STOPPED = (1 << 13),
ERR_ESP_NO_MEM = (1 << 19),
WARN_DROPPED_EVENT = (1 << 20),
ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits
};
// Lists the Q15 fixed point scaling factor for volume reduction.
// Has 100 values representing silence and a reduction [49, 48.5, ... 0.5, 0] dB.
// dB to PCM scaling factor formula: floating_point_scale_factor = 2^(-db/6.014)
@@ -56,17 +30,21 @@ static const std::vector<int16_t> Q15_VOLUME_SCALING_FACTORS = {
8218, 8706, 9222, 9770, 10349, 10963, 11613, 12302, 13032, 13805, 14624, 15491, 16410, 17384, 18415,
19508, 20665, 21891, 23189, 24565, 26022, 27566, 29201, 30933, 32767};
void I2SAudioSpeaker::setup() {
void I2SAudioSpeakerBase::setup() {
this->event_group_ = xEventGroupCreate();
if (this->event_group_ == nullptr) {
ESP_LOGE(TAG, "Failed to create event group");
ESP_LOGE(TAG, "Event group creation failed");
this->mark_failed();
return;
}
// Initialize volume control. When audio_dac is configured, this sets the DAC volume.
// When no audio_dac is configured, this initializes software volume control.
this->set_volume(this->volume_);
}
void I2SAudioSpeaker::dump_config() {
void I2SAudioSpeakerBase::dump_config() {
ESP_LOGCONFIG(TAG,
"Speaker:\n"
" Pin: %d\n"
@@ -75,10 +53,9 @@ void I2SAudioSpeaker::dump_config() {
if (this->timeout_.has_value()) {
ESP_LOGCONFIG(TAG, " Timeout: %" PRIu32 " ms", this->timeout_.value());
}
ESP_LOGCONFIG(TAG, " Communication format: %s", this->i2s_comm_fmt_.c_str());
}
void I2SAudioSpeaker::loop() {
void I2SAudioSpeakerBase::loop() {
uint32_t event_group_bits = xEventGroupGetBits(this->event_group_);
if ((event_group_bits & SpeakerEventGroupBits::COMMAND_START) && (this->state_ == speaker::STATE_STOPPED)) {
@@ -92,12 +69,12 @@ void I2SAudioSpeaker::loop() {
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_STARTING);
}
if (event_group_bits & SpeakerEventGroupBits::TASK_RUNNING) {
ESP_LOGD(TAG, "Started");
ESP_LOGV(TAG, "Started");
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING);
this->state_ = speaker::STATE_RUNNING;
}
if (event_group_bits & SpeakerEventGroupBits::TASK_STOPPING) {
ESP_LOGD(TAG, "Stopping");
ESP_LOGV(TAG, "Stopping");
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING);
this->state_ = speaker::STATE_STOPPING;
}
@@ -111,10 +88,12 @@ void I2SAudioSpeaker::loop() {
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS);
this->status_clear_error();
this->on_task_stopped();
this->state_ = speaker::STATE_STOPPED;
}
// Log any errors encounted by the task
// Log any errors encountered by the task
if (event_group_bits & SpeakerEventGroupBits::ERR_ESP_NO_MEM) {
ESP_LOGE(TAG, "Not enough memory");
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM);
@@ -133,14 +112,14 @@ void I2SAudioSpeaker::loop() {
break;
}
if (this->start_i2s_driver_(this->audio_stream_info_) != ESP_OK) {
if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) {
ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second");
this->status_momentary_error("driver-faiure", 1000);
this->status_momentary_error("driver-failure", 1000);
break;
}
if (this->speaker_task_handle_ == nullptr) {
xTaskCreate(I2SAudioSpeaker::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
&this->speaker_task_handle_);
if (this->speaker_task_handle_ == nullptr) {
@@ -157,7 +136,7 @@ void I2SAudioSpeaker::loop() {
}
}
void I2SAudioSpeaker::set_volume(float volume) {
void I2SAudioSpeakerBase::set_volume(float volume) {
this->volume_ = volume;
#ifdef USE_AUDIO_DAC
if (this->audio_dac_ != nullptr) {
@@ -166,15 +145,21 @@ void I2SAudioSpeaker::set_volume(float volume) {
}
this->audio_dac_->set_volume(volume);
} else
#endif
#endif // USE_AUDIO_DAC
{
// Fallback to software volume control by using a Q15 fixed point scaling factor
ssize_t decibel_index = remap<ssize_t, float>(volume, 0.0f, 1.0f, 0, Q15_VOLUME_SCALING_FACTORS.size() - 1);
this->q15_volume_factor_ = Q15_VOLUME_SCALING_FACTORS[decibel_index];
// Fallback to software volume control by using a Q15 fixed point scaling factor.
// At maximum volume (1.0), set to INT16_MAX to completely bypass volume processing
// and avoid any floating-point precision issues that could cause slight volume reduction.
if (volume >= 1.0f) {
this->q15_volume_factor_ = INT16_MAX;
} else {
ssize_t decibel_index = remap<ssize_t, float>(volume, 0.0f, 1.0f, 0, Q15_VOLUME_SCALING_FACTORS.size() - 1);
this->q15_volume_factor_ = Q15_VOLUME_SCALING_FACTORS[decibel_index];
}
}
}
void I2SAudioSpeaker::set_mute_state(bool mute_state) {
void I2SAudioSpeakerBase::set_mute_state(bool mute_state) {
this->mute_state_ = mute_state;
#ifdef USE_AUDIO_DAC
if (this->audio_dac_) {
@@ -184,7 +169,7 @@ void I2SAudioSpeaker::set_mute_state(bool mute_state) {
this->audio_dac_->set_mute_off();
}
} else
#endif
#endif // USE_AUDIO_DAC
{
if (mute_state) {
// Fallback to software volume control and scale by 0
@@ -196,11 +181,12 @@ void I2SAudioSpeaker::set_mute_state(bool mute_state) {
}
}
size_t I2SAudioSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) {
size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) {
if (this->is_failed()) {
ESP_LOGE(TAG, "Setup failed; cannot play audio");
return 0;
}
if (this->state_ != speaker::STATE_RUNNING && this->state_ != speaker::STATE_STARTING) {
this->start();
}
@@ -214,8 +200,8 @@ size_t I2SAudioSpeaker::play(const uint8_t *data, size_t length, TickType_t tick
size_t bytes_written = 0;
if (this->state_ == speaker::STATE_RUNNING) {
std::shared_ptr<RingBuffer> temp_ring_buffer = this->audio_ring_buffer_.lock();
if (temp_ring_buffer.use_count() == 2) {
// Only the speaker task and this temp_ring_buffer own the ring buffer, so its safe to write to
if (temp_ring_buffer != nullptr) {
// The weak_ptr locks successfully only while the speaker task owns the ring buffer, so it is safe to write
bytes_written = temp_ring_buffer->write_without_replacement((void *) data, length, ticks_to_wait);
}
}
@@ -223,7 +209,7 @@ size_t I2SAudioSpeaker::play(const uint8_t *data, size_t length, TickType_t tick
return bytes_written;
}
bool I2SAudioSpeaker::has_buffered_data() const {
bool I2SAudioSpeakerBase::has_buffered_data() const {
if (this->audio_ring_buffer_.use_count() > 0) {
std::shared_ptr<RingBuffer> temp_ring_buffer = this->audio_ring_buffer_.lock();
return temp_ring_buffer->available() > 0;
@@ -231,216 +217,27 @@ bool I2SAudioSpeaker::has_buffered_data() const {
return false;
}
void I2SAudioSpeaker::speaker_task(void *params) {
I2SAudioSpeaker *this_speaker = (I2SAudioSpeaker *) params;
xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::TASK_STARTING);
const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * 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_speaker->buffer_duration_ms_);
// The DMA buffers may have more bits per sample, so calculate buffer sizes based in the input audio stream info
const size_t ring_buffer_size = this_speaker->current_stream_info_.ms_to_bytes(ring_buffer_duration);
const uint32_t frames_to_fill_single_dma_buffer =
this_speaker->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS);
const size_t bytes_to_fill_single_dma_buffer =
this_speaker->current_stream_info_.frames_to_bytes(frames_to_fill_single_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::shared_ptr<RingBuffer> temp_ring_buffer = RingBuffer::create(ring_buffer_size);
if (temp_ring_buffer.use_count() == 1) {
transfer_buffer->set_source(temp_ring_buffer);
this_speaker->audio_ring_buffer_ = temp_ring_buffer;
successful_setup = true;
}
}
if (!successful_setup) {
xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM);
} else {
bool stop_gracefully = false;
bool tx_dma_underflow = true;
uint32_t frames_written = 0;
uint32_t last_data_received_time = millis();
xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::TASK_RUNNING);
while (this_speaker->pause_state_ || !this_speaker->timeout_.has_value() ||
(millis() - last_data_received_time) <= this_speaker->timeout_.value()) {
uint32_t event_group_bits = xEventGroupGetBits(this_speaker->event_group_);
if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) {
xEventGroupClearBits(this_speaker->event_group_, SpeakerEventGroupBits::COMMAND_STOP);
break;
}
if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY) {
xEventGroupClearBits(this_speaker->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY);
stop_gracefully = true;
}
if (this_speaker->audio_stream_info_ != this_speaker->current_stream_info_) {
// Audio stream info changed, stop the speaker task so it will restart with the proper settings.
break;
}
int64_t write_timestamp;
while (xQueueReceive(this_speaker->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_speaker->current_stream_info_.frames_to_microseconds(frames_zeroed);
} else {
tx_dma_underflow = false;
}
frames_written -= frames_sent;
if (frames_sent > 0) {
this_speaker->audio_output_callback_(frames_sent, write_timestamp);
}
}
if (this_speaker->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
const uint32_t read_delay =
(this_speaker->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) {
if (this_speaker->q15_volume_factor_ < INT16_MAX) {
// Apply the software volume adjustment by unpacking the sample into a Q31 fixed-point number, shifting it,
// multiplying by the volume factor, and packing the sample back into the original bytes per sample.
const size_t bytes_per_sample = this_speaker->current_stream_info_.samples_to_bytes(1);
const uint32_t len = bytes_read / bytes_per_sample;
// Use Q16 for samples with 1 or 2 bytes: shifted_sample * gain_factor is Q16 * Q15 -> Q31
int32_t shift = 15; // Q31 -> Q16
int32_t gain_factor = this_speaker->q15_volume_factor_; // Q15
if (bytes_per_sample >= 3) {
// Use Q23 for samples with 3 or 4 bytes: shifted_sample * gain_factor is Q23 * Q8 -> Q31
shift = 8; // Q31 -> Q23
gain_factor >>= 7; // Q15 -> Q8
}
for (uint32_t i = 0; i < len; ++i) {
int32_t sample =
audio::unpack_audio_sample_to_q31(&new_data[i * bytes_per_sample], bytes_per_sample); // Q31
sample >>= shift;
sample *= gain_factor; // Q31
audio::pack_q31_as_audio_sample(sample, &new_data[i * bytes_per_sample], bytes_per_sample);
}
}
#ifdef USE_ESP32_VARIANT_ESP32
// For ESP32 16-bit mono mode, adjacent samples need to be swapped.
if (this_speaker->current_stream_info_.get_channels() == 1 &&
this_speaker->current_stream_info_.get_bits_per_sample() == 16) {
int16_t *samples = reinterpret_cast<int16_t *>(new_data);
size_t sample_count = bytes_read / sizeof(int16_t);
for (size_t i = 0; i + 1 < sample_count; i += 2) {
int16_t tmp = samples[i];
samples[i] = samples[i + 1];
samples[i + 1] = tmp;
}
}
#endif
}
if (transfer_buffer->available() == 0) {
if (stop_gracefully && tx_dma_underflow) {
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 so timing
// callbacks are accurate. Preload the data.
i2s_channel_disable(this_speaker->tx_handle_);
const i2s_event_callbacks_t callbacks = {
.on_sent = nullptr,
};
i2s_channel_register_event_callback(this_speaker->tx_handle_, &callbacks, this_speaker);
i2s_channel_preload_data(this_speaker->tx_handle_, transfer_buffer->get_buffer_start(),
transfer_buffer->available(), &bytes_written);
} else {
// Audio is already playing, use regular I2S write to add to the DMA buffers
i2s_channel_write(this_speaker->tx_handle_, transfer_buffer->get_buffer_start(), transfer_buffer->available(),
&bytes_written, DMA_BUFFER_DURATION_MS);
}
if (bytes_written > 0) {
last_data_received_time = millis();
frames_written += this_speaker->current_stream_info_.bytes_to_frames(bytes_written);
transfer_buffer->decrease_buffer_length(bytes_written);
if (tx_dma_underflow) {
tx_dma_underflow = false;
// Reset the event queue timestamps
// Enable the on_sent callback to accurately track the timestamps of played audio
// Enable the I2S channel to start sending the preloaded audio
xQueueReset(this_speaker->i2s_event_queue_);
const i2s_event_callbacks_t callbacks = {
.on_sent = i2s_on_sent_cb,
};
i2s_channel_register_event_callback(this_speaker->tx_handle_, &callbacks, this_speaker);
i2s_channel_enable(this_speaker->tx_handle_);
}
}
}
}
}
xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::TASK_STOPPING);
if (transfer_buffer != nullptr) {
transfer_buffer.reset();
}
xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::TASK_STOPPED);
while (true) {
// Continuously delay until the loop method deletes the task
vTaskDelay(pdMS_TO_TICKS(10));
}
void I2SAudioSpeakerBase::speaker_task(void *params) {
I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) params;
this_speaker->run_speaker_task();
}
void I2SAudioSpeaker::start() {
void I2SAudioSpeakerBase::start() {
if (!this->is_ready() || this->is_failed() || this->status_has_error())
return;
if ((this->state_ == speaker::STATE_STARTING) || (this->state_ == speaker::STATE_RUNNING))
return;
// Mark STARTING immediately to avoid transient STOPPED observations before loop() processes COMMAND_START.
this->state_ = speaker::STATE_STARTING;
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START);
}
void I2SAudioSpeaker::stop() { this->stop_(false); }
void I2SAudioSpeakerBase::stop() { this->stop_(false); }
void I2SAudioSpeaker::finish() { this->stop_(true); }
void I2SAudioSpeakerBase::finish() { this->stop_(true); }
void I2SAudioSpeaker::stop_(bool wait_on_empty) {
void I2SAudioSpeakerBase::stop_(bool wait_on_empty) {
if (this->is_failed())
return;
if (this->state_ == speaker::STATE_STOPPED)
@@ -453,105 +250,16 @@ void I2SAudioSpeaker::stop_(bool wait_on_empty) {
}
}
esp_err_t I2SAudioSpeaker::start_i2s_driver_(audio::AudioStreamInfo &audio_stream_info) {
this->current_stream_info_ = audio_stream_info; // store the stream info settings the driver will use
if ((this->i2s_role_ & I2S_ROLE_SLAVE) && (this->sample_rate_ != audio_stream_info.get_sample_rate())) { // NOLINT
// Can't reconfigure I2S bus, so the sample rate must match the configured value
ESP_LOGE(TAG, "Audio stream settings are not compatible with this I2S configuration");
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_) {
// Currently can't handle the case when the incoming audio has more bits per sample than the configured value
ESP_LOGE(TAG, "Audio streams with more bits per sample than the I2S speaker's configuration is not supported");
return ESP_ERR_NOT_SUPPORTED;
}
if (!this->parent_->try_lock()) {
ESP_LOGE(TAG, "Parent I2S bus not free");
return ESP_ERR_INVALID_STATE;
}
uint32_t dma_buffer_length = audio_stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS);
i2s_chan_config_t chan_cfg = {
.id = this->parent_->get_port(),
.role = this->i2s_role_,
.dma_desc_num = DMA_BUFFERS_COUNT,
.dma_frame_num = dma_buffer_length,
.auto_clear = true,
.intr_priority = 3,
};
/* Allocate a new TX channel and get the handle of this channel */
esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg,
size_t event_queue_size) {
esp_err_t err = i2s_new_channel(&chan_cfg, &this->tx_handle_, NULL);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to allocate new I2S channel");
ESP_LOGE(TAG, "I2S channel allocation failed: %s", esp_err_to_name(err));
this->parent_->unlock();
return err;
}
i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT;
#ifdef I2S_CLK_SRC_APLL
if (this->use_apll_) {
clk_src = I2S_CLK_SRC_APLL;
}
#endif
i2s_std_gpio_config_t pin_config = this->parent_->get_pin_config();
i2s_std_clk_config_t clk_cfg = {
.sample_rate_hz = audio_stream_info.get_sample_rate(),
.clk_src = clk_src,
.mclk_multiple = this->mclk_multiple_,
};
i2s_slot_mode_t slot_mode = this->slot_mode_;
i2s_std_slot_mask_t slot_mask = this->std_slot_mask_;
if (audio_stream_info.get_channels() == 1) {
slot_mode = I2S_SLOT_MODE_MONO;
} else if (audio_stream_info.get_channels() == 2) {
slot_mode = I2S_SLOT_MODE_STEREO;
slot_mask = I2S_STD_SLOT_BOTH;
}
i2s_std_slot_config_t std_slot_cfg;
if (this->i2s_comm_fmt_ == "std") {
std_slot_cfg =
I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode);
} else if (this->i2s_comm_fmt_ == "pcm") {
std_slot_cfg =
I2S_STD_PCM_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode);
} else {
std_slot_cfg =
I2S_STD_MSB_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode);
}
#ifdef USE_ESP32_VARIANT_ESP32
// There seems to be a bug on the ESP32 (non-variant) platform where setting the slot bit width higher then the bits
// per sample causes the audio to play too fast. Setting the ws_width to the configured slot bit width seems to
// make it play at the correct speed while sending more bits per slot.
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) {
uint32_t configured_bit_width = static_cast<uint32_t>(this->slot_bit_width_);
std_slot_cfg.ws_width = configured_bit_width;
if (configured_bit_width > 16) {
std_slot_cfg.msb_right = false;
}
}
#else
std_slot_cfg.slot_bit_width = this->slot_bit_width_;
#endif
std_slot_cfg.slot_mask = slot_mask;
pin_config.dout = this->dout_pin_;
i2s_std_config_t std_cfg = {
.clk_cfg = clk_cfg,
.slot_cfg = std_slot_cfg,
.gpio_cfg = pin_config,
};
/* Initialize the channel */
err = i2s_channel_init_std_mode(this->tx_handle_, &std_cfg);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to initialize channel");
i2s_del_channel(this->tx_handle_);
@@ -559,23 +267,34 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver_(audio::AudioStreamInfo &audio_strea
this->parent_->unlock();
return err;
}
if (this->i2s_event_queue_ == nullptr) {
this->i2s_event_queue_ = xQueueCreate(I2S_EVENT_QUEUE_COUNT, sizeof(int64_t));
this->i2s_event_queue_ = xQueueCreate(event_queue_size, sizeof(int64_t));
} else {
// Reset queue to clear any stale events from previous task
xQueueReset(this->i2s_event_queue_);
}
i2s_channel_enable(this->tx_handle_);
return err;
return ESP_OK;
}
bool IRAM_ATTR I2SAudioSpeaker::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx) {
void I2SAudioSpeakerBase::stop_i2s_driver_() {
if (this->tx_handle_ != nullptr) {
i2s_channel_disable(this->tx_handle_);
i2s_del_channel(this->tx_handle_);
this->tx_handle_ = nullptr;
}
this->parent_->unlock();
}
bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx) {
int64_t now = esp_timer_get_time();
BaseType_t need_yield1 = pdFALSE;
BaseType_t need_yield2 = pdFALSE;
BaseType_t need_yield3 = pdFALSE;
I2SAudioSpeaker *this_speaker = (I2SAudioSpeaker *) user_ctx;
I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) user_ctx;
if (xQueueIsQueueFullFromISR(this_speaker->i2s_event_queue_)) {
// Queue is full, so discard the oldest event and set the warning flag to inform the user
@@ -589,14 +308,47 @@ bool IRAM_ATTR I2SAudioSpeaker::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_eve
return need_yield1 | need_yield2 | need_yield3;
}
void I2SAudioSpeaker::stop_i2s_driver_() {
i2s_channel_disable(this->tx_handle_);
i2s_del_channel(this->tx_handle_);
this->tx_handle_ = nullptr;
this->parent_->unlock();
void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_read) {
if (this->q15_volume_factor_ >= INT16_MAX) {
return; // Max volume, no processing needed
}
const size_t bytes_per_sample = this->current_stream_info_.samples_to_bytes(1);
const uint32_t len = bytes_read / bytes_per_sample;
// Use Q16 for samples with 1 or 2 bytes: shifted_sample * gain_factor is Q16 * Q15 -> Q31
int32_t shift = 15; // Q31 -> Q16
int32_t gain_factor = this->q15_volume_factor_; // Q15
if (bytes_per_sample >= 3) {
// Use Q23 for samples with 3 or 4 bytes: shifted_sample * gain_factor is Q23 * Q8 -> Q31
shift = 8; // Q31 -> Q23
gain_factor >>= 7; // Q15 -> Q8
}
for (uint32_t i = 0; i < len; ++i) {
int32_t sample = audio::unpack_audio_sample_to_q31(&data[i * bytes_per_sample], bytes_per_sample); // Q31
sample >>= shift;
sample *= gain_factor; // Q31
audio::pack_q31_as_audio_sample(sample, &data[i * bytes_per_sample], bytes_per_sample);
}
}
} // namespace i2s_audio
} // namespace esphome
void I2SAudioSpeakerBase::swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read) {
#ifdef USE_ESP32_VARIANT_ESP32
// For ESP32 16-bit mono mode, adjacent samples need to be swapped.
if (this->current_stream_info_.get_channels() == 1 && this->current_stream_info_.get_bits_per_sample() == 16) {
int16_t *samples = reinterpret_cast<int16_t *>(data);
size_t sample_count = bytes_read / sizeof(int16_t);
for (size_t i = 0; i + 1 < sample_count; i += 2) {
int16_t tmp = samples[i];
samples[i] = samples[i + 1];
samples[i + 1] = tmp;
}
}
#endif // USE_ESP32_VARIANT_ESP32
}
} // namespace esphome::i2s_audio
#endif // USE_ESP32
@@ -16,10 +16,34 @@
#include "esphome/core/helpers.h"
#include "esphome/core/ring_buffer.h"
namespace esphome {
namespace i2s_audio {
namespace esphome::i2s_audio {
class I2SAudioSpeaker : public I2SAudioOut, public speaker::Speaker, public Component {
// Shared constants for I2S audio 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;
enum SpeakerEventGroupBits : uint32_t {
COMMAND_START = (1 << 0), // indicates loop should start speaker task
COMMAND_STOP = (1 << 1), // stops the speaker task
COMMAND_STOP_GRACEFULLY = (1 << 2), // Stops the speaker task once all data has been written
TASK_STARTING = (1 << 10),
TASK_RUNNING = (1 << 11),
TASK_STOPPING = (1 << 12),
TASK_STOPPED = (1 << 13),
ERR_ESP_NO_MEM = (1 << 19),
WARN_DROPPED_EVENT = (1 << 20),
ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits
};
/// @brief Abstract base class for I2S audio speaker implementations.
/// Provides shared infrastructure (event groups, ring buffer, volume control, task lifecycle)
/// for derived I2S speaker classes.
class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public Component {
public:
float get_setup_priority() const override { return esphome::setup_priority::PROCESSOR; }
@@ -30,7 +54,9 @@ class I2SAudioSpeaker : public I2SAudioOut, public speaker::Speaker, public Comp
void set_buffer_duration(uint32_t buffer_duration_ms) { this->buffer_duration_ms_ = buffer_duration_ms; }
void set_timeout(uint32_t ms) { this->timeout_ = ms; }
void set_dout_pin(uint8_t pin) { this->dout_pin_ = (gpio_num_t) pin; }
void set_i2s_comm_fmt(std::string mode) { this->i2s_comm_fmt_ = std::move(mode); }
/// @brief Get the I2S TX channel handle
i2s_chan_handle_t get_tx_handle() const { return this->tx_handle_; }
void start() override;
void stop() override;
@@ -63,40 +89,55 @@ class I2SAudioSpeaker : public I2SAudioOut, public speaker::Speaker, public Comp
void set_mute_state(bool mute_state) override;
protected:
/// @brief Function for the FreeRTOS task handling audio output.
/// Allocates space for the buffers, reads audio from the ring buffer and writes audio to the I2S port. Stops
/// immmiately after receiving the COMMAND_STOP signal and stops only after the ring buffer is empty after receiving
/// the COMMAND_STOP_GRACEFULLY signal. Stops if the ring buffer hasn't read data for more than timeout_ milliseconds.
/// When stopping, it deallocates the buffers. It communicates its state and any errors via ``event_group_``.
/// @param params I2SAudioSpeaker component
/// @brief FreeRTOS task entry point. Casts params to I2SAudioSpeakerBase and calls run_speaker_task_().
/// @param params I2SAudioSpeakerBase component pointer
static void speaker_task(void *params);
/// @brief The main speaker task loop. Implemented by derived classes for mode-specific behavior.
virtual void run_speaker_task() = 0;
/// @brief Sends a stop command to the speaker task via ``event_group_``.
/// @param wait_on_empty If false, sends the COMMAND_STOP signal. If true, sends the COMMAND_STOP_GRACEFULLY signal.
void stop_(bool wait_on_empty);
/// @brief Callback function used to send playback timestamps the to the speaker task.
/// @brief Callback function used to send playback timestamps to the speaker task.
/// @param handle (i2s_chan_handle_t)
/// @param event (i2s_event_data_t)
/// @param user_ctx (void*) User context pointer that the callback accesses
/// @return True if a higher priority task was interrupted
static bool i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx);
/// @brief Starts the ESP32 I2S driver.
/// Attempts to lock the I2S port, starts the I2S driver using the passed in stream information, and sets the data out
/// pin. If it fails, it will unlock the I2S port and uninstalls the driver, if necessary.
/// @brief Starts the ESP32 I2S driver. Implemented by derived classes for mode-specific configuration.
/// @param audio_stream_info Stream information for the I2S driver.
/// @return ESP_ERR_NOT_ALLOWED if the I2S port can't play the incoming audio stream.
/// ESP_ERR_INVALID_STATE if the I2S port is already locked.
/// ESP_ERR_INVALID_ARG if installing the driver or setting the data outpin fails due to a parameter error.
/// ESP_ERR_NO_MEM if the driver fails to install due to a memory allocation error.
/// ESP_FAIL if setting the data out pin fails due to an IO error
/// ESP_OK if successful
esp_err_t start_i2s_driver_(audio::AudioStreamInfo &audio_stream_info);
/// @return ESP_OK if successful, or an error code
virtual esp_err_t start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) = 0;
/// @brief Shared I2S channel allocation, initialization, and event queue setup.
/// Called by derived start_i2s_driver_() implementations after building mode-specific configs.
/// @param chan_cfg I2S channel configuration
/// @param std_cfg I2S standard mode configuration (clock, slot, GPIO)
/// @param event_queue_size Size of the event queue
/// @return ESP_OK if successful, or an error code. On failure, cleans up channel and unlocks parent.
esp_err_t init_i2s_channel_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg,
size_t event_queue_size);
/// @brief Stops the I2S driver and unlocks the I2S port
void stop_i2s_driver_();
/// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup.
virtual void on_task_stopped() {}
/// @brief Apply software volume control using Q15 fixed-point scaling.
/// @param data Pointer to audio sample data (modified in place)
/// @param bytes_read Number of bytes of audio data
void apply_software_volume_(uint8_t *data, size_t bytes_read);
/// @brief Swap adjacent 16-bit mono samples for ESP32 (non-variant) hardware quirk.
/// Only applies when running on original ESP32 with 16-bit mono audio.
/// @param data Pointer to audio sample data (modified in place)
/// @param bytes_read Number of bytes of audio data
void swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read);
TaskHandle_t speaker_task_handle_{nullptr};
EventGroupHandle_t event_group_{nullptr};
@@ -115,11 +156,9 @@ class I2SAudioSpeaker : public I2SAudioOut, public speaker::Speaker, public Comp
audio::AudioStreamInfo current_stream_info_; // The currently loaded driver's stream info
gpio_num_t dout_pin_;
std::string i2s_comm_fmt_;
i2s_chan_handle_t tx_handle_;
i2s_chan_handle_t tx_handle_{nullptr};
};
} // namespace i2s_audio
} // namespace esphome
} // namespace esphome::i2s_audio
#endif // USE_ESP32
@@ -0,0 +1,307 @@
#include "i2s_audio_speaker_standard.h"
#ifdef USE_ESP32
#include <driver/i2s_std.h>
#include "esphome/components/audio/audio.h"
#include "esphome/components/audio/audio_transfer_buffer.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "esp_timer.h"
namespace esphome::i2s_audio {
static const char *const TAG = "i2s_audio.speaker.std";
static constexpr size_t DMA_BUFFERS_COUNT = 4;
static constexpr size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT + 1;
void I2SAudioSpeaker::dump_config() {
I2SAudioSpeakerBase::dump_config();
const char *fmt_str;
switch (this->i2s_comm_fmt_) {
case I2SCommFmt::PCM:
fmt_str = "pcm";
break;
case I2SCommFmt::MSB:
fmt_str = "msb";
break;
default:
fmt_str = "std";
break;
}
ESP_LOGCONFIG(TAG, " Communication format: %s", fmt_str);
}
void I2SAudioSpeaker::run_speaker_task() {
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STARTING);
const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * 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 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);
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::shared_ptr<RingBuffer> temp_ring_buffer = RingBuffer::create(ring_buffer_size);
if (temp_ring_buffer.use_count() == 1) {
transfer_buffer->set_source(temp_ring_buffer);
this->audio_ring_buffer_ = temp_ring_buffer;
successful_setup = true;
}
}
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;
uint32_t last_data_received_time = millis();
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING);
// Main speaker task loop. Continues while:
// - Paused, OR
// - No timeout configured, OR
// - Timeout hasn't elapsed since last data
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) {
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP);
ESP_LOGV(TAG, "Exiting: COMMAND_STOP received");
break;
}
if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY) {
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY);
stop_gracefully = true;
}
if (this->audio_stream_info_ != this->current_stream_info_) {
// Audio stream info changed, stop the speaker task so it will restart with the proper settings.
ESP_LOGV(TAG, "Exiting: stream info changed");
break;
}
int64_t write_timestamp;
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) {
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 (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);
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_);
}
}
}
}
}
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING);
if (transfer_buffer != nullptr) {
transfer_buffer.reset();
}
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPED);
while (true) {
// Continuously delay until the loop method deletes the task
vTaskDelay(pdMS_TO_TICKS(10));
}
}
esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) {
this->current_stream_info_ = audio_stream_info;
if ((this->i2s_role_ & I2S_ROLE_SLAVE) && (this->sample_rate_ != audio_stream_info.get_sample_rate())) { // NOLINT
// Can't reconfigure I2S bus, so the sample rate must match the configured value
ESP_LOGE(TAG, "Incompatible stream settings");
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_) {
// Currently can't handle the case when the incoming audio has more bits per sample than the configured value
ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration");
return ESP_ERR_NOT_SUPPORTED;
}
if (!this->parent_->try_lock()) {
ESP_LOGE(TAG, "Parent bus is busy");
return ESP_ERR_INVALID_STATE;
}
uint32_t dma_buffer_length = audio_stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS);
i2s_role_t i2s_role = this->i2s_role_;
i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT;
#if SOC_CLK_APLL_SUPPORTED
if (this->use_apll_) {
clk_src = i2s_clock_src_t::I2S_CLK_SRC_APLL;
}
#endif // SOC_CLK_APLL_SUPPORTED
// Log DMA configuration for debugging
ESP_LOGV(TAG, "I2S DMA config: %zu buffers x %lu frames", (size_t) DMA_BUFFERS_COUNT,
(unsigned long) dma_buffer_length);
i2s_chan_config_t chan_cfg = {
.id = this->parent_->get_port(),
.role = i2s_role,
.dma_desc_num = DMA_BUFFERS_COUNT,
.dma_frame_num = dma_buffer_length,
.auto_clear = true,
.intr_priority = 3,
};
// Build standard I2S clock/slot/gpio configuration
i2s_std_clk_config_t clk_cfg = {
.sample_rate_hz = audio_stream_info.get_sample_rate(),
.clk_src = clk_src,
.mclk_multiple = this->mclk_multiple_,
};
i2s_slot_mode_t slot_mode = this->slot_mode_;
i2s_std_slot_mask_t slot_mask = this->std_slot_mask_;
if (audio_stream_info.get_channels() == 1) {
slot_mode = I2S_SLOT_MODE_MONO;
} else if (audio_stream_info.get_channels() == 2) {
slot_mode = I2S_SLOT_MODE_STEREO;
slot_mask = I2S_STD_SLOT_BOTH;
}
i2s_std_slot_config_t slot_cfg;
switch (this->i2s_comm_fmt_) {
case I2SCommFmt::PCM:
slot_cfg =
I2S_STD_PCM_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode);
break;
case I2SCommFmt::MSB:
slot_cfg =
I2S_STD_MSB_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode);
break;
default:
slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(),
slot_mode);
break;
}
#ifdef USE_ESP32_VARIANT_ESP32
// There seems to be a bug on the ESP32 (non-variant) platform where setting the slot bit width higher than the
// bits per sample causes the audio to play too fast. Setting the ws_width to the configured slot bit width seems
// to make it play at the correct speed while sending more bits per slot.
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) {
uint32_t configured_bit_width = static_cast<uint32_t>(this->slot_bit_width_);
slot_cfg.ws_width = configured_bit_width;
if (configured_bit_width > 16) {
slot_cfg.msb_right = false;
}
}
#else
slot_cfg.slot_bit_width = this->slot_bit_width_;
#endif // USE_ESP32_VARIANT_ESP32
slot_cfg.slot_mask = slot_mask;
i2s_std_gpio_config_t gpio_cfg = this->parent_->get_pin_config();
gpio_cfg.dout = this->dout_pin_;
i2s_std_config_t std_cfg = {
.clk_cfg = clk_cfg,
.slot_cfg = slot_cfg,
.gpio_cfg = gpio_cfg,
};
esp_err_t err = this->init_i2s_channel_(chan_cfg, std_cfg, I2S_EVENT_QUEUE_COUNT);
if (err != ESP_OK) {
return err;
}
i2s_channel_enable(this->tx_handle_);
return ESP_OK;
}
} // namespace esphome::i2s_audio
#endif // USE_ESP32
@@ -0,0 +1,32 @@
#pragma once
#ifdef USE_ESP32
#include "i2s_audio_speaker.h"
namespace esphome::i2s_audio {
enum class I2SCommFmt : uint8_t {
STANDARD, // Philips / I2S standard
PCM, // PCM short
MSB, // MSB / left-justified
};
/// @brief Standard I2S speaker implementation.
/// Outputs PCM audio data directly to an I2S DAC using the standard I2S protocol.
class I2SAudioSpeaker : public I2SAudioSpeakerBase {
public:
void dump_config() override;
void set_i2s_comm_fmt(I2SCommFmt fmt) { this->i2s_comm_fmt_ = fmt; }
protected:
void run_speaker_task() override;
esp_err_t start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) override;
I2SCommFmt i2s_comm_fmt_{I2SCommFmt::STANDARD};
};
} // namespace esphome::i2s_audio
#endif // USE_ESP32
+1 -1
View File
@@ -756,7 +756,7 @@ async def write_image(config, all_frames=False):
for col in range(width):
encoder.encode(pixels[row * width + col])
encoder.end_row()
encoder.end_image()
encoder.end_image()
rhs = [HexInt(x) for x in encoder.data]
prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs)
@@ -20,8 +20,6 @@ void InternalTemperatureSensor::update() {
success = (result == 0);
#if defined(USE_LIBRETINY_VARIANT_BK7231N)
temperature = raw * -0.38f + 156.0f;
#elif defined(USE_LIBRETINY_VARIANT_BK7231T)
temperature = raw * 0.04f;
#else // USE_LIBRETINY_VARIANT
temperature = raw * 0.128f;
#endif // USE_LIBRETINY_VARIANT
+24 -18
View File
@@ -766,32 +766,38 @@ void LD2412Component::get_distance_resolution_() { this->send_command_(CMD_QUERY
void LD2412Component::query_light_control_() { this->send_command_(CMD_QUERY_LIGHT_CONTROL, nullptr, 0); }
void LD2412Component::set_basic_config() {
uint8_t min_gate = 1;
uint8_t max_gate = TOTAL_GATES;
uint16_t timeout = DEFAULT_PRESENCE_TIMEOUT;
uint8_t out_pin_level = 0x01;
#ifdef USE_NUMBER
if (!this->min_distance_gate_number_->has_state() || !this->max_distance_gate_number_->has_state() ||
!this->timeout_number_->has_state()) {
return;
if (this->min_distance_gate_number_ != nullptr) {
if (!this->min_distance_gate_number_->has_state())
return;
min_gate = static_cast<int>(this->min_distance_gate_number_->state);
}
if (this->max_distance_gate_number_ != nullptr) {
if (!this->max_distance_gate_number_->has_state())
return;
max_gate = static_cast<int>(this->max_distance_gate_number_->state) + 1;
}
if (this->timeout_number_ != nullptr) {
if (!this->timeout_number_->has_state())
return;
timeout = static_cast<int>(this->timeout_number_->state);
}
#endif
#ifdef USE_SELECT
if (!this->out_pin_level_select_->has_state()) {
return;
if (this->out_pin_level_select_ != nullptr) {
if (!this->out_pin_level_select_->has_state())
return;
out_pin_level = find_uint8(OUT_PIN_LEVELS_BY_STR, this->out_pin_level_select_->current_option().c_str());
}
#endif
uint8_t value[5] = {
#ifdef USE_NUMBER
lowbyte(static_cast<int>(this->min_distance_gate_number_->state)),
lowbyte(static_cast<int>(this->max_distance_gate_number_->state) + 1),
lowbyte(static_cast<int>(this->timeout_number_->state)),
highbyte(static_cast<int>(this->timeout_number_->state)),
#else
1, TOTAL_GATES, DEFAULT_PRESENCE_TIMEOUT, 0,
#endif
#ifdef USE_SELECT
find_uint8(OUT_PIN_LEVELS_BY_STR, this->out_pin_level_select_->current_option().c_str()),
#else
0x01, // Default value if not using select
#endif
lowbyte(min_gate), lowbyte(max_gate), lowbyte(timeout), highbyte(timeout), out_pin_level,
};
this->set_config_mode_(true);
this->send_command_(CMD_BASIC_CONF, value, sizeof(value));
+7
View File
@@ -443,6 +443,13 @@ async def component_to_code(config):
# 4-8KB flash). Even if linked, it would use locks, so explicit FreeRTOS
# mutexes are simpler and equivalent.
cg.add_define(ThreadModel.MULTI_NO_ATOMICS)
# Enable FreeRTOS static allocation so FreeRTOSQueue can use
# xQueueCreateStatic (queue storage in BSS, no heap allocation).
# Also moves FreeRTOS internal structures (timer command queue) to BSS.
# BK72xx's FreeRTOSConfig.h doesn't define this, defaulting to 0.
# The -D wins over the #ifndef default in FreeRTOS.h.
# Not enabled on RTL87xx/LN882x — costs more heap than it saves there.
cg.add_build_flag("-DconfigSUPPORT_STATIC_ALLOCATION=1")
# RTL8710B needs FreeRTOS 8.2.3+ for xTaskNotifyGive/ulTaskNotifyTake
# required by AsyncTCP 3.4.3+ (https://github.com/esphome/esphome/issues/10220)
+23 -2
View File
@@ -16,8 +16,29 @@ void loop();
namespace esphome {
void HOT yield() { ::yield(); }
// Inline the tick read so esphome::millis() matches MillisInternal::get()'s fast
// path instead of going through the Arduino core's out-of-line ::millis() wrapper.
//
// RTL87xx / LN882x (1 kHz): xTaskGetTickCount() is already ms. IRAM_ATTR + ISR
// dispatch are needed because ISR handlers (e.g. rotary_encoder) call millis().
//
// BK72xx (500 Hz): ticks * portTICK_PERIOD_MS (== 2). IRAM_ATTR and ISR dispatch
// are both unnecessary — the SDK masks FIQ + IRQ during flash writes (see hal.h),
// so no ISR runs while flash is stalled.
#if defined(USE_RTL87XX) || defined(USE_LN882X)
uint32_t IRAM_ATTR HOT millis() {
static_assert(configTICK_RATE_HZ == 1000, "millis() fast path requires 1 kHz FreeRTOS tick");
return in_isr_context() ? xTaskGetTickCountFromISR() : xTaskGetTickCount();
}
#elif defined(USE_BK72XX)
uint32_t HOT millis() {
static_assert(configTICK_RATE_HZ == 500, "BK72xx millis() fast path assumes 500 Hz FreeRTOS tick");
return xTaskGetTickCount() * portTICK_PERIOD_MS;
}
#else
uint32_t IRAM_ATTR HOT millis() { return ::millis(); }
uint64_t millis_64() { return Millis64Impl::compute(::millis()); }
#endif
uint64_t millis_64() { return Millis64Impl::compute(millis()); }
uint32_t IRAM_ATTR HOT micros() { return ::micros(); }
void HOT delay(uint32_t ms) { ::delay(ms); }
void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { ::delayMicroseconds(us); }
@@ -35,7 +56,7 @@ void arch_init() {
//
// Raise to priority 6: above WiFi/LwIP tasks (4-5) so they don't preempt the
// main loop, but below the TCP/IP thread (7) so packet processing keeps priority.
// This is safe because ESPHome yields voluntarily via yield_with_select_() and
// This is safe because ESPHome yields voluntarily via wakeable_delay() and
// the Arduino mainTask yield() after each loop() iteration.
static constexpr UBaseType_t MAIN_TASK_PRIORITY = 6;
static_assert(MAIN_TASK_PRIORITY < configMAX_PRIORITIES, "MAIN_TASK_PRIORITY must be less than configMAX_PRIORITIES");
@@ -0,0 +1,52 @@
/*
* FreeRTOS static allocation callbacks for LibreTiny platforms.
*
* Required when configSUPPORT_STATIC_ALLOCATION is enabled. These callbacks
* provide memory for the idle and timer tasks. Following ESP-IDF's approach,
* we allocate from the FreeRTOS heap (pvPortMalloc) rather than using truly
* static buffers, to avoid assumptions about memory layout.
*
* This enables xQueueCreateStatic, xTaskCreateStatic, etc. throughout ESPHome,
* allowing queue storage to live in BSS with zero runtime heap allocation.
*/
#ifdef USE_BK72XX
#include <FreeRTOS.h>
#include <task.h>
#if (configSUPPORT_STATIC_ALLOCATION == 1)
void vApplicationGetIdleTaskMemory(StaticTask_t **ppxIdleTaskTCBBuffer, StackType_t **ppxIdleTaskStackBuffer,
uint32_t *pulIdleTaskStackSize) {
/* Stack grows down on ARM — allocate stack first, then TCB,
* so the stack does not grow into the TCB. */
StackType_t *stack = (StackType_t *) pvPortMalloc(configMINIMAL_STACK_SIZE * sizeof(StackType_t));
StaticTask_t *tcb = (StaticTask_t *) pvPortMalloc(sizeof(StaticTask_t));
configASSERT(stack != NULL);
configASSERT(tcb != NULL);
*ppxIdleTaskTCBBuffer = tcb;
*ppxIdleTaskStackBuffer = stack;
*pulIdleTaskStackSize = configMINIMAL_STACK_SIZE;
}
#if (configUSE_TIMERS == 1)
void vApplicationGetTimerTaskMemory(StaticTask_t **ppxTimerTaskTCBBuffer, StackType_t **ppxTimerTaskStackBuffer,
uint32_t *pulTimerTaskStackSize) {
StackType_t *stack = (StackType_t *) pvPortMalloc(configTIMER_TASK_STACK_DEPTH * sizeof(StackType_t));
StaticTask_t *tcb = (StaticTask_t *) pvPortMalloc(sizeof(StaticTask_t));
configASSERT(stack != NULL);
configASSERT(tcb != NULL);
*ppxTimerTaskTCBBuffer = tcb;
*ppxTimerTaskStackBuffer = stack;
*pulTimerTaskStackSize = configTIMER_TASK_STACK_DEPTH;
}
#endif /* configUSE_TIMERS */
#endif /* configSUPPORT_STATIC_ALLOCATION */
#endif /* USE_BK72XX */
+39 -24
View File
@@ -10,13 +10,10 @@ namespace esphome::light {
static const char *const TAG = "light";
// Helper functions to reduce code size for logging
static void clamp_and_log_if_invalid(const char *name, float &value, const LogString *param_name, float min = 0.0f,
float max = 1.0f) {
if (value < min || value > max) {
ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, LOG_STR_ARG(param_name), value, min, max);
value = clamp(value, min, max);
}
// Cold-path logger; caller handles the clamp so the in-range hot path avoids
// the spill/reload around the call.
static void log_value_out_of_range(const char *name, float value, const LogString *param_name, float min, float max) {
ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, LOG_STR_ARG(param_name), value, min, max);
}
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN
@@ -57,6 +54,12 @@ static void log_invalid_parameter(const char *name, const LogString *message) {
PROGMEM_STRING_TABLE(ColorModeHumanStrings, "Unknown", "On/Off", "Brightness", "White", "Color temperature",
"Cold/warm white", "RGB", "RGBW", "RGB + color temperature", "RGB + cold/warm white");
// Indices 0-7 match FieldFlags bits 0-7; index 8 is color_temperature.
// PROGMEM_STRING_TABLE is constexpr-init (no RAM guard variable).
PROGMEM_STRING_TABLE(ValidateFieldNames, "Brightness", "Color brightness", "Red", "Green", "Blue", "White",
"Cold white", "Warm white", "Color temperature");
static constexpr uint8_t VALIDATE_CT_INDEX = 8;
static const LogString *color_mode_to_human(ColorMode color_mode) {
return ColorModeHumanStrings::get_log_str(ColorModeBitPolicy::to_bit(color_mode), 0);
}
@@ -277,25 +280,37 @@ LightColorValues LightCall::validate_() {
if (this->has_state())
v.set_state(this->state_);
// clamp_and_log_if_invalid already clamps in-place, so assign directly
// to avoid redundant clamp code from the setter being inlined.
#define VALIDATE_AND_APPLY(field, name_str, ...) \
if (this->has_##field()) { \
clamp_and_log_if_invalid(name, this->field##_, LOG_STR(name_str), ##__VA_ARGS__); \
v.field##_ = this->field##_; \
// FieldFlags bits 0-7 must match unit_fields_ array indices.
static_assert(FLAG_HAS_BRIGHTNESS == 1u << 0 && FLAG_HAS_COLOR_BRIGHTNESS == 1u << 1 && FLAG_HAS_RED == 1u << 2 &&
FLAG_HAS_GREEN == 1u << 3 && FLAG_HAS_BLUE == 1u << 4 && FLAG_HAS_WHITE == 1u << 5 &&
FLAG_HAS_COLD_WHITE == 1u << 6 && FLAG_HAS_WARM_WHITE == 1u << 7,
"FieldFlags bits 0-7 must match unit_fields_ indices");
// Iterate set bits only (ctz + clear-lowest) — HA can drive perform()
// at high frequency so the hot path is O(popcount).
unsigned active = this->flags_ & CLAMP_FLAGS_MASK;
while (active != 0) {
unsigned bit = __builtin_ctz(active);
active &= active - 1; // clear lowest set bit
float &value = this->unit_fields_[bit];
if (float_out_of_unit_range(value)) {
log_value_out_of_range(name, value, ValidateFieldNames::get_log_str(bit, 0), 0.0f, 1.0f);
value = clamp_unit_float(value);
}
v.unit_fields_[bit] = value;
}
VALIDATE_AND_APPLY(brightness, "Brightness")
VALIDATE_AND_APPLY(color_brightness, "Color brightness")
VALIDATE_AND_APPLY(red, "Red")
VALIDATE_AND_APPLY(green, "Green")
VALIDATE_AND_APPLY(blue, "Blue")
VALIDATE_AND_APPLY(white, "White")
VALIDATE_AND_APPLY(cold_white, "Cold white")
VALIDATE_AND_APPLY(warm_white, "Warm white")
VALIDATE_AND_APPLY(color_temperature, "Color temperature", traits.get_min_mireds(), traits.get_max_mireds())
#undef VALIDATE_AND_APPLY
// color_temperature: runtime range from traits.
if (this->has_color_temperature()) {
const float ct_min = traits.get_min_mireds();
const float ct_max = traits.get_max_mireds();
if (this->color_temperature_ < ct_min || this->color_temperature_ > ct_max) {
log_value_out_of_range(name, this->color_temperature_, ValidateFieldNames::get_log_str(VALIDATE_CT_INDEX, 0),
ct_min, ct_max);
this->color_temperature_ = clamp(this->color_temperature_, ct_min, ct_max);
}
v.color_temperature_ = this->color_temperature_;
}
v.normalize_color();
+18 -25
View File
@@ -195,25 +195,26 @@ class LightCall {
/// Some color modes also can be set using non-native parameters, transform those calls.
void transform_parameters_(const LightTraits &traits);
// Bitfield flags - each flag indicates whether a corresponding value has been set.
// Bits 0-7 index unit_fields_[] in validate_(); don't reorder (asserts in light_call.cpp).
enum FieldFlags : uint16_t {
FLAG_HAS_STATE = 1 << 0,
FLAG_HAS_TRANSITION = 1 << 1,
FLAG_HAS_FLASH = 1 << 2,
FLAG_HAS_EFFECT = 1 << 3,
FLAG_HAS_BRIGHTNESS = 1 << 4,
FLAG_HAS_COLOR_BRIGHTNESS = 1 << 5,
FLAG_HAS_RED = 1 << 6,
FLAG_HAS_GREEN = 1 << 7,
FLAG_HAS_BLUE = 1 << 8,
FLAG_HAS_WHITE = 1 << 9,
FLAG_HAS_COLOR_TEMPERATURE = 1 << 10,
FLAG_HAS_COLD_WHITE = 1 << 11,
FLAG_HAS_WARM_WHITE = 1 << 12,
FLAG_HAS_BRIGHTNESS = 1 << 0,
FLAG_HAS_COLOR_BRIGHTNESS = 1 << 1,
FLAG_HAS_RED = 1 << 2,
FLAG_HAS_GREEN = 1 << 3,
FLAG_HAS_BLUE = 1 << 4,
FLAG_HAS_WHITE = 1 << 5,
FLAG_HAS_COLD_WHITE = 1 << 6,
FLAG_HAS_WARM_WHITE = 1 << 7,
FLAG_HAS_COLOR_TEMPERATURE = 1 << 8,
FLAG_HAS_STATE = 1 << 9,
FLAG_HAS_TRANSITION = 1 << 10,
FLAG_HAS_FLASH = 1 << 11,
FLAG_HAS_EFFECT = 1 << 12,
FLAG_HAS_COLOR_MODE = 1 << 13,
FLAG_PUBLISH = 1 << 14,
FLAG_SAVE = 1 << 15,
};
static constexpr uint16_t CLAMP_FLAGS_MASK = 0x00FFu; // bits 0-7
inline bool has_transition_() { return (this->flags_ & FLAG_HAS_TRANSITION) != 0; }
inline bool has_flash_() { return (this->flags_ & FLAG_HAS_FLASH) != 0; }
@@ -222,7 +223,7 @@ class LightCall {
inline bool get_save_() { return (this->flags_ & FLAG_SAVE) != 0; }
// Helper to set flag - defaults to true for common case
void set_flag_(FieldFlags flag, bool value = true) {
void set_flag_(FieldFlags flag, bool value = true) ESPHOME_ALWAYS_INLINE {
if (value) {
this->flags_ |= flag;
} else {
@@ -231,7 +232,7 @@ class LightCall {
}
// Helper to clear flag - reduces code size for common case
void clear_flag_(FieldFlags flag) { this->flags_ &= ~flag; }
void clear_flag_(FieldFlags flag) ESPHOME_ALWAYS_INLINE { this->flags_ &= ~flag; }
// Helper to log unsupported feature and clear flag - reduces code duplication
void log_and_clear_unsupported_(FieldFlags flag, const LogString *feature, bool use_color_mode_log);
@@ -239,19 +240,11 @@ class LightCall {
LightState *parent_;
// Light state values - use flags_ to check if a value has been set.
// Group 4-byte aligned members first
uint32_t transition_length_;
uint32_t flash_length_;
uint32_t effect_;
float brightness_;
float color_brightness_;
float red_;
float green_;
float blue_;
float white_;
ESPHOME_LIGHT_UNIT_FIELDS_UNION();
float color_temperature_;
float cold_white_;
float warm_white_;
// Smaller members at the end for better packing
uint16_t flags_{FLAG_PUBLISH | FLAG_SAVE}; // Tracks which values are set
+62 -18
View File
@@ -3,11 +3,62 @@
#include "esphome/core/helpers.h"
#include "color_mode.h"
#include <cmath>
#include <cstdint>
#include <limits>
namespace esphome::light {
inline static uint8_t to_uint8_scale(float x) { return static_cast<uint8_t>(roundf(x * 255.0f)); }
// IEEE 754 bit patterns. Values in [0.0f, 1.0f] have bits <= ONE_F_BITS;
// negatives have the sign bit set (→ huge unsigned). A single unsigned compare
// replaces two soft-float __ltsf2/__gtsf2 calls on ESP8266.
static constexpr uint32_t ONE_F_BITS = 0x3F800000u; // 1.0f
static constexpr uint32_t NEG_ZERO_F_BITS = 0x80000000u; // -0.0f / sign-bit mask
static_assert(sizeof(float) == sizeof(uint32_t), "float must be 32-bit");
static_assert(std::numeric_limits<float>::is_iec559, "IEEE 754 float required");
// Union pun — memcpy/bit_cast don't fold on xtensa-gcc (see api/proto.h).
// -0.0f is numerically zero so it's reported in range (no warning, no clamp).
inline bool float_out_of_unit_range(float x) {
union {
float f;
uint32_t u;
} pun;
pun.f = x;
return pun.u > ONE_F_BITS && pun.u != NEG_ZERO_F_BITS;
}
// Clamps to [0.0f, 1.0f] without float compares. Out of range: sign bit set
// (negatives, -NaN, -Inf) → 0.0f; sign bit clear (>1, +NaN, +Inf) → 1.0f.
inline float clamp_unit_float(float x) {
union {
float f;
uint32_t u;
} pun;
pun.f = x;
if (pun.u <= ONE_F_BITS)
return x;
return (pun.u & NEG_ZERO_F_BITS) ? 0.0f : 1.0f; // sign bit → negative → clamp to 0
}
// Shared anonymous union: eight unit-range floats alias unit_fields_[8] so
// LightCall::validate_() can iterate them as a real array. GCC/Clang ext.
#define ESPHOME_LIGHT_UNIT_FIELDS_UNION() \
union { \
struct { \
float brightness_; \
float color_brightness_; \
float red_; \
float green_; \
float blue_; \
float white_; \
float cold_white_; \
float warm_white_; \
}; \
float unit_fields_[8]; \
}
/** This class represents the color state for a light object.
*
* The representation of the color state is dependent on the active color mode. A color mode consists of multiple
@@ -52,9 +103,9 @@ class LightColorValues {
green_(1.0f),
blue_(1.0f),
white_(1.0f),
color_temperature_{0.0f},
cold_white_{1.0f},
warm_white_{1.0f},
color_temperature_{0.0f},
color_mode_(ColorMode::UNKNOWN) {}
LightColorValues(ColorMode color_mode, float state, float brightness, float color_brightness, float red, float green,
@@ -220,39 +271,39 @@ class LightColorValues {
/// Get the binary true/false state of these light color values.
bool is_on() const { return this->get_state() != 0.0f; }
/// Set the state of these light color values. In range from 0.0 (off) to 1.0 (on)
void set_state(float state) { this->state_ = clamp(state, 0.0f, 1.0f); }
void set_state(float state) { this->state_ = clamp_unit_float(state); }
/// Set the state of these light color values as a binary true/false.
void set_state(bool state) { this->state_ = state ? 1.0f : 0.0f; }
/// Get the brightness property of these light color values. In range 0.0 to 1.0
float get_brightness() const { return this->brightness_; }
/// Set the brightness property of these light color values. In range 0.0 to 1.0
void set_brightness(float brightness) { this->brightness_ = clamp(brightness, 0.0f, 1.0f); }
void set_brightness(float brightness) { this->brightness_ = clamp_unit_float(brightness); }
/// Get the color brightness property of these light color values. In range 0.0 to 1.0
float get_color_brightness() const { return this->color_brightness_; }
/// Set the color brightness property of these light color values. In range 0.0 to 1.0
void set_color_brightness(float brightness) { this->color_brightness_ = clamp(brightness, 0.0f, 1.0f); }
void set_color_brightness(float brightness) { this->color_brightness_ = clamp_unit_float(brightness); }
/// Get the red property of these light color values. In range 0.0 to 1.0
float get_red() const { return this->red_; }
/// Set the red property of these light color values. In range 0.0 to 1.0
void set_red(float red) { this->red_ = clamp(red, 0.0f, 1.0f); }
void set_red(float red) { this->red_ = clamp_unit_float(red); }
/// Get the green property of these light color values. In range 0.0 to 1.0
float get_green() const { return this->green_; }
/// Set the green property of these light color values. In range 0.0 to 1.0
void set_green(float green) { this->green_ = clamp(green, 0.0f, 1.0f); }
void set_green(float green) { this->green_ = clamp_unit_float(green); }
/// Get the blue property of these light color values. In range 0.0 to 1.0
float get_blue() const { return this->blue_; }
/// Set the blue property of these light color values. In range 0.0 to 1.0
void set_blue(float blue) { this->blue_ = clamp(blue, 0.0f, 1.0f); }
void set_blue(float blue) { this->blue_ = clamp_unit_float(blue); }
/// Get the white property of these light color values. In range 0.0 to 1.0
float get_white() const { return white_; }
/// Set the white property of these light color values. In range 0.0 to 1.0
void set_white(float white) { this->white_ = clamp(white, 0.0f, 1.0f); }
void set_white(float white) { this->white_ = clamp_unit_float(white); }
/// Get the color temperature property of these light color values in mired.
float get_color_temperature() const { return this->color_temperature_; }
@@ -277,26 +328,19 @@ class LightColorValues {
/// Get the cold white property of these light color values. In range 0.0 to 1.0.
float get_cold_white() const { return this->cold_white_; }
/// Set the cold white property of these light color values. In range 0.0 to 1.0.
void set_cold_white(float cold_white) { this->cold_white_ = clamp(cold_white, 0.0f, 1.0f); }
void set_cold_white(float cold_white) { this->cold_white_ = clamp_unit_float(cold_white); }
/// Get the warm white property of these light color values. In range 0.0 to 1.0.
float get_warm_white() const { return this->warm_white_; }
/// Set the warm white property of these light color values. In range 0.0 to 1.0.
void set_warm_white(float warm_white) { this->warm_white_ = clamp(warm_white, 0.0f, 1.0f); }
void set_warm_white(float warm_white) { this->warm_white_ = clamp_unit_float(warm_white); }
friend class LightCall;
protected:
float state_; ///< ON / OFF, float for transition
float brightness_;
float color_brightness_;
float red_;
float green_;
float blue_;
float white_;
ESPHOME_LIGHT_UNIT_FIELDS_UNION();
float color_temperature_; ///< Color Temperature in Mired
float cold_white_;
float warm_white_;
ColorMode color_mode_;
};
+2
View File
@@ -35,9 +35,11 @@ LockStateForwarder = lock_ns.class_("LockStateForwarder")
LockState = lock_ns.enum("LockState")
LOCK_STATES = {
"OPEN": LockState.LOCK_STATE_OPEN,
"LOCKED": LockState.LOCK_STATE_LOCKED,
"UNLOCKED": LockState.LOCK_STATE_UNLOCKED,
"JAMMED": LockState.LOCK_STATE_JAMMED,
"OPENING": LockState.LOCK_STATE_OPENING,
"LOCKING": LockState.LOCK_STATE_LOCKING,
"UNLOCKING": LockState.LOCK_STATE_UNLOCKING,
}
+8 -3
View File
@@ -8,9 +8,10 @@ namespace esphome::lock {
static const char *const TAG = "lock";
// Lock state strings indexed by LockState enum (0-5): NONE(UNKNOWN), LOCKED, UNLOCKED, JAMMED, LOCKING, UNLOCKING
// Lock state strings indexed by LockState enum.
// Index 0 is UNKNOWN (for LOCK_STATE_NONE), also used as fallback for out-of-range
PROGMEM_STRING_TABLE(LockStateStrings, "UNKNOWN", "LOCKED", "UNLOCKED", "JAMMED", "LOCKING", "UNLOCKING");
PROGMEM_STRING_TABLE(LockStateStrings, "UNKNOWN", "LOCKED", "UNLOCKED", "JAMMED", "LOCKING", "UNLOCKING", "OPENING",
"OPEN");
const LogString *lock_state_to_string(LockState state) {
return LockStateStrings::get_log_str(static_cast<uint8_t>(state), 0);
@@ -74,12 +75,16 @@ LockCall &LockCall::set_state(optional<LockState> state) {
return *this;
}
LockCall &LockCall::set_state(const char *state) {
if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("LOCKED")) == 0) {
if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("OPEN")) == 0) {
this->set_state(LOCK_STATE_OPEN);
} else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("LOCKED")) == 0) {
this->set_state(LOCK_STATE_LOCKED);
} else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("UNLOCKED")) == 0) {
this->set_state(LOCK_STATE_UNLOCKED);
} else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("JAMMED")) == 0) {
this->set_state(LOCK_STATE_JAMMED);
} else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("OPENING")) == 0) {
this->set_state(LOCK_STATE_OPENING);
} else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("LOCKING")) == 0) {
this->set_state(LOCK_STATE_LOCKING);
} else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("UNLOCKING")) == 0) {
+3 -1
View File
@@ -26,7 +26,9 @@ enum LockState : uint8_t {
LOCK_STATE_UNLOCKED = 2,
LOCK_STATE_JAMMED = 3,
LOCK_STATE_LOCKING = 4,
LOCK_STATE_UNLOCKING = 5
LOCK_STATE_UNLOCKING = 5,
LOCK_STATE_OPENING = 6,
LOCK_STATE_OPEN = 7,
};
const LogString *lock_state_to_string(LockState state);
+9 -8
View File
@@ -472,14 +472,15 @@ async def _late_logger_init(config: ConfigType) -> None:
# esphome implement own fatal error handler which save PC/LR before reset
zephyr_add_prj_conf("RESET_ON_FATAL_ERROR", False)
zephyr_add_prj_conf("THREAD_LOCAL_STORAGE", True)
if config[CONF_HARDWARE_UART] == UART0:
zephyr_add_overlay("""&uart0 { status = "okay";};""")
if config[CONF_HARDWARE_UART] == UART1:
zephyr_add_overlay("""&uart1 { status = "okay";};""")
if config[CONF_HARDWARE_UART] == USB_CDC:
cg.add_define("USE_LOGGER_UART_SELECTION_USB_CDC")
zephyr_add_prj_conf("UART_LINE_CTRL", True)
zephyr_add_cdc_acm(config, 0)
if has_serial_logging:
if config[CONF_HARDWARE_UART] == UART0:
zephyr_add_overlay("""&uart0 { status = "okay";};""")
if config[CONF_HARDWARE_UART] == UART1:
zephyr_add_overlay("""&uart1 { status = "okay";};""")
if config[CONF_HARDWARE_UART] == USB_CDC:
cg.add_define("USE_LOGGER_UART_SELECTION_USB_CDC")
zephyr_add_prj_conf("UART_LINE_CTRL", True)
zephyr_add_cdc_acm(config, 0)
# Register at end for safe mode
await cg.register_component(log, config)
@@ -65,10 +65,12 @@ void Logger::pre_setup() {
break;
#ifdef USE_LOGGER_USB_CDC
case UART_SELECTION_USB_CDC:
#ifdef CONFIG_USB_DEVICE_STACK
uart_dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(cdc_acm_uart0));
if (device_is_ready(uart_dev)) {
usb_enable(nullptr);
}
#endif
break;
#endif
}
+4 -3
View File
@@ -45,6 +45,7 @@ optional<uint32_t> LTR390Component::read_sensor_data_(LTR390MODE mode) {
uint8_t buffer[num_bytes];
// Wait until data available
constexpr uint32_t max_wait_ms = 25;
const uint32_t now = millis();
while (true) {
std::bitset<8> status = this->reg(LTR390_MAIN_STATUS).get();
@@ -52,12 +53,12 @@ optional<uint32_t> LTR390Component::read_sensor_data_(LTR390MODE mode) {
if (available)
break;
if (millis() - now > 100) {
if (millis() - now > max_wait_ms) {
ESP_LOGW(TAG, "Sensor didn't return any data, aborting");
return {};
}
ESP_LOGD(TAG, "Waiting for data");
delay(2);
ESP_LOGV(TAG, "Waiting for data");
delay(1);
}
if (!this->read_bytes(MODEADDRESSES[mode], buffer, num_bytes)) {
+3 -1
View File
@@ -89,10 +89,12 @@
id: hello_world_label_
text: "Hello World!"
align: center
- obj:
- container:
id: hello_world_qrcode_
outline_width: 0
border_width: 0
height: 100
width: 100
hidden: !lambda |-
return lv_obj_get_width(lv_screen_active()) < 300 && lv_obj_get_height(lv_screen_active()) < 400;
widgets:
+11 -9
View File
@@ -642,26 +642,28 @@ void LvglComponent::write_random_() {
int iterations = 6 - lv_display_get_inactive_time(this->disp_) / 60000;
if (iterations <= 0)
iterations = 1;
int16_t width = lv_display_get_horizontal_resolution(this->disp_);
int16_t height = lv_display_get_vertical_resolution(this->disp_);
while (iterations-- != 0) {
int32_t col = random_uint32() % this->width_;
int32_t col = random_uint32() % width;
col = col / this->draw_rounding * this->draw_rounding;
int32_t row = random_uint32() % this->height_;
int32_t row = random_uint32() % height;
row = row / this->draw_rounding * this->draw_rounding;
// size will be between 8 and 32, and a multiple of draw_rounding
int32_t size = (random_uint32() % 25 + 8) / this->draw_rounding * this->draw_rounding;
lv_area_t area{col, row, col + size - 1, row + size - 1};
lv_area_t area{.x1 = col, .y1 = row, .x2 = col + size - 1, .y2 = row + size - 1};
// clip to display bounds just in case
if (area.x2 >= this->width_)
area.x2 = this->width_ - 1;
if (area.y2 >= this->height_)
area.y2 = this->height_ - 1;
if (area.x2 >= width)
area.x2 = width - 1;
if (area.y2 >= height)
area.y2 = height - 1;
// line_len can't exceed 1024, and minimum buffer size is 2048, so this won't overflow the buffer
size_t line_len = lv_area_get_width(&area) * lv_area_get_height(&area) / 2;
for (size_t i = 0; i != line_len; i++) {
((uint32_t *) (this->draw_buf_))[i] = random_uint32();
reinterpret_cast<uint32_t *>(this->draw_buf_)[i] = random_uint32();
}
this->draw_buffer_(&area, (lv_color_data *) this->draw_buf_);
this->draw_buffer_(&area, reinterpret_cast<lv_color_data *>(this->draw_buf_));
}
}
+6
View File
@@ -88,6 +88,12 @@ inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image,
inline void lv_obj_set_style_bg_image_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) {
::lv_obj_set_style_bg_image_src(obj, image->get_lv_image_dsc(), selector);
}
inline void lv_style_set_bg_image_src(lv_style_t *style, image::Image *image) {
::lv_style_set_bg_image_src(style, image->get_lv_image_dsc());
}
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
#ifdef USE_LVGL_ANIMIMG
inline void lv_animimg_set_src(lv_obj_t *img, std::vector<image::Image *> images) {
+5 -2
View File
@@ -77,8 +77,11 @@ class ArcType(NumberType):
# start_angle and end_angle are mapped to bg_start_angle and bg_end_angle
prop = str(prop)
if prop.endswith("_angle"):
prop = "bg_" + prop
await w.set_property(prop, config, processor=validator)
await w.set_property(
"bg_" + prop, await validator.process(config.get(prop))
)
else:
await w.set_property(prop, config, processor=validator)
if CONF_ADJUSTABLE in config:
if not config[CONF_ADJUSTABLE]:
lv_obj.remove_style(w.obj, nullptr, LV_PART.KNOB)
+15 -11
View File
@@ -52,19 +52,23 @@ class KeyboardType(WidgetType):
if mode := config.get(CONF_MODE):
await w.set_property(CONF_MODE, await KEYBOARD_MODES.process(mode))
if textarea := config.get(CONF_TEXTAREA):
# If a textarea is configured, it must be generated before the keyboard can attach it.
# If not yet configured, defer the attachment code.
if not is_widget_completed(textarea):
# Can only happen for an initial config, where the keyboard is configured before the
# textarea, so it's ok to always emit into the global context
async def add_textarea():
async with LvContext():
await w.set_property(
CONF_TEXTAREA,
(await get_widgets(config, CONF_TEXTAREA))[0].obj,
)
async def add_textarea():
async with LvContext():
await w.set_property(
CONF_TEXTAREA, (await get_widgets(config, CONF_TEXTAREA))[0].obj
)
if is_widget_completed(textarea):
await add_textarea()
else:
CORE.add_job(add_textarea)
else:
# Handles updates in automations, and properly ordered initial config. Code is generated
# into the enclosing context (main or lambda)
await w.set_property(
CONF_TEXTAREA, (await get_widgets(config, CONF_TEXTAREA))[0].obj
)
keyboard_spec = KeyboardType()
+3 -3
View File
@@ -22,7 +22,7 @@ from ..defines import (
literal,
)
from ..lv_validation import animated, lv_int, size
from ..lvcode import LocalVariable, lv, lv_assign, lv_expr, lv_obj
from ..lvcode import LocalVariable, lv, lv_assign, lv_expr, lv_obj, lv_Pvariable
from ..schemas import container_schema, part_schema
from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr
from . import Widget, WidgetType, add_widgets, get_widgets, set_obj_properties
@@ -83,8 +83,8 @@ class TabviewType(WidgetType):
await w.set_property("tab_bar_size", await size.process(config[CONF_SIZE]))
for tab_conf in config[CONF_TABS]:
w_id = tab_conf[CONF_ID]
tab_obj = cg.Pvariable(w_id, cg.nullptr, type_=lv_tab_t)
tab_widget = Widget.create(w_id, tab_obj, obj_spec)
tab_obj = lv_Pvariable(lv_tab_t, w_id)
tab_widget = Widget.create(w_id, tab_obj, obj_spec, tab_conf)
lv_assign(tab_obj, lv_expr.tabview_add_tab(w.obj, tab_conf[CONF_NAME]))
await set_obj_properties(tab_widget, tab_conf)
await add_widgets(tab_widget, tab_conf)
+4 -1
View File
@@ -37,7 +37,10 @@ void IRAM_ATTR MCP23016::gpio_intr(MCP23016 *arg) { arg->enable_loop_soon_any_co
void MCP23016::loop() {
// Invalidate cache at the start of each loop
this->reset_pin_cache_();
if (this->interrupt_pin_ != nullptr) {
// Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the
// I2C read leave INT asserted without re-firing a falling edge, which would strand us with
// stale state forever; keep looping until the line is released so we self-heal.
if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) {
this->disable_loop();
}
}
@@ -21,7 +21,10 @@ template<uint8_t N> class MCP23XXXBase : public Component, public gpio_expander:
void loop() override {
this->reset_pin_cache_();
if (this->interrupt_pin_ != nullptr) {
// Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the
// I2C read leave INT asserted without re-firing a falling edge, which would strand us with
// stale state forever; keep looping until the line is released so we self-heal.
if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) {
this->disable_loop();
}
}
+109 -2
View File
@@ -1,20 +1,31 @@
from collections.abc import Callable
from esphome import automation
import esphome.codegen as cg
from esphome.components import audio
import esphome.config_validation as cv
from esphome.const import (
CONF_ENTITY_CATEGORY,
CONF_FORMAT,
CONF_ICON,
CONF_ID,
CONF_NUM_CHANNELS,
CONF_ON_IDLE,
CONF_ON_STATE,
CONF_ON_TURN_OFF,
CONF_ON_TURN_ON,
CONF_SAMPLE_RATE,
CONF_VOLUME,
)
from esphome.core import CORE
from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity
from esphome.core.entity_helpers import (
entity_duplicate_validator,
inherit_property_from,
setup_entity,
)
from esphome.coroutine import CoroPriority, coroutine_with_priority
from esphome.cpp_generator import MockObjClass
from esphome.cpp_generator import MockObj, MockObjClass
from esphome.types import ConfigType
CODEOWNERS = ["@jesserockz"]
@@ -34,6 +45,102 @@ MEDIA_PLAYER_FORMAT_PURPOSE_ENUM = {
"announcement": MediaPlayerFormatPurpose.PURPOSE_ANNOUNCEMENT,
}
# Public API for external components. Do not remove.
FORMAT_MAPPING = {
"FLAC": "flac",
"MP3": "mp3",
"OPUS": "opus",
"WAV": "wav",
}
def build_supported_format_struct(
format_config: ConfigType, purpose: MockObj
) -> cg.StructInitializer:
"""Build a MediaPlayerSupportedFormat struct from a format config and purpose.
Public API for external components. Do not remove.
"""
args = [
MediaPlayerSupportedFormat,
("format", FORMAT_MAPPING[format_config[CONF_FORMAT]]),
("sample_rate", format_config[CONF_SAMPLE_RATE]),
("num_channels", format_config[CONF_NUM_CHANNELS]),
("purpose", purpose),
]
# Omit sample_bytes for MP3: ffmpeg transcoding in Home Assistant fails
# if the number of bytes per sample is specified for MP3.
if format_config[CONF_FORMAT] != "MP3":
args.append(("sample_bytes", 2))
return cg.StructInitializer(*args)
def validate_preferred_format(
component_name: str, audio_device_key: str
) -> Callable[[ConfigType], ConfigType]:
"""Return a validator that inherits audio device settings and validates format constraints.
Public API for external components. Do not remove.
"""
def validator(config: ConfigType) -> ConfigType:
# Inherit settings from audio device if not manually set
inherit_property_from(CONF_NUM_CHANNELS, audio_device_key)(config)
inherit_property_from(CONF_SAMPLE_RATE, audio_device_key)(config)
# Opus only supports 48 kHz
if config.get(CONF_FORMAT) == "OPUS" and config.get(CONF_SAMPLE_RATE) != 48000:
raise cv.Invalid("Opus only supports a sample rate of 48000 Hz")
# Validate the settings are compatible with the audio device
audio.final_validate_audio_schema(
component_name,
audio_device=audio_device_key,
bits_per_sample=16,
channels=config.get(CONF_NUM_CHANNELS),
sample_rate=config.get(CONF_SAMPLE_RATE),
)(config)
return config
return validator
def request_codecs_for_format_configs(
config: ConfigType, format_config_keys: list[str]
) -> None:
"""Scan format configs for configured formats and request the needed codec support.
If any config uses "NONE" (accepts any format), all codecs are requested.
Public API for external components. Do not remove.
"""
needed_formats: set[str] = set()
need_all = False
for key in format_config_keys:
if format_config := config.get(key):
fmt = format_config[CONF_FORMAT]
if fmt == "NONE":
need_all = True
else:
needed_formats.add(fmt)
if need_all:
audio.request_flac_support()
audio.request_mp3_support()
audio.request_opus_support()
else:
if "FLAC" in needed_formats:
audio.request_flac_support()
if "MP3" in needed_formats:
audio.request_mp3_support()
if "OPUS" in needed_formats:
audio.request_opus_support()
# Local config key constants
CONF_ANNOUNCEMENT = "announcement"
CONF_ON_PLAY = "on_play"

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