Skip to content
Get started

ESPHome 2026.9.0: Faster builds and encrypted updates

Every month ESPHome ships a release like this, so you can catch up on what’s new, what’s fixed, and anything worth knowing about before you update.

Here’s the plain-language version for anyone short on time or newer to ESPHome – the full technical writeup continues below. Most setups won’t need any changes, but a few will.

  • Modbus Controller users: If your config uses modbus_controller with custom_command, register_count, force_new_range, or skip_updates, those options have been renamed or replaced and your config needs updating.
  • ESP32 devices with a custom MAC: If you’ve burned a custom MAC into an ESP32 and use Ethernet, Bluetooth, or Thread/Zigbee, those interfaces will get new addresses after updating – so DHCP reservations, Bluetooth pairings, and network filters keyed to the old MAC need updating too.
  • Certain UART sensors: A handful of components (including mhz19, daly_bms, and teleinfo) now reject unsupported UART settings at config time – if any of yours were on the wrong baud rate or parity, they’ll flag a clear error you can fix on the UART bus.

New features: Updates are quicker – ESPHome now downloads and sets up its build tools in parallel – and can be encrypted using the same key that already secures the link to Home Assistant.

Also in this release: Six new components, around twenty additions to existing ones, and a broad sweep of fixes covering audio, Bluetooth, WiFi provisioning, and memory safety.

If you’re running a typical config, aside from the items above there’s nothing else you need to check before updating.

Keep reading below for the full release overview and technical detail – especially if you write custom lambdas, use external components, or run a non-standard setup.

ESPHome 2026.9.0 focuses on foundational build system and platform work: the scaffolding for an ESP8266 native toolchain lands across eight infrastructure PRs, PlatformIO installs are parallelized end to end (the package phase drops from 36% of build time to 25% on CI benchmarks), and a sweep across 60 files pushes 211 conditional log string literals off ESP8266 RAM into flash.

Beyond the platform work, this release adds Noise (ChaCha20-Poly1305) encryption for OTA updates, cuts what encryption costs in flash by close to half on every platform, extends Improv serial beyond WiFi so Ethernet-only boards can report their status and web server URL to setup tools, teaches WiFi provisioning to shut down the AP and captive portal when the window closes, and rewrites IR transmission on the BK7238 and RTL8720C to run from a hardware timer interrupt instead of a busy-wait. The multi-release modbus overhaul continues with a heap-free write path, a consolidated range-join option, and continuous polling. A hardening pass stops the device from rebooting when the heap runs out during WiFi scans, stalled API writes or OTA verification. Six new components (ds1603l, d01, sfa40, mk2pvrouter, snapshot, noise) and around twenty feature additions to existing components round out the release. The bundled Device Builder masks credentials in the YAML diff view, boots ESP32-C6 boards after a browser flash, and lets paired build servers recover from an address change on their own.

  • If you have a device with a custom MAC burned into the ESP32 eFuses and use Ethernet, Bluetooth, or 802.15.4, expect a new MAC on those interfaces after upgrading; update DHCP reservations, ACLs and Bluetooth pairings
  • If you use modbus_controller with custom_command:, rename it to custom_pdu: and drop the leading device address byte from the payload
  • If you use modbus_controller with register_count: or force_new_range:, migrate to reuse_previous_range: and re-verify any FP32 packed as register_count: 1 + response_size: 4
  • If you use modbus_controller skip_updates: on a sensor, split the slow registers onto a second modbus_controller with the same address: and a slower update_interval:
  • If you use modbus_controller switch or output with a non-zero offset: on holding registers, re-verify the target register (writes now land at address + offset/2 instead of address + offset)
  • If you have a modbus_controller switch write_lambda that inverts the returned bool to flip the displayed state, publish the state explicitly or invert in the read lambda instead
  • If you use homeassistant.event variables: with lambda source, tag the value with !lambda to silence the new deprecation warning
  • If you use esp32_hosted, remove any pinned ESP-IDF version below 5.3 or bump the pin to 5.3 or newer
  • If you use one of the affected UART components (cm1106, daly_bms, hrxl_maxsonar_wr, hydreon_rgxx, mhz19, pylontech, teleinfo, vbus, wl_134, cse7761) with non-default UART settings, expect a configuration error and adjust the UART bus to the component’s required baud rate and parity
  • If you use API user-defined actions with long variable names or descriptions on ESP8266, keep each action’s total name and metadata text under 384 bytes or split into smaller actions
  • If you have lambdas calling modbus_controller ModbusCommandItem factories or queue_command(), migrate to the entity write helpers or modbus_client actions before the 2027.3.0 removal

