Compare commits

..

177 Commits

Author SHA1 Message Date
kbx81 70c6021986 Merge branch 'central-netif' into multi-interface-poc
# Conflicts:
#	esphome/components/wifi/wifi_component.cpp
2026-05-23 20:19:32 -05:00
Keith Burzinski 1485675928 Merge branch 'dev' into central-netif 2026-05-23 20:09:33 -05:00
Keith Burzinski 3ed1356bb6 type
Co-authored-by: J. Nick Koston <nick+github@koston.org>
2026-05-23 20:08:08 -05:00
Kevin Ahrendt 5cb145a8c3 [ethernet] Offload W5500 bulk SPI transfers from the busy-wait path (#16596) 2026-05-23 17:53:53 -04:00
Kevin Ahrendt 74001ccf05 [wifi] Wake main loop when requesting high performance mode (#16598) 2026-05-23 17:39:20 -04:00
Kevin Ahrendt 58931f2610 [audio] Add clear_buffered_data method to RingBufferAudioSource (#16594) 2026-05-23 17:37:59 -04:00
Jonathan Swoboda f616103621 [esp32] Replace per-class -Wno-error=X demotes with blanket -Wno-error for ESP-IDF toolchain (#16599) 2026-05-23 15:44:25 -04:00
J. Nick Koston 188ff7ebfd [bluetooth_proxy] Recover slot stuck in DISCONNECTING when CLOSE_EVT is dropped (#16588) 2026-05-23 14:30:12 -05:00
J. Nick Koston d6bc4fea1c Merge branch 'dev' into central-netif 2026-05-23 13:34:04 -05:00
Clyde Stubbs be99553fd4 [ci] Fix flash memory overflow on tests (#16587) 2026-05-23 14:26:53 +10:00
Jonathan Swoboda b0dc688c14 [esp32] Demote IDF #warning deprecations from error under ESP-IDF toolchain (#16584) 2026-05-22 20:30:25 -04:00
J. Nick Koston 2b422cbd99 [lvgl] Build widget update action schemas lazily (#16569)
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
2026-05-23 00:20:39 +00:00
dependabot[bot] 9930b3c216 Bump github/codeql-action from 4.35.5 to 4.36.0 (#16579)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-22 19:18:31 -05:00
dependabot[bot] 55f4e5cb75 Bump the docker-actions group across 1 directory with 2 updates (#16578)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-22 19:18:20 -05:00
J. Nick Koston 71550bb3be [lvgl] Memoize and lazily build container_schema (#16567) 2026-05-22 18:39:25 -05:00
J. Nick Koston a58b4edb6a [ci] Gate unconditional CI jobs on a single determine-jobs output instead of a path filter (#16580) 2026-05-22 18:39:06 -05:00
Clyde Stubbs f85fdb475a [homeassistant] Reduce log spam for sensors (#16555) 2026-05-23 08:07:51 +10:00
dependabot[bot] 4a78c8d45a Bump pytest-codspeed from 5.0.2 to 5.0.3 (#16575)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-22 15:55:49 -05:00
Kevin Ahrendt c3bef24389 [i2s_audio] Reset dout GPIO when stopping speaker driver (#16573) 2026-05-22 14:43:50 -05:00
J. Nick Koston 7182b1a8ae [uart] Wake main loop on ESP8266 software serial RX (#16562) 2026-05-22 14:30:43 -05:00
J. Nick Koston 64e32ebe04 [esp8266] Use os_timer-based esp_delay() in delay() (#16563) 2026-05-22 14:30:28 -05:00
Edvard Filistovič 94b10981e1 [libretiny] Fix LN882H IRAM_ATTR injection point in patch_linker.py (#16570) 2026-05-22 14:09:32 -05:00
J. Nick Koston 680c9fc9c0 [dashboard] Fix flaky test_websocket_refresh_command on Windows CI (#16565) 2026-05-22 08:49:03 -05:00
dependabot[bot] 99de741f99 Bump docker/build-push-action from 7.1.0 to 7.2.0 in /.github/actions/build-image (#16545)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-22 08:08:29 -05:00
dependabot[bot] ac530c33b0 Bump actions/stale from 10.2.0 to 10.3.0 (#16544)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-22 08:08:14 -05:00
Kevin Ahrendt 0b5e7ae8fa [sendspin] Bump sendspin-cpp to v0.6.1 (#16553) 2026-05-22 06:43:08 -04:00
kbx81 f818bddac8 Merge remote-tracking branch 'upstream/dev' into multi-interface-poc 2026-05-21 21:39:48 -05:00
kbx81 1f0af903ea feat(ethernet): Unit B + B+ — enable_on_boot lifecycle with lazy-init
Brings ethernet to feature parity with wifi's per-interface lifecycle, and
applies the same lazy-init pattern as wifi's Unit B+ so enable_on_boot:false
genuinely reclaims DMA-capable internal SRAM.

Unit B — lifecycle API surface (mirrors WiFiComponent):
- New `enable_on_boot: true` (default) YAML option on ethernet.
- New set_enable_on_boot(), enable(), disable(), is_disabled(), is_enabled()
  methods on EthernetComponent.
- ESP32 path: enable() calls esp_eth_start(), disable() calls esp_eth_stop().
- RP2040 path: stub methods that log a warning — arduino-pico's
  LwipIntfDev doesn't expose a clean start/stop hook; schema parity only.

Unit B+ — lazy-init refactor (mirrors WiFiComponent::wifi_lazy_init_()):
- New ethernet_lazy_init_() method (idempotent, guarded by
  ethernet_initialized_ flag) holds the entire heavy init body that used to
  live in setup(): SPI bus init, netif creation, MAC/PHY allocation, eth
  driver install, netif attach, event handler registration.
- setup() becomes thin: 300ms power-stabilization delay, then if
  enable_on_boot_=true call lazy_init + esp_eth_start, else mark
  disabled_=true and return.
- enable() calls ethernet_lazy_init_() first, then esp_eth_start() — so a
  runtime enable after enable_on_boot:false works end-to-end.

Safe-default getter guards — external callers (sendspin, ethernet_info,
mdns, etc.) may invoke MAC/IP/duplex queries before/regardless of whether
ethernet is enabled. Without guards these call into esp_eth_ioctl(null, ...)
and esp_netif_get_*(null, ...), producing error spam + erroneous
mark_failed() calls during dump_config():
- get_eth_mac_address_raw() falls back to esp_read_mac(ESP_MAC_ETH) — the
  hardware MAC, same value the driver would have returned.
- get_duplex_mode() returns ETH_DUPLEX_HALF.
- get_link_speed() returns ETH_SPEED_10M.
- get_ip_addresses() returns empty (zero) addresses.
- dump_connect_params_() early-returns with "(uninitialized)" log line.

For a user's reboot-to-toggle workflow with both interfaces declared but
only one active per boot: the inactive interface costs zero DMA-capable
memory. WiFi-side reclaims ~15-30KB DMA-capable, ethernet-side reclaims
~3-8KB (W5500 SPI driver is gentler than wifi).

Field-tested on ESP32-S3 + W5500. Verifies clean dump_config() output and
no false "ethernet was marked as failed" state when ethernet is dormant.
2026-05-21 21:38:45 -05:00
kbx81 ed289390df feat(wifi): Unit B+ — defer esp_wifi_init() to lazy-init
WiFi already had enable_on_boot + enable()/disable()/is_disabled() lifecycle,
but enable_on_boot:false didn't actually save any memory. wifi_pre_setup_()
called esp_wifi_init() and esp_netif_create_default_wifi_sta() unconditionally
during setup(), which allocates ~15-30KB of DMA-capable internal SRAM (RX/TX
buffers, driver state, PHY init). The flag only skipped esp_wifi_start() in
the followup branch — the driver was already resident, just not associated.

This commit splits wifi_pre_setup_() into two parts:

- wifi_pre_setup_() (light, kept in setup() always): MAC setup, event group
  creation, WIFI_EVENT/IP_EVENT handler registration. No DMA allocation.

- wifi_lazy_init_() (heavy, NEW): esp_netif_create_default_wifi_sta()/_ap(),
  esp_wifi_init(), esp_wifi_set_storage(). The DMA-allocating calls.
  Guarded by wifi_initialized_ flag for idempotency.

setup() calls wifi_lazy_init_() only when enable_on_boot_=true. The else
branch sets WIFI_COMPONENT_STATE_DISABLED without any heavy init — the
dormant interface costs zero DMA-capable memory.

enable() calls wifi_lazy_init_() before start(), so a runtime enable after
boot-time disable does the heavy init on demand. Idempotent — subsequent
enable/disable cycles don't re-allocate.

disable() is unchanged — it stops wifi but doesn't deinit. A future
"release_on_disable" variant could call esp_wifi_deinit() to actually free
the memory at runtime, but that requires coordinating with consumers
holding wifi-bound sockets and is out of scope here.

ESP-IDF only. Other platforms (Arduino on ESP32, ESP8266) keep the existing
behavior — their wifi_pre_setup_() lives in different per-platform files.

Field-tested on ESP32-S3 with W5500 SPI ethernet + audio + bluetooth_proxy.
Before Unit B+: ~14KB free internal during peak load, crash on W5500 SPI
DMA buffer allocation. After Unit B+: ~32KB free internal, Min Free 78KB
in some test configurations — sufficient headroom for the other DMA
consumers (I2S audio, BT controller) to operate.
2026-05-21 21:32:38 -05:00
kbx81 8f3010ac64 feat(network): Unit A — explicit default-route management
Builds on PR #14012's NetworkComponent + PR #14255's priority list to make
the user's stated interface priority actually drive runtime default-route
selection. Without this, ESP-IDF's auto-selection picks the default netif by
each netif's hardcoded `route_prio` field (WiFi STA = 100, Ethernet = 50,
WiFi AP = 10) — which inverts the user's intent on same-subnet
multi-homing configurations where wifi+ethernet share a broadcast domain.

Changes:

- NetworkComponent gains an IP_EVENT handler registered in setup() that
  re-arbitrates the default netif on every interface up/down. The handler
  walks the priority list in order, picks the highest-priority netif that
  is up, and calls esp_netif_set_default_netif() on it. ESP-IDF then sets
  its internal "manual override" flag so subsequent auto-selection events
  don't undo our choice.
- New StaticVector<NetworkPriorityEntry, 4> stores the priority list with
  zero heap allocation. The interface-name string pointer is a YAML literal
  with static storage duration.
- The timeout_ms field is parsed and stored but not yet consumed by Unit A;
  it's wired up for Unit D (runtime timeout fallback).
- New getters get_active_interface() / get_active_netif() expose the
  currently-active interface for Unit C consumers.
- Python codegen iterates CORE.data[KEY_NETWORK_PRIORITY] and emits
  add_priority_entry() calls per YAML order.

Field-tested on ESP32-S3 with W5500 SPI ethernet + WiFi STA on the same
subnet. The log line "[network] Default interface: <name>" confirms the
arbitration logic fires correctly on IP_EVENT_*_GOT_IP.

Standalone — no schema changes, single-interface configs unaffected.
2026-05-21 21:29:47 -05:00
Jesse Hills 0b2eb6481f [light] Add light.effect.next / light.effect.previous actions (#16491) 2026-05-22 13:42:50 +12:00
Jesse Hills 1d3eea098e [core] Support YAML frontmatter for arbitrary user metadata (#16552) 2026-05-22 13:00:22 +12:00
dependabot[bot] 4ff8eb4b15 Bump ruff from 0.15.13 to 0.15.14 (#16543)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-21 22:08:56 +00:00
J. Nick Koston aea1e4d136 [core] Refresh compiled config cache after upload/logs fallback (#16548) 2026-05-21 17:05:17 -05:00
Jonathan Swoboda 38b8b41ccc [sx126x] Assert NSS before wait_busy so commands wake the chip from sleep (#16546) 2026-05-21 18:03:07 -04:00
J. Nick Koston 96eced0378 [api] Break api_connection/api_server include cycle to drop custom unique_ptr deleter (#16542) 2026-05-21 15:42:57 -05:00
Jonathan Swoboda 1ea95264bd [tuya] Restore null guard on status_pin lost in #16353 (#16539) 2026-05-21 18:08:09 +00:00
Jonathan Swoboda d2bda0a402 [esp32] Defer esp_panic_handler wrap so arduino-esp32 IDF component skips it (#16538) 2026-05-21 14:03:55 -04:00
Jonathan Swoboda 56fd77e4c8 [espidf] Honor the dict shorthand for library.json dependencies (#16537) 2026-05-21 13:01:54 -05:00
Jonathan Swoboda 3719ea740a [espidf] Default to remote HEAD when cg.add_library URL has no #ref (#16535) 2026-05-21 13:01:19 -05:00
Jonathan Swoboda 750ae56778 [espidf] Backport ninja linux-arm64 entry into tools.json on aarch64 hosts (#16527) 2026-05-21 12:05:27 -04:00
Kevin Ahrendt 01494f7431 [audio] Bump esp-audio-libs to v3.1.0 (#16519) 2026-05-21 11:57:32 -04:00
J. Nick Koston 233a60f106 [ci] Pin uv version in setup-uv to fix Windows manifest fetch flake (#16534) 2026-05-21 10:53:34 -05:00
Jonathan Swoboda e0076cb1a8 [core] Persist & restore CORE.toolchain through StorageJSON (#16531) 2026-05-21 10:37:46 -05:00
Jonathan Swoboda b619e3e8c7 [espidf] Write version.txt after extract so bootloader shows the real version (#16532) 2026-05-21 10:37:10 -05:00
Jonathan Swoboda f2bfe5cd17 [espidf] Fix tarfile extract crashing on Python 3.11 with None mode (#16530) 2026-05-21 10:36:27 -05:00
Jonathan Swoboda 90715373f2 [espidf] Filter noisy 'git rev-parse' errors when .git is stripped (#16521) 2026-05-21 10:35:51 -05:00
Jonathan Swoboda 52e7d3ccfb [esp32] Use new sdkconfig key names that replaced deprecated ones (#16522) 2026-05-21 10:35:25 -05:00
dependabot[bot] a70e358cea Bump zeroconf from 0.149.13 to 0.149.16 (#16533)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-21 09:55:28 -05:00
dependabot[bot] 43a1c2067e Bump zeroconf from 0.149.12 to 0.149.13 (#16520)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-21 08:28:09 -05:00
kbx81 9d9af645ac Merge remote-tracking branch 'upstream/dev' into multi-interface-poc 2026-05-20 23:52:07 -05:00
Jesse Hills 11760307f7 Merge branch 'release' into dev 2026-05-21 13:49:17 +12:00
Jesse Hills 15c546b809 Merge pull request #16523 from esphome/bump-2026.5.0
2026.5.0
2026-05-21 13:48:28 +12:00
Jesse Hills 104c8bed41 Bump version to 2026.5.0 2026-05-21 11:16:58 +12:00
Jesse Hills 49bfa12eb7 Merge branch 'beta' into dev 2026-05-21 10:14:14 +12:00
Jesse Hills ca859de212 Merge pull request #16518 from esphome/bump-2026.5.0b4
2026.5.0b4
2026-05-21 10:13:39 +12:00
Jesse Hills de783e72d5 Bump version to 2026.5.0b4 2026-05-21 09:10:52 +12:00
Jonathan Swoboda cd7e2d79c4 [esp32] Decouple esp-idf toolchain version check from PIO, honor framework source: override (#16516) 2026-05-21 09:10:52 +12:00
Jonathan Swoboda ecf823b871 [espidf] Drop version field from generated idf_component.yml (#16511) 2026-05-21 09:10:52 +12:00
dependabot[bot] 9fdad68138 Bump aioesphomeapi from 45.0.3 to 45.0.4 (#16513)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-21 09:10:51 +12:00
dependabot[bot] b79a306d02 Bump zeroconf from 0.149.7 to 0.149.12 (#16510)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-21 09:10:51 +12:00
Jonathan Swoboda 870f628637 [esp32] Decouple esp-idf toolchain version check from PIO, honor framework source: override (#16516) 2026-05-20 20:40:59 +00:00
Jonathan Swoboda 52c9a2d07b [espidf] Drop version field from generated idf_component.yml (#16511) 2026-05-20 14:31:58 -04:00
Jonathan Swoboda 60afad442c [esp32] Fix sdkconfig int values silently clamped to default (#16515) 2026-05-20 13:36:18 -04:00
dependabot[bot] fbe212944b Bump aioesphomeapi from 45.0.3 to 45.0.4 (#16513)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 10:51:53 -05:00
dependabot[bot] 8927ade789 Bump zeroconf from 0.149.7 to 0.149.12 (#16510)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:40:20 +00:00
Rapsssito 9bfae9e782 Remove redundant esp_netif_init 2026-05-20 09:07:57 +02:00
kbx81 eb64707d94 fix(network): lower NETWORK_PRIORITY_BASE below NetworkComponent's own priority
PR #14255 sets NETWORK_PRIORITY_BASE = 300.0, but PR #14012's
NetworkComponent uses setup_priority::AFTER_BLUETOOTH = 300.0f. When the
highest-priority interface (first in the priority list) tied with
NetworkComponent at 300, the runtime tie-break was determined by
registration order — and NetworkComponent registers AFTER ethernet (its
codegen runs at CoroPriority.NETWORK_SERVICES = 55, below COMMUNICATION
= 60 used by ethernet/wifi).

Result: ethernet's setup() ran before NetworkComponent::setup(),
esp_netif_init() had not yet been called, esp_netif_new() returned
NULL, and EthernetComponent::setup() dereferenced NULL in
esp_netif_attach() — LoadProhibited crash at boot.

Drop the base to 250.0 (matches the historical setup_priority::WIFI /
::ETHERNET default, so a single-entry priority list behaves identically
to a no-priority-block config) and shrink the step to 5.0 to keep all
interfaces in the same priority band, above BEFORE_CONNECTION (220.0)
and below AFTER_BLUETOOTH (300.0).
2026-05-19 23:16:01 -05:00
kbx81 7814e99b6f fix(network): enable USE_SETUP_PRIORITY_OVERRIDE when priority is configured
PR #14255 generates calls to Component::set_setup_priority(float) from
ethernet/wifi to_code(), but that method's body in core/component.cpp is
gated by #ifdef USE_SETUP_PRIORITY_OVERRIDE. Without the define the
declaration exists but no implementation is linked, producing:

  undefined reference to `esphome::Component::set_setup_priority(float)`

The existing convention in cpp_helpers.register_component() is to add
the define whenever CONF_SETUP_PRIORITY appears in a component's YAML.
Mirror that here: when the user declares `network: priority:`, the
priority-driven setup_priority overrides will be emitted, so the define
must be on.
2026-05-19 23:10:32 -05:00
kbx81 8ad6813d44 Merge PR #14255: network priority
Resolves conflicts with PR #14012 (centralized netif init):
- wifi_component_esp_idf.cpp: dropped pr-14255's ESP_ERR_INVALID_STATE
  tolerance hunk (made moot by #14012 removing the call entirely).
- ethernet/__init__.py: kept dev's refactored _to_code_esp32 structure;
  added pr-14255's priority lookup and conditional CONFIG_ESP_WIFI_ENABLED
  gating; preserved dev's top-level import shape.
- network/__init__.py: merged CONF_ID import (#14012) with CONF_PRIORITY
  + CONF_TIMEOUT imports (pr-14255).

Also fixed two latent bugs in pr-14255 where `"wifi" in net_priority`
compared a string against a list of dicts (always False). Replaced with
set comprehension over the normalized interface names.
2026-05-19 22:45:14 -05:00
kbx81 1aa0a489f6 Merge PR #14012: centralize ESP32 network init 2026-05-19 22:37:40 -05:00
Jesse Hills 63fe977adb Merge branch 'beta' into dev 2026-05-20 14:34:33 +12:00
Jesse Hills 94badfcb19 Merge pull request #16507 from esphome/bump-2026.5.0b3
2026.5.0b3
2026-05-20 14:33:57 +12:00
Jesse Hills 19c4da2aa5 Bump version to 2026.5.0b3 2026-05-20 12:53:26 +12:00
Kevin Ahrendt e4c8d1f430 [sendspin] Bump sendspin to v0.6.0 (#16496) 2026-05-20 12:53:26 +12:00
Kevin Ahrendt 302938f875 [i2s_audio] Optimize SPDIF encoder and suport higher bit depth audio (#16504)
Co-authored-by: Keith Burzinski <kbx81x@gmail.com>
2026-05-20 12:53:26 +12:00
Jonathan Swoboda 65e1e210de [espidf] Print RAM summary on ESP32-S3 / unified-DIRAM variants (#16494) 2026-05-20 12:53:26 +12:00
luar123 43cc9fc879 [zigbee] don't allow zigbee + thread or access point (#16499) 2026-05-20 12:53:25 +12:00
Kevin Ahrendt 41ad2ba763 [i2s_audio] Compute ring buffer size with SPDIF sample count (#16400) 2026-05-20 12:53:25 +12:00
Brandon Harvey 25739091da [sen6x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16465) 2026-05-20 12:53:25 +12:00
Brandon Harvey bbf5fe8450 [sgp4x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16464) 2026-05-20 12:53:25 +12:00
Brandon Harvey e9ef58d99d [sen5x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16463) 2026-05-20 12:53:25 +12:00
dependabot[bot] e1793a1eff Bump zeroconf from 0.149.3 to 0.149.7 (#16492)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 12:53:25 +12:00
Jesse Hills 9bb70d568d [ci] Move ha-addon and schema release triggers to version-notifier (#16490) 2026-05-20 12:53:25 +12:00
Keith Burzinski be3ccd29f6 Merge branch 'dev' into central-netif 2026-05-19 18:03:15 -05:00
Kevin Ahrendt 0912122634 [sendspin] Bump sendspin to v0.6.0 (#16496) 2026-05-19 15:16:00 -04:00
Kevin Ahrendt 9924d998f1 [i2s_audio] Optimize SPDIF encoder and suport higher bit depth audio (#16504)
Co-authored-by: Keith Burzinski <kbx81x@gmail.com>
2026-05-19 13:37:41 -05:00
dependabot[bot] e979d461f0 Bump codecov/codecov-action from 6.0.0 to 6.0.1 (#16500)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-19 12:15:50 -05:00
J. Nick Koston 863af482ec [esp32_ble_server] Honor client offset and MTU in long reads (#16458) 2026-05-19 12:14:15 -05:00
J. Nick Koston 80ed541032 [core] Add progmem_memcpy HAL helper (#16470) 2026-05-19 12:13:58 -05:00
Jonathan Swoboda 1d0ddfac5d [espidf] Print RAM summary on ESP32-S3 / unified-DIRAM variants (#16494) 2026-05-19 12:57:18 -04:00
luar123 c0e71fc713 [zigbee] don't allow zigbee + thread or access point (#16499) 2026-05-19 12:53:36 -04:00
Rodrigo Martín 73b8491936 Update esphome/components/network/network_component.h
Co-authored-by: Keith Burzinski <kbx81x@gmail.com>
2026-05-19 17:50:02 +02:00
Rapsssito 1332ebe729 Exclude from non ESP32 devices 2026-05-19 09:20:08 +02:00
Kevin Ahrendt 7ecfe4b5c9 [i2s_audio] Compute ring buffer size with SPDIF sample count (#16400) 2026-05-18 21:53:19 -05:00
kbx81 028a54422e preen 2026-05-18 18:31:31 -05:00
kbx81 de53e7a6b1 preen 2026-05-18 18:24:54 -05:00
Brandon Harvey 36fc36071d [sen6x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16465) 2026-05-19 11:20:08 +12:00
Brandon Harvey cb581271ed [sgp4x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16464) 2026-05-19 11:19:51 +12:00
Brandon Harvey b0af4a9f0d [sen5x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16463) 2026-05-19 11:19:48 +12:00
kbx81 0d1d00b654 Merge branch 'dev' into central-netif 2026-05-18 18:19:46 -05:00
dependabot[bot] edb59476b1 Bump zeroconf from 0.149.3 to 0.149.7 (#16492)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 23:29:03 -07:00
Jesse Hills 9c696f5de1 [ci] Move ha-addon and schema release triggers to version-notifier (#16490) 2026-05-18 15:56:44 +12:00
Jesse Hills 6804965bd8 Merge branch 'beta' into dev 2026-05-18 15:31:01 +12:00
Jesse Hills 213df0412d Merge pull request #16488 from esphome/bump-2026.5.0b2
2026.5.0b2
2026-05-18 15:28:53 +12:00
Jesse Hills cdf74c180e Bump version to 2026.5.0b2 2026-05-18 11:11:54 +12:00
Jonathan Swoboda df31c72e4e [espidf] Switch direct framework downloader to esphome-libs/esp-idf tarballs (#16484) 2026-05-18 11:11:54 +12:00
dependabot[bot] 4f188bf9bb Bump zeroconf from 0.148.0 to 0.149.3 (#16480)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 11:11:54 +12:00
dependabot[bot] 20f92ad5e9 Bump aioesphomeapi from 45.0.2 to 45.0.3 (#16479)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 11:11:54 +12:00
J. Nick Koston f301e90fd9 [ci] Use larger app partition for esp32-s3-idf component test grouping (#16430) 2026-05-18 11:11:54 +12:00
dependabot[bot] 2dbaaf1efd Bump aioesphomeapi from 45.0.1 to 45.0.2 (#16469)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 11:11:54 +12:00
Clyde Stubbs da237b5070 [lvgl] Fix image define (#16468) 2026-05-18 11:11:54 +12:00
Clyde Stubbs 6a8f24b951 [ft5x06] Fix setting calibration values (#16446) 2026-05-18 11:11:54 +12:00
dependabot[bot] 26907f17f5 Bump aioesphomeapi from 45.0.0 to 45.0.1 (#16467)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 11:11:54 +12:00
Jonathan Swoboda c6a74222f1 [esp32_hosted][fingerprint_grow] Fix two remaining ESP32 toolchain warnings (#16442) 2026-05-18 11:11:54 +12:00
J. Nick Koston 5ec0879a10 [core] Fix KeyError: 'esp32' on upload when validated-config cache is used (#16457) 2026-05-18 11:11:54 +12:00
J. Nick Koston 50495c7085 [wifi] Refuse to compile when wifi_ssid is the device-builder placeholder (#16444) 2026-05-18 11:11:54 +12:00
Kevin Ahrendt 25dbef83de [sound_level] Use RingBufferAudioSource (#16436) 2026-05-18 11:11:54 +12:00
Kevin Ahrendt 4f895425ca [audio] Bump microMP3 to v0.2.1 (#16429) 2026-05-18 11:11:54 +12:00
Jonathan Swoboda c037058c19 [esp32_hosted] Bump esp_hosted to 2.12.7 (#16440) 2026-05-18 11:11:54 +12:00
Jonathan Swoboda ecac6b64ec [espidf] Gate esp_idf_size --ng on IDF version (#16441) 2026-05-18 11:11:54 +12:00
Jonathan Swoboda 3831aa809f [multiple] Fix -Wformat= mismatches in component .cpp sources (#16433) 2026-05-18 11:11:54 +12:00
Jonathan Swoboda da8286f554 [docker] Install libusb-1.0 so ESP-IDF tools can validate openocd (#16424)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-18 11:11:54 +12:00
Jonathan Swoboda d5c6efb2fe [tests] Fix -Wformat= mismatches in test YAML lambdas/logger.log (#16435) 2026-05-18 11:11:54 +12:00
Jonathan Swoboda dd1818661c [esp32] Sweep ESP-IDF toolchain warnings + bump deprecated mark_failed (#16432) 2026-05-18 11:11:53 +12:00
Keith Burzinski fb659f9ac4 [tinyusb] Reject logger.hardware_uart: USB_CDC (#16417)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 11:11:53 +12:00
Keith Burzinski ab273a1f8f [tinyusb] Reject tinyusb: configured without a USB class companion (#16413)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 11:11:53 +12:00
Jonathan Swoboda 84b5931299 [espidf] Trim has_outdated_files watch list; embed IDF version in sdkconfig (#16416) 2026-05-18 11:11:53 +12:00
Jonathan Swoboda c863d58999 [espidf] Stop perpetual reconfigure loop on native ESP-IDF builds (#16415) 2026-05-18 11:11:53 +12:00
Jesse Hills 42ad2a6272 [espidf] Accept list input in _str_to_lst_of_str helper (#16485) 2026-05-18 10:49:03 +12:00
Jonathan Swoboda 6690725860 [espidf] Switch direct framework downloader to esphome-libs/esp-idf tarballs (#16484) 2026-05-18 07:29:38 +12:00
dependabot[bot] 155232875a Bump zeroconf from 0.148.0 to 0.149.3 (#16480)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-16 19:08:19 -07:00
dependabot[bot] 01c0d3163e Bump aioesphomeapi from 45.0.2 to 45.0.3 (#16479)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-16 18:41:09 -07:00
J. Nick Koston 7c5d5f75dc [ci] Use larger app partition for esp32-s3-idf component test grouping (#16430) 2026-05-15 22:16:52 -07:00
dependabot[bot] fb0bfea1c8 Bump aioesphomeapi from 45.0.1 to 45.0.2 (#16469)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-15 22:06:33 -07:00
J. Nick Koston 48d17571c8 [tests] Mock determine_cpp_unit_tests in clang_tidy_mode tests (#16456) 2026-05-15 15:50:58 -07:00
Clyde Stubbs df100681e0 [lvgl] Fix image define (#16468) 2026-05-15 22:36:53 +00:00
Clyde Stubbs 1a287bf785 [ft5x06] Fix setting calibration values (#16446) 2026-05-16 10:30:04 +12:00
dependabot[bot] ff34e1061b Bump resvg-py from 0.3.1 to 0.3.2 (#16466)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-15 14:43:08 -07:00
dependabot[bot] b78b78cbbb Bump aioesphomeapi from 45.0.0 to 45.0.1 (#16467)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-15 14:42:51 -07:00
dependabot[bot] 4c090c6b85 Bump github/codeql-action from 4.35.4 to 4.35.5 (#16461)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-15 12:37:56 -07:00
dependabot[bot] ec6669fa67 Bump requests from 2.34.1 to 2.34.2 (#16460)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-15 12:37:41 -07:00
dependabot[bot] 59f8c1019f Bump pytest-codspeed from 5.0.1 to 5.0.2 (#16459)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-15 12:37:28 -07:00
david-collett fb70095ba1 [esp32_ble_server] Fix incorrect BLECharacteristic read truncation (#16420) (#16422)
Co-authored-by: Dave <dave@morty>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-05-15 10:47:26 -07:00
Jonathan Swoboda 65d6bb18ed [esp32_hosted][fingerprint_grow] Fix two remaining ESP32 toolchain warnings (#16442) 2026-05-15 17:32:51 +00:00
J. Nick Koston 47eb2adbf2 [core] Fix KeyError: 'esp32' on upload when validated-config cache is used (#16457) 2026-05-15 10:29:15 -07:00
J. Nick Koston 35631be260 [writer] Mark storage_should_clean as public API for device-builder (#16443) 2026-05-15 10:20:31 -07:00
J. Nick Koston 96106d25bc [wifi] Refuse to compile when wifi_ssid is the device-builder placeholder (#16444) 2026-05-15 10:20:15 -07:00
J. Nick Koston 1674ed9744 [ci] Use uv for pip installs across CI workflows (#16451) 2026-05-15 10:18:27 -07:00
J. Nick Koston 46be0f4f62 [ci] Log top 30 pytest durations (#16455) 2026-05-15 10:18:07 -07:00
J. Nick Koston ec1826a6ed [yaml_util] Promote include-discovery helper, share it with bundle (#16447) 2026-05-15 10:17:50 -07:00
Simone Chemelli 8b3bc47547 [uptime] Update device_class for Uptime sensor (#16434)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-05-15 17:16:01 +00:00
J. Nick Koston 4381a8baaa [ci] pr-title-check: skip all bot authors, not just dependabot (#16453) 2026-05-15 09:59:35 -07:00
esphome[bot] 4189979391 Synchronise Device Classes from Home Assistant (#16452)
Co-authored-by: esphomebot <esphome@openhomefoundation.org>
2026-05-15 09:57:49 -07:00
J. Nick Koston 1b1e21d470 [ci] sync-device-classes: drop branch-switch hack, skip no-commit-to-branch instead (#16450) 2026-05-15 09:54:01 -07:00
pre-commit-ci-lite[bot] 578196ab85 [pre-commit.ci lite] apply automatic fixes 2026-02-28 22:24:08 +00:00
Roy Walker d9b712ee5f Fix timeout to use ESPhome built-in function. 2026-02-28 16:22:28 -06:00
Roy Walker 1a61cd622e Add support for timeouts before next network connection is turned up and add support for openthread and modem. 2026-02-28 13:41:55 -06:00
pre-commit-ci-lite[bot] 26bdf58daf [pre-commit.ci lite] apply automatic fixes 2026-02-26 17:36:14 +00:00
Roy Walker c915a2b8f5 Remove duplicate logger and fix import. 2026-02-26 11:34:18 -06:00
Roy Walker 7fdb95c2ef Merge branch 'rwalker777-network-priority' of https://github.com/rwalker777/esphome into rwalker777-network-priority 2026-02-26 11:31:10 -06:00
Roy Walker e44365abca Remove duplicate CONF_OUTPUT_POWER from import. 2026-02-26 11:30:20 -06:00
rwalker777 532641d523 Merge branch 'esphome:dev' into rwalker777-network-priority 2026-02-26 11:30:07 -06:00
rwalker777 bc36892e7d Merge branch 'dev' into rwalker777-network-priority 2026-02-24 13:45:27 -06:00
Roy Walker 3a02c2f8af Fix validation on priority import. 2026-02-24 11:16:21 -06:00
pre-commit-ci-lite[bot] 4a1f9af319 [pre-commit.ci lite] apply automatic fixes 2026-02-24 17:08:56 +00:00
rwalker777 9e29bdfdad Merge branch 'esphome:dev' into rwalker777-network-priority 2026-02-24 11:03:34 -06:00
Roy Walker 20c975103b Fix Wifi not connecting with Ethernet config but disconnected. 2026-02-22 20:30:34 -06:00
Roy Walker 549b9f85ae Fix wifi so it doesn't double register. 2026-02-22 19:20:13 -06:00
Roy Walker 0fe2310db4 Fix wifi and ethernet coexisting. 2026-02-22 18:42:35 -06:00
Roy Walker 5af3e5caef Fix stab at network priority support. 2026-02-22 18:22:47 -06:00
Rapsssito 47854ff9de Add missing imports 2026-02-16 13:31:19 +01:00
Rapsssito 8a1ddfb1cc Typo 2026-02-16 13:21:13 +01:00
Rapsssito cde89212fc Typo 2026-02-16 13:17:00 +01:00
Rapsssito 0a518c1e4c Switch to a network component 2026-02-16 13:13:23 +01:00
Rapsssito 8c7d2d984e Just store if it is initialized 2026-02-16 12:47:14 +01:00
Rapsssito 8390a98614 [ethernet, network, openthread, wifi] centralize esp32 netif intialization 2026-02-16 12:36:22 +01:00
114 changed files with 3784 additions and 868 deletions
+2 -2
View File
@@ -47,7 +47,7 @@ runs:
- name: Build and push to ghcr by digest
id: build-ghcr
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
@@ -73,7 +73,7 @@ runs:
- name: Build and push to dockerhub by digest
id: build-dockerhub
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
@@ -35,6 +35,10 @@ runs:
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os != 'Windows'
shell: bash
+4
View File
@@ -32,6 +32,10 @@ jobs:
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Install apt dependencies
run: |
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
with:
python-version: "3.11"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Set TAG
run: |
+26 -19
View File
@@ -6,14 +6,6 @@ on:
branches: [dev, beta, release]
pull_request:
paths:
- "**"
- "!.github/workflows/*.yml"
- "!.github/actions/build-image/*"
- ".github/workflows/ci.yml"
- "!.yamllint"
- "!.github/dependabot.yml"
- "!docker/**"
merge_group:
permissions:
@@ -39,11 +31,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Generate cache-key
id: cache-key
# PROBE: salt prefix forces every venv-cache restore in this
# workflow run to miss so the uv install path from #16451 is
# actually exercised across Linux/macOS/Windows x 3.11/3.13/3.14.
# DO NOT MERGE -- remove this salt before landing.
run: echo key="uvprobe-${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -64,6 +52,10 @@ jobs:
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
@@ -101,6 +93,8 @@ jobs:
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -179,6 +173,10 @@ jobs:
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Install device-builder + esphome from PR
# Install device-builder with its esphome + test extras
# first so its pinned versions of pytest/etc. land, then
@@ -193,7 +191,7 @@ jobs:
# own CI). No ``--cov`` here -- this is purely a downstream
# smoke check against this PR's esphome code.
working-directory: device-builder
run: pytest -q -n auto --maxfail=5 --durations=10 --no-cov --ignore=tests/benchmarks
run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks
pytest:
name: Run pytest
@@ -219,6 +217,8 @@ jobs:
runs-on: ${{ matrix.os }}
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -234,14 +234,14 @@ jobs:
if: matrix.os == 'windows-latest'
run: |
. ./venv/Scripts/activate.ps1
pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Run pytest
if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest'
run: |
. venv/bin/activate
pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Save Python virtual environment cache
@@ -257,6 +257,7 @@ jobs:
needs:
- common
outputs:
core-ci: ${{ steps.determine.outputs.core-ci }}
integration-tests: ${{ steps.determine.outputs.integration-tests }}
integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }}
clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
@@ -310,6 +311,7 @@ jobs:
echo "$output" | jq
# Extract individual fields
echo "core-ci=$(echo "$output" | jq -r '.core_ci')" >> $GITHUB_OUTPUT
echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT
echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT
echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT
@@ -369,6 +371,10 @@ jobs:
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
@@ -388,7 +394,7 @@ jobs:
. venv/bin/activate
mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]')
echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests"
pytest -vv --no-cov --tb=native -n auto "${test_files[@]}"
pytest -vv --no-cov --tb=native --durations=30 -n auto "${test_files[@]}"
cpp-unit-tests:
name: Run C++ unit tests
@@ -961,7 +967,8 @@ jobs:
runs-on: ubuntu-latest
needs:
- common
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release')
- determine-jobs
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
category: "/language:${{matrix.language}}"
+4 -3
View File
@@ -29,10 +29,11 @@ jobs:
} = require('./.github/scripts/detect-tags.js');
const title = context.payload.pull_request.title;
const author = context.payload.pull_request.user.login;
const user = context.payload.pull_request.user;
// Skip bot PRs (e.g. dependabot) - they have their own title format
if (author === 'dependabot[bot]') {
// Skip bot PRs (e.g. dependabot, esphome[bot] device-class sync) -
// they have their own title formats.
if (user.type === 'Bot') {
return;
}
+7 -75
View File
@@ -99,15 +99,15 @@ jobs:
python-version: "3.11"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to docker hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -178,17 +178,17 @@ jobs:
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to docker hub
if: matrix.registry == 'dockerhub'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry
if: matrix.registry == 'ghcr'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -212,74 +212,6 @@ jobs:
docker buildx imagetools create $(jq -Rcnr 'inputs | . / "," | map("-t " + .) | join(" ")' <<< "${{ steps.tags.outputs.tags}}") \
$(printf '${{ steps.tags.outputs.image }}@sha256:%s ' *)
deploy-ha-addon-repo:
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
runs-on: ubuntu-latest
needs:
- init
- deploy-manifest
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
owner: esphome
repositories: home-assistant-addon
permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token)
- name: Trigger Workflow
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
let description = "ESPHome";
if (context.eventName == "release") {
description = ${{ toJSON(github.event.release.body) }};
}
github.rest.actions.createWorkflowDispatch({
owner: "esphome",
repo: "home-assistant-addon",
workflow_id: "bump-version.yml",
ref: "main",
inputs: {
version: "${{ needs.init.outputs.tag }}",
content: description
}
})
deploy-esphome-schema:
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
runs-on: ubuntu-latest
needs: [init]
environment: ${{ needs.init.outputs.deploy_env }}
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
owner: esphome
repositories: esphome-schema
permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token)
- name: Trigger Workflow
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
github.rest.actions.createWorkflowDispatch({
owner: "esphome",
repo: "esphome-schema",
workflow_id: "generate-schemas.yml",
ref: "main",
inputs: {
version: "${{ needs.init.outputs.tag }}",
}
})
version-notifier:
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
runs-on: ubuntu-latest
@@ -302,7 +234,7 @@ jobs:
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
github.rest.actions.createWorkflowDispatch({
await github.rest.actions.createWorkflowDispatch({
owner: "esphome",
repo: "version-notifier",
workflow_id: "notify.yml",
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Stale
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
remove-stale-when-updated: true
+16 -13
View File
@@ -30,11 +30,6 @@ jobs:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Create sync branch
# Switch off dev before pre-commit runs so the
# no-commit-to-branch hook passes (it blocks dev/release/beta).
run: git checkout -B sync/device-classes
- name: Checkout Home Assistant
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -55,6 +50,10 @@ jobs:
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Install Home Assistant
run: |
@@ -71,22 +70,26 @@ jobs:
# which would otherwise abort the workflow before the auto-fixes
# can flow into the sync PR.
#
# pylint is skipped: this workflow only installs a subset of the
# runtime deps (HA + requirements*.txt), so pylint surfaces
# import-error / relative-beyond-top-level noise that the main CI
# already gates on real PRs.
# SKIP:
# - no-commit-to-branch is a local guard against committing on
# dev/release/beta; CI runs on dev by definition, and
# peter-evans/create-pull-request creates the branch itself.
# - pylint surfaces import-error / relative-beyond-top-level
# noise here because this workflow installs only a subset of
# the runtime deps (HA + requirements*.txt); main CI already
# gates pylint on real PRs.
env:
SKIP: pylint
SKIP: pylint,no-commit-to-branch
run: python script/run-in-env.py pre-commit run --all-files || true
- name: Verify pre-commit clean
# Second pass: re-run all hooks against the now-fixed tree.
# Auto-fixers exit 0 (nothing to change); any remaining failure
# from a check-only hook (flake8 / yamllint / ci-custom) is a
# real issue and fails the workflow loudly. pylint stays skipped
# for the same reason as above.
# real issue and fails the workflow loudly. Same SKIP list as
# above for the same reasons.
env:
SKIP: pylint
SKIP: pylint,no-commit-to-branch
run: python script/run-in-env.py pre-commit run --all-files
- name: Commit changes
+1 -1
View File
@@ -11,7 +11,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.12
rev: v0.15.14
hooks:
# Run the linter.
- id: ruff
+22 -1
View File
@@ -50,6 +50,7 @@ from esphome.const import (
CONF_TOPIC,
CONF_USERNAME,
CONF_WEB_SERVER,
CONF_WIFI,
ENV_NOGITIGNORE,
KEY_CORE,
KEY_TARGET_PLATFORM,
@@ -733,6 +734,13 @@ def write_cpp_file() -> int:
def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
# Keep this gate here, NOT in config validation: device-builder needs
# `esphome config` to keep succeeding with placeholders so onboarding can run.
if CONF_WIFI in config:
from esphome.components.wifi import check_placeholder_credentials
check_placeholder_credentials(config)
# NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py
# If you change this format, update the regex in that script as well
_LOGGER.info("Compiling app... Build path: %s", CORE.build_path)
@@ -2441,7 +2449,10 @@ def run_esphome(argv):
# Skipped when -s overrides are passed, since the cache was written
# against the previous substitution set.
config: ConfigType | None = None
if args.command in ("upload", "logs") and not command_line_substitutions:
cache_eligible = (
args.command in ("upload", "logs") and not command_line_substitutions
)
if cache_eligible:
from esphome.compiled_config import load_compiled_config
config = load_compiled_config(conf_path)
@@ -2456,6 +2467,16 @@ def run_esphome(argv):
command_line_substitutions,
skip_external_update=skip_external,
)
# Refresh the cache so the next upload/logs hits the fast path
# instead of re-running read_config. Skip when the storage
# sidecar is absent (no compile has run): the cache would
# never be loaded back, so writing secrets to disk is wasted.
if cache_eligible and config is not None:
from esphome.compiled_config import save_compiled_config
from esphome.storage_json import ext_storage_path
if ext_storage_path(conf_path.name).exists():
save_compiled_config(config)
if config is None:
return 2
CORE.config = config
+12 -85
View File
@@ -260,42 +260,20 @@ class ConfigBundleCreator:
def _discover_yaml_includes(self) -> None:
"""Discover YAML files loaded during config parsing.
Deliberately uses a fresh re-parse and force-loads every deferred
``IncludeFile`` to include *all* potentially-reachable includes,
even branches not selected by the local substitutions. Bundles are
meant to be compiled on another system where command-line
substitution overrides may choose a different branch — e.g.
``!include network/${eth_model}/config.yaml`` must ship every
candidate so the remote build can pick any one.
Entries with unresolved substitution variables in the filename
path are skipped with a warning (they cannot be resolved without
the substitution pass).
Secrets files are tracked separately so we can filter them to
only include the keys this config actually references.
Delegates to :func:`yaml_util.discover_user_yaml_files`, which does a
fresh re-parse and force-loads every deferred ``IncludeFile`` so that
*all* potentially-reachable includes are captured (even branches not
selected by local substitutions). Bundles are meant to be compiled on
another system where command-line substitution overrides may choose a
different branch — e.g. ``!include network/${eth_model}/config.yaml``
must ship every candidate so the remote build can pick any one.
"""
# Must be a fresh parse: IncludeFile.load() caches its result in
# _content, and we discover files by listening for loader calls. On
# an already-parsed tree the cache is populated, .load() returns
# without calling the loader, the listener never fires, and the
# referenced files would be silently dropped from the bundle.
with yaml_util.track_yaml_loads() as loaded_files:
try:
data = yaml_util.load_yaml(self._config_path)
except EsphomeError:
_LOGGER.debug(
"Bundle: re-loading YAML for include discovery failed, "
"proceeding with partial file list"
)
else:
_force_load_include_files(data)
for fpath in loaded_files:
if fpath == self._config_path.resolve():
discovered = yaml_util.discover_user_yaml_files(self._config_path)
self._secrets_paths.update(discovered.secrets)
config_resolved = self._config_path.resolve()
for fpath in discovered.files:
if fpath == config_resolved:
continue # Already added as config
if fpath.name in const.SECRETS_FILES:
self._secrets_paths.add(fpath)
self._add_file(fpath)
def _discover_component_files(self) -> None:
@@ -625,57 +603,6 @@ def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None:
tar.addfile(info, io.BytesIO(data))
def _force_load_include_files(obj: Any, _seen: set[int] | None = None) -> None:
"""Recursively resolve any ``IncludeFile`` instances in a YAML tree.
Nested ``!include`` returns a deferred ``IncludeFile`` that is only
resolved during the substitution pass. During bundle discovery we need
the referenced files to actually load so the ``track_yaml_loads``
listener fires for them.
``IncludeFile`` instances with unresolved substitution variables in the
filename cannot be loaded — we skip and warn about those.
"""
if _seen is None:
_seen = set()
if isinstance(obj, yaml_util.IncludeFile):
if id(obj) in _seen:
return
_seen.add(id(obj))
if obj.has_unresolved_expressions():
_LOGGER.warning(
"Bundle: cannot resolve !include %s (referenced from %s) "
"with substitutions in path",
obj.file,
obj.parent_file,
)
return
try:
loaded = obj.load()
except EsphomeError as err:
_LOGGER.warning(
"Bundle: failed to load !include %s (referenced from %s): %s",
obj.file,
obj.parent_file,
err,
)
return
_force_load_include_files(loaded, _seen)
elif isinstance(obj, dict):
if id(obj) in _seen:
return
_seen.add(id(obj))
for value in obj.values():
_force_load_include_files(value, _seen)
elif isinstance(obj, (list, tuple)):
if id(obj) in _seen:
return
_seen.add(id(obj))
for item in obj:
_force_load_include_files(item, _seen)
def _resolve_include_path(include_path: Any) -> Path | None:
"""Resolve an include path to absolute, skipping system includes."""
if isinstance(include_path, str) and include_path.startswith("<"):
@@ -1,5 +1,6 @@
#include "api_connection.h"
#ifdef USE_API
#include "api_connection_buffer.h" // for encode_to_buffer / get_batch_delay_ms_ inlines
#ifdef USE_API_NOISE
#include "api_frame_helper_noise.h"
#endif
+11 -40
View File
@@ -11,7 +11,8 @@
#endif
#include "api_pb2.h"
#include "api_pb2_service.h"
#include "api_server.h"
#include "list_entities.h"
#include "subscribe_state.h"
#include "esphome/core/application.h"
#include "esphome/core/component.h"
#ifdef USE_ESP32_CRASH_HANDLER
@@ -36,6 +37,9 @@ class ComponentIterator;
namespace esphome::api {
// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h.
class APIServer;
// Keepalive timeout in milliseconds
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
// Maximum number of entities to process in a single batch during initial state/info sending
@@ -411,44 +415,10 @@ class APIConnection final : public APIServerConnectionBase {
// Non-template buffer management for send_message
bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg);
// Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes.
// ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites.
static inline uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
const void *msg, APIConnection *conn,
uint32_t remaining_size) {
#ifdef HAS_PROTO_MESSAGE_DUMP
if (conn->flags_.log_only_mode) {
auto *proto_msg = static_cast<const ProtoMessage *>(msg);
DumpBuffer dump_buf;
conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
return 1;
}
#endif
const uint8_t footer_size = conn->helper_->frame_footer_size();
// First message uses max padding (already in buffer), subsequent use exact header size
size_t to_add;
if (conn->flags_.batch_first_message) {
conn->flags_.batch_first_message = false;
conn->batch_header_size_ = conn->helper_->frame_header_padding();
to_add = calculated_size;
} else {
conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
to_add = calculated_size + conn->batch_header_size_ + footer_size;
}
// Check if it fits (using actual header size, not max padding)
uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
if (total_calculated_size > remaining_size)
return 0;
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
shared_buf.resize(shared_buf.size() + to_add);
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
return total_calculated_size;
}
// Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites.
// Defined in api_connection_buffer.h (needs APIServer complete).
static uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
const void *msg, APIConnection *conn, uint32_t remaining_size);
// Noinline version of encode_to_buffer for cold paths (entity info, zero-payload messages).
// All cold callers share this single copy instead of each getting an ALWAYS_INLINE expansion.
@@ -792,7 +762,8 @@ class APIConnection final : public APIServerConnectionBase {
// Read by process_batch_multi_ to pass into MessageInfo.
uint8_t batch_header_size_{0};
uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
// Defined in api_connection_buffer.h (needs APIServer complete).
uint32_t get_batch_delay_ms_() const;
// Message will use 8 more bytes than the minimum size, and typical
// MTU is 1500. Sometimes users will see as low as 1460 MTU.
// If its IPv6 the header is 40 bytes, and if its IPv4
@@ -0,0 +1,54 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_API
// Inline APIConnection methods that need APIServer complete. Include this
// instead of api_connection.h when calling encode_to_buffer or get_batch_delay_ms_.
#include "api_connection.h"
#include "api_server.h"
namespace esphome::api {
inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t calculated_size,
MessageEncodeFn encode_fn, const void *msg,
APIConnection *conn, uint32_t remaining_size) {
#ifdef HAS_PROTO_MESSAGE_DUMP
if (conn->flags_.log_only_mode) {
auto *proto_msg = static_cast<const ProtoMessage *>(msg);
DumpBuffer dump_buf;
conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
return 1;
}
#endif
const uint8_t footer_size = conn->helper_->frame_footer_size();
// First message uses max padding (already in buffer), subsequent use exact header size
size_t to_add;
if (conn->flags_.batch_first_message) {
conn->flags_.batch_first_message = false;
conn->batch_header_size_ = conn->helper_->frame_header_padding();
to_add = calculated_size;
} else {
conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
to_add = calculated_size + conn->batch_header_size_ + footer_size;
}
// Check if it fits (using actual header size, not max padding)
uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
if (total_calculated_size > remaining_size)
return 0;
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
shared_buf.resize(shared_buf.size() + to_add);
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
return total_calculated_size;
}
inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
} // namespace esphome::api
#endif
-5
View File
@@ -30,11 +30,6 @@ APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-c
APIServer::APIServer() { global_api_server = this; }
// Custom deleter defined here so `delete` sees the complete APIConnection type.
// This prevents libc++ from emitting an "incomplete type" error when other
// translation units only have the forward declaration of APIConnection.
void APIServer::APIConnectionDeleter::operator()(APIConnection *p) const { delete p; }
void APIServer::socket_failed_(const LogString *msg) {
ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno);
this->destroy_socket_();
+4 -10
View File
@@ -3,6 +3,8 @@
#include "esphome/core/defines.h"
#ifdef USE_API
#include "api_buffer.h"
// Must precede clients_ so APIConnection is complete for default_delete (libc++).
#include "api_connection.h"
#include "api_noise_context.h"
#include "api_pb2.h"
#include "api_pb2_service.h"
@@ -12,8 +14,6 @@
#include "esphome/core/controller.h"
#include "esphome/core/log.h"
#include "esphome/core/string_ref.h"
#include "list_entities.h"
#include "subscribe_state.h"
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
#endif
@@ -191,15 +191,9 @@ class APIServer final : public Component,
bool is_connected_with_state_subscription() const;
// Range-for view over the populated slice [0, api_connection_count_). Read-only with respect
// to ownership callers get `const unique_ptr&` so they can invoke non-const methods on the
// to ownership; callers get `const unique_ptr&` so they can invoke non-const methods on the
// APIConnection but cannot reset/move the slot and break the count invariant.
// Custom deleter is defined out-of-line in api_server.cpp so libc++ does not
// eagerly instantiate `delete static_cast<APIConnection *>(p)` here, where
// only the forward declaration of APIConnection is visible (incomplete type).
struct APIConnectionDeleter {
void operator()(APIConnection *p) const;
};
using APIConnectionPtr = std::unique_ptr<APIConnection, APIConnectionDeleter>;
using APIConnectionPtr = std::unique_ptr<APIConnection>;
class ActiveClientsView {
const APIConnectionPtr *begin_;
const APIConnectionPtr *end_;
+1 -1
View File
@@ -335,7 +335,7 @@ async def to_code(config):
add_idf_component(
name="esphome/esp-audio-libs",
ref="3.0.0",
ref="3.1.0",
)
data = _get_data()
@@ -252,6 +252,22 @@ void RingBufferAudioSource::consume(size_t bytes) {
}
}
void RingBufferAudioSource::clear_buffered_data() {
// Release the held item before reset() so the source no longer references memory the reset will reclaim.
if (this->acquired_item_ != nullptr) {
this->ring_buffer_->receive_release(this->acquired_item_);
this->acquired_item_ = nullptr;
}
this->current_data_ = nullptr;
this->current_available_ = 0;
this->queued_data_ = nullptr;
this->queued_length_ = 0;
this->item_trailing_ptr_ = nullptr;
this->item_trailing_length_ = 0;
this->splice_length_ = 0;
this->ring_buffer_->reset();
}
bool RingBufferAudioSource::has_buffered_data() const {
// splice_length_ is deliberately not considered here. It holds an incomplete frame whose completion
// bytes must still arrive through the ring buffer, which ring_buffer_->available() already reports.
@@ -250,6 +250,10 @@ class RingBufferAudioSource : public AudioReadableBuffer {
/// exposure stays in place and fill() returns 0 until it is fully consumed.
size_t fill(TickType_t ticks_to_wait, bool pre_shift) override;
/// @brief Discards all buffered audio: releases any held ring buffer item, clears the source's in-flight
/// state, and resets the underlying ring buffer. Must be invoked from the ring buffer's consumer thread.
void clear_buffered_data();
/// @brief Returns a mutable pointer to the currently exposed audio data.
/// The pointer may reference the ring buffer's internal storage or, when exposing a stitched frame
/// across a wrap boundary, an internal splice buffer. In either case mutations are safe but data
@@ -135,12 +135,26 @@ void BluetoothConnection::loop() {
// - For V3_WITH_CACHE: Services are never sent, disable after INIT state
// - For V3_WITHOUT_CACHE: Disable only after service discovery is complete
// (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent)
if (this->state() != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
this->send_service_ == DONE_SENDING_SERVICES)) {
// Never disable while DISCONNECTING — BLEClientBase::loop() needs to keep running so the
// 10s safety timeout can force IDLE if CLOSE_EVT is never delivered.
if (this->state() != espbt::ClientState::INIT && this->state() != espbt::ClientState::DISCONNECTING &&
(this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
this->send_service_ == DONE_SENDING_SERVICES)) {
this->disable_loop();
}
}
void BluetoothConnection::on_disconnect_complete(esp_err_t reason) {
// Called from both the CLOSE_EVT handler and the DISCONNECTING safety timeout in the
// base class. Free the proxy slot, notify the API client, and reset send_service_.
// address_ may already be 0 if reset_connection_ ran earlier on this teardown.
if (this->address_ == 0) {
return;
}
ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, reason);
this->reset_connection_(reason);
}
void BluetoothConnection::reset_connection_(esp_err_t reason) {
// Send disconnection notification
this->proxy_->send_device_connection(this->address_, false, 0, reason);
@@ -372,14 +386,6 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason);
break;
}
case ESP_GATTC_CLOSE_EVT: {
ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_,
param->close.reason);
// Now the GATT connection is fully closed and controller resources are freed
// Safe to mark the connection slot as available
this->reset_connection_(param->close.reason);
break;
}
case ESP_GATTC_OPEN_EVT: {
if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) {
this->reset_connection_(param->open.status);
@@ -33,6 +33,8 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase {
protected:
friend class BluetoothProxy;
void on_disconnect_complete(esp_err_t reason) override;
bool supports_efficient_uuids_() const;
void send_service_for_discovery_();
void reset_connection_(esp_err_t reason);
@@ -1,5 +1,6 @@
#include "bluetooth_proxy.h"
#include "esphome/components/api/api_server.h"
#include "esphome/core/log.h"
#include "esphome/core/macros.h"
#include "esphome/core/application.h"
+93 -47
View File
@@ -113,6 +113,7 @@ ARDUINO_FRAMEWORK_NAME = "framework-arduinoespressif32"
ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}"
ARDUINO_LIBS_NAME = f"{ARDUINO_FRAMEWORK_NAME}-libs"
ARDUINO_LIBS_PKG = f"pioarduino/{ARDUINO_LIBS_NAME}"
ARDUINO_ESP32_COMPONENT_NAME = "espressif/arduino-esp32"
LOG_LEVELS_IDF = [
"NONE",
@@ -792,19 +793,15 @@ PLATFORM_VERSION_LOOKUP = {
}
def _check_pio_versions(config):
config = config.copy()
value = config[CONF_FRAMEWORK]
def _resolve_framework_version(value: ConfigType) -> cv.Version:
"""Resolve a named or raw framework version and validate the minimum.
Normalises value[CONF_VERSION] to its string form and returns the parsed
cv.Version. Shared between the PIO and esp-idf toolchain paths; toolchain-
specific concerns (source defaults, platform_version) live in the per-
toolchain functions.
"""
if value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP:
if CONF_SOURCE in value or CONF_PLATFORM_VERSION in value:
raise cv.Invalid(
"Version needs to be explicitly set when a custom source or platform_version is used."
)
platform_lookup = PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]]
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup))
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
version = ARDUINO_FRAMEWORK_VERSION_LOOKUP[value[CONF_VERSION]]
else:
@@ -817,7 +814,38 @@ def _check_pio_versions(config):
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
if version < cv.Version(3, 0, 0):
raise cv.Invalid("Only Arduino 3.0+ is supported.")
recommended_version = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"]
recommended = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"]
else:
if version < cv.Version(5, 0, 0):
raise cv.Invalid("Only ESP-IDF 5.0+ is supported.")
recommended = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]
if version != recommended:
_LOGGER.warning(
"The selected framework version is not the recommended one. "
"If there are connectivity or build issues please remove the manual version."
)
return version
def _check_pio_versions(config: ConfigType) -> ConfigType:
config = config.copy()
value = config[CONF_FRAMEWORK]
is_named_version = value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP
if is_named_version and (CONF_SOURCE in value or CONF_PLATFORM_VERSION in value):
raise cv.Invalid(
"Version needs to be explicitly set when a custom source or platform_version is used."
)
if is_named_version:
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(
str(PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]])
)
version = _resolve_framework_version(value)
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
platform_lookup = ARDUINO_PLATFORM_VERSION_LOOKUP.get(version)
value[CONF_SOURCE] = value.get(
CONF_SOURCE, _format_framework_arduino_version(version)
@@ -825,9 +853,6 @@ def _check_pio_versions(config):
if _is_framework_url(value[CONF_SOURCE]):
value[CONF_SOURCE] = f"{ARDUINO_FRAMEWORK_PKG}@{value[CONF_SOURCE]}"
else:
if version < cv.Version(5, 0, 0):
raise cv.Invalid("Only ESP-IDF 5.0+ is supported.")
recommended_version = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]
platform_lookup = ESP_IDF_PLATFORM_VERSION_LOOKUP.get(version)
value[CONF_SOURCE] = value.get(
CONF_SOURCE,
@@ -843,12 +868,6 @@ def _check_pio_versions(config):
)
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup))
if version != recommended_version:
_LOGGER.warning(
"The selected framework version is not the recommended one. "
"If there are connectivity or build issues please remove the manual version."
)
if value[CONF_PLATFORM_VERSION] != _parse_pio_platform_version(
str(PLATFORM_VERSION_LOOKUP["recommended"])
):
@@ -860,19 +879,26 @@ def _check_pio_versions(config):
return config
def _check_esp_idf_versions(config):
config = _check_pio_versions(config)
def _check_esp_idf_versions(config: ConfigType) -> ConfigType:
config = config.copy()
value = config[CONF_FRAMEWORK]
# Remove unwanted keys if present
for key in (CONF_SOURCE, CONF_PLATFORM_VERSION):
value.pop(key, None)
# platform_version is a PlatformIO concept; drop it if a user carried it
# over from a PIO-style config. CONF_SOURCE, on the other hand, is kept:
# it lets a user override the framework tarball URL under the esp-idf
# toolchain (the espidf framework downloader consults it).
value.pop(CONF_PLATFORM_VERSION, None)
# Official ESP-IDF frameworks don't use extra
version = cv.Version.parse(value[CONF_VERSION])
version = cv.Version(version.major, version.minor, version.patch)
version = _resolve_framework_version(value)
value[CONF_VERSION] = str(version)
if CONF_SOURCE in value:
_LOGGER.warning(
"A custom framework source is set. "
"If there are connectivity or build issues please remove the manual source."
)
# Official ESP-IDF frameworks don't use the 'extra' semver component.
value[CONF_VERSION] = str(cv.Version(version.major, version.minor, version.patch))
return config
@@ -1718,6 +1744,31 @@ async def _add_yaml_idf_components(components: list[ConfigType]):
)
@coroutine_with_priority(CoroPriority.FINAL - 1)
async def _finalize_arduino_aware_flags():
"""Build flags that depend on whether arduino-esp32 is linked in.
Scheduler runs lower priority values later, so ``FINAL - 1`` fires
after every ``FINAL`` job (incl. ``_add_yaml_idf_components``) --
by then ``KEY_COMPONENTS`` is fully populated.
- Skip our esp_panic_handler wrap when Arduino is linked; Arduino
wraps the same symbol and the linker errors on the duplicate.
- Define USE_ARDUINO in the hybrid esp-idf+arduino-esp32-component
case so ESPHome's ``#ifdef USE_ARDUINO`` paths light up. The
framework=arduino branch already adds it inline in to_code.
"""
arduino_linked = (
CORE.using_arduino
or ARDUINO_ESP32_COMPONENT_NAME in CORE.data[KEY_ESP32][KEY_COMPONENTS]
)
if not arduino_linked:
cg.add_build_flag("-Wl,--wrap=esp_panic_handler")
cg.add_define("USE_ESP32_CRASH_HANDLER")
elif not CORE.using_arduino:
cg.add_build_flag("-DUSE_ARDUINO")
async def to_code(config):
framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
conf = config[CONF_FRAMEWORK]
@@ -1765,11 +1816,9 @@ async def to_code(config):
Path(__file__).parent / "iram_fix.py.script",
)
else:
cg.add_build_flag("-Wno-error=format")
cg.add_build_flag("-Wno-error=maybe-uninitialized")
cg.add_build_flag("-Wno-error=overloaded-virtual")
cg.add_build_flag("-Wno-error=reorder")
cg.add_build_flag("-Wno-error=volatile")
# Undo IDF's blanket -Werror so third-party libraries and user
# lambdas don't need a -Wno-error=<class> entry per warning class.
cg.add_build_flag("-Wno-error")
# -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates
cg.add_build_flag("-Wno-missing-field-initializers")
@@ -1777,11 +1826,8 @@ async def to_code(config):
cg.add_build_flag("-DUSE_ESP32")
cg.add_define("USE_NATIVE_64BIT_TIME")
cg.add_build_flag("-Wl,-z,noexecstack")
# Arduino already wraps esp_panic_handler for its own backtrace handler,
# so only add our wrap when using ESP-IDF framework to avoid linker conflicts.
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
cg.add_build_flag("-Wl,--wrap=esp_panic_handler")
cg.add_define("USE_ESP32_CRASH_HANDLER")
# Deferred so KEY_COMPONENTS is fully populated -- see the coroutine.
CORE.add_job(_finalize_arduino_aware_flags)
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
variant = config[CONF_VARIANT]
cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}")
@@ -1962,7 +2008,7 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_HEAP_PLACE_FUNCTION_INTO_FLASH", True)
# Setup watchdog
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT", True)
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_INIT", True)
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True)
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0", False)
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1", False)
@@ -2004,7 +2050,8 @@ async def to_code(config):
if not advanced[CONF_ENABLE_LWIP_MDNS_QUERIES]:
add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False)
if not advanced[CONF_ENABLE_LWIP_BRIDGE_INTERFACE]:
add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0)
# Kconfig range is [1,63]; 0 gets clamped to the default.
add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 1)
_configure_lwip_max_sockets(conf)
@@ -2096,7 +2143,6 @@ async def to_code(config):
for key, flag in ASSERTION_LEVELS.items():
add_idf_sdkconfig_option(flag, assertion_level == key)
add_idf_sdkconfig_option("CONFIG_COMPILER_OPTIMIZATION_DEFAULT", False)
compiler_optimization = advanced[CONF_COMPILER_OPTIMIZATION]
for key, flag in COMPILER_OPTIMIZATIONS.items():
add_idf_sdkconfig_option(flag, compiler_optimization == key)
@@ -2251,7 +2297,8 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 2)
elif advanced[CONF_DISABLE_FATFS]:
add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", True)
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 0)
# Kconfig range is [1,10]; 0 gets clamped to the default.
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 1)
for name, value in conf[CONF_SDKCONFIG_OPTIONS].items():
add_idf_sdkconfig_option(name, RawSdkconfigValue(value))
@@ -2488,9 +2535,8 @@ def _write_sdkconfig():
def _platformio_library_to_dependency(library: Library) -> tuple[str, dict[str, str]]:
dependency: dict[str, str] = {}
name, version, path = generate_idf_component(library)
name, _version, path = generate_idf_component(library)
dependency["override_path"] = str(path)
dependency["version"] = version
return name, dependency
@@ -2542,7 +2588,7 @@ def _write_idf_component_yml():
if CORE.using_toolchain_esp_idf:
add_idf_component(
name="espressif/arduino-esp32",
name=ARDUINO_ESP32_COMPONENT_NAME,
ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]),
)
@@ -72,6 +72,7 @@ void BLEClientBase::loop() {
// never delivered CLOSE_EVT/DISCONNECT_EVT, services would leak without this call.
this->release_services();
this->set_idle_();
this->on_disconnect_complete(ESP_GATT_CONN_TIMEOUT);
}
}
@@ -418,6 +419,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
this->log_gattc_lifecycle_event_("CLOSE");
this->release_services();
this->set_idle_();
this->on_disconnect_complete(param->close.reason);
break;
}
case ESP_GATTC_SEARCH_RES_EVT: {
@@ -140,6 +140,12 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
void log_gattc_warning_(const char *operation, esp_err_t err);
void log_connection_params_(const char *param_type);
void handle_connection_result_(esp_err_t ret);
/// Hook called once a connection has been fully torn down (after release_services() and
/// set_idle_()), from both the CLOSE_EVT handler and the DISCONNECTING safety timeout.
/// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state)
/// override this to release that state. `reason` is the controller reason code, or
/// ESP_GATT_CONN_TIMEOUT for the safety-timeout path.
virtual void on_disconnect_complete(esp_err_t reason) {}
/// Transition to IDLE and reset conn_id — call when the connection is fully dead.
void set_idle_() {
this->set_state(espbt::ClientState::IDLE);
@@ -149,6 +155,10 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
void set_disconnecting_() {
this->disconnecting_started_ = millis();
this->set_state(espbt::ClientState::DISCONNECTING);
// BluetoothConnection::loop() disables the component loop after service discovery
// completes, so the DISCONNECTING timeout check in loop() would never run if CLOSE_EVT
// gets lost. Re-enable the loop so the 10s safety timeout can force IDLE.
this->enable_loop();
}
// Compact error logging helpers to reduce flash usage
void log_error_(const char *message);
@@ -196,42 +196,35 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt
(*this->on_read_callback_)(param->read.conn_id);
}
uint16_t max_offset = 22;
// Use the client-supplied offset for long reads; short reads always start at 0.
// The Bluedroid stack truncates ATT_READ_RSP / ATT_READ_BLOB_RSP to MTU-1, so we
// just provide as much data as we have from the requested offset and let the stack
// handle framing. The client issues subsequent blob reads with increasing offsets
// until it has received the whole value.
const uint16_t offset = param->read.is_long ? param->read.offset : 0;
esp_gatt_status_t status = ESP_GATT_OK;
esp_gatt_rsp_t response;
if (param->read.is_long) {
if (this->value_read_offset_ >= this->value_.size()) {
response.attr_value.len = 0;
response.attr_value.offset = this->value_read_offset_;
this->value_read_offset_ = 0;
} else if (this->value_.size() - this->value_read_offset_ < max_offset) {
// Last message in the chain
response.attr_value.len = this->value_.size() - this->value_read_offset_;
response.attr_value.offset = this->value_read_offset_;
memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len);
this->value_read_offset_ = 0;
} else {
response.attr_value.len = max_offset;
response.attr_value.offset = this->value_read_offset_;
memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len);
this->value_read_offset_ += max_offset;
}
response.attr_value.offset = offset;
if (offset > this->value_.size()) {
status = ESP_GATT_INVALID_OFFSET;
response.attr_value.len = 0;
} else {
response.attr_value.offset = 0;
if (this->value_.size() + 1 > max_offset) {
response.attr_value.len = max_offset;
this->value_read_offset_ = max_offset;
} else {
response.attr_value.len = this->value_.size();
size_t remaining = this->value_.size() - offset;
if (remaining > ESP_GATT_MAX_ATTR_LEN) {
ESP_LOGW(TAG, "Characteristic length %u exceeds buffer size of %u, truncating",
static_cast<unsigned>(remaining), ESP_GATT_MAX_ATTR_LEN);
remaining = ESP_GATT_MAX_ATTR_LEN;
}
memcpy(response.attr_value.value, this->value_.data(), response.attr_value.len);
response.attr_value.len = remaining;
memcpy(response.attr_value.value, this->value_.data() + offset, remaining);
}
response.attr_value.handle = this->handle_;
response.attr_value.auth_req = ESP_GATT_AUTH_REQ_NONE;
esp_err_t err =
esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, ESP_GATT_OK, &response);
esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, status, &response);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ble_gatts_send_response failed: %d", err);
}
@@ -79,7 +79,6 @@ class BLECharacteristic {
esp_gatt_char_prop_t properties_;
uint16_t handle_{0xFFFF};
uint16_t value_read_offset_{0};
std::vector<uint8_t> value_;
std::vector<BLEDescriptor *> descriptors_;
@@ -121,7 +121,7 @@ void Esp32HostedUpdate::setup() {
}
} else {
ESP_LOGW(TAG, "Invalid app description magic word: 0x%08" PRIx32 " (expected 0x%08" PRIx32 ")",
app_desc->magic_word, ESP_APP_DESC_MAGIC_WORD);
app_desc->magic_word, static_cast<uint32_t>(ESP_APP_DESC_MAGIC_WORD));
this->state_ = update::UPDATE_STATE_NO_UPDATE;
}
} else {
+12 -12
View File
@@ -5,6 +5,7 @@
#include <Arduino.h>
#include <core_esp8266_features.h>
#include <coredecls.h>
extern "C" {
#include <user_interface.h>
@@ -71,23 +72,22 @@ uint32_t IRAM_ATTR HOT millis() {
return result;
}
// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object
// call to the original millis() that --wrap can't intercept, so calling ::delay()
// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still
// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and
// WiFi run correctly. Theoretically less power-efficient than Arduino's
// os_timer-based delay() for long waits, but nearly all ESPHome delays are short
// (sensor/I²C/SPI settling in the 1100 ms range) where the difference is
// negligible.
// Delegate to Arduino's 1-arg esp_delay(), which uses os_timer + esp_suspend to
// suspend the cont task for `ms` milliseconds without polling millis(). This
// matches pre-2026.5.0 behavior (when esphome::delay() forwarded to ::delay())
// and lets the SDK run freely while we wait, which timing-sensitive
// interrupt-driven code (e.g. ESP8266 software-serial RX in components like
// fingerprint_grow) depends on. The poll-based busy-wait that this replaced
// rarely yielded inside short waits like delay(1), starving WiFi/SDK tasks and
// extending interrupt latency. Unlike ::delay(), esp_delay()'s 1-arg form does
// not call millis(), so the slow Arduino millis() body is not pulled into IRAM
// by this path (the --wrap=millis goal of #15662 is preserved).
void HOT delay(uint32_t ms) {
if (ms == 0) {
optimistic_yield(1000);
return;
}
uint32_t start = millis();
while (millis() - start < ms) {
optimistic_yield(1000);
}
esp_delay(ms);
}
void arch_restart() {
+6
View File
@@ -58,6 +58,12 @@ __attribute__((always_inline)) inline const char *progmem_read_ptr(const char *c
__attribute__((always_inline)) inline uint16_t progmem_read_uint16(const uint16_t *addr) {
return pgm_read_word(addr); // NOLINT
}
// Bulk PROGMEM copy: routes to the SDK's aligned-flash `memcpy_P` so callers
// don't have to drop to a byte-by-byte `progmem_read_byte` loop, which on
// ESP8266 is ~4x as many flash accesses as the bulk path.
__attribute__((always_inline)) inline void progmem_memcpy(void *dst, const void *src, size_t len) {
memcpy_P(dst, src, len); // NOLINT
}
// NOLINTNEXTLINE(readability-identifier-naming)
__attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); }
+1 -1
View File
@@ -17,7 +17,7 @@ from esphome.core import HexInt
from esphome.types import ConfigType
CODEOWNERS = ["@jesserockz"]
AUTO_LOAD = ["network"]
byte_vector = cg.std_vector.template(cg.uint8)
peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6)
@@ -149,12 +149,6 @@ bool ESPNowComponent::is_wifi_enabled() {
}
void ESPNowComponent::setup() {
#ifndef USE_WIFI
// Initialize LwIP stack for wake_loop_threadsafe() socket support
// When WiFi component is present, it handles esp_netif_init()
ESP_ERROR_CHECK(esp_netif_init());
#endif
if (this->enable_on_boot_) {
this->enable_();
} else {
@@ -174,8 +168,6 @@ void ESPNowComponent::enable() {
void ESPNowComponent::enable_() {
if (!this->is_wifi_enabled()) {
esp_event_loop_create_default();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
+37 -6
View File
@@ -3,7 +3,11 @@ import logging
from esphome import automation, pins
import esphome.codegen as cg
from esphome.components.network import ip_address_literal
from esphome.components.network import (
KEY_NETWORK_PRIORITY,
get_network_priority,
ip_address_literal,
)
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import (
@@ -13,6 +17,7 @@ from esphome.const import (
CONF_DNS1,
CONF_DNS2,
CONF_DOMAIN,
CONF_ENABLE_ON_BOOT,
CONF_GATEWAY,
CONF_ID,
CONF_INTERRUPT_PIN,
@@ -27,6 +32,7 @@ from esphome.const import (
CONF_PAGE_ID,
CONF_PIN,
CONF_POLLING_INTERVAL,
CONF_PRIORITY,
CONF_RESET_PIN,
CONF_SPI,
CONF_STATIC_IP,
@@ -48,7 +54,6 @@ from esphome.core import (
import esphome.final_validate as fv
from esphome.types import ConfigType
CONFLICTS_WITH = ["wifi"]
AUTO_LOAD = ["network"]
LOGGER = logging.getLogger(__name__)
@@ -348,6 +353,7 @@ BASE_SCHEMA = cv.Schema(
cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name,
cv.Optional(CONF_USE_ADDRESS): cv.string_strict,
cv.Optional(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean,
cv.Optional(CONF_ON_CONNECT): automation.validate_automation(single=True),
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(single=True),
}
@@ -487,6 +493,11 @@ async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
# Apply network priority if configured, otherwise use the existing default
prio = get_network_priority("ethernet")
if prio is not None:
cg.add(var.set_setup_priority(prio))
if CORE.is_esp32:
await _to_code_esp32(var, config)
elif CORE.is_rp2040:
@@ -494,6 +505,9 @@ async def to_code(config):
cg.add(var.set_type(ETHERNET_TYPES[config[CONF_TYPE]]))
cg.add(var.set_use_address(config[CONF_USE_ADDRESS]))
# enable_on_boot defaults to true in C++ - only set if false
if not config[CONF_ENABLE_ON_BOOT]:
cg.add(var.set_enable_on_boot(False))
CORE.data.setdefault(KEY_ETHERNET, {})[ETHERNET_TYPE_KEY] = config[CONF_TYPE]
if CONF_MANUAL_IP in config:
@@ -576,10 +590,16 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None:
)
cg.add(var.add_phy_register(reg))
# Disable WiFi when using Ethernet to save memory
add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False)
# Also disable WiFi/BT coexistence since WiFi is disabled
add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False)
# Disable WiFi when using Ethernet alone to save memory.
# When network: priority: lists both interfaces, WiFi must remain enabled.
net_priority = CORE.data.get(KEY_NETWORK_PRIORITY, [])
priority_ifaces = {e["interface"] for e in net_priority}
running_with_wifi = "wifi" in priority_ifaces and "ethernet" in priority_ifaces
if not running_with_wifi:
# Disable WiFi when using Ethernet to save memory
add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False)
# Also disable WiFi/BT coexistence since WiFi is disabled
add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False)
# Re-enable ESP-IDF's Ethernet driver (excluded by default to save compile time)
include_builtin_idf_component("esp_eth")
@@ -665,6 +685,17 @@ def _final_validate_rmii_pins(config: ConfigType) -> None:
def _final_validate(config: ConfigType) -> ConfigType:
"""Final validation for Ethernet component."""
# Allow ethernet + wifi coexistence only when both are declared in network: priority:
full = fv.full_config.get()
net_priority = full.get("network", {}).get(CONF_PRIORITY, [])
priority_ifaces = {e["interface"] for e in net_priority}
has_priority_config = "ethernet" in priority_ifaces and "wifi" in priority_ifaces
if "wifi" in full and not has_priority_config:
raise cv.Invalid(
"Component ethernet cannot be used together with component wifi "
"unless both are listed under 'network: priority:'"
)
_final_validate_spi(config)
_final_validate_rmii_pins(config)
return config
@@ -124,6 +124,17 @@ class EthernetComponent final : public Component {
void on_powerdown() override { powerdown(); }
bool is_connected() { return this->state_ == EthernetComponentState::CONNECTED; }
// Per-interface lifecycle (parallels WiFiComponent::enable/disable/is_disabled).
// enable_on_boot defaults to true; when false, setup() runs all the driver/netif
// installation but skips esp_eth_start(), keeping the link cold until enable() is
// called. This is the primary lever for memory reclamation in multi-interface
// configurations where only one interface should carry traffic at a time.
void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; }
void enable();
void disable();
bool is_disabled() { return this->disabled_; }
bool is_enabled() { return !this->disabled_; }
void set_type(EthernetType type);
#ifdef USE_ETHERNET_MANUAL_IP
void set_manual_ip(const ManualIP &manual_ip);
@@ -194,6 +205,16 @@ class EthernetComponent final : public Component {
void finish_connect_();
void dump_connect_params_();
#ifdef USE_ESP32
// ESP-IDF only: defers the SPI bus init, netif creation, MAC/PHY install, driver
// install, netif attach, and event handler registration (which together allocate
// ~3-8KB of DMA-capable internal SRAM via SPI driver state + eth driver RX queue)
// until ethernet actually needs to come up. Idempotent — guarded by the
// ethernet_initialized_ flag. Called from setup() when enable_on_boot_=true, or
// from enable() on first runtime enable. Mirrors wifi_lazy_init_() in WiFi.
void ethernet_lazy_init_();
#endif
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
void notify_ip_state_listeners_();
#endif
@@ -287,6 +308,17 @@ class EthernetComponent final : public Component {
bool started_{false};
bool connected_{false};
bool got_ipv4_address_{false};
// Codegen-time YAML option. When false, setup() defers esp_eth_start().
bool enable_on_boot_{true};
// Mirror of "is the link intentionally stopped" — set when setup() honors
// enable_on_boot=false, cleared by enable(), set again by disable().
bool disabled_{false};
#ifdef USE_ESP32
// Tracks whether ethernet_lazy_init_() has completed successfully. Allows enable()
// to be called at runtime after enable_on_boot:false without re-allocating, and
// ensures setup() skips the heavy init when enable_on_boot_ is false.
bool ethernet_initialized_{false};
#endif
#if LWIP_IPV6
uint8_t ipv6_count_{0};
bool ipv6_setup_done_{false};
@@ -5,6 +5,7 @@
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "w5500_custom_spi.h"
#include <lwip/dns.h>
#include <cinttypes>
@@ -137,6 +138,24 @@ void EthernetComponent::setup() {
delay(300); // NOLINT
}
if (this->enable_on_boot_) {
this->ethernet_lazy_init_();
if (!this->ethernet_initialized_) {
// lazy_init bailed early via ESPHL_ERROR_CHECK or mark_failed; nothing more to do.
return;
}
esp_err_t err = esp_eth_start(this->eth_handle_);
ESPHL_ERROR_CHECK(err, "ETH start error");
} else {
ESP_LOGCONFIG(TAG, "Skipping init (enable_on_boot: false)");
this->disabled_ = true;
}
}
void EthernetComponent::ethernet_lazy_init_() {
if (this->ethernet_initialized_)
return;
esp_err_t err;
#ifdef USE_ETHERNET_SPI
@@ -163,11 +182,7 @@ void EthernetComponent::setup() {
err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO);
ESPHL_ERROR_CHECK(err, "SPI bus initialize error");
#endif
err = esp_netif_init();
ESPHL_ERROR_CHECK(err, "ETH netif init error");
err = esp_event_loop_create_default();
ESPHL_ERROR_CHECK(err, "ETH event loop error");
// Network interface setup handled by network component
esp_netif_config_t cfg = ESP_NETIF_DEFAULT_ETH();
this->eth_netif_ = esp_netif_new(&cfg);
@@ -207,6 +222,10 @@ void EthernetComponent::setup() {
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
w5500_config.poll_period_ms = this->polling_interval_;
#endif
// Install the custom SPI driver that offloads the bulk RX/TX frame transfers off the busy-wait
// path. w5500_config (and the devcfg it references) outlives esp_eth_mac_new_w5500() below, which
// runs the driver's init().
install_w5500_async_spi(w5500_config);
#elif defined(USE_ETHERNET_DM9051)
dm9051_config.int_gpio_num = this->interrupt_pin_;
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
@@ -370,9 +389,41 @@ void EthernetComponent::setup() {
ESPHL_ERROR_CHECK(err, "GOT IPv6 event handler register error");
#endif /* USE_NETWORK_IPV6 */
/* start Ethernet driver state machine */
err = esp_eth_start(this->eth_handle_);
ESPHL_ERROR_CHECK(err, "ETH start error");
this->ethernet_initialized_ = true;
}
void EthernetComponent::enable() {
if (!this->disabled_)
return;
ESP_LOGD(TAG, "Enabling");
this->ethernet_lazy_init_();
if (!this->ethernet_initialized_) {
ESP_LOGE(TAG, "Cannot enable - init failed");
return;
}
esp_err_t err = esp_eth_start(this->eth_handle_);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_eth_start failed: %s", esp_err_to_name(err));
return;
}
this->disabled_ = false;
// The ETH_EVENT_START handler will set started_=true; the loop state machine
// will then drive the STOPPED -> CONNECTING -> CONNECTED transitions.
this->enable_loop();
}
void EthernetComponent::disable() {
if (this->disabled_)
return;
ESP_LOGD(TAG, "Disabling");
esp_err_t err = esp_eth_stop(this->eth_handle_);
if (err != ESP_OK) {
ESP_LOGW(TAG, "esp_eth_stop failed: %s — disabling anyway", esp_err_to_name(err));
}
this->disabled_ = true;
// ETH_EVENT_STOP will clear started_; loop() will transition to STOPPED.
}
void EthernetComponent::dump_config() {
@@ -486,6 +537,8 @@ void EthernetComponent::dump_config() {
network::IPAddresses EthernetComponent::get_ip_addresses() {
network::IPAddresses addresses;
if (!this->ethernet_initialized_)
return addresses; // all-zero IPs
esp_netif_ip_info_t ip;
esp_err_t err = esp_netif_get_ip_info(this->eth_netif_, &ip);
if (err != ESP_OK) {
@@ -708,6 +761,10 @@ void EthernetComponent::start_connect_() {
}
void EthernetComponent::dump_connect_params_() {
if (!this->ethernet_initialized_) {
ESP_LOGCONFIG(TAG, " uninitialized/disabled");
return;
}
esp_netif_ip_info_t ip;
esp_netif_get_ip_info(this->eth_netif_, &ip);
const ip_addr_t *dns_ip1;
@@ -775,6 +832,13 @@ void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy
#endif
void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) {
if (!this->ethernet_initialized_) {
// External callers (sendspin, ethernet_info, mdns, etc.) may ask for the MAC
// before/regardless of whether ethernet is enabled. Fall back to the system MAC
// assigned to the ETH interface — same value the driver would have returned.
esp_read_mac(mac, ESP_MAC_ETH);
return;
}
esp_err_t err;
err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_MAC_ADDR, mac);
ESPHL_ERROR_CHECK(err, "ETH_CMD_G_MAC error");
@@ -794,6 +858,8 @@ const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer(
}
eth_duplex_t EthernetComponent::get_duplex_mode() {
if (!this->ethernet_initialized_)
return ETH_DUPLEX_HALF;
esp_err_t err;
eth_duplex_t duplex_mode;
err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_DUPLEX_MODE, &duplex_mode);
@@ -802,6 +868,8 @@ eth_duplex_t EthernetComponent::get_duplex_mode() {
}
eth_speed_t EthernetComponent::get_link_speed() {
if (!this->ethernet_initialized_)
return ETH_SPEED_10M;
esp_err_t err;
eth_speed_t speed;
err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_SPEED, &speed);
@@ -361,6 +361,23 @@ void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; }
void EthernetComponent::set_interrupt_pin(int8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; }
void EthernetComponent::set_reset_pin(int8_t reset_pin) { this->reset_pin_ = reset_pin; }
void EthernetComponent::enable() {
// RP2040 uses arduino-pico's LwipIntfDev which manages link state internally;
// there is no clean enable/disable hook today. The YAML option is accepted on
// RP2040 for schema parity but has no effect.
if (!this->disabled_)
return;
ESP_LOGW(TAG, "enable_on_boot/disable not supported");
this->disabled_ = false;
}
void EthernetComponent::disable() {
if (this->disabled_)
return;
ESP_LOGW(TAG, "enable_on_boot/disable not supported");
this->disabled_ = true;
}
} // namespace esphome::ethernet
#endif // USE_ETHERNET && USE_RP2040
@@ -0,0 +1,118 @@
#include "w5500_custom_spi.h"
#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500)
#include <driver/spi_master.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <cstring>
#include <new>
namespace esphome::ethernet {
namespace {
// Per-device context returned by init() and handed back to read/write/deinit.
struct W5500CustomSpiContext {
spi_device_handle_t handle;
SemaphoreHandle_t lock;
};
// Transfers up to the ESP32 SPI hardware FIFO size (64 bytes) stay on the polling path; larger
// transfers (the frame payloads) use the blocking, DMA-backed transmit.
constexpr uint32_t W5500_SPI_BULK_THRESHOLD = 64;
constexpr uint32_t W5500_SPI_LOCK_TIMEOUT_MS = 50;
void *w5500_custom_spi_init(const void *spi_config) {
const auto *config = static_cast<const eth_w5500_config_t *>(spi_config);
auto *ctx = new (std::nothrow) W5500CustomSpiContext{};
if (ctx == nullptr) {
return nullptr;
}
// The W5500 SPI frame carries the 16-bit address in the command phase and the 8-bit control
// byte in the address phase; mirror what the stock driver configures.
spi_device_interface_config_t devcfg = *config->spi_devcfg;
devcfg.command_bits = 16;
devcfg.address_bits = 8;
if (spi_bus_add_device(config->spi_host_id, &devcfg, &ctx->handle) != ESP_OK) {
delete ctx;
return nullptr;
}
ctx->lock = xSemaphoreCreateMutex();
if (ctx->lock == nullptr) {
spi_bus_remove_device(ctx->handle);
delete ctx;
return nullptr;
}
return ctx;
}
esp_err_t w5500_custom_spi_deinit(void *spi_ctx) {
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
spi_bus_remove_device(ctx->handle);
vSemaphoreDelete(ctx->lock);
delete ctx;
return ESP_OK;
}
// Runs one transaction under the device lock, choosing the polling vs blocking transmit by size.
// Bulk payloads (> FIFO size) block so the calling task sleeps while DMA runs; small register
// accesses stay on the cheaper polling path. Used by both read and write.
esp_err_t w5500_custom_spi_transfer(W5500CustomSpiContext *ctx, spi_transaction_t *trans, uint32_t len) {
if (xSemaphoreTake(ctx->lock, pdMS_TO_TICKS(W5500_SPI_LOCK_TIMEOUT_MS)) != pdTRUE) {
return ESP_ERR_TIMEOUT;
}
esp_err_t ret;
if (len > W5500_SPI_BULK_THRESHOLD) {
ret = spi_device_transmit(ctx->handle, trans);
} else {
ret = spi_device_polling_transmit(ctx->handle, trans);
}
xSemaphoreGive(ctx->lock);
return ret;
}
esp_err_t w5500_custom_spi_write(void *spi_ctx, uint32_t cmd, uint32_t addr, const void *data, uint32_t len) {
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
spi_transaction_t trans = {};
trans.cmd = static_cast<uint16_t>(cmd);
trans.addr = addr;
trans.length = 8 * len;
trans.tx_buffer = data;
return w5500_custom_spi_transfer(ctx, &trans, len);
}
esp_err_t w5500_custom_spi_read(void *spi_ctx, uint32_t cmd, uint32_t addr, void *data, uint32_t len) {
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
spi_transaction_t trans = {};
// Reads of <= 4 bytes use the transaction's inline RX buffer to avoid 4-byte boundary
// overwrites of adjacent registers (same guard the stock driver uses).
const bool use_rxdata = len <= 4;
trans.flags = use_rxdata ? SPI_TRANS_USE_RXDATA : 0;
trans.cmd = static_cast<uint16_t>(cmd);
trans.addr = addr;
trans.length = 8 * len;
trans.rx_buffer = data;
esp_err_t ret = w5500_custom_spi_transfer(ctx, &trans, len);
if (use_rxdata && (ret == ESP_OK)) {
memcpy(data, trans.rx_data, len);
}
return ret;
}
} // namespace
void install_w5500_async_spi(eth_w5500_config_t &config) {
// Point the custom driver's config at the W5500 config itself; init() reads spi_host_id and
// spi_devcfg back out of it. The self-reference is valid because both the config and the
// spi_devcfg it points at outlive the esp_eth_mac_new_w5500() call that runs init().
config.custom_spi_driver.config = &config;
config.custom_spi_driver.init = w5500_custom_spi_init;
config.custom_spi_driver.deinit = w5500_custom_spi_deinit;
config.custom_spi_driver.read = w5500_custom_spi_read;
config.custom_spi_driver.write = w5500_custom_spi_write;
}
} // namespace esphome::ethernet
#endif // USE_ESP32 && USE_ETHERNET_W5500
@@ -0,0 +1,35 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500)
#include <esp_idf_version.h>
// IDF 6.0 moved the per-chip SPI MAC drivers to the Espressif Component Registry; eth_w5500_config_t
// is no longer reachable through esp_eth.h and needs the explicit header.
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
#include <esp_eth_mac_w5500.h>
#else
#include <esp_eth.h>
#endif
namespace esphome::ethernet {
// Installs a custom W5500 SPI driver that offloads the bulk frame transfers off the busy-wait path.
//
// The stock W5500 driver runs every SPI transfer through spi_device_polling_transmit(), which
// busy-waits the CPU for the whole transfer. The frame payload (one large read per received frame,
// one large write per transmitted frame) is by far the biggest transfer, so the RX task and the TX
// caller each spin for hundreds of microseconds per frame. This driver sends payload transfers
// through the blocking, interrupt-driven spi_device_transmit() instead, so the calling task sleeps
// while DMA moves the bytes. Small register accesses stay on the polling path, where the busy-wait
// is cheaper than an interrupt round-trip.
//
// Must be called before esp_eth_mac_new_w5500(). The driver reads spi_host_id and spi_devcfg back
// out of `config` in its init() callback, so `config` (and the spi_devcfg it points at) must stay
// alive until esp_eth_mac_new_w5500() returns.
void install_w5500_async_spi(eth_w5500_config_t &config);
} // namespace esphome::ethernet
#endif // USE_ESP32 && USE_ETHERNET_W5500
@@ -206,6 +206,7 @@ uint8_t FingerprintGrowComponent::save_fingerprint_() {
break;
case ENROLL_MISMATCH:
ESP_LOGE(TAG, "Scans do not match");
[[fallthrough]];
default:
return this->data_[0];
}
@@ -15,6 +15,16 @@ void FT5x06Touchscreen::setup() {
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
}
// reading the chip registers to get max x/y does not seem to work.
if (this->display_ != nullptr) {
if (this->x_raw_max_ == this->x_raw_min_) {
this->x_raw_max_ = this->display_->get_native_width();
}
if (this->y_raw_max_ == this->y_raw_min_) {
this->y_raw_max_ = this->display_->get_native_height();
}
}
// wait 200ms after reset.
this->set_timeout(200, [this] { this->continue_setup_(); });
}
@@ -39,15 +49,6 @@ void FT5x06Touchscreen::continue_setup_() {
this->mark_failed();
return;
}
// reading the chip registers to get max x/y does not seem to work.
if (this->display_ != nullptr) {
if (this->x_raw_max_ == this->x_raw_min_) {
this->x_raw_max_ = this->display_->get_native_width();
}
if (this->y_raw_max_ == this->y_raw_min_) {
this->y_raw_max_ = this->display_->get_native_height();
}
}
}
void FT5x06Touchscreen::update_touches() {
@@ -71,7 +72,7 @@ void FT5x06Touchscreen::update_touches() {
uint16_t x = encode_uint16(data[i][0] & 0x0F, data[i][1]);
uint16_t y = encode_uint16(data[i][2] & 0xF, data[i][3]);
ESP_LOGD(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y);
ESP_LOGV(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y);
if (status == 0 || status == 2) {
this->add_raw_touch_position_(id, x, y);
}
@@ -17,9 +17,9 @@ void HomeassistantSensor::setup() {
}
if (this->attribute_ != nullptr) {
ESP_LOGD(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val);
ESP_LOGV(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val);
} else {
ESP_LOGD(TAG, "'%s': Got state %.2f", this->entity_id_, *val);
ESP_LOGV(TAG, "'%s': Got state %.2f", this->entity_id_, *val);
}
this->publish_state(*val);
});
@@ -89,10 +89,10 @@ def _set_num_channels_from_config(config):
def _set_stream_limits(config):
if config.get(CONF_SPDIF_MODE, False):
# SPDIF mode: fixed to 16-bit stereo at configured sample rate
# SPDIF mode: 16/24/32-bit audio and stereo at configured sample rate
audio.set_stream_limits(
min_bits_per_sample=16,
max_bits_per_sample=16,
max_bits_per_sample=32,
min_channels=2,
max_channels=2,
min_sample_rate=config.get(CONF_SAMPLE_RATE),
@@ -213,9 +213,6 @@ def _final_validate(config):
)
if config[CONF_CHANNEL] != CONF_STEREO:
raise cv.Invalid("SPDIF mode only supports stereo channel configuration")
# bits_per_sample is converted to float by the schema
if config[CONF_BITS_PER_SAMPLE] != 16:
raise cv.Invalid("SPDIF mode only supports 16 bits per sample")
if not config[CONF_USE_APLL]:
raise cv.Invalid(
"SPDIF mode requires 'use_apll: true' for accurate clock generation"
@@ -138,21 +138,21 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
// Reset lockstep records queue so it starts paired with the (also-reset) i2s_event_queue_.
xQueueReset(this->write_records_queue_);
const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * SPDIF_DMA_BUFFERS_COUNT;
// Ensure ring buffer duration is at least the duration of all DMA buffers
const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_);
// The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info
const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1);
// Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and
// avoids unnecessary single-frame splices.
const size_t ring_buffer_size =
(this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame;
// For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames
// For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames (~4 ms at 48 kHz),
// not the ~15 ms a standard I2S DMA buffer holds. Derive the DMA floor from actual block size.
const uint32_t frames_to_fill_single_dma_buffer = SPDIF_BLOCK_SAMPLES;
const size_t bytes_to_fill_single_dma_buffer =
this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer);
const size_t dma_buffers_floor_bytes = bytes_to_fill_single_dma_buffer * SPDIF_DMA_BUFFERS_COUNT;
// Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and
// avoids unnecessary single-frame splices. Ensure it is at least large enough to cover all DMA buffers.
const size_t requested_ring_buffer_bytes =
(this->current_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame;
const size_t ring_buffer_size = std::max(dma_buffers_floor_bytes, requested_ring_buffer_bytes);
bool successful_setup = false;
std::unique_ptr<audio::RingBufferAudioSource> audio_source;
@@ -177,7 +177,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
// on_sent events drain in lockstep without crediting any audio frames.
this->spdif_encoder_->set_preload_mode(true);
for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) {
esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS));
// i2s_channel_preload_data is non-blocking (returns immediately when the preload buffer fills), so no wait.
esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(0);
if (preload_err != ESP_OK) {
break; // DMA preload buffer full or error
}
@@ -410,8 +411,9 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s
this->sample_rate_, audio_stream_info.get_sample_rate());
return ESP_ERR_NOT_SUPPORTED;
}
if (audio_stream_info.get_bits_per_sample() != 16) {
ESP_LOGE(TAG, "Only supports 16 bits per sample");
const uint8_t bits_per_sample = audio_stream_info.get_bits_per_sample();
if (bits_per_sample != 16 && bits_per_sample != 24 && bits_per_sample != 32) {
ESP_LOGE(TAG, "Only supports 16, 24, or 32 bits per sample (got %u)", (unsigned) bits_per_sample);
return ESP_ERR_NOT_SUPPORTED;
}
if (audio_stream_info.get_channels() != 2) {
@@ -419,11 +421,8 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s
return ESP_ERR_NOT_SUPPORTED;
}
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO &&
(i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) {
ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration");
return ESP_ERR_NOT_SUPPORTED;
}
// Tell the encoder what input width to expect. 32-bit input is truncated to 24-bit on the wire.
this->spdif_encoder_->set_bytes_per_sample(bits_per_sample / 8);
if (!this->parent_->try_lock()) {
ESP_LOGE(TAG, "Parent bus is busy");
@@ -2,6 +2,7 @@
#ifdef USE_ESP32
#include <driver/gpio.h>
#include <driver/i2s_std.h>
#include "esphome/components/audio/audio.h"
@@ -299,6 +300,15 @@ void I2SAudioSpeakerBase::stop_i2s_driver_() {
i2s_channel_disable(this->tx_handle_);
i2s_del_channel(this->tx_handle_);
this->tx_handle_ = nullptr;
// i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only
// clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that
// setup installed. If another speaker reuses this port (shared bus), its audio still reaches our
// dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a
// SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise.
gpio_reset_pin(this->dout_pin_);
gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT);
gpio_set_level(this->dout_pin_, 0);
}
this->parent_->unlock();
}
@@ -19,7 +19,6 @@
namespace esphome::i2s_audio {
// Shared constants used by both standard and SPDIF speaker implementations
static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15;
static constexpr size_t TASK_STACK_SIZE = 4096;
static constexpr ssize_t TASK_PRIORITY = 19;
@@ -16,6 +16,7 @@ namespace esphome::i2s_audio {
static const char *const TAG = "i2s_audio.speaker.std";
static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15;
static constexpr size_t DMA_BUFFERS_COUNT = 4;
// Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight,
// doubled so that a transient backlog never overruns the queue (which would desync the lockstep
@@ -17,7 +17,7 @@ static constexpr uint8_t PREAMBLE_M = 0x1d; // Left channel (not block start)
static constexpr uint8_t PREAMBLE_W = 0x1b; // Right channel
// BMC encoding of 4 zero bits starting at phase HIGH: 00_11_00_11 = 0x33
// Since both aux nibbles (bits 4-7, 8-11) are zero for 16-bit audio and phase is preserved, both are 0x33.
// Used as a constant in the 16-bit subframe path, where bits 4-11 are always zero.
static constexpr uint32_t BMC_ZERO_NIBBLE = 0x33;
// Constexpr BMC encoder for compile-time LUT generation.
@@ -36,21 +36,43 @@ static constexpr uint16_t bmc_lut_encode(uint32_t data, uint8_t num_bits) {
return bmc;
}
// 4-bit BMC lookup table: 16 entries (16 bytes in flash)
// Index: 4-bit data value (0-15), always phase=true start
// Compile-time parity helper (constexpr-friendly, runs only at LUT build time).
static constexpr uint32_t bmc_lut_parity(uint32_t value, uint32_t num_bits) {
uint32_t p = 0;
for (uint32_t b = 0; b < num_bits; b++)
p ^= (value >> b) & 1u;
return p;
}
// Combined BMC + phase-delta lookup tables.
// Each entry packs the BMC pattern (lower bits, phase=high start) together with
// a phase-mask delta in bits 16-31 (0xFFFF if the input has odd parity, else 0).
// XORing the delta into the running phase mask propagates parity across chunks
// without an explicit popcount.
// 4-bit BMC lookup table: 16 entries x uint32_t = 64 bytes in flash.
// Bits 0-7 : 8-bit BMC pattern (phase=high start)
// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0)
static constexpr auto BMC_LUT_4 = [] {
std::array<uint8_t, 16> t{};
for (uint32_t i = 0; i < 16; i++)
t[i] = static_cast<uint8_t>(bmc_lut_encode(i, 4));
std::array<uint32_t, 16> t{};
for (uint32_t i = 0; i < 16; i++) {
uint32_t bmc = bmc_lut_encode(i, 4);
uint32_t delta = bmc_lut_parity(i, 4) ? 0xFFFF0000u : 0u;
t[i] = bmc | delta;
}
return t;
}();
// 8-bit BMC lookup table: 256 entries (512 bytes in flash)
// Index: 8-bit data value (0-255), always phase=true start
// 8-bit BMC lookup table: 256 entries x uint32_t = 1024 bytes in flash.
// Bits 0-15 : 16-bit BMC pattern (phase=high start)
// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0)
static constexpr auto BMC_LUT_8 = [] {
std::array<uint16_t, 256> t{};
for (uint32_t i = 0; i < 256; i++)
t[i] = bmc_lut_encode(i, 8);
std::array<uint32_t, 256> t{};
for (uint32_t i = 0; i < 256; i++) {
uint32_t bmc = bmc_lut_encode(i, 8);
uint32_t delta = bmc_lut_parity(i, 8) ? 0xFFFF0000u : 0u;
t[i] = bmc | delta;
}
return t;
}();
@@ -63,7 +85,7 @@ bool SPDIFEncoder::setup() {
}
ESP_LOGV(TAG, "Buffer allocated (%zu bytes)", SPDIF_BLOCK_SIZE_BYTES);
// Build initial channel status block with default sample rate
// Build initial channel status block with default sample rate and width
this->build_channel_status_();
this->reset();
@@ -73,7 +95,7 @@ bool SPDIFEncoder::setup() {
void SPDIFEncoder::reset() {
this->spdif_block_ptr_ = this->spdif_block_buf_.get();
this->frame_in_block_ = 0;
this->is_left_channel_ = true;
this->block_buf_is_silence_block_ = false;
}
void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) {
@@ -84,31 +106,27 @@ void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) {
}
}
void SPDIFEncoder::set_bytes_per_sample(uint8_t bytes_per_sample) {
if (bytes_per_sample != 2 && bytes_per_sample != 3 && bytes_per_sample != 4) {
ESP_LOGE(TAG, "Unsupported bytes per sample: %u", (unsigned) bytes_per_sample);
return;
}
if (this->bytes_per_sample_ != bytes_per_sample) {
this->bytes_per_sample_ = bytes_per_sample;
this->build_channel_status_();
// Discard any partial block built at the previous width so we never mix widths on the wire.
this->reset();
ESP_LOGD(TAG, "Input width set to %u-bit", (unsigned) bytes_per_sample * 8);
}
}
void SPDIFEncoder::build_channel_status_() {
// IEC 60958-3 Consumer Channel Status Block (192 bits = 24 bytes)
// Transmitted LSB-first within each byte, one bit per frame via C bit
//
// Byte 0: Control bits
// Bit 0: 0 = Consumer format (not professional AES3)
// Bit 1: 0 = PCM audio (not non-audio data like AC3)
// Bit 2: 0 = No copyright assertion
// Bits 3-5: 000 = No pre-emphasis
// Bits 6-7: 00 = Mode 0 (basic consumer format)
//
// Byte 1: Category code (0x00 = general, 0x01 = CD, etc.)
//
// Byte 2: Source/channel numbers
// Bits 0-3: Source number (0 = unspecified)
// Bits 4-7: Channel number (0 = unspecified)
//
// Byte 3: Sample frequency and clock accuracy
// Bits 0-3: Sample frequency code
// Bits 4-5: Clock accuracy (00 = Level II, ±1000 ppm, appropriate for ESP32)
// Bits 6-7: Reserved (0)
//
// Bytes 4-23: Reserved (zeros for basic compliance)
// Transmitted LSB-first within each byte, one bit per frame via C bit.
// Any cached silence block was built for the previous channel status; it is now stale.
this->block_buf_is_silence_block_ = false;
// Clear all bytes first
this->channel_status_.fill(0);
// Byte 0: Consumer, PCM audio, no copyright, no pre-emphasis, Mode 0
@@ -140,132 +158,148 @@ void SPDIFEncoder::build_channel_status_() {
// Byte 3: freq_code in bits 0-3, clock accuracy (00) in bits 4-5
this->channel_status_[3] = freq_code; // Clock accuracy bits 4-5 are already 0
// Bytes 4-23 remain zero (word length not specified, no original sample freq, etc.)
// Byte 4: Word length encoding (IEC 60958-3 consumer)
// bit 0: max length flag (0 = max 20 bits, 1 = max 24 bits)
// bits 1-3: word length code relative to the max
// For our supported widths:
// 16-bit (max 20): 0b0010 = 0x02 -- "16 bits, max 20"
// 24-bit (max 24): 0b1101 = 0x0D -- "24 bits, max 24"
// 32-bit input is truncated to 24-bit on the wire, so use the 24-bit code.
uint8_t word_length_code;
switch (this->bytes_per_sample_) {
case 2:
word_length_code = 0x02;
break;
case 3: // Shared case
case 4:
word_length_code = 0x0D;
break;
default:
word_length_code = 0x00; // not specified
break;
}
this->channel_status_[4] = word_length_code;
}
HOT void SPDIFEncoder::encode_sample_(const uint8_t *pcm_sample) {
// ============================================================================
// Build raw 32-bit subframe (IEC 60958 format)
// ============================================================================
// Bit layout:
// Bits 0-3: Preamble (handled separately, not in raw_subframe)
// Bits 4-7: Auxiliary audio data (zeros for 16-bit audio)
// Bits 8-11: Audio LSB extension (zeros for 16-bit audio)
// Bits 12-27: 16-bit audio sample (MSB-aligned in 20-bit audio field)
// Bit 28: V (Validity) - 0 = valid audio
// Bit 29: U (User data) - 0
// Bit 30: C (Channel status) - from channel status block
// Bit 31: P (Parity) - even parity over bits 4-31
// ============================================================================
// Extract the C bit for the given frame from channel_status_ and shift it into bit 30
// so it can be OR'd directly into a raw subframe.
ESPHOME_ALWAYS_INLINE static inline uint32_t c_bit_for_frame(const std::array<uint8_t, 24> &channel_status,
uint32_t frame) {
return static_cast<uint32_t>((channel_status[frame >> 3] >> (frame & 7)) & 1u) << 30;
}
// Place 16-bit audio sample at bits 12-27 (little-endian input: [0]=LSB, [1]=MSB)
uint32_t raw_subframe = (static_cast<uint32_t>(pcm_sample[1]) << 20) | (static_cast<uint32_t>(pcm_sample[0]) << 12);
// ============================================================================
// IEC 60958 subframe bit layout
// ============================================================================
// Bits 0-3: Preamble (handled separately, not in raw_subframe)
// Bits 4-7: Auxiliary audio data / 24-bit audio LSB
// Bits 8-11: Audio LSB extension (zero for 16-bit, low nibble of audio for 24-bit)
// Bits 12-27: Audio sample (16 high bits in 16-bit mode, mid 16 bits in 24-bit mode)
// Bit 28: V (Validity) - 0 = valid audio
// Bit 29: U (User data) - 0
// Bit 30: C (Channel status) - from channel status block
// Bit 31: P (Parity) - even parity over bits 4-31
// ============================================================================
// V = 0 (valid audio), U = 0 (no user data)
// C = channel status bit for current frame (same bit used for both L and R subframes)
bool c_bit = this->get_channel_status_bit_(this->frame_in_block_);
if (c_bit) {
raw_subframe |= (1U << 30);
// Build a raw IEC 60958 subframe from PCM little-endian input of width Bps bytes.
// Caller is responsible for OR-ing in the C bit and parity.
template<uint8_t Bps> ESPHOME_ALWAYS_INLINE static inline uint32_t build_raw_subframe(const uint8_t *pcm_sample) {
static_assert(Bps == 2 || Bps == 3 || Bps == 4, "Unsupported bytes per sample");
if constexpr (Bps == 2) {
// 16-bit input: MSB-aligned in the 20-bit audio field, bits 12-27.
return (static_cast<uint32_t>(pcm_sample[1]) << 20) | (static_cast<uint32_t>(pcm_sample[0]) << 12);
} else if constexpr (Bps == 3) {
// 24-bit input: full 24-bit audio field, bits 4-27.
return (static_cast<uint32_t>(pcm_sample[2]) << 20) | (static_cast<uint32_t>(pcm_sample[1]) << 12) |
(static_cast<uint32_t>(pcm_sample[0]) << 4);
} else { // Bps == 4
// 32-bit input truncated to 24-bit: drop the lowest byte.
return (static_cast<uint32_t>(pcm_sample[3]) << 20) | (static_cast<uint32_t>(pcm_sample[2]) << 12) |
(static_cast<uint32_t>(pcm_sample[1]) << 4);
}
}
// Calculate even parity over bits 4-30
// This ensures consistent BMC ending phase regardless of audio content
uint32_t bits_4_30 = (raw_subframe >> 4) & 0x07FFFFFF; // 27 bits (4-30)
uint32_t ones_count = __builtin_popcount(bits_4_30);
uint32_t parity = ones_count & 1; // 1 if odd count, 0 if even
raw_subframe |= parity << 31; // Set P bit to make total even
// BMC-encode a subframe and write the two output uint32 words to dst. Caller passes
// raw_subframe with the C bit set (bit 30) and the P bit cleared (bit 31 = 0). P is
// derived from the cumulative parity-mask delta of the per-byte LUT lookups.
//
// I2S halfword swap means word[0] transmits as: bits 24-31, 16-23, 8-15, 0-7.
// word[1] transmits as: bits 16-31, 0-15. Within each halfword, MSB-first.
// All preambles end at phase HIGH, so phase=true at the start of bit 4.
//
// P-bit derivation: BMC_LUT_*'s upper half encodes the parity of the input chunk. Each
// chunk's parity delta is shifted down (`lut >> 16`) into a phase_mask that lives in the
// low 16 bits, so the same value can also be XORed against subsequent BMC patterns to
// invert phase. XOR'ing those deltas through all chunks (with bit 31 = 0) yields the
// parity of bits 4-30 in the low bits of phase_mask -- the required value of the P bit
// for even total parity. The BMC of bit 31 lives in bit 0 of the high-byte BMC output
// (i = 7 maps to position (8-1-7)*2 = 0); flipping the source bit flips only the lower
// BMC bit (= phase XOR bit), so applying P is `bmc_24_31 ^= phase_mask & 1u`.
template<uint8_t Bps>
ESPHOME_ALWAYS_INLINE static inline void bmc_encode_subframe(uint32_t raw_subframe, uint8_t preamble, uint32_t *dst) {
if constexpr (Bps == 2) {
// 16-bit path: bits 4-11 are zero, encoded inline as BMC_ZERO_NIBBLE constants.
// Eight zero source bits with start phase=HIGH end at phase=HIGH (popcount of zeros is even),
// so encoding of bits 12-15 starts at phase=true. Zeros contribute 0 to parity.
uint32_t nibble = (raw_subframe >> 12) & 0xF;
uint32_t lut_n = BMC_LUT_4[nibble];
uint32_t bmc_12_15 = lut_n & 0xFFu;
uint32_t phase_mask = lut_n >> 16; // 0xFFFFu if odd parity, else 0
// ============================================================================
// Select preamble based on position in block and channel
// ============================================================================
// B = block start (left channel, frame 0 of 192-frame block)
// M = left channel (frames 1-191)
// W = right channel (all frames)
uint8_t preamble;
if (this->is_left_channel_) {
preamble = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M;
uint32_t byte_mid = (raw_subframe >> 16) & 0xFF;
uint32_t lut_m = BMC_LUT_8[byte_mid];
uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask;
phase_mask ^= lut_m >> 16;
uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition
uint32_t lut_h = BMC_LUT_8[byte_hi];
uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask;
phase_mask ^= lut_h >> 16;
// phase_mask now reflects parity of bits 4-30. Apply P by flipping bit 0 of bmc_24_31.
bmc_24_31 ^= phase_mask & 1u;
dst[0] = bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast<uint32_t>(preamble) << 24);
dst[1] = bmc_24_31 | (bmc_16_23 << 16);
} else {
preamble = PREAMBLE_W;
// 24-bit (and 32-bit truncated) path: bits 4-11 are live audio.
uint32_t byte_lo = (raw_subframe >> 4) & 0xFF;
uint32_t lut_l = BMC_LUT_8[byte_lo];
uint32_t bmc_4_11 = lut_l & 0xFFFFu;
uint32_t phase_mask = lut_l >> 16; // 0xFFFFu if odd parity, else 0
uint32_t nibble = (raw_subframe >> 12) & 0xF;
uint32_t lut_n = BMC_LUT_4[nibble];
uint32_t bmc_12_15 = (lut_n & 0xFFu) ^ (phase_mask & 0xFFu);
phase_mask ^= lut_n >> 16;
uint32_t byte_mid = (raw_subframe >> 16) & 0xFF;
uint32_t lut_m = BMC_LUT_8[byte_mid];
uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask;
phase_mask ^= lut_m >> 16;
uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition
uint32_t lut_h = BMC_LUT_8[byte_hi];
uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask;
phase_mask ^= lut_h >> 16;
bmc_24_31 ^= phase_mask & 1u;
// word[0]: bits 24-31 = preamble, bits 8-23 = bmc(4-11), bits 0-7 = bmc(12-15)
// word[1]: bits 16-31 = bmc(16-23), bits 0-15 = bmc(24-31)
dst[0] = bmc_12_15 | (bmc_4_11 << 8) | (static_cast<uint32_t>(preamble) << 24);
dst[1] = bmc_24_31 | (bmc_16_23 << 16);
}
}
// ============================================================================
// BMC encode the data portion (bits 4-31) using lookup tables
// ============================================================================
// The I2S uses 16-bit halfword swap: bits 16-31 transmit before bits 0-15.
// This applies to BOTH word[0] and word[1].
//
// word[0] transmission order: [16-23] → [24-31] → [0-7] → [8-15]
// For correct S/PDIF subframe order (preamble → aux → audio):
// - bits 16-23: preamble (8 BMC bits)
// - bits 24-31: BMC(subframe bits 4-7) - first aux nibble
// - bits 0-7: BMC(subframe bits 8-11) - second aux nibble
// - bits 8-15: BMC(subframe bits 12-15) - audio low nibble
//
// word[1] transmission order: [16-31] → [0-15]
// For correct S/PDIF subframe order:
// - bits 16-31: BMC(subframe bits 16-23) - audio mid byte
// - bits 0-15: BMC(subframe bits 24-31) - audio high nibble + VUCP
// ============================================================================
// All preambles end at phase HIGH. Bits 4-11 are always zero for 16-bit audio;
// two zero nibbles flip phase 8 times total → back to HIGH.
// So bits 12-15 always start encoding at phase=true.
// Bits 12-15: 4-bit LUT lookup (always phase=true start)
uint32_t nibble = (raw_subframe >> 12) & 0xF;
uint32_t bmc_12_15 = BMC_LUT_4[nibble];
// Phase tracking via branchless XOR mask:
// - 0x0000 means phase=true (use LUT value directly)
// - 0xFFFF means phase=false (complement LUT value)
// End phase = start XOR (popcount & 1) since zero-bits flip phase,
// and for even bit widths: #zeros parity == popcount parity.
uint32_t phase_mask = -(__builtin_popcount(nibble) & 1u) & 0xFFFF;
// Bits 16-23: 8-bit LUT lookup with phase correction
uint32_t byte_mid = (raw_subframe >> 16) & 0xFF;
uint32_t bmc_16_23 = BMC_LUT_8[byte_mid] ^ phase_mask;
phase_mask ^= -(__builtin_popcount(byte_mid) & 1u) & 0xFFFF;
// Bits 24-31: 8-bit LUT lookup with phase correction
uint32_t byte_hi = (raw_subframe >> 24) & 0xFF;
uint32_t bmc_24_31 = BMC_LUT_8[byte_hi] ^ phase_mask;
// ============================================================================
// Combine with correct positioning for I2S transmission
// ============================================================================
// I2S with halfword swap: transmits bits 16-31, then bits 0-15.
// Within each halfword, MSB (highest bit) is transmitted first.
//
// For upper halfword (bits 16-31): bit 31 → bit 16
// For lower halfword (bits 0-15): bit 15 → bit 0
//
// Desired S/PDIF order: preamble → bmc_4_7 → bmc_8_11 → bmc_12_15
//
// word[0] layout for correct transmission:
// bits 24-31: preamble (transmitted 1st, as MSB of upper halfword)
// bits 16-23: BMC_ZERO_NIBBLE (transmitted 2nd, aux bits 4-7)
// bits 8-15: BMC_ZERO_NIBBLE (transmitted 3rd, aux bits 8-11)
// bits 0-7: bmc_12_15 (transmitted 4th, audio low nibble)
//
// word[1] layout:
// bits 16-31: bmc_16_23 (transmitted 5th)
// bits 0-15: bmc_24_31 (transmitted 6th)
this->spdif_block_ptr_[0] =
bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast<uint32_t>(preamble) << 24);
this->spdif_block_ptr_[1] = bmc_24_31 | (bmc_16_23 << 16);
this->spdif_block_ptr_ += 2;
// ============================================================================
// Update position tracking
// ============================================================================
if (!this->is_left_channel_) {
// Completed a stereo frame, advance frame counter
if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) {
this->frame_in_block_ = 0;
}
template<uint8_t Bps> void SPDIFEncoder::encode_silence_frame_() {
static constexpr uint8_t SILENCE[4] = {0, 0, 0, 0};
uint32_t raw = build_raw_subframe<Bps>(SILENCE) | c_bit_for_frame(this->channel_status_, this->frame_in_block_);
uint8_t preamble_l = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M;
bmc_encode_subframe<Bps>(raw, preamble_l, this->spdif_block_ptr_);
bmc_encode_subframe<Bps>(raw, PREAMBLE_W, this->spdif_block_ptr_ + 2);
this->spdif_block_ptr_ += 4;
if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) {
this->frame_in_block_ = 0;
}
this->is_left_channel_ = !this->is_left_channel_;
}
esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) {
@@ -295,79 +329,162 @@ esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) {
return err;
}
size_t SPDIFEncoder::get_pending_pcm_bytes() const {
if (this->spdif_block_ptr_ == nullptr || this->spdif_block_buf_ == nullptr) {
return 0;
template<uint8_t Bps>
HOT esp_err_t SPDIFEncoder::write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait,
uint32_t *blocks_sent, size_t *bytes_consumed) {
const uint8_t *pcm_data = src;
const uint8_t *const pcm_end = src + size;
uint32_t block_count = 0;
// Hot state lives in locals so the compiler can keep it in registers across the
// per-frame encoding work; byte writes through block_ptr may alias the member fields,
// which would block register allocation if the encoding read them directly from this->*.
uint32_t *block_ptr = this->spdif_block_ptr_;
uint32_t *const block_buf = this->spdif_block_buf_.get();
uint32_t *const block_end = block_buf + SPDIF_BLOCK_SIZE_U32;
uint32_t frame = this->frame_in_block_;
const std::array<uint8_t, 24> &channel_status = this->channel_status_;
auto save_state = [&]() {
this->spdif_block_ptr_ = block_ptr;
this->frame_in_block_ = static_cast<uint8_t>(frame);
};
auto report_out_params = [&]() {
if (blocks_sent != nullptr)
*blocks_sent = block_count;
if (bytes_consumed != nullptr)
*bytes_consumed = pcm_data - src;
};
// Send a completed block if the buffer is full, propagating any error.
// send_block_ resets this->spdif_block_ptr_ to block_buf on success and leaves it
// unchanged on error -- mirror both behaviors in our local block_ptr.
auto maybe_send = [&]() -> esp_err_t {
if (block_ptr >= block_end) {
esp_err_t err = this->send_block_(ticks_to_wait);
if (err != ESP_OK) {
save_state();
report_out_params();
return err;
}
block_ptr = block_buf;
++block_count;
}
return ESP_OK;
};
// Hot path: encode L+R pairs in two peeled sub-loops. Frame 0 carries the only
// buffer-full check and uses PREAMBLE_B (a block fills exactly when frame wraps from
// 191 back to 0). Frames 1..191 use PREAMBLE_M and need no buffer-full check or
// preamble branch. The encoding body is inlined here so block_ptr lives in a register
// for the duration of the loop.
while (pcm_data + 2 * Bps <= pcm_end) {
if (frame == 0) {
esp_err_t err = maybe_send();
if (err != ESP_OK)
return err;
uint32_t c_bit = c_bit_for_frame(channel_status, 0);
uint32_t raw_l = build_raw_subframe<Bps>(pcm_data) | c_bit;
uint32_t raw_r = build_raw_subframe<Bps>(pcm_data + Bps) | c_bit;
bmc_encode_subframe<Bps>(raw_l, PREAMBLE_B, block_ptr);
bmc_encode_subframe<Bps>(raw_r, PREAMBLE_W, block_ptr + 2);
block_ptr += 4;
frame = 1;
pcm_data += 2 * Bps;
}
// The inner loop runs until min(SPDIF_BLOCK_SAMPLES, frame + input_frames). The
// input-size bound is folded into end_frame so a single `frame < end_frame` test
// governs termination.
uint32_t input_frames = static_cast<uint32_t>(pcm_end - pcm_data) / (2u * Bps);
uint32_t end_frame = SPDIF_BLOCK_SAMPLES;
if (frame + input_frames < end_frame)
end_frame = frame + input_frames;
while (frame < end_frame) {
uint32_t c_bit = c_bit_for_frame(channel_status, frame);
uint32_t raw_l = build_raw_subframe<Bps>(pcm_data) | c_bit;
uint32_t raw_r = build_raw_subframe<Bps>(pcm_data + Bps) | c_bit;
bmc_encode_subframe<Bps>(raw_l, PREAMBLE_M, block_ptr);
bmc_encode_subframe<Bps>(raw_r, PREAMBLE_W, block_ptr + 2);
block_ptr += 4;
++frame;
pcm_data += 2 * Bps;
}
if (frame >= SPDIF_BLOCK_SAMPLES)
frame = 0;
}
// Each PCM sample (2 bytes) produces 2 uint32_t values in the SPDIF buffer
// So pending uint32s / 2 = pending samples, and each sample is 2 bytes
size_t pending_uint32s = this->spdif_block_ptr_ - this->spdif_block_buf_.get();
size_t pending_samples = pending_uint32s / 2;
return pending_samples * 2; // 2 bytes per sample
// Send any complete block that was just finished.
if (block_ptr >= block_end) {
esp_err_t err = this->send_block_(ticks_to_wait);
if (err != ESP_OK) {
save_state();
report_out_params();
return err;
}
block_ptr = block_buf;
++block_count;
}
save_state();
report_out_params();
return ESP_OK;
}
HOT esp_err_t SPDIFEncoder::write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent,
size_t *bytes_consumed) {
const uint8_t *pcm_data = src;
const uint8_t *pcm_end = src + size;
uint32_t block_count = 0;
if (size > 0) {
// Real PCM is about to be encoded into the buffer, so it is no longer a full-silence block.
this->block_buf_is_silence_block_ = false;
}
switch (this->bytes_per_sample_) {
case 2:
return this->write_typed_<2>(src, size, ticks_to_wait, blocks_sent, bytes_consumed);
case 3:
return this->write_typed_<3>(src, size, ticks_to_wait, blocks_sent, bytes_consumed);
case 4:
return this->write_typed_<4>(src, size, ticks_to_wait, blocks_sent, bytes_consumed);
default:
return ESP_ERR_INVALID_STATE;
}
}
while (pcm_data < pcm_end) {
// Check if there's a pending complete block from a previous failed send
if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
esp_err_t err = this->send_block_(ticks_to_wait);
if (err != ESP_OK) {
if (blocks_sent != nullptr) {
*blocks_sent = block_count;
}
if (bytes_consumed != nullptr) {
*bytes_consumed = pcm_data - src;
}
return err;
}
++block_count;
template<uint8_t Bps> esp_err_t SPDIFEncoder::flush_with_silence_typed_(TickType_t ticks_to_wait) {
// If a complete block is already pending (from a previous failed send), emit just that block.
// Otherwise pad the partial block with silence (or generate a full silence block if empty) and
// send. Always emits exactly one block on success.
if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
const bool was_empty = (this->spdif_block_ptr_ == this->spdif_block_buf_.get());
// Continuous-silence idle case: a full silence block is byte-identical every time for the
// active channel status, so when the buffer already holds one, re-send it as-is.
if (was_empty && this->block_buf_is_silence_block_) {
return this->send_block_(ticks_to_wait);
}
// Encode one 16-bit sample
this->encode_sample_(pcm_data);
pcm_data += 2;
}
// Send any complete block that was just finished
if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
esp_err_t err = this->send_block_(ticks_to_wait);
if (err != ESP_OK) {
if (blocks_sent != nullptr) {
*blocks_sent = block_count;
}
if (bytes_consumed != nullptr) {
*bytes_consumed = pcm_data - src;
}
return err;
// Pad with silence frames at the configured width.
while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
this->encode_silence_frame_<Bps>();
}
++block_count;
// The buffer is a reusable full-silence block only if it was built entirely from silence; a
// partial real-audio block padded out with silence is not.
this->block_buf_is_silence_block_ = was_empty;
}
if (blocks_sent != nullptr) {
*blocks_sent = block_count;
}
if (bytes_consumed != nullptr) {
*bytes_consumed = size;
}
return ESP_OK;
return this->send_block_(ticks_to_wait);
}
esp_err_t SPDIFEncoder::flush_with_silence(TickType_t ticks_to_wait) {
// If a complete block is already pending (from a previous failed send), emit just that block.
// Otherwise pad the partial block with silence (or generate a full silence block if empty)
// and send. Always emits exactly one block on success.
if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
static const uint8_t SILENCE[2] = {0, 0};
while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
this->encode_sample_(SILENCE);
}
switch (this->bytes_per_sample_) {
case 2:
return this->flush_with_silence_typed_<2>(ticks_to_wait);
case 3:
return this->flush_with_silence_typed_<3>(ticks_to_wait);
case 4:
return this->flush_with_silence_typed_<4>(ticks_to_wait);
default:
return ESP_ERR_INVALID_STATE;
}
return this->send_block_(ticks_to_wait);
}
} // namespace esphome::i2s_audio
@@ -24,8 +24,6 @@ static constexpr uint16_t SPDIF_BLOCK_SIZE_BYTES = SPDIF_BLOCK_SAMPLES * (EMULAT
static constexpr uint32_t SPDIF_BLOCK_SIZE_U32 = SPDIF_BLOCK_SIZE_BYTES / sizeof(uint32_t); // 3072 bytes / 4 = 768
// I2S frame count for one SPDIF block (for new driver where frame = 8 bytes for 32-bit stereo)
static constexpr uint32_t SPDIF_BLOCK_I2S_FRAMES = SPDIF_BLOCK_SIZE_BYTES / 8; // 3072 / 8 = 384 frames
// PCM bytes needed for one complete SPDIF block (192 stereo frames * 2 bytes per sample * 2 channels)
static constexpr uint16_t SPDIF_PCM_BYTES_PER_BLOCK = SPDIF_BLOCK_SAMPLES * 2 * 2; // = 768 bytes
/// Callback signature for block completion (raw function pointer for minimal overhead)
/// @param user_ctx User context pointer passed during callback registration
@@ -64,8 +62,16 @@ class SPDIFEncoder {
/// @brief Check if currently in preload mode
bool is_preload_mode() const { return this->preload_mode_; }
/// @brief Set input PCM width: 2 = 16-bit, 3 = 24-bit, 4 = 32-bit (truncated to 24-bit on the wire).
/// Must be called before write() if input width changes from the default (16-bit). Triggers a
/// channel-status rebuild to reflect the new word length.
void set_bytes_per_sample(uint8_t bytes_per_sample);
/// @brief Get the configured input PCM width in bytes per sample
uint8_t get_bytes_per_sample() const { return this->bytes_per_sample_; }
/// @brief Convert PCM audio data to SPDIF BMC encoded data
/// @param src Source PCM audio data (16-bit stereo)
/// @param src Source PCM audio data (stereo, width matches set_bytes_per_sample)
/// @param size Size of source data in bytes
/// @param ticks_to_wait Timeout for blocking writes
/// @param blocks_sent Optional pointer to receive the number of complete SPDIF blocks sent
@@ -74,17 +80,6 @@ class SPDIFEncoder {
esp_err_t write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent = nullptr,
size_t *bytes_consumed = nullptr);
/// @brief Get the number of PCM bytes currently pending in the partial block buffer
/// @return Number of pending PCM bytes (0 to SPDIF_PCM_BYTES_PER_BLOCK - 1)
size_t get_pending_pcm_bytes() const;
/// @brief Get the number of PCM frames currently pending in the partial block buffer
/// @return Number of pending PCM frames (0 to SPDIF_BLOCK_SAMPLES - 1)
uint32_t get_pending_frames() const { return this->get_pending_pcm_bytes() / 4; }
/// @brief Check if there is a partial block pending
bool has_pending_data() const { return this->spdif_block_ptr_ != this->spdif_block_buf_.get(); }
/// @brief Emit one complete SPDIF block: pad any pending partial block with silence and send,
/// or send a full silence block if nothing is pending. Always produces exactly one block on success.
/// @param ticks_to_wait Timeout for blocking writes
@@ -95,7 +90,7 @@ class SPDIFEncoder {
void reset();
/// @brief Set the sample rate for Channel Status Block encoding
/// @param sample_rate Sample rate in Hz (e.g., 44100, 48000, 96000)
/// @param sample_rate Sample rate in Hz (e.g., 44100, 48000)
/// Call this before writing audio data to ensure correct channel status.
void set_sample_rate(uint32_t sample_rate);
@@ -103,8 +98,19 @@ class SPDIFEncoder {
uint32_t get_sample_rate() const { return this->sample_rate_; }
protected:
/// @brief Encode a single 16-bit PCM sample into the current block position
HOT void encode_sample_(const uint8_t *pcm_sample);
/// @brief Encode a single stereo silence frame at the current block position.
/// @note Used only by flush_with_silence_typed_ to pad; the hot write path inlines the
/// encoding body directly into write_typed_ to keep block_ptr / frame_in_block_ in registers.
template<uint8_t Bps> void encode_silence_frame_();
/// @brief Templated write loop. Called from the public write() via runtime dispatch on bytes_per_sample_.
template<uint8_t Bps>
HOT esp_err_t write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent,
size_t *bytes_consumed);
/// @brief Templated flush-with-silence. Pads the pending block with zeros at the configured width
/// (or builds a full silence block when nothing is pending) and sends it. Always emits one block.
template<uint8_t Bps> esp_err_t flush_with_silence_typed_(TickType_t ticks_to_wait);
/// @brief Send the completed block via the appropriate callback
esp_err_t send_block_(TickType_t ticks_to_wait);
@@ -112,15 +118,6 @@ class SPDIFEncoder {
/// @brief Build the channel status block from current configuration
void build_channel_status_();
/// @brief Get the channel status bit for a specific frame
/// @param frame Frame number (0-191)
/// @return The C bit value for this frame
ESPHOME_ALWAYS_INLINE inline bool get_channel_status_bit_(uint8_t frame) const {
// Channel status is 192 bits transmitted over 192 frames
// Bit N is transmitted in frame N, LSB-first within each byte
return (this->channel_status_[frame >> 3] >> (frame & 7)) & 1;
}
// Member ordering optimized to minimize padding (largest alignment first)
// 4-byte aligned members (pointers and uint32_t)
@@ -133,9 +130,13 @@ class SPDIFEncoder {
uint32_t sample_rate_{48000}; // Sample rate for Channel Status Block encoding
// 1-byte aligned members (grouped together to avoid internal padding)
uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block
bool is_left_channel_{true}; // Alternates L/R for stereo samples
bool preload_mode_{false}; // Whether to use preload callback vs write callback
uint8_t bytes_per_sample_{2}; // Input PCM width: 2/3/4 (16/24/32-bit). 32-bit truncates to 24-bit on the wire.
uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block
bool preload_mode_{false}; // Whether to use preload callback vs write callback
// True when spdif_block_buf_ currently holds a complete full-silence block valid for the active
// channel status. A full silence block is deterministic for a given sample rate and word length,
// so when this is set flush_with_silence() can re-send the buffer verbatim instead of re-encoding.
bool block_buf_is_silence_block_{false};
// Channel Status Block (192 bits = 24 bytes, transmitted over 192 frames)
// Placed last since std::array<uint8_t> has 1-byte alignment
@@ -13,7 +13,9 @@ import subprocess
# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it.
# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it.
# - LN882H: stock linker has no glob for ".sram.text", so we inject
# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH).
# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH)
# immediately after KEEP(*(.vectors)), so the vector table stays at
# __copysection_ram0_start (0x20000000) for correct Cortex-M4 VTOR alignment.
#
# All families also get a post-link summary showing where IRAM_ATTR landed.
@@ -27,7 +29,11 @@ _KEEP_LINE = (
"__esphome_sram_text_end = .; "
+ _MARKER + "\n"
)
_LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)")
# Inject after KEEP(*(.vectors)) so the vector table stays at
# __copysection_ram0_start (0x20000000). Cortex-M4 VTOR requires a 512-byte-
# aligned address; injecting before the vectors would push them to an
# unaligned offset and mis-route every IRQ handler.
_LN_COPY = re.compile(r"(KEEP\(\*\(\.vectors\)\)[^\n]*\n)")
def _detect(env):
@@ -56,7 +62,7 @@ KNOWN_VARIANTS = frozenset({
def _inject_keep(host_section):
"""Return a patcher that injects _KEEP_LINE at the top of `host_section`."""
"""Return a patcher that injects _KEEP_LINE after `host_section` match."""
def patch(content):
if _MARKER in content:
return content
+31 -2
View File
@@ -86,10 +86,22 @@ class EffectRef:
component_path: list[str | int] # path_context when the action was validated
@dataclass
class EffectCycleRef:
"""A pending light.effect.next/previous action to validate.
Records that the referenced light needs at least one effect configured.
"""
light_id: ID
component_path: list[str | int]
@dataclass
class LightData:
gamma_tables: dict = field(default_factory=dict) # gamma_value -> fwd_arr
effect_refs: list[EffectRef] = field(default_factory=list)
effect_cycle_refs: list[EffectCycleRef] = field(default_factory=list)
def _get_data() -> LightData:
@@ -160,13 +172,15 @@ def _final_validate(config: ConfigType) -> ConfigType:
this never runs but the ID validator will catch the missing light ID separately.
"""
data = _get_data()
if not data.effect_refs:
if not data.effect_refs and not data.effect_cycle_refs:
return config
# Drain the list so we only validate once even though
# Drain the lists so we only validate once even though
# FINAL_VALIDATE_SCHEMA runs for each light platform instance.
refs = data.effect_refs
data.effect_refs = []
cycle_refs = data.effect_cycle_refs
data.effect_cycle_refs = []
fconf = fv.full_config.get()
@@ -188,6 +202,21 @@ def _final_validate(config: ConfigType) -> ConfigType:
path=[cv.ROOT_CONFIG_PATH] + ref.component_path,
)
for ref in cycle_refs:
try:
light_path = fconf.get_path_for_id(ref.light_id)[:-1]
light_config = fconf.get_config_for_path(light_path)
except KeyError:
continue
if not light_config.get(CONF_EFFECTS):
raise cv.FinalExternalInvalid(
f"Light '{ref.light_id}' has no effects configured, but a "
f"'light.effect.next' or 'light.effect.previous' action "
f"references it. Add at least one effect to the light.",
path=[cv.ROOT_CONFIG_PATH] + ref.component_path,
)
return config
+41
View File
@@ -104,6 +104,47 @@ template<bool HasTransitionLength, typename... Ts> class DimRelativeAction : pub
transition_length_{};
};
// Cycle through the light's configured effects. `Forward` selects direction
// at compile time so the chosen branch is the only one that gets instantiated
// per action site. `include_none` is runtime so a single set of templates
// covers both the "wrap through None" and "skip None" variants.
template<bool Forward, typename... Ts> class LightEffectCycleAction : public Action<Ts...> {
public:
explicit LightEffectCycleAction(LightState *parent) : parent_(parent) {}
void set_include_none(bool include_none) { this->include_none_ = include_none; }
void play(const Ts &...) override {
size_t count = this->parent_->get_effect_count();
if (count == 0) {
return;
}
uint32_t current = this->parent_->get_current_effect_index();
uint32_t next;
if (this->include_none_) {
uint32_t total = static_cast<uint32_t>(count) + 1;
if constexpr (Forward) {
next = (current + 1) % total;
} else {
next = (current + total - 1) % total;
}
} else {
if constexpr (Forward) {
next = (current % static_cast<uint32_t>(count)) + 1;
} else {
next = (current <= 1) ? static_cast<uint32_t>(count) : current - 1;
}
}
auto call = this->parent_->turn_on();
call.set_effect(next);
call.perform();
}
protected:
LightState *parent_;
bool include_none_{false};
};
template<typename... Ts> class LightIsOnCondition : public Condition<Ts...> {
public:
explicit LightIsOnCondition(LightState *state) : state_(state) {}
+74 -2
View File
@@ -26,8 +26,8 @@ from esphome.const import (
CONF_WARM_WHITE,
CONF_WHITE,
)
from esphome.core import CORE, EsphomeError, Lambda
from esphome.cpp_generator import LambdaExpression
from esphome.core import CORE, ID, EsphomeError, Lambda
from esphome.cpp_generator import LambdaExpression, MockObj, TemplateArgsType
from esphome.types import ConfigType
from .types import (
@@ -39,12 +39,15 @@ from .types import (
DimRelativeAction,
LightCall,
LightControlAction,
LightEffectCycleAction,
LightIsOffCondition,
LightIsOnCondition,
LightState,
ToggleAction,
)
CONF_INCLUDE_NONE = "include_none"
@automation.register_action(
"light.toggle",
@@ -253,6 +256,75 @@ async def light_control_to_code(config, action_id, template_arg, args):
return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda)
def _record_effect_cycle_ref(config: ConfigType) -> ConfigType:
"""Record a cycle-action reference for later validation against the target light."""
from . import EffectCycleRef, _get_data
_get_data().effect_cycle_refs.append(
EffectCycleRef(
light_id=config[CONF_ID],
component_path=path_context.get(),
)
)
return config
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA = cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(LightState),
cv.Optional(CONF_INCLUDE_NONE, default=False): cv.boolean,
}
)
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA.add_extra(_record_effect_cycle_ref)
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA = automation.maybe_simple_id(
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA
)
@automation.register_action(
"light.effect.next",
LightEffectCycleAction,
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA,
synchronous=True,
)
async def light_effect_next_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
return await _light_effect_cycle_to_code(config, action_id, template_arg, True)
@automation.register_action(
"light.effect.previous",
LightEffectCycleAction,
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA,
synchronous=True,
)
async def light_effect_previous_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
return await _light_effect_cycle_to_code(config, action_id, template_arg, False)
async def _light_effect_cycle_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
forward: bool,
) -> MockObj:
paren = await cg.get_variable(config[CONF_ID])
cycle_template_arg = cg.TemplateArguments(forward, *template_arg)
var = cg.new_Pvariable(action_id, cycle_template_arg, paren)
cg.add(var.set_include_none(config[CONF_INCLUDE_NONE]))
return var
CONF_RELATIVE_BRIGHTNESS = "relative_brightness"
LIGHT_DIM_RELATIVE_ACTION_SCHEMA = cv.Schema(
{
+1
View File
@@ -39,6 +39,7 @@ LIMIT_MODES = {
# Actions
ToggleAction = light_ns.class_("ToggleAction", automation.Action)
LightControlAction = light_ns.class_("LightControlAction", automation.Action)
LightEffectCycleAction = light_ns.class_("LightEffectCycleAction", automation.Action)
DimRelativeAction = light_ns.class_("DimRelativeAction", automation.Action)
AddressableSet = light_ns.class_("AddressableSet", automation.Action)
LightIsOnCondition = light_ns.class_("LightIsOnCondition", automation.Condition)
+5
View File
@@ -55,6 +55,7 @@ from .automation import layers_to_code, lvgl_update
from .defines import (
CONF_ALIGN_TO_LAMBDA_ID,
LOGGER,
add_lv_use,
get_focused_widgets,
get_lv_images_used,
get_refreshed_widgets,
@@ -71,6 +72,7 @@ from .keypads import KEYPADS_CONFIG, keypads_to_code
from .lv_validation import lv_bool
from .lvcode import LvContext, LvglComponent, lv_event_t_ptr, lvgl_static
from .schemas import (
BASE_PROPS,
DISP_BG_SCHEMA,
FULL_STYLE_SCHEMA,
STYLE_REMAP,
@@ -100,6 +102,7 @@ from .widgets import (
get_screen_active,
set_obj_properties,
)
from .widgets.img import CONF_IMAGE
# Import only what we actually use directly in this file
from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code
@@ -433,6 +436,8 @@ async def to_code(configs):
# This must be done after all widgets are created
styles_used = df.get_styles_used()
if any(BASE_PROPS.get(x) is lvalid.lv_image for x in styles_used):
add_lv_use(CONF_IMAGE)
for use in df.get_lv_uses():
df.add_define(f"LV_USE_{use.upper()}")
cg.add_define(f"USE_LVGL_{use.upper()}")
+6 -4
View File
@@ -74,11 +74,11 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) {
lv_style_set_text_font(style, font->get_lv_font());
}
#endif
#if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE)
#if LV_USE_IMAGE
#ifdef USE_IMAGE
#ifdef USE_LVGL_IMAGE
// Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda.
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); }
#endif // LV_USE_IMAGE
inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) {
::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector);
@@ -93,7 +93,8 @@ inline void lv_style_set_bg_image_src(lv_style_t *style, image::Image *image) {
inline void lv_style_set_bitmap_mask_src(lv_style_t *style, image::Image *image) {
::lv_style_set_bitmap_mask_src(style, image->get_lv_image_dsc());
}
#endif // USE_LVGL_IMAGE
#endif
#ifdef USE_LVGL_ANIMIMG
inline void lv_animimg_set_src(lv_obj_t *img, std::vector<image::Image *> images) {
auto *dsc = static_cast<std::vector<lv_image_dsc_t *> *>(lv_obj_get_user_data(img));
@@ -109,6 +110,7 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector<image::Image *> images
lv_animimg_set_src(img, (const void **) dsc->data(), dsc->size());
}
#endif // USE_LVGL_ANIMIMG
#endif // USE_IMAGE
#ifdef USE_LVGL_METER
int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value);
+32 -10
View File
@@ -1,4 +1,5 @@
from collections.abc import Callable
from typing import Any
from esphome import config_validation as cv
from esphome.automation import Trigger, validate_automation
@@ -534,7 +535,16 @@ def strip_defaults(schema: cv.Schema):
return cv.Schema({cv.Optional(k): v for k, v in schema.schema.items()})
def container_schema(widget_type: WidgetType, extras=None):
# Keyed by (id(widget_type), id(extras)); strong refs in the value keep both
# alive so id() can't be recycled.
_CONTAINER_SCHEMA_CACHE: dict[
tuple[int, int], tuple[Any, Any, Callable[[Any], Any]]
] = {}
def container_schema(
widget_type: WidgetType, extras: Any = None
) -> Callable[[Any], Any]:
"""
Create a schema for a container widget of a given type. All obj properties are available, plus
the extras passed in, plus any defined for the specific widget being specified.
@@ -542,19 +552,31 @@ def container_schema(widget_type: WidgetType, extras=None):
:param extras: Additional options to be made available, e.g. layout properties for children
:return: The schema for this type of widget.
"""
schema = obj_schema(widget_type).extend(
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
)
if extras:
schema = schema.extend(extras)
# Delayed evaluation for recursion
cache_key = (id(widget_type), id(extras))
cached = _CONTAINER_SCHEMA_CACHE.get(cache_key)
if cached is not None:
cached_widget_type, cached_extras, cached_validator = cached
if cached_widget_type is widget_type and cached_extras is extras:
return cached_validator
schema = schema.extend(widget_type.schema)
cached_schema: cv.Schema | None = None
def validator(value):
def get_schema() -> cv.Schema:
nonlocal cached_schema
if cached_schema is None:
schema = obj_schema(widget_type).extend(
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
)
if extras:
schema = schema.extend(extras)
cached_schema = schema.extend(widget_type.schema)
return cached_schema
def validator(value: Any) -> Any:
value = value or {}
return append_layout_schema(schema, value)(value)
return append_layout_schema(get_schema(), value)(value)
_CONTAINER_SCHEMA_CACHE[cache_key] = (widget_type, extras, validator)
return validator
+2
View File
@@ -9,6 +9,7 @@ from .defines import (
CONF_THEME,
LValidator,
add_lv_use,
get_styles_used,
get_theme_widget_map,
literal,
)
@@ -25,6 +26,7 @@ def has_style_props(config) -> bool:
async def style_set(svar, style):
for prop, validator in ALL_STYLES.items():
if (value := style.get(prop)) is not None:
get_styles_used().add(prop)
if isinstance(validator, LValidator):
value = await validator.process(value)
if isinstance(value, list):
+33 -3
View File
@@ -1,4 +1,6 @@
from collections.abc import Callable
import sys
from typing import Any
from esphome import codegen as cg, config_validation as cv
from esphome.automation import register_action
@@ -15,6 +17,7 @@ from esphome.const import (
from esphome.core import ID, EsphomeError, TimePeriod
from esphome.coroutine import FakeAwaitable
from esphome.cpp_generator import MockObj
from esphome.schema_extractors import EnableSchemaExtraction
from esphome.types import Expression
from ..defines import (
@@ -73,6 +76,34 @@ from ..types import (
EVENT_LAMB = "event_lamb__"
def _build_update_schema(widget_type: "WidgetType") -> Schema:
# Local import: ..schemas imports WidgetType from this module.
from ..schemas import base_update_schema
return base_update_schema(widget_type, widget_type.parts).extend(
widget_type.modify_schema
)
def _update_action_schema(
widget_type: "WidgetType",
) -> Schema | Callable[[Any], Any]:
# Eager when extracting so build_language_schema.py sees the mapping;
# lazy otherwise to skip ~200 ms of import-time voluptuous work.
if EnableSchemaExtraction:
return _build_update_schema(widget_type)
cached: Schema | None = None
def validator(value: Any) -> Any:
nonlocal cached
if cached is None:
cached = _build_update_schema(widget_type)
return cached(value)
return validator
class WidgetType:
"""
Describes a type of Widget, e.g. "bar" or "line"
@@ -113,18 +144,17 @@ class WidgetType:
# Local import to avoid circular import
from ..automation import update_to_code
from ..schemas import WIDGET_TYPES, base_update_schema
from ..schemas import WIDGET_TYPES
if not is_mock:
if self.name in WIDGET_TYPES:
raise EsphomeError(f"Duplicate definition of widget type '{self.name}'")
WIDGET_TYPES[self.name] = self
# Register the update action automatically, adding widget-specific properties
register_action(
f"lvgl.{self.name}.update",
ObjUpdateAction,
base_update_schema(self, self.parts).extend(self.modify_schema),
_update_action_schema(self),
synchronous=True,
)(update_to_code)
+240 -1
View File
@@ -5,8 +5,15 @@ import esphome.codegen as cg
from esphome.components.esp32 import add_idf_sdkconfig_option
from esphome.components.psram import is_guaranteed as psram_is_guaranteed
import esphome.config_validation as cv
from esphome.const import CONF_ENABLE_IPV6, CONF_MIN_IPV6_ADDR_COUNT
from esphome.const import (
CONF_ENABLE_IPV6,
CONF_ID,
CONF_MIN_IPV6_ADDR_COUNT,
CONF_PRIORITY,
CONF_TIMEOUT,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
import esphome.final_validate as fv
CODEOWNERS = ["@esphome/core"]
AUTO_LOAD = ["mdns"]
@@ -18,7 +25,30 @@ _LOGGER = logging.getLogger(__name__)
KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking"
CONF_ENABLE_HIGH_PERFORMANCE = "enable_high_performance"
# Network priority tracking infrastructure
# Components can query this to determine their relative setup priority and fallback timeout.
# CORE.data[KEY_NETWORK_PRIORITY] is a list of dicts:
# [{"interface": "ethernet", "timeout": 30000}, {"interface": "wifi", "timeout": None}, ...]
# where timeout is in milliseconds, or None meaning "start the next interface immediately".
KEY_NETWORK_PRIORITY = "network_priority"
VALID_NETWORK_TYPES = ["ethernet", "openthread", "wifi", "modem"]
# Setup priority base values — first in list gets the highest priority.
#
# The base equals the historical setup_priority::WIFI / ::ETHERNET default
# (250.0), so a single-entry priority list yields exactly the same setup order
# as a config with no priority block. Subsequent entries step down by a small
# amount to break ties without crossing other priority bands.
#
# Important: must stay strictly less than setup_priority::AFTER_BLUETOOTH
# (300.0), which NetworkComponent itself uses — otherwise the highest-priority
# interface could tie with NetworkComponent and run before esp_netif_init().
NETWORK_PRIORITY_BASE = 250.0
NETWORK_PRIORITY_STEP = 5.0
network_ns = cg.esphome_ns.namespace("network")
NetworkComponent = network_ns.class_("NetworkComponent", cg.Component)
IPAddress = network_ns.class_("IPAddress")
@@ -105,8 +135,160 @@ def has_high_performance_networking() -> bool:
return CORE.data.get(KEY_HIGH_PERFORMANCE_NETWORKING, False)
def _get_priority_entry(iface: str) -> dict | None:
"""Return the priority entry dict for the given interface, or None if not configured."""
priority_list = CORE.data.get(KEY_NETWORK_PRIORITY)
if priority_list is None:
return None
iface_lower = iface.lower()
for entry in priority_list:
if entry["interface"] == iface_lower:
return entry
return None
def get_network_priority(iface: str) -> float | None:
"""Get the setup priority for the given network interface type.
Returns the float setup priority for ``iface`` based on the order declared
under ``network: priority:``. Interfaces listed first receive a higher
setup priority so they are initialised before lower-priority ones.
If no ``network: priority:`` has been configured this returns ``None`` and
the calling component should fall back to its own default setup priority.
Args:
iface: Interface type string one of ``"ethernet"``, ``"wifi"``,
``"openthread"`` or ``"modem"`` (case-insensitive).
Returns:
float setup priority, or None if no priority list was configured.
Example usage inside a component's ``to_code``::
from esphome.components import network
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
prio = network.get_network_priority("ethernet")
if prio is not None:
cg.add(var.set_setup_priority(prio))
...
"""
priority_list = CORE.data.get(KEY_NETWORK_PRIORITY)
if priority_list is None:
return None
iface_lower = iface.lower()
for idx, entry in enumerate(priority_list):
if entry["interface"] == iface_lower:
return NETWORK_PRIORITY_BASE - (idx * NETWORK_PRIORITY_STEP)
return None
def get_network_timeout(iface: str) -> int | None:
"""Get the fallback timeout in milliseconds for the given network interface.
Returns the timeout (in ms) that the runtime should wait for ``iface`` to
connect before attempting to bring up the next interface in the priority
list. Returns ``None`` if no timeout was configured for this interface,
meaning the next interface should start immediately.
Args:
iface: Interface type string one of ``"ethernet"``, ``"wifi"``,
``"openthread"`` or ``"modem"`` (case-insensitive).
Returns:
int timeout in milliseconds, or None if no timeout is configured.
Example usage inside a component's ``to_code``::
from esphome.components import network
async def to_code(config):
...
timeout_ms = network.get_network_timeout("ethernet")
if timeout_ms is not None:
cg.add(var.set_fallback_timeout(timeout_ms))
...
"""
entry = _get_priority_entry(iface)
if entry is None:
return None
return entry.get("timeout")
def _validate_timeout(value):
"""Accept any common ESPHome/HA time period format, or a plain integer as seconds.
Accepted formats: 30s, 10sec, 1min, 500ms, 1h, 1.5h, 30 (plain int 30s).
"""
if isinstance(value, int):
# Plain integer — treat as seconds, e.g. timeout: 30 means 30s
return cv.positive_time_period_milliseconds(f"{value}s")
return cv.positive_time_period_milliseconds(value)
def _priority_entry_schema(value):
"""Validate a single priority list entry in either plain string or mapping form.
Plain string form (no timeout):
- ethernet
Mapping form with optional timeout:
- ethernet:
timeout: 30s
"""
if isinstance(value, str):
return cv.one_of(*VALID_NETWORK_TYPES, lower=True)(value)
if isinstance(value, dict):
if len(value) != 1:
raise cv.Invalid(
"Each priority entry must have exactly one interface name as its key"
)
iface = next(iter(value))
cv.one_of(*VALID_NETWORK_TYPES, lower=True)(iface)
opts = cv.Schema(
{
cv.Optional(CONF_TIMEOUT): _validate_timeout,
}
)(value[iface] or {})
return {iface: opts}
raise cv.Invalid(
f"Expected an interface name string or a mapping, got {type(value).__name__}"
)
def _normalize_priority_entry(value) -> dict:
"""Normalize a validated priority entry to a canonical dict.
Returns a dict with keys:
- ``interface``: str, lowercase interface name
- ``timeout``: int milliseconds, or None
"""
if isinstance(value, str):
return {"interface": value, "timeout": None}
# Mapping form — exactly one key (the interface name)
iface, opts = next(iter(value.items()))
timeout = opts.get(CONF_TIMEOUT)
timeout_ms = int(timeout.total_milliseconds) if timeout is not None else None
return {"interface": iface, "timeout": timeout_ms}
def _validate_priority_list(value):
"""Validate and normalize the full priority list, rejecting duplicates."""
raw = cv.ensure_list(_priority_entry_schema)(value)
entries = [_normalize_priority_entry(e) for e in raw]
interfaces = [e["interface"] for e in entries]
if len(interfaces) != len(set(interfaces)):
raise cv.Invalid("Duplicate entries are not allowed in 'priority'")
return entries
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(NetworkComponent),
cv.SplitDefault(
CONF_ENABLE_IPV6,
bk72xx=False,
@@ -130,15 +312,53 @@ CONFIG_SCHEMA = cv.Schema(
),
cv.Optional(CONF_MIN_IPV6_ADDR_COUNT, default=0): cv.positive_int,
cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All(cv.boolean, cv.only_on_esp32),
cv.Optional(CONF_PRIORITY): _validate_priority_list,
}
)
def _final_validate(config):
"""Check that every interface named in 'priority' has a corresponding component block."""
full = fv.full_config.get()
for entry in config.get(CONF_PRIORITY, []):
iface = entry["interface"]
if iface not in full:
raise cv.Invalid(
f"'{iface}' is listed in 'network: priority:' but no '{iface}:' "
f"component is configured",
[CONF_PRIORITY],
)
FINAL_VALIDATE_SCHEMA = _final_validate
@coroutine_with_priority(CoroPriority.NETWORK)
async def to_code(config):
cg.add_define("USE_NETWORK")
# ESP32 with Arduino uses ESP-IDF network APIs directly, no Arduino Network library needed
# Store the user-declared network priority list in CORE.data so that ethernet,
# wifi and other network components can query it via get_network_priority() and
# get_network_timeout() during their own to_code phase.
if CONF_PRIORITY in config:
priority_list = config[CONF_PRIORITY]
CORE.data[KEY_NETWORK_PRIORITY] = priority_list
# Enable Component::set_setup_priority() so the per-interface to_code
# calls below have a defined symbol to link against. Without this define
# the implementation in core/component.cpp is compiled out.
cg.add_define("USE_SETUP_PRIORITY_OVERRIDE")
def _fmt(entry):
if entry["timeout"] is not None:
return f"{entry['interface']} (timeout: {entry['timeout']}ms)"
return entry["interface"]
_LOGGER.info(
"Network interface priority: %s",
" > ".join(_fmt(e) for e in priority_list),
)
# Apply high performance networking settings
# Config can explicitly enable/disable, or default to component-driven behavior
enable_high_perf = config.get(CONF_ENABLE_HIGH_PERFORMANCE)
@@ -224,3 +444,22 @@ async def to_code(config):
cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY")
if CORE.is_rp2040:
cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_IPV6")
# Pvariable creation lives in a separate coroutine at NETWORK_SERVICES so it
# emits after wifi/ethernet at COMMUNICATION. This keeps compile-time config
# (above) separate from C++ object lifecycle and allows wiring in interface
# pointers via get_variable().
if CORE.is_esp32:
CORE.add_job(network_component_to_code, config)
@coroutine_with_priority(CoroPriority.NETWORK_SERVICES)
async def network_component_to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
# Pass the priority list to the C++ component. NetworkComponent::add_priority_entry
# captures the interface-name string literal pointer; CORE.data[KEY_NETWORK_PRIORITY]
# holds the normalized list of dicts (`{"interface": str, "timeout": int|None}`).
for entry in CORE.data.get(KEY_NETWORK_PRIORITY, []):
timeout_ms = entry["timeout"] if entry["timeout"] is not None else 0
cg.add(var.add_priority_entry(entry["interface"], timeout_ms))
@@ -0,0 +1,119 @@
#include "network_component.h"
#include "esphome/core/defines.h"
#if defined(USE_NETWORK) && defined(USE_ESP32)
#include "esphome/core/log.h"
#include <cstring>
#include "esp_err.h"
#include "esp_event.h"
#include "esp_netif.h"
namespace esphome::network {
static const char *const TAG = "network";
void NetworkComponent::setup() {
// Initialize ESP-IDF network interfaces and ensure the default event loop exists
esp_err_t err;
err = esp_netif_init();
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_netif_init failed: (%d) %s", err, esp_err_to_name(err));
this->mark_failed();
return;
}
err = esp_event_loop_create_default();
// ESP_ERR_INVALID_STATE is returned if the default loop already exists,
// which is fine since we just want to make sure it exists
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
ESP_LOGE(TAG, "esp_event_loop_create_default failed: (%d) %s", err, esp_err_to_name(err));
this->mark_failed();
return;
}
// Register an IP_EVENT handler so we can re-arbitrate the default netif on every
// interface up/down. ESP-IDF's built-in auto-selection picks by route_prio (WiFi STA = 100
// > Ethernet = 50), which inverts the user's stated priority for same-subnet configurations.
// We register AFTER esp-idf's internal handler, so our esp_netif_set_default_netif() call
// wins and stays sticky thanks to esp-idf's "manual override" flag.
err = esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, &NetworkComponent::event_handler_, this);
if (err != ESP_OK) {
ESP_LOGW(TAG, "IP_EVENT handler register failed: %s — default route arbitration disabled",
esp_err_to_name(err));
}
// Defensive: arbitrate now in case an interface came up before our handler registered
// (unlikely given our AFTER_BLUETOOTH priority but cheap).
this->update_default_netif_();
}
void NetworkComponent::add_priority_entry(const char *interface, uint32_t timeout_ms) {
if (this->priority_list_.size() >= MAX_NETWORK_PRIORITY_ENTRIES) {
ESP_LOGW(TAG, "Priority list full; ignoring '%s'", interface);
return;
}
this->priority_list_.push_back({interface, timeout_ms});
}
const char *NetworkComponent::interface_to_ifkey_(const char *interface) {
// Standard ESP-IDF netif keys. esphome's wifi/ethernet/openthread components create
// netifs using these defaults.
if (std::strcmp(interface, "ethernet") == 0)
return "ETH_DEF";
if (std::strcmp(interface, "wifi") == 0)
return "WIFI_STA_DEF"; // STA carries uplink; AP never wins default route
if (std::strcmp(interface, "openthread") == 0)
return "OT_DEF";
if (std::strcmp(interface, "modem") == 0)
return "PPP_DEF";
return nullptr;
}
void NetworkComponent::event_handler_(void *arg, esp_event_base_t /*base*/, int32_t /*id*/, void * /*data*/) {
auto *self = static_cast<NetworkComponent *>(arg);
self->update_default_netif_();
}
void NetworkComponent::update_default_netif_() {
// No priority list configured → leave ESP-IDF's route_prio-based auto-selection alone.
// Single-interface configs behave exactly as before.
if (this->priority_list_.empty()) {
return;
}
for (const auto &entry : this->priority_list_) {
const char *ifkey = interface_to_ifkey_(entry.interface);
if (ifkey == nullptr)
continue;
esp_netif_t *netif = esp_netif_get_handle_from_ifkey(ifkey);
if (netif == nullptr)
continue; // component for this interface hasn't run setup() yet
// is_netif_up returns true only when the netif has link + IP, which is what
// we want for "this interface can carry traffic right now."
if (!esp_netif_is_netif_up(netif))
continue;
if (netif != this->active_netif_) {
ESP_LOGI(TAG, "Default interface: %s", entry.interface);
esp_netif_set_default_netif(netif);
this->active_interface_ = entry.interface;
this->active_netif_ = netif;
}
return;
}
// No priority-listed interface is currently up.
if (this->active_netif_ != nullptr) {
ESP_LOGD(TAG, "No active interface in priority list");
this->active_interface_ = nullptr;
this->active_netif_ = nullptr;
// We intentionally don't clear esp-idf's default — the next interface that comes
// up will trigger our handler again and we'll re-pick.
}
}
} // namespace esphome::network
#endif
@@ -0,0 +1,54 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_NETWORK) && defined(USE_ESP32)
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esp_event.h"
#include "esp_netif.h"
namespace esphome::network {
// Cap matches the number of interface types the priority list accepts in YAML
// (ethernet, wifi, openthread, modem). StaticVector keeps zero heap allocation.
inline constexpr size_t MAX_NETWORK_PRIORITY_ENTRIES = 4;
struct NetworkPriorityEntry {
const char *interface; // YAML name: "ethernet", "wifi", "openthread", "modem"
uint32_t timeout_ms; // 0 = no timeout; consumed by Unit D (runtime fallback)
};
class NetworkComponent : public Component {
public:
void setup() override;
// AFTER_BLUETOOTH: BLE controller must initialize before esp_netif_init per IDF guidance.
float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; }
// Codegen-time priority list construction. Called once per `network: priority:` entry
// in YAML order. The interface name pointer must have static storage duration.
void add_priority_entry(const char *interface, uint32_t timeout_ms);
// Currently-active interface in priority order (the one set as default netif).
// Returns nullptr if no priority list is configured or no interface is up.
const char *get_active_interface() const { return this->active_interface_; }
esp_netif_t *get_active_netif() const { return this->active_netif_; }
protected:
// Maps a YAML interface name to its ESP-IDF netif if-key.
// Returns nullptr if the interface name is not recognized.
static const char *interface_to_ifkey_(const char *interface);
// ESP-IDF event handler trampoline. Fires on IP_EVENT_* and re-arbitrates the default netif.
static void event_handler_(void *arg, esp_event_base_t base, int32_t id, void *data);
// Walk priority_list_ in order. Set the highest-priority netif that is up as the
// ESP-IDF default. No-op if priority_list_ is empty (single-interface configs).
void update_default_netif_();
StaticVector<NetworkPriorityEntry, MAX_NETWORK_PRIORITY_ENTRIES> priority_list_;
const char *active_interface_{nullptr};
esp_netif_t *active_netif_{nullptr};
};
} // namespace esphome::network
#endif
@@ -35,9 +35,8 @@ void OpenThreadComponent::setup() {
esp_vfs_eventfd_config_t eventfd_config = {
.max_fds = 3,
};
// Network interface setup handled by network component
ESP_ERROR_CHECK(nvs_flash_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_vfs_eventfd_register(&eventfd_config));
xTaskCreate(
-2
View File
@@ -25,7 +25,6 @@ from esphome.const import (
CONF_TEMPERATURE_COMPENSATION,
CONF_TIME_CONSTANT,
CONF_VOC,
DEVICE_CLASS_AQI,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PM1,
DEVICE_CLASS_PM10,
@@ -77,7 +76,6 @@ def _gas_sensor(
return sensor.sensor_schema(
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
).extend(
{
-3
View File
@@ -14,7 +14,6 @@ from esphome.const import (
CONF_TEMPERATURE,
CONF_TYPE,
CONF_VOC,
DEVICE_CLASS_AQI,
DEVICE_CLASS_CARBON_DIOXIDE,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PM1,
@@ -93,13 +92,11 @@ CONFIG_SCHEMA = (
cv.Optional(CONF_VOC): sensor.sensor_schema(
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_NOX): sensor.sensor_schema(
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_CO2): sensor.sensor_schema(
+1 -1
View File
@@ -206,7 +206,7 @@ async def to_code(config: ConfigType) -> None:
)
# sendspin-cpp library
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.5.0")
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1")
cg.add_define("USE_SENDSPIN", True) # for MDNS
+2
View File
@@ -96,6 +96,7 @@ from esphome.const import (
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_TEMPERATURE_DELTA,
DEVICE_CLASS_TIMESTAMP,
DEVICE_CLASS_UPTIME,
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS,
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS,
DEVICE_CLASS_VOLTAGE,
@@ -174,6 +175,7 @@ DEVICE_CLASSES = [
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_TEMPERATURE_DELTA,
DEVICE_CLASS_TIMESTAMP,
DEVICE_CLASS_UPTIME,
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS,
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS,
DEVICE_CLASS_VOLTAGE,
-3
View File
@@ -15,7 +15,6 @@ from esphome.const import (
CONF_STORE_BASELINE,
CONF_TEMPERATURE_SOURCE,
CONF_VOC,
DEVICE_CLASS_AQI,
ICON_RADIATOR,
STATE_CLASS_MEASUREMENT,
)
@@ -72,13 +71,11 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_VOC): sensor.sensor_schema(
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
).extend(VOC_SENSOR),
cv.Optional(CONF_NOX): sensor.sensor_schema(
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
).extend(NOX_SENSOR),
cv.Optional(CONF_STORE_BASELINE, default=True): cv.boolean,
+6 -6
View File
@@ -30,8 +30,8 @@ static constexpr uint8_t OCP_140MA = 0x38; // 140 mA max current
static constexpr float LOW_DATA_RATE_OPTIMIZE_THRESHOLD = 16.38f; // 16.38 ms
uint8_t SX126x::read_fifo_(uint8_t offset, std::vector<uint8_t> &packet) {
this->wait_busy_();
this->enable();
this->wait_busy_();
this->transfer_byte(RADIO_READ_BUFFER);
this->transfer_byte(offset);
uint8_t status = this->transfer_byte(0x00);
@@ -43,8 +43,8 @@ uint8_t SX126x::read_fifo_(uint8_t offset, std::vector<uint8_t> &packet) {
}
void SX126x::write_fifo_(uint8_t offset, const std::vector<uint8_t> &packet) {
this->wait_busy_();
this->enable();
this->wait_busy_();
this->transfer_byte(RADIO_WRITE_BUFFER);
this->transfer_byte(offset);
for (const uint8_t &byte : packet) {
@@ -55,8 +55,8 @@ void SX126x::write_fifo_(uint8_t offset, const std::vector<uint8_t> &packet) {
}
uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
this->wait_busy_();
this->enable();
this->wait_busy_();
this->transfer_byte(opcode);
uint8_t status = this->transfer_byte(0x00);
for (int32_t i = 0; i < size; i++) {
@@ -67,8 +67,8 @@ uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
}
void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
this->wait_busy_();
this->enable();
this->wait_busy_();
this->transfer_byte(opcode);
for (int32_t i = 0; i < size; i++) {
this->transfer_byte(data[i]);
@@ -78,8 +78,8 @@ void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
}
void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) {
this->wait_busy_();
this->enable();
this->wait_busy_();
this->write_byte(RADIO_READ_REGISTER);
this->write_byte((reg >> 8) & 0xFF);
this->write_byte((reg >> 0) & 0xFF);
@@ -91,8 +91,8 @@ void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) {
}
void SX126x::write_register_(uint16_t reg, uint8_t *data, uint8_t size) {
this->wait_busy_();
this->enable();
this->wait_busy_();
this->write_byte(RADIO_WRITE_REGISTER);
this->write_byte((reg >> 8) & 0xFF);
this->write_byte((reg >> 0) & 0xFF);
+9 -7
View File
@@ -206,15 +206,17 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff
if (this->status_pin_reported_ != -1) {
this->init_state_ = TuyaInitState::INIT_DATAPOINT;
this->send_empty_command_(TuyaCommandType::DATAPOINT_QUERY);
bool is_pin_equals =
this->status_pin_ != nullptr && this->status_pin_->get_pin() == this->status_pin_reported_;
// Configure status pin toggling (if reported and configured) or WIFI_STATE periodic send
if (!is_pin_equals) {
ESP_LOGW(TAG, "Supplied status_pin does not equals the reported pin %i. Using supplied pin anyway.",
if (this->status_pin_ != nullptr) {
if (this->status_pin_->get_pin() != this->status_pin_reported_) {
ESP_LOGW(TAG, "Supplied status_pin does not equal the reported pin %i. Using supplied pin anyway.",
this->status_pin_reported_);
}
ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin());
this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); });
} else {
ESP_LOGW(TAG, "MCU reported status_pin %i but no status_pin was configured; running in limited mode.",
this->status_pin_reported_);
}
ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin());
this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); });
} else {
this->init_state_ = TuyaInitState::INIT_WIFI;
ESP_LOGV(TAG, "Configured WIFI_STATE periodic send");
+5 -4
View File
@@ -513,10 +513,11 @@ async def uart_write_to_code(config, action_id, template_arg, args):
@coroutine_with_priority(CoroPriority.FINAL)
async def final_step():
"""Final code generation step to configure optional UART features."""
if CORE.is_esp32 and CORE.has_networking:
# Wake-on-RX is essentially free on ESP32 (just an ISR function pointer
# registration) — enable by default to reduce RX buffer overflow risk
# by waking the main loop immediately when data arrives.
if (CORE.is_esp32 or CORE.is_esp8266) and CORE.has_networking:
# Wake-on-RX is essentially free (just an ISR function pointer
# registration on ESP32, an inline flag set on ESP8266 software
# serial) — enable by default to reduce RX buffer overflow risk by
# waking the main loop immediately when data arrives.
cg.add_define("USE_UART_WAKE_LOOP_ON_RX")
@@ -4,6 +4,9 @@
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifdef USE_UART_WAKE_LOOP_ON_RX
#include "esphome/core/wake.h"
#endif
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
@@ -149,7 +152,11 @@ void ESP8266UartComponent::dump_config() {
if (this->hw_serial_ != nullptr) {
ESP_LOGCONFIG(TAG, " Using hardware serial interface.");
} else {
ESP_LOGCONFIG(TAG, " Using software serial");
ESP_LOGCONFIG(TAG, " Using software serial"
#ifdef USE_UART_WAKE_LOOP_ON_RX
"\n Wake on data RX: ENABLED"
#endif
);
}
this->check_logger_conflict();
}
@@ -266,6 +273,12 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) {
arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_;
// Clear RX pin so that the interrupt doesn't re-trigger right away again.
arg->rx_pin_.clear_interrupt();
#ifdef USE_UART_WAKE_LOOP_ON_RX
// Wake the main loop so the consuming component drains the byte promptly
// instead of waiting for the next loop_interval_ tick. Important for timing
// sensitive setups that poll read() in a tight loop (e.g. fingerprint_grow).
wake_loop_isrsafe();
#endif
}
void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) {
if (this->gpio_tx_pin_ == nullptr) {
+2 -3
View File
@@ -4,7 +4,7 @@ import esphome.config_validation as cv
from esphome.const import (
CONF_TIME_ID,
DEVICE_CLASS_DURATION,
DEVICE_CLASS_TIMESTAMP,
DEVICE_CLASS_UPTIME,
ENTITY_CATEGORY_DIAGNOSTIC,
ICON_TIMER,
STATE_CLASS_TOTAL_INCREASING,
@@ -33,9 +33,8 @@ CONFIG_SCHEMA = cv.typed_schema(
).extend(cv.polling_component_schema("60s")),
"timestamp": sensor.sensor_schema(
UptimeTimestampSensor,
icon=ICON_TIMER,
accuracy_decimals=0,
device_class=DEVICE_CLASS_TIMESTAMP,
device_class=DEVICE_CLASS_UPTIME,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
)
.extend(
+57 -2
View File
@@ -12,6 +12,7 @@ from esphome.components.esp32 import (
only_on_variant,
)
from esphome.components.network import (
get_network_priority,
has_high_performance_networking,
ip_address_literal,
)
@@ -42,7 +43,6 @@ from esphome.const import (
CONF_ON_CONNECT,
CONF_ON_DISCONNECT,
CONF_ON_ERROR,
CONF_OUTPUT_POWER,
CONF_PASSWORD,
CONF_POWER_SAVE_MODE,
CONF_PRIORITY,
@@ -54,10 +54,18 @@ from esphome.const import (
CONF_TTLS_PHASE_2,
CONF_USE_ADDRESS,
CONF_USERNAME,
CONF_WIFI,
PLACEHOLDER_WIFI_SSID,
Platform,
PlatformFramework,
)
from esphome.core import CORE, CoroPriority, HexInt, coroutine_with_priority
from esphome.core import (
CORE,
CoroPriority,
EsphomeError,
HexInt,
coroutine_with_priority,
)
import esphome.final_validate as fv
from esphome.types import ConfigType
@@ -432,6 +440,7 @@ def _validate(config):
return config
CONF_OUTPUT_POWER = "output_power"
CONF_PASSIVE_SCAN = "passive_scan"
CONFIG_SCHEMA = cv.All(
cv.Schema(
@@ -565,6 +574,10 @@ def wifi_network(config, ap, static_ip):
@coroutine_with_priority(CoroPriority.COMMUNICATION)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
prio = get_network_priority("wifi")
if prio is not None:
cg.add(var.set_setup_priority(prio))
cg.add(var.set_use_address(config[CONF_USE_ADDRESS]))
# Track if any network uses Enterprise authentication
@@ -903,3 +916,45 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
"wifi_component_pico_w.cpp": {PlatformFramework.RP2040_ARDUINO},
}
)
def _placeholder_wifi_credentials(config: ConfigType) -> list[str]:
"""Return human-readable locations where the dashboard's placeholder wifi
values still appear. Empty list means no placeholders were found.
"""
placeholders: list[str] = []
wifi_conf = config.get(CONF_WIFI)
if not wifi_conf:
return placeholders
for idx, network in enumerate(wifi_conf.get(CONF_NETWORKS, [])):
ssid = network.get(CONF_SSID)
if isinstance(ssid, str) and ssid == PLACEHOLDER_WIFI_SSID:
placeholders.append(f"wifi.networks[{idx}].ssid")
ap_conf = wifi_conf.get(CONF_AP)
if ap_conf:
ap_ssid = ap_conf.get(CONF_SSID)
if isinstance(ap_ssid, str) and ap_ssid == PLACEHOLDER_WIFI_SSID:
placeholders.append("wifi.ap.ssid")
return placeholders
def check_placeholder_credentials(config: ConfigType) -> None:
"""Raise EsphomeError if any wifi credential is the dashboard placeholder.
Call only at compile time. NEVER from CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA,
or any path reached by `esphome config`; device-builder relies on
validation passing with the placeholders still in place.
"""
locations = _placeholder_wifi_credentials(config)
if not locations:
return
formatted = ", ".join(locations)
raise EsphomeError(
f"wifi configuration still contains the dashboard placeholder value "
f"'{PLACEHOLDER_WIFI_SSID}' at: {formatted}. "
f"Open secrets.yaml and replace 'wifi_ssid' (and 'wifi_password') "
f"with your real wifi credentials before flashing."
)
+17 -4
View File
@@ -632,11 +632,11 @@ void WiFiComponent::setup() {
#endif
if (this->enable_on_boot_) {
#ifdef USE_ESP32
this->wifi_lazy_init_();
#endif
this->start();
} else {
#ifdef USE_ESP32
esp_netif_init();
#endif
this->state_ = WIFI_COMPONENT_STATE_DISABLED;
}
}
@@ -1278,6 +1278,11 @@ void WiFiComponent::enable() {
ESP_LOGD(TAG, "Enabling");
this->state_ = WIFI_COMPONENT_STATE_OFF;
#ifdef USE_ESP32
// Idempotent — only allocates DMA buffers + netifs on the first call. After this,
// start() can safely run.
this->wifi_lazy_init_();
#endif
this->start();
}
@@ -2193,7 +2198,15 @@ bool WiFiComponent::request_high_performance() {
}
// Give the semaphore (non-blocking). This increments the count.
return xSemaphoreGive(this->high_performance_semaphore_) == pdTRUE;
bool success = xSemaphoreGive(this->high_performance_semaphore_) == pdTRUE;
// Wake the main loop so the switch to high-performance mode is applied on the
// next tick instead of waiting up to loop_interval.
if (success) {
App.wake_loop_threadsafe();
}
return success;
}
bool WiFiComponent::release_high_performance() {
+12
View File
@@ -694,6 +694,12 @@ class WiFiComponent final : public Component {
bool wifi_apply_hostname_();
bool wifi_sta_connect_(const WiFiAP &ap);
void wifi_pre_setup_();
#ifdef USE_ESP32
// ESP-IDF only: defers esp_wifi_init() + netif creation (which allocate ~15-30KB of
// DMA-capable internal SRAM) until wifi actually needs to come up. Idempotent.
// Called from setup() only when enable_on_boot_=true, and from enable() on first use.
void wifi_lazy_init_();
#endif
WiFiSTAConnectStatus wifi_sta_connect_status_() const;
bool is_connected_() const {
return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED &&
@@ -889,6 +895,12 @@ class WiFiComponent final : public Component {
bool rrm_{false};
#endif
bool enable_on_boot_{true};
#ifdef USE_ESP32
// Tracks whether esp_wifi_init() + netif creation has happened. Allows enable()
// to be called at runtime without re-allocating, and ensures the heavy init is
// skipped entirely when enable_on_boot_ is false until first enable().
bool wifi_initialized_{false};
#endif
bool got_ipv4_address_{false};
bool keep_scan_results_{false};
bool has_completed_scan_after_captive_portal_start_{
@@ -145,23 +145,15 @@ void WiFiComponent::wifi_pre_setup_() {
get_mac_address_raw(mac);
set_mac_address(mac);
}
esp_err_t err = esp_netif_init();
if (err != ERR_OK) {
ESP_LOGE(TAG, "esp_netif_init failed: %s", esp_err_to_name(err));
return;
}
// Network interface setup handled by network component
s_wifi_event_group = xEventGroupCreate();
if (s_wifi_event_group == nullptr) {
ESP_LOGE(TAG, "xEventGroupCreate failed");
return;
}
err = esp_event_loop_create_default();
if (err != ERR_OK) {
ESP_LOGE(TAG, "esp_event_loop_create_default failed: %s", esp_err_to_name(err));
return;
}
esp_event_handler_instance_t instance_wifi_id, instance_ip_id;
err = esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, nullptr, &instance_wifi_id);
esp_err_t err =
esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, nullptr, &instance_wifi_id);
if (err != ERR_OK) {
ESP_LOGE(TAG, "esp_event_handler_instance_register failed: %s", esp_err_to_name(err));
return;
@@ -171,6 +163,16 @@ void WiFiComponent::wifi_pre_setup_() {
ESP_LOGE(TAG, "esp_event_handler_instance_register failed: %s", esp_err_to_name(err));
return;
}
// NOTE: netif creation + esp_wifi_init() used to live here. They allocate ~15-30KB of
// DMA-capable internal SRAM, which competes with W5500 SPI DMA and I2S DMA on
// memory-tight devices. They are now deferred to wifi_lazy_init_(), called from
// setup() when enable_on_boot_ is true, or from enable() on first runtime enable.
// This makes enable_on_boot:false genuinely skip the wifi DMA allocation.
}
void WiFiComponent::wifi_lazy_init_() {
if (this->wifi_initialized_)
return;
s_sta_netif = esp_netif_create_default_wifi_sta();
@@ -183,7 +185,7 @@ void WiFiComponent::wifi_pre_setup_() {
ESP_LOGW(TAG, "starting wifi without nvs");
cfg.nvs_enable = false;
}
err = esp_wifi_init(&cfg);
esp_err_t err = esp_wifi_init(&cfg);
if (err != ERR_OK) {
ESP_LOGE(TAG, "esp_wifi_init failed: %s", esp_err_to_name(err));
return;
@@ -193,6 +195,7 @@ void WiFiComponent::wifi_pre_setup_() {
ESP_LOGE(TAG, "esp_wifi_set_storage failed: %s", esp_err_to_name(err));
return;
}
this->wifi_initialized_ = true;
}
bool WiFiComponent::wifi_mode_(optional<bool> sta, optional<bool> ap) {
+2
View File
@@ -50,6 +50,8 @@ _LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@luar123", "@tomaszduda23"]
CONFLICTS_WITH = ["openthread"]
BASE_SCHEMA = cv.Schema(
{
cv.Optional(CONF_REPORT): cv.All(
+3 -7
View File
@@ -117,15 +117,11 @@ def final_validate_esp32(config: ConfigType) -> ConfigType:
if not CORE.is_esp32:
return config
if CONF_WIFI in fv.full_config.get():
if config[CONF_ROUTER] and CONF_AP in fv.full_config.get()[CONF_WIFI]:
raise cv.Invalid(
"Only Zigbee End Device can be used together with a Wifi Access Point."
)
if CONF_AP in fv.full_config.get()[CONF_WIFI]:
_LOGGER.warning(
"Wifi Access Point might be unstable while Zigbee is active, use only as fallback."
raise cv.Invalid(
"A Wifi Access Point can not be used together with Zigbee."
)
elif config[CONF_ROUTER]:
if config[CONF_ROUTER]:
_LOGGER.warning(
"The Zigbee Router might miss packets while Wifi is active and could destabilize "
"your network. Use only if Wifi is off most of the time."
+10
View File
@@ -1367,6 +1367,7 @@ DEVICE_CLASS_TEMPERATURE = "temperature"
DEVICE_CLASS_TEMPERATURE_DELTA = "temperature_delta"
DEVICE_CLASS_TIMESTAMP = "timestamp"
DEVICE_CLASS_UPDATE = "update"
DEVICE_CLASS_UPTIME = "uptime"
DEVICE_CLASS_VIBRATION = "vibration"
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds"
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts"
@@ -1415,3 +1416,12 @@ ENTITY_CATEGORY_DIAGNOSTIC = "diagnostic"
# The corresponding constant exists in c++
# when update_interval is set to never, it becomes SCHEDULER_DONT_RUN milliseconds
SCHEDULER_DONT_RUN = 4294967295
# Sentinel values written by the esphome-device-builder dashboard into
# secrets.yaml on first boot so that !secret wifi_ssid / !secret wifi_password
# references resolve cleanly through validation before the user has finished
# the onboarding wizard. Compilation refuses if these reach the binary so that
# a user who dismisses onboarding can't accidentally flash a device that will
# never associate with their wifi.
PLACEHOLDER_WIFI_SSID = "REPLACE_WITH_YOUR_WIFI_NETWORK"
PLACEHOLDER_WIFI_PASSWORD = "REPLACE_WITH_YOUR_WIFI_PASSWORD" # noqa: S105
+8 -1
View File
@@ -5,7 +5,7 @@ import math
import os
from pathlib import Path
import re
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from esphome.const import (
CONF_COMMENT,
@@ -569,6 +569,12 @@ class EsphomeCore:
self.build_path: Path | None = None
# The validated configuration, this is None until the config has been validated
self.config: ConfigType | None = None
# YAML frontmatter loaded from user YAML files. Frontmatter is a leading
# YAML document separated by `---` from the actual configuration. It is
# ignored by config validation and code generation, but kept here so it
# can be inspected by callers (tooling, future features). Keyed by the
# resolved Path of the source file.
self.frontmatter: dict[Path, Any] = {}
# The pending tasks in the task queue (mostly for C++ generation)
# This is a priority queue (with heapq)
# Each item is a tuple of form: (-priority, unique number, task)
@@ -634,6 +640,7 @@ class EsphomeCore:
self.config_path = None
self.build_path = None
self.config = None
self.frontmatter = {}
self.event_loop = _FakeEventLoop()
self.task_counter = 0
self.variables = {}
+5 -1
View File
@@ -1,6 +1,7 @@
#pragma once
#include <string>
#include <cstdint>
#include <cstring>
#include <string>
#include "gpio.h"
#include "esphome/core/defines.h"
#include "esphome/core/time_64.h"
@@ -42,6 +43,9 @@ void __attribute__((noreturn)) arch_restart();
inline uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; }
inline const char *progmem_read_ptr(const char *const *addr) { return *addr; }
inline uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; }
// Bulk copy out of PROGMEM. PROGMEM is a no-op everywhere except ESP8266, so a
// plain `std::memcpy` is correct and the fast path here.
inline void progmem_memcpy(void *dst, const void *src, size_t len) { std::memcpy(dst, src, len); }
#endif
} // namespace esphome
+38 -61
View File
@@ -93,7 +93,7 @@ class URLSource(Source):
class GitSource(Source):
def __init__(self, url: str, ref: str):
def __init__(self, url: str, ref: str | None):
self.url = url
self.ref = ref
@@ -109,7 +109,7 @@ class GitSource(Source):
return path
def __str__(self):
return f"{self.url}#{self.ref}"
return f"{self.url}#{self.ref}" if self.ref else self.url
class InvalidIDFComponent(Exception):
@@ -154,41 +154,6 @@ class IDFComponent:
self.path = self.source.download(self.get_sanitized_name(), force=force)
def _sanitize_version(version: str) -> str:
"""
Sanitize a version string by removing common requirement prefixes or a leading v.
Args:
version: Version string to clean.
Returns:
Cleaned version string without common requirement symbols.
"""
version = version.strip()
prefixes = (
"^",
"~=",
"~",
">=",
"<=",
"==",
"!=",
">",
"<",
"=",
"v",
"V",
)
for p in prefixes:
if version.startswith(p):
version = version[len(p) :]
break
return version.strip()
def _get_package_from_pio_registry(
username: str | None, pkgname: str, requirements: str
) -> tuple[str, str, str | None, str | None]:
@@ -387,7 +352,6 @@ def _convert_library_to_component(library: Library) -> IDFComponent:
IDFComponent: The resolved component with name, version, and URL
Raises:
ValueError: If a repository URL is missing a reference (#)
RuntimeError: If no artifact can be found for the library
"""
name = None
@@ -396,20 +360,25 @@ def _convert_library_to_component(library: Library) -> IDFComponent:
# Repository is provided directly
if library.repository:
# Parse repository URL to extract name and version
# Parse repository URL: path becomes the component name, fragment
# (if any) becomes the git ref stored on GitSource. A missing
# fragment is fine -- clone_or_update leaves the depth-1 clone on
# the remote's default branch, matching PIO's lib_deps behavior
# and external_components handling.
split_result = urlsplit(library.repository)
if not split_result.fragment.strip():
raise ValueError(f"Missing ref in URL {library.repository}")
# Sanitize name
name = str(split_result.path).strip("/")
name = name.removesuffix(".git")
# Sanitize version
version = _sanitize_version(split_result.fragment)
# IDF Component Manager only accepts "*", a 40-char commit hash, or
# semver here. The actual git ref is preserved in GitSource.ref;
# override_path makes this field cosmetic at build time.
version = "*"
repository = urlunsplit(split_result._replace(fragment=""))
source = GitSource(str(repository), split_result.fragment)
ref = split_result.fragment.strip() or None
source = GitSource(str(repository), ref)
# Version is provided - resolve using PlatformIO registry
elif library.version:
@@ -619,9 +588,6 @@ def generate_idf_component_yml(component: IDFComponent) -> str:
if description:
data["description"] = description
# Do not use the version from library.json/library.properties; it may be incorrect.
data["version"] = component.version
repository = component.data.get("repository", {}).get("url", None)
if repository:
data["repository"] = repository
@@ -631,20 +597,11 @@ def generate_idf_component_yml(component: IDFComponent) -> str:
if "dependencies" not in data:
data["dependencies"] = {}
# Add this dependency to dependencies
dep = {}
dep["version"] = dependency.version
# Should use dependency.path as override path
try:
dep["override_path"] = str(dependency.path)
except RuntimeError as e:
# No local path: only a GitSource can substitute its URL.
if not isinstance(dependency.source, GitSource):
raise e
dep["git"] = dependency.source.url
data["dependencies"][dependency.get_sanitized_name()] = dep
# Every dependency goes through _generate_idf_component →
# component.download() before this runs, so .path is always set.
data["dependencies"][dependency.get_sanitized_name()] = {
"override_path": str(dependency.path),
}
return yaml_util.dump(data)
@@ -699,6 +656,26 @@ def _process_dependencies(component: IDFComponent):
if not dependencies:
return
# PIO's library.json accepts both the list-of-dicts form and the
# shorthand dict form ``{"owner/Name": "version_spec"}``. Normalize
# the dict form so the loop below sees a uniform list. Iterating a
# dict gives string keys, which would silently fail the
# ``"name" in dependency`` substring check and skip every entry.
if isinstance(dependencies, dict):
normalized = []
for raw_name, spec in dependencies.items():
if "/" in raw_name:
owner, pkgname = raw_name.split("/", 1)
else:
owner, pkgname = None, raw_name
entry = {"name": pkgname, "owner": owner}
if isinstance(spec, dict):
entry.update(spec)
else:
entry["version"] = spec
normalized.append(entry)
dependencies = normalized
_LOGGER.info("Processing %s@%s component dependencies...", name, version)
for dependency in dependencies:
# Validate dependency structure
+140 -16
View File
@@ -7,6 +7,7 @@ import json
import logging
import os
from pathlib import Path
import platform
import shutil
import subprocess
import sys
@@ -17,7 +18,7 @@ import requests
from esphome.config_validation import Version
from esphome.core import CORE
from esphome.helpers import ProgressBar, get_str_env, rmtree
from esphome.helpers import ProgressBar, get_str_env, rmtree, write_file_if_changed
PathType = str | os.PathLike
@@ -26,16 +27,18 @@ _LOGGER = logging.getLogger(__name__)
_SCRIPTS_DIR = Path(__file__).parent
def _str_to_lst_of_str(a: str) -> list[str]:
def _str_to_lst_of_str(a: str | list[str]) -> list[str]:
"""
Convert a string to a list of string
Args:
a: A string containing semicolon-separated values
a: A string containing semicolon-separated values, or an already-split list
Returns:
list of strings
"""
if isinstance(a, list):
return a
return list(f.strip() for f in a.split(";") if f.strip())
@@ -67,10 +70,11 @@ ESPHOME_IDF_DEFAULT_FEATURES = _str_to_lst_of_str(
)
ESPHOME_IDF_FRAMEWORK_MIRRORS = _str_to_lst_of_str(
os.environ.get(
"ESPHOME_IDF_FRAMEWORK_MIRRORS",
"https://github.com/espressif/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.zip;https://github.com/espressif/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.zip",
)
os.environ.get("ESPHOME_IDF_FRAMEWORK_MIRRORS")
or [
"https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz",
"https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.tar.xz",
]
)
ESP_IDF_CONSTRAINTS_MIRRORS = _str_to_lst_of_str(
@@ -546,11 +550,11 @@ def _tar_extract_all(
if not (mode & stat.S_IXUSR):
mode &= ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
mode |= stat.S_IRUSR | stat.S_IWUSR
elif member.isdir() or member.issym():
# Ignore mode for directories & symlinks
mode = None
else:
# Block special files
elif not (member.isdir() or member.issym()):
# Block special files. Directories and symlinks keep
# their masked-original mode — passing None here would
# crash tarfile.extract on Python <3.12 (its chmod
# path calls os.chmod unconditionally).
continue
member.mode = mode
@@ -780,12 +784,109 @@ def download_from_mirrors(
return None
def _write_idf_version_txt(framework_path: Path, version: str) -> None:
"""Write <framework_path>/version.txt if missing.
IDF's build.cmake picks the version it embeds in the firmware (and
stamps onto the bootloader) in this order: ``${IDF_PATH}/version.txt``
if present, else ``git describe`` against IDF_PATH, else the
``IDF_VERSION_MAJOR/MINOR/PATCH`` triplet from ``tools/cmake/version.cmake``.
On a clean esphome-libs tarball ``.git`` is fully stripped, so
git_describe returns ``HEAD-HASH-NOTFOUND`` (falsy) and the triplet
wins -- correct by luck. But a *partial* ``.git`` (e.g. a custom
framework.source pointed at a real git URL where build artifacts
mark the tree dirty) makes git_describe return ``<hash>-dirty``,
which is what then gets baked into the bootloader. Dropping
version.txt forces the right answer regardless.
"""
version_txt = framework_path / "version.txt"
if version_txt.exists():
return
try:
version_txt.write_text(f"v{version}\n", encoding="utf-8")
except OSError as e:
_LOGGER.warning(
"Could not write %s (%s); bootloader version string may be incorrect.",
version_txt,
e,
)
# Backport of espressif/esp-idf#18272: every ESPHome-supported IDF release
# through v6.0 ships a tools.json whose ninja 1.12.1 entry has no
# ``linux-arm64`` source. ``idf_tools.py`` then either fails to find a
# matching binary or grabs the x86_64 one, which can't execute on
# aarch64. cmake is already populated across the same release range; we
# only need to inject ninja. Values lifted verbatim from the IDF v6.0.1
# tools.json where the fix landed natively.
_NINJA_ARM64_BACKPORT: dict[str, dict[str, str | int]] = {
"1.12.1": {
"rename_dist": "ninja-linux-arm64-v1.12.1.zip",
"sha256": "5c25c6570b0155e95fce5918cb95f1ad9870df5768653afe128db822301a05a1",
"size": 121787,
"url": "https://github.com/ninja-build/ninja/releases/download/v1.12.1/ninja-linux-aarch64.zip",
},
}
def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None:
"""Inject ninja linux-arm64 entries into the framework's tools.json on aarch64.
Idempotent: a tools.json that already has the entry, or a host that
isn't aarch64, is a no-op. Applied unconditionally on every install
check so a build dir extracted before the backport got fixed up
without forcing a clean.
"""
if platform.machine() != "aarch64":
return
tools_json = framework_path / "tools" / "tools.json"
if not tools_json.is_file():
return
try:
with open(tools_json, encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
_LOGGER.warning(
"Could not parse %s for linux-arm64 backport (%s); "
"skipping. A clean reinstall of the framework directory "
"may be needed.",
tools_json,
e,
)
return
changed = False
for tool in data.get("tools", []):
if tool.get("name") != "ninja":
continue
for ver in tool.get("versions", []):
entry = _NINJA_ARM64_BACKPORT.get(ver.get("name"))
if entry is None or ver.get("linux-arm64"):
continue
ver["linux-arm64"] = entry
changed = True
if changed:
# write_file_if_changed stages a tempfile in the destination dir
# and atomically replaces — safe against mid-write interruption
# and concurrent invocations.
write_file_if_changed(tools_json, json.dumps(data, indent=2) + "\n")
_LOGGER.info(
"Patched %s to add ninja linux-arm64 download "
"(espressif/esp-idf#18272 backport).",
tools_json,
)
def _check_esphome_idf_framework_install(
version: str,
targets: list[str],
tools: list[str],
force: bool = False,
env: dict[str, str] | None = None,
source_url: str | None = None,
) -> tuple[Path, bool]:
"""
Check and install ESP-IDF framework.
@@ -796,6 +897,11 @@ def _check_esphome_idf_framework_install(
tools: list of tools to install
force: If True, force reinstallation
env: Optional dictionary of environment variables to set
source_url: Optional override URL for the framework tarball. Supports
the same ``{VERSION}`` / ``{MAJOR}`` / ``{MINOR}`` / ``{PATCH}`` /
``{EXTRA}`` substitutions as ESPHOME_IDF_FRAMEWORK_MIRRORS. When
set, it replaces the default mirror list no implicit fallback,
so a misspelled URL fails loudly.
Returns:
tuple of (framework_path, install_flag)
@@ -817,6 +923,10 @@ def _check_esphome_idf_framework_install(
env_stamp_file = framework_path / ESPHOME_STAMP_FILE
idf_tools_path = framework_path / "tools" / "idf_tools.py"
_LOGGER.info("Checking ESP-IDF %s framework ...", version)
# Logged every invocation (not just on install) so the user can verify the
# override. A changed URL needs ``esphome clean`` to force a re-download.
if source_url:
_LOGGER.info("Using framework source override: %s", source_url)
# 2. Download and extract the framework if not already extracted.
# The marker is written last after extraction succeeds, so its presence
@@ -844,14 +954,23 @@ def _check_esphome_idf_framework_install(
except ValueError:
pass
download_from_mirrors(
ESPHOME_IDF_FRAMEWORK_MIRRORS, substitutions, tmp.file
)
mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS
download_from_mirrors(mirrors, substitutions, tmp.file)
_LOGGER.info("Extracting ESP-IDF %s framework ...", version)
archive_extract_all(tmp.file, framework_path, progress_header="Extracting")
extracted_marker.touch()
# Idempotent post-extract patch: written every invocation so a build
# dir extracted before this fix gets the file too, without forcing a
# clean. Skips when version.txt already exists.
_write_idf_version_txt(framework_path, version)
# Apply the ninja linux-arm64 backport on every invocation, not just on
# fresh extracts — idempotent and cheap, and lets a build dir carrying
# a pre-patch tools.json get fixed up without forcing a clean.
_patch_tools_json_for_linux_arm64(framework_path)
# 3. Check if the framework tools are the same and correctly installed
if not install:
install = True
@@ -1008,6 +1127,7 @@ def check_esp_idf_install(
tools: list[str] | None = None,
features: list[str] | None = None,
force: bool = False,
source_url: str | None = None,
) -> tuple[Path, Path]:
"""
Check and install ESP-IDF framework and Python environment.
@@ -1018,6 +1138,10 @@ def check_esp_idf_install(
tools: list of tools to install
features: Features to install
force: If True, force reinstallation
source_url: Optional override URL for the framework tarball. When
set, it replaces the default mirror list (no fallback). Forwarded
to ``_check_esphome_idf_framework_install``; supports the same URL
substitutions.
Returns:
tuple of (framework_path, python_env_path)
@@ -1040,7 +1164,7 @@ def check_esp_idf_install(
# 1) Framework
framework_path, installed = _check_esphome_idf_framework_install(
version, targets, tools, force=force, env=env
version, targets, tools, force=force, env=env, source_url=source_url
)
features = features or ESPHOME_IDF_DEFAULT_FEATURES
+6
View File
@@ -66,6 +66,12 @@ FILTER_IDF_LINES: list[str] = [
# Drop the blank line rich emits after the note so the build log
# doesn't end with an orphan gap before ESPHome's own status lines.
r"\s*$",
# ESP-IDF shells out to ``git rev-parse`` to embed a commit hash;
# esphome-libs strips ``.git`` from the tarball so those probes fail
# noisily without affecting the build.
r"-- git rev-parse returned ",
r"fatal: not a git repository",
r"Stopping at filesystem boundary",
]
+4 -3
View File
@@ -94,9 +94,10 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None:
_LOGGER.debug("Skipping size summary: %s", e)
return
dram = data.get("memory_types", {}).get("DRAM") or {}
ram_used = dram.get("used")
ram_total = dram.get("size")
memory_types = data.get("memory_types", {})
ram_region = memory_types.get("DRAM") or memory_types.get("DIRAM") or {}
ram_used = ram_region.get("used")
ram_total = ram_region.get("size")
if ram_total and ram_used is not None:
print(f"RAM: {_format_bar(ram_used, ram_total)}")
+16 -1
View File
@@ -10,6 +10,7 @@ import shutil
import subprocess
from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION
from esphome.const import CONF_FRAMEWORK, CONF_SOURCE
from esphome.core import CORE, EsphomeError
from esphome.espidf.framework import check_esp_idf_install, get_framework_env
from esphome.espidf.size_summary import print_summary
@@ -37,13 +38,27 @@ def _get_core_framework_version():
return str(CORE.data[KEY_ESP32][KEY_IDF_VERSION])
def _get_framework_source_override() -> str | None:
"""Return the user-supplied esp32.framework.source override, if any.
The override lets a user point the IDF tarball download at a custom URL
(mirror, fork, local server). Substitutions like ``{VERSION}`` /
``{MAJOR}`` etc. work the same as in the default mirror list.
"""
if CORE.config is None:
return None
return CORE.config.get(KEY_ESP32, {}).get(CONF_FRAMEWORK, {}).get(CONF_SOURCE)
def _get_esphome_esp_idf_paths(
version: str | None = None,
) -> tuple[os.PathLike, os.PathLike]:
version = version or _get_core_framework_version()
paths = _cache().paths
if version not in paths:
paths[version] = check_esp_idf_install(version)
paths[version] = check_esp_idf_install(
version, source_url=_get_framework_source_override()
)
return paths[version]
+2 -2
View File
@@ -2,7 +2,7 @@ dependencies:
bblanchon/arduinojson:
version: "7.4.2"
esphome/esp-audio-libs:
version: 3.0.0
version: 3.1.0
esphome/esp-micro-speech-features:
version: 1.2.3
esphome/micro-decoder:
@@ -100,6 +100,6 @@ dependencies:
esp32async/asynctcp:
version: 3.4.91
sendspin/sendspin-cpp:
version: 0.5.0
version: 0.6.1
lvgl/lvgl:
version: 9.5.0
+32 -1
View File
@@ -14,6 +14,7 @@ from esphome.const import (
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
Toolchain,
)
from esphome.core import CORE
from esphome.helpers import write_file_if_changed
@@ -98,6 +99,7 @@ class StorageJSON:
no_mdns: bool,
framework: str | None = None,
core_platform: str | None = None,
toolchain: str | None = None,
) -> None:
# Version of the storage JSON schema
assert storage_version is None or isinstance(storage_version, int)
@@ -134,6 +136,8 @@ class StorageJSON:
self.framework = framework
# The core platform of this firmware. Like "esp32", "rp2040", "host" etc.
self.core_platform = core_platform
# The toolchain used for the build ("platformio" / "esp-idf")
self.toolchain = toolchain
def as_dict(self):
return {
@@ -153,6 +157,7 @@ class StorageJSON:
"no_mdns": self.no_mdns,
"framework": self.framework,
"core_platform": self.core_platform,
"toolchain": self.toolchain,
}
def to_json(self):
@@ -189,6 +194,7 @@ class StorageJSON:
),
framework=esph.target_framework,
core_platform=esph.target_platform,
toolchain=esph.toolchain.value if esph.toolchain is not None else None,
)
@staticmethod
@@ -236,6 +242,7 @@ class StorageJSON:
no_mdns = storage.get("no_mdns", False)
framework = storage.get("framework")
core_platform = storage.get("core_platform")
toolchain = storage.get("toolchain")
return StorageJSON(
storage_version,
name,
@@ -253,6 +260,7 @@ class StorageJSON:
no_mdns,
framework,
core_platform,
toolchain,
)
@staticmethod
@@ -273,10 +281,33 @@ class StorageJSON:
"""
CORE.name = self.name
CORE.build_path = self.build_path
# Restore toolchain so upload/logs picks the right firmware_bin path.
# An unknown value (corrupt sidecar, or written by a newer ESPHome)
# just leaves CORE.toolchain None — the fallback then picks PlatformIO.
if self.toolchain and CORE.toolchain is None:
try:
CORE.toolchain = Toolchain(self.toolchain)
except ValueError:
_LOGGER.debug(
"Ignoring unknown toolchain %r from %s",
self.toolchain,
storage_path(),
)
target_platform = self.core_platform or self.target_platform.lower()
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: self.core_platform or self.target_platform.lower(),
KEY_TARGET_PLATFORM: target_platform,
KEY_TARGET_FRAMEWORK: self.framework,
}
# The compile pipeline populates CORE.data[KEY_ESP32] when esp32's
# validator runs; on the cache fast path that validator is skipped,
# so populate the variant upload_using_esptool reads via
# esp32.get_esp32_variant(). target_platform on disk is the variant
# (e.g. "ESP32S3"); core_platform is the family (e.g. "esp32").
if target_platform == const.PLATFORM_ESP32:
from esphome.components.esp32.const import KEY_ESP32
from esphome.const import KEY_VARIANT
CORE.data[KEY_ESP32] = {KEY_VARIANT: self.target_platform}
def __eq__(self, o) -> bool:
return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict()
+15
View File
@@ -87,6 +87,21 @@ def replace_file_content(text, pattern, repl):
def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool:
"""Return True when the build tree must be wiped before reuse.
Predicate is True when *old* is missing (first build),
``src_version`` differs, ``build_path`` differs, or a previously
loaded integration was removed in *new*. Adding integrations or
changing unrelated fields (friendly name, esphome version, etc.)
does not trigger a clean.
Used by esphome-device-builder (esphome/device-builder) to gate
its remote-build artifact materialiser so a local remote local
cycle preserves PlatformIO's local object cache instead of wiping
it on every cycle. The signature, semantics, and ``None`` handling
for *old* are part of the public contract; keep them stable so the
offloader's wipe decision tracks core's.
"""
if old is None:
return True
+152 -2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from collections.abc import Callable, Generator
from contextlib import contextmanager, suppress
from dataclasses import dataclass, field
import functools
import inspect
from io import BytesIO, TextIOBase, TextIOWrapper
@@ -233,6 +234,130 @@ class IncludeFile:
return has_substitution_or_expression(str(self.file))
def force_load_include_files(
obj: Any,
*,
warn_on_unresolved: bool = True,
_seen: set[int] | None = None,
) -> None:
"""Recursively resolve any deferred ``IncludeFile`` instances in a YAML tree.
Nested ``!include`` returns a deferred ``IncludeFile`` that is only resolved
later (substitution / packages pass). Callers that need every referenced
file to actually load bundle discovery, on-device YAML recovery invoke
this while a :func:`track_yaml_loads` listener is active so the underlying
loader fires and records every reachable file.
``IncludeFile`` instances whose path contains unresolved substitution
variables cannot be loaded. By default a warning is logged for each one;
pass ``warn_on_unresolved=False`` (used by discovery paths that run on a
fresh re-parse where substitutions haven't been applied yet) to demote it
to a debug log.
"""
if _seen is None:
_seen = set()
if isinstance(obj, IncludeFile):
if id(obj) in _seen:
return
_seen.add(id(obj))
if obj.has_unresolved_expressions():
log = _LOGGER.warning if warn_on_unresolved else _LOGGER.debug
log(
"Cannot resolve !include %s (referenced from %s) with substitutions in path",
obj.file,
obj.parent_file,
)
return
try:
loaded = obj.load()
except EsphomeError as err:
_LOGGER.warning(
"Failed to load !include %s (referenced from %s): %s",
obj.file,
obj.parent_file,
err,
)
return
force_load_include_files(
loaded, warn_on_unresolved=warn_on_unresolved, _seen=_seen
)
elif isinstance(obj, dict):
if id(obj) in _seen:
return
_seen.add(id(obj))
for value in obj.values():
force_load_include_files(
value, warn_on_unresolved=warn_on_unresolved, _seen=_seen
)
elif isinstance(obj, (list, tuple)):
if id(obj) in _seen:
return
_seen.add(id(obj))
for item in obj:
force_load_include_files(
item, warn_on_unresolved=warn_on_unresolved, _seen=_seen
)
@dataclass(slots=True)
class DiscoveredYamlFiles:
"""Result of :func:`discover_user_yaml_files`.
``files`` contains every resolved path the YAML loader touched while we
were re-parsing the user's config; ``secrets`` is the subset whose
*un-resolved* filename matched :data:`esphome.const.SECRETS_FILES` (so
a ``secrets.yaml`` symlinked to a differently-named target is still
flagged as secrets).
"""
files: list[Path] = field(default_factory=list)
secrets: set[Path] = field(default_factory=set)
def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles:
"""Fresh-re-parse ``config_path`` and report every file the YAML loader
pulled in, plus which of them came in under a secrets filename.
Does NOT run schema validation, substitutions, or package resolution so
component-internal YAML loaded by validators (LVGL helpers, dashboard
imports, etc.) is *not* captured. Deferred ``!include`` references whose
paths don't depend on substitutions are force-loaded here so they're
captured too.
Must run on a fresh parse because :meth:`IncludeFile.load` caches its
result; on an already-resolved tree :meth:`load` returns without invoking
the loader and the listener would not fire for the referenced files.
"""
from esphome.const import SECRETS_FILES
secrets: set[Path] = set()
def _capture_secret(fname: Path) -> None:
if Path(fname).name in SECRETS_FILES:
secrets.add(Path(fname).resolve())
with track_yaml_loads() as loaded:
_load_listeners.append(_capture_secret)
try:
try:
data = load_yaml(config_path)
except EsphomeError:
return DiscoveredYamlFiles(list(loaded), secrets)
force_load_include_files(data, warn_on_unresolved=False)
finally:
_load_listeners.remove(_capture_secret)
# Deduplicate while preserving first-seen order.
seen: set[Path] = set()
unique: list[Path] = []
for path in loaded:
if path not in seen:
seen.add(path)
unique.append(path)
return DiscoveredYamlFiles(unique, secrets)
def _add_data_ref(fn):
@functools.wraps(fn)
def wrapped(loader, node):
@@ -643,10 +768,35 @@ def _load_yaml_internal_with_type(
content: TextIOWrapper,
yaml_loader: Callable[[Path], dict[str, Any]],
) -> Any:
"""Load a YAML file."""
"""Load a YAML file.
Supports an optional leading YAML frontmatter document: when the file
contains two YAML documents separated by ``---``, the first document is
treated as metadata and stored in :attr:`CORE.frontmatter` keyed by the
resolved file path, while the second document is returned as the actual
configuration. Frontmatter is ignored by config validation and code
generation.
"""
loader = loader_type(content, fname, yaml_loader)
try:
return loader.get_single_data() or OrderedDict()
documents: list[Any] = []
while loader.check_data():
documents.append(loader.get_data())
if len(documents) > 2:
raise EsphomeError(
f"YAML file '{fname}' contains {len(documents)} documents but "
f"at most two are supported (an optional frontmatter document "
f"followed by the configuration)."
)
if len(documents) == 2:
frontmatter = documents[0]
config = documents[1]
if frontmatter is not None:
CORE.frontmatter[Path(fname).resolve()] = frontmatter
return config if config is not None else OrderedDict()
if len(documents) == 1:
return documents[0] or OrderedDict()
return OrderedDict()
except yaml.YAMLError as exc:
raise EsphomeError(exc) from exc
finally:
+4 -4
View File
@@ -12,19 +12,19 @@ platformio==6.1.19
esptool==5.2.0
click==8.3.3
esphome-dashboard==20260425.0
aioesphomeapi==45.0.0
zeroconf==0.148.0
aioesphomeapi==45.0.4
zeroconf==0.149.16
puremagic==1.30
ruamel.yaml==0.19.1 # dashboard_import
ruamel.yaml.clib==0.2.15 # dashboard_import
esphome-glyphsets==0.2.0
pillow==12.2.0
resvg-py==0.3.1
resvg-py==0.3.2
freetype-py==2.5.1
jinja2==3.1.6
bleak==2.1.1
smpclient==6.0.0
requests==2.34.1
requests==2.34.2
# esp-idf >= 5.0 requires this
pyparsing >= 3.3.2
+2 -2
View File
@@ -1,6 +1,6 @@
pylint==4.0.5
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
ruff==0.15.13 # also change in .pre-commit-config.yaml when updating
ruff==0.15.14 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
pre-commit
@@ -16,7 +16,7 @@ hypothesis==6.92.1
# CodSpeed benchmarks under tests/benchmarks/python/
# (skipped via pytest.importorskip when missing -- only required for the
# benchmarks job in .github/workflows/ci.yml)
pytest-codspeed==5.0.1
pytest-codspeed==5.0.3
# Used by the import-time regression check (.github/workflows/ci.yml → import-time job)
importtime-waterfall==1.0.0
+80
View File
@@ -5,6 +5,7 @@ This script is a centralized way to determine which CI jobs need to run based on
what files have changed. It outputs JSON with the following structure:
{
"core_ci": true/false,
"integration_tests": true/false,
"integration_test_buckets": [{"name": "1/3", "tests": ["tests/integration/test_foo.py", ...]}, ...],
"clang_tidy": true/false,
@@ -22,6 +23,11 @@ what files have changed. It outputs JSON with the following structure:
}
The CI workflow uses this information to:
- Gate the unconditional jobs (ci-custom, pytest, pre-commit-ci-lite) via core_ci;
false when a pull_request only touches CI-irrelevant meta paths (other workflow
files, .github/actions/build-image/*, .yamllint, .github/dependabot.yml, docker/**)
so workflow-only PRs satisfy the required CI Status check without running the
unconditional jobs. Always true on non-pull_request events and under --force-all.
- Skip or run integration tests
- Skip or run clang-tidy (and whether to do a full scan)
- Skip or run clang-format
@@ -712,6 +718,69 @@ def should_run_benchmarks(branch: str | None = None) -> bool:
return any(get_component_from_path(f) in benchmarked_components for f in files)
# Files / path patterns whose changes alone don't warrant running the
# unconditional CI jobs (`ci-custom`, `pytest`, `pre-commit-ci-lite`).
# Single source of truth for what we treat as "CI-irrelevant" on
# pull_request events; ci.yml used to encode this in its own
# `pull_request.paths` filter, but that hid the required `CI Status`
# check on PRs that only touched these files (dependabot Action bumps,
# dependabot.yml edits, docker/ changes, etc.) and forced admin
# force-merges.
#
# ci.yml itself is deliberately *not* ignored — editing the CI workflow
# must still run CI. Workflows that have their own dedicated triggers
# (codeql.yml, ci-docker.yml, ...) are matched via the
# `.github/workflows/*.yml` prefix below and exclude ci.yml explicitly.
CI_IRRELEVANT_EXACT_FILES = frozenset(
{
".yamllint",
".github/dependabot.yml",
}
)
def _is_ci_irrelevant_path(path: str) -> bool:
"""Whether a single changed path is irrelevant to the unconditional CI jobs."""
if path in CI_IRRELEVANT_EXACT_FILES:
return True
# docker/** — all descendants
if path.startswith("docker/"):
return True
# .github/workflows/*.yml — top-level workflow files other than ci.yml
# (ci.yml itself must still trigger full CI when edited).
if path.startswith(".github/workflows/") and path.endswith(".yml"):
if path == ".github/workflows/ci.yml":
return False
if "/" not in path[len(".github/workflows/") :]:
return True
# .github/actions/build-image/* — direct children only, matches the
# single-star glob the workflow used to encode.
if path.startswith(".github/actions/build-image/"):
rest = path[len(".github/actions/build-image/") :]
if rest and "/" not in rest:
return True
return False
def should_run_core_ci(branch: str | None = None) -> bool:
"""Determine if the unconditional CI jobs (ci-custom/pytest/pre-commit-ci-lite) should run.
Returns False only when every changed file is in the CI-irrelevant set
above (see ``_is_ci_irrelevant_path``). Empty diffs return True so we
never accidentally skip CI when the diff probe fails.
Args:
branch: Branch to compare against. If None, uses default.
Returns:
True if the unconditional CI jobs should run, False otherwise.
"""
files = changed_files(branch)
if not files:
return True
return any(not _is_ci_irrelevant_path(f) for f in files)
def _any_changed_file_endswith(branch: str | None, extensions: tuple[str, ...]) -> bool:
"""Check if a changed file ends with any of the specified extensions."""
return any(file.endswith(extensions) for file in changed_files(branch))
@@ -1075,6 +1144,16 @@ def main() -> None:
args = parser.parse_args()
# Determine what should run
# core_ci gates the unconditional jobs in ci.yml (ci-custom, pytest,
# pre-commit-ci-lite). Non-pull_request events (push to dev/beta/release
# and merge_group) always run them so behavior like venv-cache saves on
# push to dev is preserved.
event_name = os.environ.get("GITHUB_EVENT_NAME", "")
run_core_ci = (
True
if args.force_all or event_name != "pull_request"
else should_run_core_ci(args.branch)
)
if args.force_all:
integration_run_all, integration_test_files = True, []
run_clang_tidy = True
@@ -1255,6 +1334,7 @@ def main() -> None:
component_test_batches = []
output: dict[str, Any] = {
"core_ci": run_core_ci,
"integration_tests": run_integration,
"integration_test_buckets": integration_test_buckets,
"clang_tidy": run_clang_tidy,
@@ -9,13 +9,17 @@ import pytest
from esphome import config_validation as cv
from esphome.components.light import (
EffectCycleRef,
EffectRef,
_final_validate,
_get_data,
available_effects_str,
find_effect_index,
)
from esphome.components.light.automation import _record_effect_ref
from esphome.components.light.automation import (
_record_effect_cycle_ref,
_record_effect_ref,
)
from esphome.config import Config, path_context
from esphome.const import CONF_EFFECT, CONF_EFFECTS, CONF_ID, CONF_NAME
from esphome.core import ID, Lambda
@@ -215,6 +219,111 @@ def test_final_validate_drains_refs() -> None:
fv.full_config.reset(token)
# --- _final_validate: EffectCycleRef ---
def _setup_cycle_final_validate(
cycle_refs: list[EffectCycleRef],
light_configs: list[ConfigType],
declare_ids: list[tuple[ID, list[str | int]]],
) -> Token:
"""Set up CORE.data and fv.full_config for EffectCycleRef final_validate tests."""
data = _get_data()
data.effect_cycle_refs = cycle_refs
full_conf = Config()
full_conf["light"] = light_configs
for id_, path in declare_ids:
full_conf.declare_ids.append((id_, path))
return fv.full_config.set(full_conf)
def test_final_validate_cycle_accepts_light_with_effects() -> None:
"""Cycle ref against a light with effects should not raise."""
light_id = ID("led1", is_declaration=True)
token = _setup_cycle_final_validate(
cycle_refs=[
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
],
light_configs=[{CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse")}],
declare_ids=[(light_id, ["light", 0, CONF_ID])],
)
try:
_final_validate({})
finally:
fv.full_config.reset(token)
def test_final_validate_cycle_rejects_light_without_effects_key() -> None:
"""Cycle ref against a light with no CONF_EFFECTS key should raise."""
light_id = ID("led1", is_declaration=True)
token = _setup_cycle_final_validate(
cycle_refs=[
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
],
light_configs=[{CONF_ID: light_id}],
declare_ids=[(light_id, ["light", 0, CONF_ID])],
)
try:
with pytest.raises(cv.FinalExternalInvalid, match="no effects configured"):
_final_validate({})
finally:
fv.full_config.reset(token)
def test_final_validate_cycle_rejects_light_with_empty_effects() -> None:
"""Cycle ref against a light with empty effects list should raise."""
light_id = ID("led1", is_declaration=True)
token = _setup_cycle_final_validate(
cycle_refs=[
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
],
light_configs=[{CONF_ID: light_id, CONF_EFFECTS: []}],
declare_ids=[(light_id, ["light", 0, CONF_ID])],
)
try:
with pytest.raises(cv.FinalExternalInvalid, match="no effects configured"):
_final_validate({})
finally:
fv.full_config.reset(token)
def test_final_validate_cycle_unknown_light_id_skipped() -> None:
"""Cycle refs to unknown light IDs should be silently skipped."""
data = _get_data()
data.effect_cycle_refs = [
EffectCycleRef(
light_id=ID("nonexistent", is_declaration=True),
component_path=["esphome"],
)
]
full_conf = Config()
token = fv.full_config.set(full_conf)
try:
_final_validate({})
finally:
fv.full_config.reset(token)
def test_final_validate_drains_cycle_refs() -> None:
"""Cycle refs should be drained after validation to avoid redundant runs."""
light_id = ID("led1", is_declaration=True)
token = _setup_cycle_final_validate(
cycle_refs=[
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
],
light_configs=[{CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse")}],
declare_ids=[(light_id, ["light", 0, CONF_ID])],
)
try:
_final_validate({})
assert _get_data().effect_cycle_refs == []
finally:
fv.full_config.reset(token)
# --- _record_effect_ref ---
@@ -278,3 +387,19 @@ def test_record_effect_ref_skips_no_effect_key() -> None:
config: ConfigType = {CONF_ID: ID("led1", is_declaration=True)}
_record_effect_ref(config)
assert _get_data().effect_refs == []
# --- _record_effect_cycle_ref ---
@pytest.mark.usefixtures("_path_ctx")
def test_record_effect_cycle_ref() -> None:
"""Cycle-action config should be recorded with light_id and path."""
light_id = ID("led1", is_declaration=True)
config: ConfigType = {CONF_ID: light_id}
result = _record_effect_cycle_ref(config)
assert result is config
data = _get_data()
assert len(data.effect_cycle_refs) == 1
assert data.effect_cycle_refs[0].light_id is light_id
assert data.effect_cycle_refs[0].component_path == ["esphome"]
@@ -0,0 +1,87 @@
"""Tests for container_schema() memoization and lazy build."""
from __future__ import annotations
from collections.abc import Generator
from unittest.mock import patch
import pytest
from esphome import config_validation as cv
import esphome.components.lvgl # noqa: F401
from esphome.components.lvgl import schemas as lvgl_schemas
from esphome.components.lvgl.schemas import WIDGET_TYPES, container_schema
@pytest.fixture(autouse=True)
def _clear_container_schema_cache() -> Generator[None]:
cache = getattr(lvgl_schemas, "_CONTAINER_SCHEMA_CACHE", None)
if cache is not None:
cache.clear()
yield
if cache is not None:
cache.clear()
def _widget_type(name: str = "obj"):
wt = WIDGET_TYPES.get(name)
assert wt is not None, f"widget type {name!r} not registered"
return wt
def test_same_args_return_same_validator() -> None:
wt = _widget_type("obj")
assert container_schema(wt) is container_schema(wt)
def test_extras_none_vs_truthy_get_different_validators() -> None:
wt = _widget_type("obj")
no_extras = container_schema(wt)
extras = {cv.Optional("custom_extra"): cv.string}
assert no_extras is not container_schema(wt, extras)
def test_different_widget_types_get_different_validators() -> None:
assert container_schema(_widget_type("obj")) is not container_schema(
_widget_type("label")
)
def test_schema_build_is_deferred_until_first_validation() -> None:
wt = _widget_type("obj")
with patch.object(
lvgl_schemas, "obj_schema", wraps=lvgl_schemas.obj_schema
) as obj_schema_mock:
validator = container_schema(wt)
assert obj_schema_mock.call_count == 0
validator({})
assert obj_schema_mock.call_count == 1
validator({})
assert obj_schema_mock.call_count == 1
def test_cached_validator_produces_equivalent_output() -> None:
wt = _widget_type("obj")
cached = container_schema(wt)
cached_result = cached({})
lvgl_schemas._CONTAINER_SCHEMA_CACHE.clear()
reference = container_schema(wt)
assert cached is not reference
assert cached_result == reference({})
def test_id_recycling_is_caught_by_identity_guard() -> None:
wt = _widget_type("obj")
real_extras = {cv.Optional("a"): cv.int_}
validator_a = container_schema(wt, real_extras)
cache_key = (id(wt), id(real_extras))
cached_entry = lvgl_schemas._CONTAINER_SCHEMA_CACHE[cache_key]
sentinel = {cv.Optional("a"): cv.int_}
lvgl_schemas._CONTAINER_SCHEMA_CACHE[cache_key] = (
cached_entry[0],
sentinel,
cached_entry[2],
)
assert container_schema(wt, real_extras) is not validator_a
@@ -0,0 +1,53 @@
"""Tests for lvgl.<widget>.update lazy schema build."""
from __future__ import annotations
from unittest.mock import patch
from esphome.automation import ACTION_REGISTRY
import esphome.components.lvgl # noqa: F401
from esphome.components.lvgl.schemas import WIDGET_TYPES
from esphome.components.lvgl.widgets import _update_action_schema
from esphome.config_validation import Schema
def _widget_type(name: str = "obj"):
wt = WIDGET_TYPES.get(name)
assert wt is not None, f"widget type {name!r} not registered"
return wt
def test_registry_entry_uses_lazy_validator() -> None:
entry = ACTION_REGISTRY["lvgl.label.update"]
assert callable(entry.raw_schema)
assert not isinstance(entry.raw_schema, Schema)
def test_lazy_validator_defers_build_until_first_call() -> None:
wt = _widget_type("label")
with patch(
"esphome.components.lvgl.widgets._build_update_schema",
wraps=lambda w: Schema({}),
) as build_mock:
validator = _update_action_schema(wt)
assert build_mock.call_count == 0
validator({})
assert build_mock.call_count == 1
validator({})
assert build_mock.call_count == 1
def test_eager_build_when_schema_extraction_enabled() -> None:
wt = _widget_type("label")
with patch("esphome.components.lvgl.widgets.EnableSchemaExtraction", True):
result = _update_action_schema(wt)
assert isinstance(result, Schema)
def test_lazy_and_eager_produce_equivalent_validation() -> None:
wt = _widget_type("label")
with patch("esphome.components.lvgl.widgets.EnableSchemaExtraction", True):
eager = _update_action_schema(wt)
lazy = _update_action_schema(wt)
sample = {"id": "label_id"}
assert lazy(sample) == eager(sample)
@@ -1,6 +1,8 @@
<<: !include common.yaml
esp32_ble_tracker:
esp32_ble:
max_connections: 9
bluetooth_proxy:
@@ -1,13 +1,3 @@
substitutions:
i2s_bclk_pin: GPIO27
i2s_lrclk_pin: GPIO26
i2s_mclk_pin: GPIO25
i2s_dout_pin: GPIO12
spdif_data_pin: GPIO4
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
i2s_audio:
- id: i2s_output
@@ -20,6 +10,5 @@ speaker:
use_apll: true
timeout: 2s
sample_rate: 48000
bits_per_sample: 16bit
channel: stereo
i2s_mode: primary

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