Led by @bdraco, this release lays the entire scaffolding for building ESP8266 firmware without PlatformIO. ESPHome now has a shared registry and download layer (#18570), a linker-script surgery module and per-board build metadata ported from platform-espressif8266 4.2.1 (#18555), a toolchain seam that validates a --toolchain choice on every platform (#18556), a resumable, sha256-verified installer for the Arduino ESP8266 core 3.1.2 and xtensa gcc 10.3 (#18557), and a native library backend that resolves cg.add_library() entries against the framework tree (#18558). Supporting pieces came with it: the idedata and size-summary helpers and the library converter moved into shared build helpers (#18661, #18685), and a rebuild now logs which source change triggered it (#18552).

This infrastructure ships dormant, with no user-facing option calling it yet; it is the foundation, shipped ahead so the native toolchain itself can land in 2026.10.0. A significant number of ESPHome installs still run on ESP8266, and a native build path gives ESPHome direct control over downloads, caching and incremental builds, the same route the ESP32 took with native ESP-IDF in 2026.7.0.

The install phase of a PlatformIO build was serial from top to bottom, which showed up most sharply on Raspberry Pi class hardware and inside the Home Assistant App. This release parallelizes it end to end:

  • Registry library and ESP-IDF tool archives download a few at a time instead of one by one, under a single combined progress bar, cutting cold-start install time (#18662); when two builds want the same archive at once, the waiting one shows the other’s download progress instead of sitting at 0% (#18983)
  • A prefetch subprocess resolves and downloads PlatformIO packages in parallel into PlatformIO’s own cache (#18769), and the install pass extracts them with one worker per usable core (#18775); CI benchmarks on the esp8266-arduino leg show the package phase dropping from 17.1s to 10.3s, 36% of the build down to 25%
  • The docker image’s own PlatformIO library preinstall got the same treatment, cutting a ~100s step to ~46s on every arch leg (#18777), and the CI compile-test image compresses with multithreaded zstd instead of gzip, taking another 40s off the pipeline (#18812)
  • ESP-IDF native builds cache the discovered component list inside the extracted framework directory, skipping a full CMake configure pass on every rebuild after an sdkconfig change, ~2.2s on desktop and several times more on SBCs (#18752)
  • PlatformIO checks whether each registry library is a private package before looking at its own cache, and on any machine without a PlatformIO login that check sleeps half a second per library; ESPHome never uses private packages, so it now answers the check itself, cutting a no-op rebuild of a six-library config from 2.9s to 0.4s (#18823)

A concerted sweep across the codebase pushes conditional log string literals off ESP8266 RAM and into flash, where the rest of the log format string already lives. @bdraco scanned every ESP_LOG* call site and converted 211 bare literals across 113 lines in 60 files, wrapping each in LOG_STR_LITERAL so ESP8266 keeps them in flash while other platforms see a no-op (#18907, #18906). A d1_mini with dht, adc, resistance, wifi and wireguard drops 80 bytes of RAM from this alone. In the same vein, str_contains_ignore_case now keeps its needle in flash on ESP8266 via a PSTR wrapper and an _P-family implementation (#18574), and the 65-byte base64 alphabet table is gone entirely, replaced by arithmetic range mapping that saves 64 bytes of RAM and doubles decode speed on host (#18454). Every ESP8266 device with API encryption benefits from the base64 change automatically.

Two coordinated efforts trim per-instance overhead and unused sources across the tree. @bharvey88 and @bdraco drove a series of over 30 PRs moving trivial one-line accessors from .cpp files into headers so the compiler can inline them, covering WiFi, Ethernet, logger, light, select, sensor, text sensor, climate, cover, fan, switch, valve, text, datetime, thermostat, sprinkler, MQTT, WireGuard, display, safe_mode and more (representative measurements: 32 bytes saved on ESP8266 for wifi scan result accessors, 48 bytes for the remaining wifi accessors, similar per-component savings across the tree). Separately, @bdraco extended the FILTER_SOURCE_FILES pattern so binary_sensor click/multi-click sources, all three sensor filter files, and esp32/gpio.cpp are only copied and compiled when the config actually uses them (#18602), and picked up other unused sources across light/JSON schemas, i2s_audio SPDIF, MQTT entity types, uart debug, esp32_ble advertising, time posix TZ and uptime timestamps (#18675, #18676, #18677, #18678, #18679, #18680, #18746, #18750). A few one-off wins round it out: APINoiseFrameHelper is 104 bytes smaller per open encrypted connection (#18420), the camera image reader is created lazily instead of per connection (#18421), and API message-type storage widened to uint16_t without adding per-connection RAM (#18526).

On ESP-IDF, C++ exceptions are disabled, so an allocation that fails when the heap is exhausted aborts and reboots the device instead of returning null. A sweep across the tree removes that abort from the paths a busy device actually hits, where the device can likely recover instead of rebooting. A WiFi scan whose results cannot be allocated is now dropped with a warning and retried, and with a single configured network the ESP32 driver filters the scan by SSID and keeps only the strongest 12 records, so the common case also uses the least RAM (#19254, #19253). A stalled API write on a lossy link used to allocate and free about 1 KB per write; each connection now keeps one reusable overflow buffer with a capped backlog, ending the heap fragmentation that could take a low-heap device down during the 90 second OTA confirmation window, and encoding got about 8% faster along the way (#19093). The OTA noise session, auth buffer and signature block, the Nextion command queue and the W5500 SPI context all moved off aborting allocation paths as well, failing the operation instead of the device (#19245, #19249, #19251, #19246, #19248).

This is a smaller cycle for the bundled ESPHome Device Builder, and most of it already reached you through the 2026.8.x patch releases; this section covers everything since the 2026.8.0 release notes.

In the dashboard and editor:

  • The YAML diff view used to render credentials in plain text the moment you flipped the diff toggle; it now masks the same sensitive values the editor does, with a reveal toggle (frontend#1664)
  • A WiFi password containing # made every WiFi write append a second wifi_password: line to secrets.yaml; the writer now handles quoted values, heals an already duplicated file, and reports a broken secrets.yaml as your file to fix rather than an internal error (#2624, frontend#1673)
  • Dot-prefixed top-level keys, the documented way to park YAML anchors, no longer render as bogus cards or get deleted when you edit the section above them through the structured form (frontend#1689, #2652)
  • The add-component form stops demanding pins and dimensions that the selected display model already predefines (#2648), and @Daniel085 brought the device comment back to the tile card (frontend#1671)
  • The catalog picker renders only while its dialog is open (frontend#1698, frontend#1710, frontend#1715), and platform-scoped component triggers resolve by the instance’s platform (#2671, frontend#1720)

Flashing and provisioning:

  • On ESPHome Web, flashing an ESP32-C6, H2 or P4 left the chip in the ROM bootloader instead of booting the new firmware, so Improv never answered; the reset after flashing now boots the firmware (frontend#1679)
  • Prepare for first use gained an Erase the device first option so a board that ran other firmware does not come up with stale WiFi credentials (frontend#1680), and the WiFi credentials dialog prefills the stored values (frontend#1739)
  • Dismissing the install dialog keeps the compile running (frontend#1732)

Keys and build servers:

  • Rewriting a static API key replaces a duplicated ota: encryption: key: with the bare block that inherits it (#2665), devices that already offer OTA encryption are nudged toward ota: encryption: (frontend#1714), the encryption key shows in the device menu for devices keyed through ota: encryption: (#2679, frontend#1722), and Home Assistant adoption and its API key handoff got a round of hardening (#2693, #2716, #2720)
  • A paired build server that moves to a new IP on another subnet used to be reachable again only by editing the endpoint by hand; after five minutes of silence it now dials back to announce its new address, so the pairing heals itself (#2650)

Under the hood, out-of-band edit detection uses a versioned whole-file fingerprint, so a future algorithm change can never again trigger a fleet-wide regeneration on upgrade (#2617), the Docker container reaps orphaned processes when running as PID 1 (#2636), version history ignores the fake executable bit Samba puts on files edited from Windows (#2646), and the JSON config endpoint serialises non-string keys (#2660).

@bdraco adds Noise (ChaCha20-Poly1305) encryption to the esphome OTA platform (#18489), using the same protocol the native API already uses. Until now the firmware image travelled over the network in plaintext, so a passive listener on the LAN could capture the WiFi credentials and API encryption key embedded in the image; the SHA256 password only authenticated the client, it did not make the transfer confidential. A device with an api encryption key now offers OTA encryption with that key and the CLI uses it whenever the device offers, so once a device runs this release, enabling encryption is an ordinary update; the update that brings older firmware to this release still travels in plaintext (#18979). An encryption: block on the OTA platform is what makes the device require it: both the device and the CLI then fail closed so an attacker cannot strip encryption and force a plaintext upload. A bare encryption: inherits the api encryption key, and a password and encryption are mutually exclusive because the key already authenticates the uploader. This work sits on top of a new shared noise component (#18490) that lifts the Noise plumbing out of the api component so both api and ota share it, from the noise-c dependency and the hardware RNG that seeds the handshake to key validation and the responder role; there is no user configuration for noise itself.

The noise-c and libsodium forks behind API and OTA encryption had only been lightly adapted for embedded targets. @bdraco went through them in full, cut them by nearly half in size and made them faster in most cases (#18989, #19030, #19062, #18692). The ESP8266 and RP2040 now run X25519 on 13-bit limb arithmetic that needs no 64-bit products, the Ed25519 base point table shrank from 30 KB to 12 KB on every platform, the SHA256 transform is compact on every platform and, on the ESP32 and RP2040, two to three times faster when cold, and on the ESP8266 the ChaCha20-Poly1305 transport is a quarter cheaper per packet with its constants out of DRAM. What turning on API encryption costs in 2026.8.0 and in this release, static RAM being the image’s own data, not per-connection heap:

BoardFlash 2026.8.0Flash 2026.9.0Static RAM 2026.8.0Static RAM 2026.9.0
ESP8266 d1 mini80.9 KB41.9 KB1376 B48 B
ESP32 AtomU (IDF)65.9 KB37.7 KB56 B32 B
Pico W (RP2040)65.5 KB34.7 KB48 B24 B

A Noise handshake drops from about 580ms to about 300ms on the ESP8266, from 63ms to 46ms on the ESP32 and from about 260ms to about 200ms on the Pico W; on the ESP8266 and the Pico W an encrypted API connect is almost entirely the handshake, so connects speed up by about the same amount.

@kbx81 makes improv_serial aware of the whole network stack instead of only WiFi, so it now works on Ethernet-only boards and other WiFi-less devices (#17598). The component depends on network instead of wifi, gates every WiFi call behind #ifdef USE_WIFI, and implements the new Improv Get Network State RPC (0x07) that reports which interfaces are up and the web server URL for each one. The Improv state machine still uses WiFi provisioning where WiFi exists; on WiFi-less builds it reports STOPPED so clients do not offer a WiFi form that cannot succeed. @bdraco additionally adds an optional uart_id: so Improv can run on a dedicated UART bus separate from the logger’s serial port (#18794), which also makes the protocol end-to-end testable via the new host integration test.

Provisioning shuts down when the window closes

Section titled “Provisioning shuts down when the window closes”

Also from @kbx81: the WiFi access point and captive portal are now torn down when the provisioning window closes, matching the treatment BLE Improv already had (#17466). An unprovisioned device that times out is now fully unprovisionable until it is power-cycled - BLE Improv stops, the captive portal stops, the access point disappears, and new API clients are turned away. Because a device whose access point is its only network connection becomes unreachable when the window closes, provisioning: now warns at config time when WiFi is configured with an access point and no station credentials.

@kbx81 rewrites remote_transmitter on the Beken BK7238 and Realtek RTL8720C to pace the IR envelope with a hardware timer interrupt chain instead of a priority-boosted busy-wait (#18648, #18660). Interrupts stay enabled throughout, so WiFi and lwIP tasks run normally during long transmissions and remote_receiver decodes the device’s own frames off the air during a send. non_blocking: is now supported on those two chips (previously ESP32-RMT only): the send returns immediately after arming the chain and on_complete fires from loop(). Bench measurements on an RM4 Pro (RTL8720CF) show 25.01-25.09ms repeat gaps for a configured 25ms (tighter than the busy-wait’s 25.02-25.05ms) and 27 captured transmissions all bit-perfect; an FK UFO-R4 (BK7238) measured 16-30us duty-apply latency and 39/39 bit-perfect NEC envelopes. The BK7231N keeps the bit-bang implementation it had before, since its SDK snapshot lacks the PWM driver interface the interrupt chain needs (#18958).

The long-running modbus refactor from @exciton continues with a large batch of improvements:

  • Persistent writer devices (#18082): switch, number, output and select writers become their own hub devices with a heap-free write path that allocates nothing after setup(), and write_lambda gets full access to the underlying device so a coil switch can drive a holding-register write
  • Unified range polling (#18071): ranges move onto the same PollingDevice architecture, saving 1,756 bytes of flash on the ESP8266 test config with no RAM change
  • One range option (#18085): register_count and force_new_range are consolidated into a single tri-state reuse_previous_range option that says how a sensor relates to the range built just before it
  • custom_pdu (#18652): custom_command is renamed to custom_pdu, with auto-migration when the leading byte matches the controller address
  • Continuous reads (#18542, #18080): a new continuous: option on the client actions and the controller makes reads stream back-to-back to fill idle bus time
  • Write-path fixes, shipping as breaking changes: the switch and output write offset is now byte-accurate where it was previously doubled (#18787), and a switch write_lambda return value only changes the value written to the device, so an active-low relay finally displays the requested state instead of the inverted one (#18788)

Users of custom_command, register_count, force_new_range or skip_updates should review their configs.

Six new components join the tree:

  • ds1603l by @JakeLC15 - a UART ultrasonic liquid-level sensor with configurable min/max level and volume mapping (#13133)
  • d01 by @ch604 - a PM2.5 particulate sensor that broadcasts its own packets every 1.4 seconds (#17788)
  • sfa40 by @NoQuarrel - the Sensirion SFA40 formaldehyde sensor with temperature and humidity readings (#17815)
  • mk2pvrouter by @FredM67 - Mk2PVRouter telemetry over UART, modelled on the TeleInfo component (#8487)
  • snapshot by @clydebarrow - a display platform that draws into memory and writes BMP files, works anywhere the host platform runs, and pairs with a new headless: option on the SDL display for documentation captures and CI golden-image tests (#17917)
  • noise by @bdraco - a shared internal Noise primitives component that api and the new encrypted OTA both consume; no user configuration (#18490)
  • zigbee time platform by @luar123 adds a Zigbee-sourced time provider on ESP32 (#18656)
  • Package-relative file paths by @bdraco: a file or directory option whose config-relative path does not exist now resolves against the YAML file that declares it, so a package or !included file can ship its own assets next to its YAML (#19259)
  • tuya water_heater by @iago-veiga exposes a Tuya-MCU electric water heater as a single water_heater entity, collapsing the previous template-plus-lambda pattern into one block (#17323)
  • template climate by @rsre builds a full climate entity out of ESPHome primitives, with per-field set actions, an on_control trigger, an optimistic mode that applies commands locally, and a climate.template.publish action for reporting the device’s own state (#14455)
  • lvgl list widget by @clydebarrow adds a scrollable container designed to be filled at runtime, with add_text, add, remove and clear actions plus on_add/on_remove triggers (#18018)
  • lvgl table widget also by @clydebarrow adds a lightweight grid of text elements (#18422) and support for LVGL 9.5’s radial and conical gradients (#18818)
  • online_image auto-detection by @guillempages picks up the image format from the server’s Content-Type header (#16337), plus QOI format support in runtime_image (#16945) and a decoder that stays allocated between images of the same format (#18488)
  • ethernet spi_id by @jesserockz lets a SPI ethernet chip (W5500, DM9051, ENC28J60, CH390) attach to an existing spi: bus so it can share pins with other SPI peripherals like a display on boards such as the M5Stack CoreS3 (#18530)
  • network tcp_send_buffer by @bdraco exposes lwIP’s per-socket send buffer size on ESP-IDF, giving Bluetooth proxies a way to raise the TCP buffer without paying the RAM cost of enable_high_performance (#18610)
  • epaper_spi UC8179 by @jesserockz adds a monochrome UC8179 driver with three refresh modes and registers models for the Waveshare 7.5in v2 panel and the Seeed reTerminal E1001 (#17568)
  • api user-defined action metadata by @bharvey88 adds optional description and example fields on API actions and variables so Home Assistant renders a proper form; ESP8266 configs with unusually long action metadata may need trimming to fit the 384-byte-per-action limit (#18881)
  • sen6x algorithm tuning by @bharvey88 exposes Sensirion’s VOC/NOx tuning parameters (#18779)
  • emontx AP and F tags by @FredM67 adds apparent power and frequency sensor tags with correct defaults (#18586)
  • climate_ir_lg advanced commands by @ireun adds an advanced_commands_support flag that fixes heat/cool temperature setting, decodes JET mode, adds swing settings and rejects previously misdecoded frames (#10875)
  • mitsubishi_cn105 Fahrenheit by @crnjan implements the non-linear Fahrenheit mapping the Mitsubishi A/C hardware actually uses (#15488)
  • hoermann_hcp buttons by @zweckj adds venting and half-open buttons (#18544)
  • audio_http persistent buffer by @n-IA-hane keeps the HTTP decoder ring buffer allocated between playback sessions to avoid heap fragmentation on memory-tight devices (#18708)
  • rp2_ble_tracker automation by @Bl00d-B0b wires the shared BLE automation surface onto the RP2 tracker (#18717)
  • esp32_hosted by @jesserockz gains ESP-NOW on the ESP32-P4: the host forwards every esp_now_* call to the co-processor over esp-hosted, so espnow: works unchanged on boards like the M5Stack Tab5 once the co-processor runs the matching esp-hosted firmware (#17712)
  • esp32_ble by @jesserockz reference counts advertising, so a device that only uses esp32_improv stops advertising once provisioning ends instead of showing up in every phone’s Bluetooth list forever (#18943)
  • sendspin media source by @kahrendt adds a codecs option that sets which audio codecs the media source advertises, in order of preference (#19047)

Several targeted changes for network-heavy devices. Bluetooth proxy congestion warnings now fire once per transaction and once per connection instead of on every drop cycle, ending the log flood a bulk transfer to opendisplay-style screens produced (#18605). esp32_ble_tracker now warns at config time when the scan window exceeds 600ms while WiFi is configured, because holding the shared radio for over a second at a time starves WiFi and causes disconnects (#18725). espota2 now logs the erase-prepare window separately from the upload window and reports total OTA time (#18582), so users see why there is a silent pause after the handshake instead of interpreting it as a stall. On a lossy link an OTA upload could give up moments before a lost chunk acknowledgement was retransmitted; the device now waits 105s for data and the CLI 160s for an acknowledgement, so a retry always finds the device free (#19041).

On the ESP8266, LEAmDNS could free the same packet buffer twice when an mDNS packet arrived while a send was failing, corrupting the heap right as Home Assistant connected; its main loop calls are now guarded against that re-entrancy (#18990). The logger’s UART on the ESP32-S3 now runs from the XTAL clock, which stops the garbled stack traces the APB clock produced (#17939), and the debug component reports the reboot source after a watchdog-flavoured esp_restart() and no longer prints an empty source (#17537).

After a ble.disable and ble.enable cycle, a Bluetooth proxy connection slot could get stuck connecting forever and stop scanning, because an idle slot never registered with the new stack; every client now re-registers when the stack cycles, and an enable issued while a disable is still pending cancels it instead of being ignored (#19068). Starting an OTA while a proxy connection was active left the shared radio biased toward Bluetooth for the whole transfer; the preference now reverts to balanced when the OTA starts (#19082).

ESP32 custom eFuse MAC applied consistently

Section titled “ESP32 custom eFuse MAC applied consistently”

@kbx81 fixes an inconsistency where a custom MAC burned into the ESP32’s eFuses was only applied to WiFi (#18452). The apply now runs in app_main() before any component sets up, so Ethernet, Bluetooth and 802.15.4 all derive their addresses from the custom base MAC. Only devices with a burned custom eFuse MAC AND Ethernet/Bluetooth/802.15.4 are affected; on those devices Ethernet, Bluetooth and Thread/Zigbee will change on-wire MAC addresses on upgrade, so DHCP reservations, MAC filters and BLE pairings may need updating (set ignore_efuse_custom_mac: true under esp32.advanced to keep the old behaviour).

Led by @jesserockz, a repo-wide sweep in eleven parts added parameter and return type annotations to every mid-sized component’s Python code (over 130 components in this batch, plus a smaller follow-up in #18697), landing across PRs #18338 through #18348. Because component modules do not use from __future__ import annotations, every annotation is evaluated at import time, so the sweep also had to add every missing import for the names it introduced; an all-modules import check pins that. No behaviour changes.

Audio on ESP32 got a round of fixes:

  • A speaker start request that arrived while the previous task was still stopping is kept instead of dropped (#19027)
  • The I2S driver is no longer started a second time while the bus lock is still held, which cost a one second delay before playback (#19045)
  • The speaker’s state changes now all run on the loop thread, so a stop can cancel a start the loop has not processed yet and the speaker no longer wedges in a restart loop (#19089)
  • The MP3 decoder treats a mid-stream format notice as the recoverable status it is instead of ending playback (#19028)
  • The audio components check their buffers through a locked pointer or a null test instead of use_count() (#19046), and a running StaticTask is suspended before its stack is freed, closing a use-after-free (#19048)

And elsewhere:

  • MQTT light discovery publishes brightness: true again, so Home Assistant sends brightness and colour as separate fields instead of folding one into the other (#18950)
  • dallas_temp filters the 85 °C reading a DS18B20 returns after a reset (#17877)
  • ble_client nodes that never read services now report established, so the client releases its discovered services (#17920)
  • template water heater stops republishing on every loop iteration while a temperature lambda returns NAN (#19013)
  • pmsa003i no longer logs garbage from an uninitialized buffer when the I2C read fails (#19053)
  • tuya builds without a network component (#18948)
  • LVGL qrcode, keyboard and tabview widgets declare their label dependency (#18387)
  • rf_bridge bucket sniffing works with the Portisch firmware (#17683)
  • anova re-asserts the temperature unit on every poll (#17141)
  • atm90e32 verifies its offset calibration writes (#18701)
  • usb_uart keeps working when the comm interface claim fails (#18968)
  • The removed udp options moved to packet_transport no longer appear in the generated schema (#19032)

This release includes 275 pull requests from over 40 contributors. A huge thank you to everyone who made 2026.9.0 possible:

  • @exciton - 25 PRs including the multi-release Modbus overhaul: PollingDevice-based controller polling, continuous polling, heap-free writer entities, a compile-time register decoder, and the switch to typed address-based read callbacks across sdm_meter, growatt_solar, havells_solar, kuntze, pzemac, pzemdc, and selec_meter
  • @jesserockz - 20 PRs including the 12-part sweep adding Python type annotations across the component tree, the FINAL_VALIDATE_SCHEMA return-type migration, the uart check_uart_settings move to final validation, the new UC8179 e-paper driver, and the ethernet spi_id option
  • @clydebarrow - 15 PRs including the new SDL snapshot and headless display mode, LVGL list and table widgets, LVGL radial and conical gradients, ESP32-S31 support in mipi_rgb, and CodSpeed benchmark reliability fixes
  • @kbx81 - 9 PRs including the provisioning window shutdown behavior, improv_serial support for Ethernet and non-WiFi interfaces, the ESP32 custom eFuse base-MAC fix, and ISR-driven remote_transmitter on RTL8720C and BK7238
  • @guillempages - 8 PRs including runtime_image format auto-detection, QOI decoding, MIME types, decoder retention, and BMP dimension validation, plus the portable str_contains_ignore_case helper
  • @bharvey88 - 6 PRs including the WiFi scan list dedupe helper shared with improv_serial, sen6x VOC/NOx algorithm tuning, description and example metadata on user-defined API actions, and BK72xx deep_sleep wakeup validation
  • @swoboda1337 - 6 PRs including CI diff-size fallback and max-parallel removal, external_components override logging, and ESP32-S31 ADC and GPIO support
  • @FredM67 - 4 PRs including the new mk2pvrouter component and emontx apparent power and frequency sensor support
  • @p1ngb4ck - 4 PRs including the USBUartChannelBase extraction, moving CONF_SLOT and CONF_LABEL into the shared const module, and uncovering a silent safe_mode error
  • @crnjan - 3 PRs including mitsubishi_cn105 Fahrenheit support, deferred status requests, and marking configurable classes as final
  • @rwrozelle - 2 PRs including the IntervalSyncer PollingComponent refactor and an openthread shutdown fix
  • @n-IA-hane - 2 PRs including SPI PSRAM DMA for external buffers and an audio_http persistent ring buffer option
  • @Bl00d-B0b - 2 PRs including rp2_ble_tracker automation triggers and scan actions plus a LibreTiny WiFi STA state reset on synchronous connect failure
  • @JakeLC15 - the new DS1603L ultrasonic liquid-level sensor component
  • @ch604 - the new D01 PM2.5 sensor component
  • @NoQuarrel - the new SFA40 formaldehyde sensor component

Also thank you to @bdraco, @ireun, @iago-veiga, @leodrivera, @JoppyFurr, @DavidvtWout, @alaraun, @zweckj, @kahrendt, @luar123, @Gafielt, @MakerYuichi, @mfishma, @Zebble, @rsre, @mipa87, @ryan-ronnander, @AndreKR, @ssieb, @ptr727, @dmd79, @Gytisla, @btli, @raykholo, and @CircuitSetup for their contributions, and to everyone who reported issues, tested pre-releases, and helped in the community.

  • Modbus Controller: custom_command renamed to custom_pdu and now takes the PDU only (function code + data), not a full frame. The controller’s address: and CRC are appended automatically. Configs whose first byte matched the controller’s address: are auto-migrated with a deprecation warning until 2027.3.0; frames targeting a different unit address must be moved to a sensor on the correct controller. #18652
  • Modbus Controller: address: 0 (Modbus broadcast) is now rejected with a validation error. A broadcast is never answered, so it could never be polled. #18652
  • Modbus Controller: The per-sensor skip_updates option is retired and has no effect. It logs a deprecation warning at configuration time (removed in 2027.3.0). To poll some registers less often, add a second modbus_controller with the same address: and a slower update_interval:, and attach the slow sensors to it. offline_skip_updates (controller-level) is unaffected. #18652
  • Modbus Controller: register_count and force_new_range are replaced by a new tri-state reuse_previous_range option (auto/true/false). force_new_range: true migrates automatically to reuse_previous_range: false with a deprecation warning (removed in 2027.3.0). A register_count matching the derived width warns as redundant; a divergent one now fails validation with migration instructions. The old vendor pattern of register_count: 1 + response_size: 4 for an FP32 no longer validates. Range splitting is now strictly address-ordered, so where isolated items were previously sorted first the range may now split at their position. A RAW value or text sensor with a response_size now spans ceil(response_size / 2) registers, one more than before for odd values. #18085
  • Modbus Controller: Holding-register switch and output platforms now apply a non-zero offset/byte_offset byte-accurately (address + offset/2) instead of doubling it. Configs with a non-zero write offset on these platforms must re-verify the target register. Odd offsets on these two platforms are now rejected at validation, as a 16-bit register write cannot target half a register. The number platform’s odd offsets are unchanged. #18787
  • Modbus Controller: A switch write_lambda returning a bool different from x now only changes the value written to the device; the switch reports the requested state. Lambdas that relied on the return value flipping the displayed state should publish explicitly or invert in the read lambda instead. The lambda’s payload parameter changes type from std::vector to a fixed-capacity RegisterValues / PduBuffer; push_back, clear, assign and indexing still work, but resize, reserve, insert and passing to std::vector& helpers no longer compile. A lambda that fills payload and returns {} now sends the buffer (as documented); previously that combination sent nothing. #18788, #18082
  • Modbus Controller: For a custom_pdu poll, the address argument delivered to on_command_sent, on_online and on_offline triggers is now -1 (no decodable address) instead of the sensor’s synthesized address. Automations filtering on a custom sensor’s synthetic address must test for -1 instead. The function_code argument for a custom_pdu whose first byte has the 0x80 bit set is now reported with that bit masked (0x83 reports as 0x03). #18071
  • Runtime Image: The image decoder is now kept allocated between decodes to avoid memory churn when decoding multiple images of the same format. This adds a small persistent memory overhead for the decoder object; on memory-constrained devices, reducing runtime image usage or restarting less frequently may be needed. #18488
  • API: homeassistant.event variables are now compiled as lambdas when they use !lambda or look like lambda source; a plain string that looks like lambda source now logs a deprecation warning and will be treated as static text in 2027.3.0. Tag lambda values with !lambda explicitly. homeassistant.action now accepts plain static strings (previously rejected) and sends them as text. A cv.returning_lambda value whose only return sits inside a comment, or only matches as a substring like the_return_value, now fails validation instead of failing at C++ compile. #18759
  • API: User-defined actions now accept optional description and per-variable description and example metadata using a new mapping form for variables:. On ESP8266 only, each action’s action name, variable names, description and example text must total at most 384 bytes; configs that exceed this fail validation with a message identifying the action and its size. Other platforms have no limit. #18881
  • API: The legacy media player supports_pause field is no longer sent. Clients on API 1.11 or newer already use feature_flags, so current Home Assistant is unaffected; only very old clients that never learned feature_flags would lose pause capability. #18801
  • Preferences: IntervalSyncer is now a PollingComponent, so its interval can be suspended and resumed with component.suspend and component.resume. No YAML changes are required; the previous flash_write_interval: 0 loop-every-iteration special case was already dead code (coerced to 1ms by the validator). Lambdas that called the old set_write_interval() still compile via a deprecated shim that forwards to set_update_interval(), removed in 2027.3.0. #14370
  • ESP32: When a custom MAC address is burned into the eFuses, ESPHome now applies it as the system base MAC before any component runs, so every interface (Wi-Fi, Ethernet, Bluetooth, 802.15.4) derives its address from it. Previously only Wi-Fi did. Devices with a custom eFuse MAC and Ethernet, Bluetooth, or 802.15.4 will appear on the network with a new MAC after upgrading: DHCP reservations, ACLs and Bluetooth bonds keyed to the old MAC need to be updated, and 802.15.4 (Thread/Zigbee) nodes may need to be re-commissioned. Devices without a custom eFuse MAC (the vast majority) are unaffected; setting ignore_efuse_custom_mac: true under esp32: advanced: disables the behavior. #18452
  • ESP8266: Arduino framework versions before 3.0.0 are rejected at validation. Those builds have failed since ESPHome moved to C++20 in 2025.7, so nothing that compiled changes, but the check also covers config, logs and upload, so a YAML still pinned below 3.0.0 needs the framework: version: pin removed or moved to 3.1.2 before those commands work again. #18917
  • ESP32 Hosted: Now requires ESP-IDF 5.3 or newer. The legacy fallback to esp_hosted 2.0.11 (which had a known double-free crash on ESP32-P4 + C6) has been removed and older ESP-IDF versions now fail validation with a clear error. Configs that pin an ESP-IDF version below 5.3 alongside esp32_hosted must remove the pin or bump it to 5.3 or newer. #18417
  • UART: UART settings validation (baud_rate, parity, data_bits, stop_bits) for components that require specific values now happens at configuration time instead of only being logged at runtime. Nine previously unchecked components (cm1106, daly_bms, hrxl_maxsonar_wr, hydreon_rgxx, mhz19, pylontech, teleinfo, vbus, wl_134) and twelve components with incomplete checks now enforce their required UART parameters. Users with non-default UART settings that a component never supported will now get a configuration error instead of a runtime log error. #18940

Advanced users with lambdas that touch component internals should note the following C++ changes. These APIs are not covered by the formal breaking-change policy (they are undocumented public methods), but lambdas often depend on them:

  • Preferences: IntervalSyncer::set_write_interval() is deprecated in favour of set_update_interval() (the new PollingComponent base’s method) and will be removed in 2027.3.0. Existing lambdas calling set_write_interval() continue to compile via a forwarding shim. #14370

  • Modbus: New compile-time helpers registers_to_value<VALUE_TYPE>() and registers_to_uint32() are available in modbus_helpers.h for lambdas that know the value type at compile time. The runtime registers_to_number() is unchanged. Prefer the templated form when the type is known, as it inlines to a handful of instructions and returns the value’s natural type instead of int64_t. #18863

  • Modbus Controller: ModbusCommandItem, its factories (create_read_command, create_write_single_command, create_custom_command and friends), queue_command(), unqueue_command(), on_write_register_response() and the FunctionCode::CUSTOM alias are deprecated and removed in 2027.3.0. Migrate one-shot writes to the entity write helpers exposed through WriterDevice (available as item-> inside a write_lambda), or to the modbus_client write actions. Use FunctionCode::INVALID for the sentinel value formerly named FunctionCode::CUSTOM. #18071

  • Modbus Controller: write_lambda has a stronger item pointer now. item is the entity’s own persistent modbus device (not a throwaway command object), and the write helpers plus item->queue_pdu() are the entire API. A lambda can drive any register, coil, or custom-PDU write through it, regardless of the entity’s own register type. Before/after for a coil switch writing a holding register:

    // Before: throwaway ModbusCommandItem
    auto cmd = ModbusCommandItem::create_write_single_command(parent_, 0x30, x ? 1 : 0);
    parent_->queue_command(cmd);
    return {};
    // After: entity's own device
    item->write_single_register(0x30, x ? 0x0001 : 0x0000);
    return {};

    The payload parameter’s type also changes from std::vector<uint16_t> to a fixed-capacity RegisterValues (writes) or PduBuffer (custom PDUs). push_back, clear, assign and indexing keep working; resize, reserve, insert and passing to std::vector& helpers no longer compile. Filling payload and returning {} now sends the buffer (as the docs have always described); previously that combination sent nothing. #18082

  • Modbus Controller: SensorItem::register_count and SensorItem::force_new_range are removed. Lambdas that read a sensor item’s register span should call entity_count() instead; the join behaviour previously encoded in force_new_range is now expressed by the tri-state reuse_previous_range option on the following sensor. #18085

  • Time: RealTimeClock::set_timezone() (the C++ string-argument overload) and the on-device POSIX TZ string parser (parse_posix_tz() and helpers) are removed. There is no string-based C++ replacement: set timezone: in YAML, or let Home Assistant push a pre-parsed timezone over the API. Home Assistant 2026.3.0 and newer send the pre-parsed struct; older clients keep the codegen timezone. #18383

  • Core: Deprecated EntityBase::get_device_class_ref(), get_device_class(), get_unit_of_measurement(), get_icon_ref() and get_icon() removed. Use get_device_class_to(), get_unit_of_measurement_ref() and get_icon_to(). #18375
  • Core: Deprecated free functions gamma_correct() and gamma_uncorrect() removed. Use LightState::gamma_correct_lut() and LightState::gamma_uncorrect_lut(). #18376
  • Core: Deprecated esp_log_vprintf_() __FlashStringHelper overload removed; the const char* overload is unchanged. #18377
  • Core: make_name_with_suffix() std::string overloads removed (hard removal, no deprecation cycle). Use make_name_with_suffix_to() with a stack buffer sized via MAX_NAME_WITH_SUFFIX_SIZE from helpers.h. #18828
  • WiFi: Deprecated WiFiComponent::wifi_ssid() removed. Use the heap-free wifi_ssid_to(). #18378
  • Ethernet: Deprecated EthernetComponent::get_eth_mac_address_pretty() removed. Use get_eth_mac_address_pretty_into_buffer(). #18379
  • Modbus: Deprecated ModbusDevice::waiting_for_response() removed. Use ready_for_immediate_send(). #18381
  • Modbus: queue_pdu() refuses PDUs with the exception bit (0x80) set; receive parsers treat every 0x80-set response as the 2-byte spec exception shape; the “no device accepted broadcast” warning is removed; management codes 0x07/0x0B/0x0C/0x11 may now be broadcast. #18847
  • Modbus: New compile-time register decoder registers_to_value<VALUE_TYPE>() and registers_to_uint32() in modbus_helpers.h; registers_to_number() unchanged. #18863
  • Modbus Controller: ModbusCommandItem, its factories, queue_command(), unqueue_command(), on_write_register_response() and FunctionCode::CUSTOM are deprecated (removed in 2027.3.0). Migrate one-shot writes to the entity write helpers (WriterDevice) or the modbus_client actions. #18071
  • Modbus Controller: SensorItem::register_count and SensorItem::force_new_range are removed; every platform constructor loses its register_count / force_new_range parameters (replaced by entity_count() and reuse_previous_range). #18085
  • Web Server IDF: Deprecated AsyncWebServerRequest::url() removed. Use url_to(). #18382
  • Time: On-device POSIX TZ string parser removed; set_timezone() overloads and RealTimeClock::apply_timezone_() are gone. The GetTimeResponse.timezone proto field is marked deprecated and is no longer decoded. Set timezone: in YAML or let Home Assistant push the pre-parsed timezone. #18383
  • UART: UARTDevice::check_uart_settings() is deprecated (removed in 2027.3.0). Use uart.final_validate_device_schema() in your component’s Python FINAL_VALIDATE_SCHEMA instead. #18940
  • USB UART: USBUartChannel is now a final concrete type deriving from a new USBUartChannelBase; subclass the base if you need to extend channel behaviour. #17472
  • Mitsubishi CN105: All leaf configurable classes and actions (MitsubishiCN105Component, MitsubishiCN105Climate, MitsubishiCN105VerticalVaneDirectionSelect, SetRemoteTemperatureAction, ClearRemoteTemperatureAction, VaneControlAction, LegacySetRemoteTemperatureAction, LegacyClearRemoteTemperatureAction) are marked final and can no longer be subclassed. #18272
  • ESP32 IDF: The default IDF component exclusion list grew significantly. External components that include headers from app_trace, console, esp-tls, esp_driver_cam, esp_driver_gptimer, esp_driver_i2c, esp_driver_ledc, esp_driver_sdio, esp_driver_sdm, esp_driver_sdmmc, esp_driver_sdspi, json, protobuf-c, rt, sdmmc, tcp_transport, bt, esp_coex, esp_hal_ieee802154, esp_phy, esp_wifi, ieee802154, wpa_supplicant, esp_gdbstub, esp_http_server or nvs_sec_provider must call esp32.include_builtin_idf_component("<name>") in to_code (or users can add them under esp32: framework: advanced: include_builtin_idf_components:). #18536, #18599, #18604, #18748
  • ESP32 IDF: The mbedTLS root certificate bundle is no longer built unless a component asks for it. External components that call esp_crt_bundle_attach() must call esp32.require_certificate_bundle() in to_code (or guard the call with #if CONFIG_MBEDTLS_CERTIFICATE_BUNDLE). #18747

For detailed migration guides and API documentation, see the ESPHome Developers Documentation.

For the complete list of every merged pull request in this release, see the full 2026.9.0 changelog.

Comments