From 482721d1ce3e31696eb7f9a95c9144a70a49f61f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 11:09:33 -0600 Subject: [PATCH 01/17] [libretiny] Tune oversized lwIP defaults for ESPHome The Beken/RTL SDKs ship lwIP defaults tuned for a general-purpose WiFi SoC: TCP_SND_BUF=10*MSS (14.6KB), MEM_SIZE=32KB, 12 TCP + 22 UDP sockets. These waste significant RAM on memory-constrained chips. ESPAsyncWebServer allocates malloc(tcp_sndbuf()) per response chunk, and at 14.6KB this causes OOM on BK7231N. This tunes lwIP to match ESP32/ESP8266 ranges: - TCP_SND_BUF/TCP_WND: 4*MSS (was 10*MSS) - MEM_SIZE: 12KB BK72XX / 16KB RTL,LN (was 32KB) - Socket counts: dynamic from component registrations - All derived pool sizes adjusted accordingly Also splits socket consumer tracking into TCP/UDP, adds get_socket_counts() API, and updates ESP32 to use it. Closes https://github.com/esphome/esphome/issues/14095 --- esphome/components/esp32/__init__.py | 48 ++++---- esphome/components/libretiny/__init__.py | 110 +++++++++++++++++- esphome/components/mdns/__init__.py | 2 +- esphome/components/socket/__init__.py | 64 +++++++++- esphome/components/udp/__init__.py | 2 +- .../socket/test_wake_loop_threadsafe.py | 8 +- 6 files changed, 190 insertions(+), 44 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 06677006ea3..ac73bf449e5 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1258,21 +1258,13 @@ def _configure_lwip_max_sockets(conf: dict) -> None: This function runs in to_code() after all components have registered their socket needs. User-provided sdkconfig_options take precedence. """ - from esphome.components.socket import KEY_SOCKET_CONSUMERS + from esphome.components.socket import get_socket_counts # Check if user manually specified CONFIG_LWIP_MAX_SOCKETS user_max_sockets = conf[CONF_SDKCONFIG_OPTIONS].get("CONFIG_LWIP_MAX_SOCKETS") - socket_consumers: dict[str, int] = CORE.data.get(KEY_SOCKET_CONSUMERS, {}) - total_sockets = sum(socket_consumers.values()) - - # Early return if no sockets registered and no user override - if total_sockets == 0 and user_max_sockets is None: - return - - components_list = ", ".join( - f"{name}={count}" for name, count in sorted(socket_consumers.items()) - ) + tcp_sockets, udp_sockets = get_socket_counts() + total_sockets = tcp_sockets + udp_sockets # User specified their own value - respect it but warn if insufficient if user_max_sockets is not None: @@ -1281,22 +1273,21 @@ def _configure_lwip_max_sockets(conf: dict) -> None: user_max_sockets, ) - # Warn if user's value is less than what components need - if total_sockets > 0: - user_sockets_int = 0 - with contextlib.suppress(ValueError, TypeError): - user_sockets_int = int(user_max_sockets) + user_sockets_int = 0 + with contextlib.suppress(ValueError, TypeError): + user_sockets_int = int(user_max_sockets) - if user_sockets_int < total_sockets: - _LOGGER.warning( - "CONFIG_LWIP_MAX_SOCKETS is set to %d but your configuration " - "needs %d sockets (registered: %s). You may experience socket " - "exhaustion errors. Consider increasing to at least %d.", - user_sockets_int, - total_sockets, - components_list, - total_sockets, - ) + if user_sockets_int < total_sockets: + _LOGGER.warning( + "CONFIG_LWIP_MAX_SOCKETS is set to %d but your configuration " + "needs %d sockets (%d TCP + %d UDP). You may experience " + "socket exhaustion errors. Consider increasing to at least %d.", + user_sockets_int, + total_sockets, + tcp_sockets, + udp_sockets, + total_sockets, + ) # User's value already added via sdkconfig_options processing return @@ -1307,9 +1298,10 @@ def _configure_lwip_max_sockets(conf: dict) -> None: log_level = logging.INFO if max_sockets > DEFAULT_MAX_SOCKETS else logging.DEBUG _LOGGER.log( log_level, - "Setting CONFIG_LWIP_MAX_SOCKETS to %d (registered: %s)", + "Setting CONFIG_LWIP_MAX_SOCKETS to %d (%d TCP + %d UDP)", max_sockets, - components_list, + tcp_sockets, + udp_sockets, ) add_idf_sdkconfig_option("CONFIG_LWIP_MAX_SOCKETS", max_sockets) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 01445da7ee7..5804421f9e6 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -275,6 +275,103 @@ BASE_SCHEMA.add_extra(_detect_variant) BASE_SCHEMA.add_extra(_update_core_data) +def _configure_lwip(config: dict) -> None: + """Configure lwIP options for LibreTiny platforms. + + The Beken/RTL SDKs ship with lwIP defaults tuned for a general-purpose + WiFi SoC, wildly oversized for ESPHome's IoT use case. This causes OOM + on memory-constrained chips like BK7231N. + + See https://github.com/esphome/esphome/issues/14095 + + Comparison of SDK defaults vs ESPHome targets: + + TCP_MSS is 1460 on BK/RTL/LN, 1440 on ESP32, 1460 on ESP8266. + + Setting ESP8266 ESP32 BK/RTL SDK New + ───────────────────────────────────────────────────────────── + TCP_SND_BUF 2×MSS 4×MSS 10×MSS 4×MSS + TCP_WND 4×MSS 4×MSS 10×MSS 4×MSS + MEM_SIZE 1.6KB N/A* 32KB 12KB/16KB + MAX_SOCKETS_TCP 5 16 12 dynamic + MAX_SOCKETS_UDP 4 16 22 dynamic + TCP_SND_QUEUELEN ~8 17 20 17 + MEMP_NUM_TCP_SEG 10 16 40 17 + MEMP_NUM_TCP_PCB 5 16 12 =TCP + MEMP_NUM_UDP_PCB 4 16 24/7** =UDP+2 + MEMP_NUM_NETCONN 0 10 38 =sum + MEMP_NUM_NETBUF 0 2 16 4 + MEMP_NUM_TCPIP_MSG_INPKT 4 8 16 8 + + MEM_SIZE: 12KB for BK72XX, 16KB for RTL87XX/LN882H. + * ESP32 uses MEM_LIBC_MALLOC=1 (no dedicated lwIP heap). + ** MEMP_NUM_UDP_PCB: BK base=24 (22+2), RTL/LN platform override=7. + "dynamic" = auto-calculated from component socket registrations via + socket.get_socket_counts() with minimums of 10 TCP / 6 UDP. + """ + from esphome.components.socket import ( + MIN_TCP_SOCKETS, + MIN_UDP_SOCKETS, + get_socket_counts, + ) + + raw_tcp, raw_udp = get_socket_counts() + # Apply platform minimums — ensure headroom for ESPHome's needs + tcp_sockets = max(MIN_TCP_SOCKETS, raw_tcp) + udp_sockets = max(MIN_UDP_SOCKETS, raw_udp) + listening_tcp = 4 + + # TCP_SND_BUF / TCP_WND: 4×MSS matches ESP32 and ESP8266 TCP_WND + # SDK default is 10×MSS=14,600 — ESPAsyncWebServer malloc(tcp_sndbuf()) + # per response chunk causes OOM at that size on BK7231N + tcp_snd_buf = "(4*TCP_MSS)" # 4×1460=5,840 (SDK: 10×1460=14,600) + tcp_wnd = "(4*TCP_MSS)" # 4×1460=5,840 (SDK: 10×1460=14,600) + + # lwIP heap — BK72XX has less headroom than RTL/LN (SDK: 32KB for both) + mem_size = 12288 if CORE.is_bk72xx else 16384 + + # TCP_SND_QUEUELEN: max pbufs queued for send buffer + # ESP-IDF formula: (4 * TCP_SND_BUF + (TCP_MSS - 1)) / TCP_MSS + # With 4×MSS: (4*5840 + 1459) / 1460 = 17 — match ESP32 + tcp_snd_queuelen = 17 # SDK: 20, ESP32: 17 + # MEMP_NUM_TCP_SEG: segment pool, must be >= TCP_SND_QUEUELEN (lwIP sanity check) + memp_num_tcp_seg = tcp_snd_queuelen # SDK: 40 + + lwip_opts: list[str] = [ + # Disable statistics — not needed for production, saves RAM + "LWIP_STATS=0", # SDK: 1 + "MEM_STATS=0", # SDK: 1 + "MEMP_STATS=0", # SDK: 1 + # TCP send buffer — ESPAsyncWebServer allocates malloc(tcp_sndbuf()) + # per response chunk. At 14.6KB this causes OOM on BK7231N (#14095) + f"TCP_SND_BUF={tcp_snd_buf}", # SDK: 10×MSS (14,600) + # TCP receive window — match send buffer ratio (ESP8266: 4×MSS) + f"TCP_WND={tcp_wnd}", # SDK: 10×MSS (14,600) + # Socket counts — auto-calculated from component registrations + # API=4 TCP, web_server=6 TCP, OTA=1 TCP, mDNS=2 UDP, etc. + f"MAX_SOCKETS_TCP={tcp_sockets}", # SDK: 12 + f"MAX_SOCKETS_UDP={udp_sockets}", # SDK: 22 + # Listening sockets — ESPHome needs API + web_server + OTA at most + f"MAX_LISTENING_SOCKETS_TCP={listening_tcp}", # SDK: 4 + # lwIP heap — SDK allocates 32KB, ESPHome needs far less + f"MEM_SIZE={mem_size}", # SDK: 32,768 + # Queued segment limits — derived from 4×MSS buffer size + f"TCP_SND_QUEUELEN={tcp_snd_queuelen}", # SDK: 20, match ESP32 + f"MEMP_NUM_TCP_SEG={memp_num_tcp_seg}", # SDK: 40, must be >= queuelen + # PCB pools — 1:1 with socket counts + f"MEMP_NUM_TCP_PCB={tcp_sockets}", # SDK: 12 + # UDP PCB pool — +2 for lwIP internal use (DHCP, DNS) + f"MEMP_NUM_UDP_PCB={udp_sockets + 2}", # SDK: 24 (22+2) + # Netconn pool — sum of all socket types + f"MEMP_NUM_NETCONN={tcp_sockets + listening_tcp + udp_sockets}", # SDK: 38 + # Netbuf pool — ESP8266 uses 0, conservative value + "MEMP_NUM_NETBUF=4", # SDK: 16 + # Inbound message pool — between ESP8266 (4) and SDK (16) + "MEMP_NUM_TCPIP_MSG_INPKT=8", # SDK: 16 + ] + cg.add_platformio_option("custom_options.lwip", lwip_opts) + + # pylint: disable=use-dict-literal async def component_to_code(config): var = cg.new_Pvariable(config[CONF_ID]) @@ -389,11 +486,12 @@ async def component_to_code(config): "custom_options.sys_config#h", _BK7231N_SYS_CONFIG_OPTIONS ) - # Disable LWIP statistics to save RAM - not needed in production - # Must explicitly disable all sub-stats to avoid redefinition warnings - cg.add_platformio_option( - "custom_options.lwip", - ["LWIP_STATS=0", "MEM_STATS=0", "MEMP_STATS=0"], - ) + # Tune lwIP for ESPHome's actual needs. + # The SDK defaults (TCP_SND_BUF=10*MSS, MAX_SOCKETS_TCP=12, MEM_SIZE=32KB) + # are wildly oversized for an IoT device. ESPAsyncWebServer allocates + # malloc(tcp_sndbuf()) per response chunk — at 14.6KB this causes silent + # OOM on memory-constrained chips like BK7231N. + # See https://github.com/esphome/esphome/issues/14095 + _configure_lwip(config) await cg.register_component(var, config) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index f87f9296157..c7fb28e7665 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -56,7 +56,7 @@ def _consume_mdns_sockets(config: ConfigType) -> ConfigType: from esphome.components import socket # mDNS needs 2 sockets (IPv4 + IPv6 multicast) - socket.consume_sockets(2, "mdns")(config) + socket.consume_sockets(2, "mdns", socket.SOCKET_UDP)(config) return config diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index e364da78f8f..5ece7a97951 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -1,9 +1,13 @@ from collections.abc import Callable, MutableMapping +from enum import StrEnum +import logging import esphome.codegen as cg import esphome.config_validation as cv from esphome.core import CORE +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@esphome/core"] CONF_IMPLEMENTATION = "implementation" @@ -13,33 +17,85 @@ IMPLEMENTATION_BSD_SOCKETS = "bsd_sockets" # Socket tracking infrastructure # Components register their socket needs and platforms read this to configure appropriately -KEY_SOCKET_CONSUMERS = "socket_consumers" +KEY_SOCKET_CONSUMERS_TCP = "socket_consumers_tcp" +KEY_SOCKET_CONSUMERS_UDP = "socket_consumers_udp" + +# Recommended minimum socket counts to ensure headroom +# Platforms should apply these (or their own) on top of get_socket_counts() +MIN_TCP_SOCKETS = 10 +MIN_UDP_SOCKETS = 6 # Wake loop threadsafe support tracking KEY_WAKE_LOOP_THREADSAFE_REQUIRED = "wake_loop_threadsafe_required" +class SocketType(StrEnum): + TCP = "tcp" + UDP = "udp" + + +# Legacy aliases +SOCKET_TCP = SocketType.TCP +SOCKET_UDP = SocketType.UDP + +_SOCKET_TYPE_KEYS = { + SocketType.TCP: KEY_SOCKET_CONSUMERS_TCP, + SocketType.UDP: KEY_SOCKET_CONSUMERS_UDP, +} + + def consume_sockets( - value: int, consumer: str + value: int, consumer: str, socket_type: SocketType = SocketType.TCP ) -> Callable[[MutableMapping], MutableMapping]: """Register socket usage for a component. Args: value: Number of sockets needed by the component consumer: Name of the component consuming the sockets + socket_type: Type of socket (SocketType.TCP or SocketType.UDP) Returns: A validator function that records the socket usage """ + typed_key = _SOCKET_TYPE_KEYS[socket_type] def _consume_sockets(config: MutableMapping) -> MutableMapping: - consumers: dict[str, int] = CORE.data.setdefault(KEY_SOCKET_CONSUMERS, {}) + consumers: dict[str, int] = CORE.data.setdefault(typed_key, {}) consumers[consumer] = consumers.get(consumer, 0) + value return config return _consume_sockets +def get_socket_counts() -> tuple[int, int]: + """Return (tcp_count, udp_count) of raw registered socket needs. + + Platforms call this during code generation to configure lwIP socket limits. + All components will have registered their needs by then. + + Platforms should apply their own minimums on top of these values. + """ + tcp_consumers = CORE.data.get(KEY_SOCKET_CONSUMERS_TCP, {}) + udp_consumers = CORE.data.get(KEY_SOCKET_CONSUMERS_UDP, {}) + tcp = sum(tcp_consumers.values()) + udp = sum(udp_consumers.values()) + + tcp_list = ", ".join( + f"{name}={count}" for name, count in sorted(tcp_consumers.items()) + ) + udp_list = ", ".join( + f"{name}={count}" for name, count in sorted(udp_consumers.items()) + ) + _LOGGER.debug( + "Socket counts: TCP=%d (%s), UDP=%d (%s)", + tcp, + tcp_list or "none", + udp, + udp_list or "none", + ) + return tcp, udp + + def require_wake_loop_threadsafe() -> None: """Mark that wake_loop_threadsafe support is required by a component. @@ -66,7 +122,7 @@ def require_wake_loop_threadsafe() -> None: CORE.data[KEY_WAKE_LOOP_THREADSAFE_REQUIRED] = True cg.add_define("USE_WAKE_LOOP_THREADSAFE") # Consume 1 socket for the shared wake notification socket - consume_sockets(1, "socket.wake_loop_threadsafe")({}) + consume_sockets(1, "socket.wake_loop_threadsafe", SOCKET_UDP)({}) CONFIG_SCHEMA = cv.Schema( diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index c9586d0b956..74a890076ed 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -73,7 +73,7 @@ def _consume_udp_sockets(config: ConfigType) -> ConfigType: # UDP uses up to 2 sockets: 1 broadcast + 1 listen # Whether each is used depends on code generation, so register worst case - socket.consume_sockets(2, "udp")(config) + socket.consume_sockets(2, "udp", socket.SOCKET_UDP)(config) return config diff --git a/tests/components/socket/test_wake_loop_threadsafe.py b/tests/components/socket/test_wake_loop_threadsafe.py index b4bc95176db..a40b6068a8a 100644 --- a/tests/components/socket/test_wake_loop_threadsafe.py +++ b/tests/components/socket/test_wake_loop_threadsafe.py @@ -66,12 +66,12 @@ def test_require_wake_loop_threadsafe__no_networking_does_not_consume_socket() - CORE.config = {"logger": {}} # Track initial socket consumer state - initial_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS, {}) + initial_udp = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {}) # Call require_wake_loop_threadsafe socket.require_wake_loop_threadsafe() # Verify no socket was consumed - consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS, {}) - assert "socket.wake_loop_threadsafe" not in consumers - assert consumers == initial_consumers + udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {}) + assert "socket.wake_loop_threadsafe" not in udp_consumers + assert udp_consumers == initial_udp From 4114620aee5c52fc3c7209cc9858e5996d65ba95 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 13:20:28 -0600 Subject: [PATCH 02/17] =?UTF-8?q?[libretiny]=20Fix=20RTL87xx=20BD=5FRAM=20?= =?UTF-8?q?overflow=20=E2=80=94=20use=20per-SDK=20lwIP=20defaults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original table treated BK/RTL/LN SDK defaults as identical, but they differ significantly: - BK72XX: MEM_SIZE=32KB, TCP_SND_BUF=10×MSS — wildly oversized - RTL87XX: MEM_SIZE=5KB, TCP_SND_BUF=5×MSS — already conservative - LN882H: MEM_LIBC_MALLOC=1, TCP_SND_BUF=7×MSS — uses system heap Setting MEM_SIZE=16KB on RTL87XX was an 11KB *increase* from its 5KB SDK default, causing BD_RAM overflow on the mqtt test. Fix: only set MEM_SIZE on BK72XX (where the 32KB→12KB reduction is needed). RTL87XX keeps its SDK 5KB default, LN882H doesn't use it. The comparison table is updated with accurate per-SDK columns. Co-Authored-By: Claude Opus 4.6 --- esphome/components/libretiny/__init__.py | 116 ++++++++++++----------- 1 file changed, 60 insertions(+), 56 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 5804421f9e6..7064e911c7d 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -278,34 +278,34 @@ BASE_SCHEMA.add_extra(_update_core_data) def _configure_lwip(config: dict) -> None: """Configure lwIP options for LibreTiny platforms. - The Beken/RTL SDKs ship with lwIP defaults tuned for a general-purpose - WiFi SoC, wildly oversized for ESPHome's IoT use case. This causes OOM - on memory-constrained chips like BK7231N. + The BK/RTL/LN SDKs each ship different lwIP defaults. BK72XX defaults are + wildly oversized for ESPHome's IoT use case, causing OOM on BK7231N. + RTL87XX and LN882H have more conservative defaults but still need tuning + for ESPHome's socket usage patterns. See https://github.com/esphome/esphome/issues/14095 - Comparison of SDK defaults vs ESPHome targets: + Comparison of SDK defaults vs ESPHome targets (TCP_MSS=1460 on all LT): - TCP_MSS is 1460 on BK/RTL/LN, 1440 on ESP32, 1460 on ESP8266. + Setting ESP8266 ESP32 BK SDK RTL SDK LN SDK New + ──────────────────────────────────────────────────────────────────────────── + TCP_SND_BUF 2×MSS 4×MSS 10×MSS 5×MSS 7×MSS 4×MSS + TCP_WND 4×MSS 4×MSS 10×MSS 2×MSS 3×MSS 4×MSS + MEM_SIZE 1.6KB N/A* 32KB 5KB N/A* 5KB BK + MAX_SOCKETS_TCP 5 16 12 —** —** dynamic + MAX_SOCKETS_UDP 4 16 22 —** —** dynamic + TCP_SND_QUEUELEN ~8 17 20 13 35 17 + MEMP_NUM_TCP_SEG 10 16 40 20 =qlen 17 + MEMP_NUM_TCP_PCB 5 16 12 10 8 =TCP + MEMP_NUM_UDP_PCB 4 16 24→7*** 6→7*** 4→7*** =UDP+2 + MEMP_NUM_NETCONN 0 10 —**** —**** =sum =sum + MEMP_NUM_NETBUF 0 2 —**** —**** 8 4 + MEMP_NUM_TCPIP_MSG_INPKT 4 8 —**** —**** 12 8 - Setting ESP8266 ESP32 BK/RTL SDK New - ───────────────────────────────────────────────────────────── - TCP_SND_BUF 2×MSS 4×MSS 10×MSS 4×MSS - TCP_WND 4×MSS 4×MSS 10×MSS 4×MSS - MEM_SIZE 1.6KB N/A* 32KB 12KB/16KB - MAX_SOCKETS_TCP 5 16 12 dynamic - MAX_SOCKETS_UDP 4 16 22 dynamic - TCP_SND_QUEUELEN ~8 17 20 17 - MEMP_NUM_TCP_SEG 10 16 40 17 - MEMP_NUM_TCP_PCB 5 16 12 =TCP - MEMP_NUM_UDP_PCB 4 16 24/7** =UDP+2 - MEMP_NUM_NETCONN 0 10 38 =sum - MEMP_NUM_NETBUF 0 2 16 4 - MEMP_NUM_TCPIP_MSG_INPKT 4 8 16 8 - - MEM_SIZE: 12KB for BK72XX, 16KB for RTL87XX/LN882H. - * ESP32 uses MEM_LIBC_MALLOC=1 (no dedicated lwIP heap). - ** MEMP_NUM_UDP_PCB: BK base=24 (22+2), RTL/LN platform override=7. + * ESP32 and LN882H use MEM_LIBC_MALLOC=1 (system heap, no dedicated pool). + ** RTL/LN SDKs don't define MAX_SOCKETS_TCP/UDP (LibreTiny-specific). + *** MEMP_NUM_UDP_PCB: SDK base values, all overridden to 7 by LT platform. + **** Not defined in SDK — lwIP opt.h defaults apply. "dynamic" = auto-calculated from component socket registrations via socket.get_socket_counts() with minimums of 10 TCP / 6 UDP. """ @@ -321,54 +321,58 @@ def _configure_lwip(config: dict) -> None: udp_sockets = max(MIN_UDP_SOCKETS, raw_udp) listening_tcp = 4 - # TCP_SND_BUF / TCP_WND: 4×MSS matches ESP32 and ESP8266 TCP_WND - # SDK default is 10×MSS=14,600 — ESPAsyncWebServer malloc(tcp_sndbuf()) - # per response chunk causes OOM at that size on BK7231N - tcp_snd_buf = "(4*TCP_MSS)" # 4×1460=5,840 (SDK: 10×1460=14,600) - tcp_wnd = "(4*TCP_MSS)" # 4×1460=5,840 (SDK: 10×1460=14,600) + # TCP_SND_BUF: ESPAsyncWebServer allocates malloc(tcp_sndbuf()) per + # response chunk. At 10×MSS=14.6KB (BK default) this causes OOM (#14095). + # 4×MSS=5,840 matches ESP32. RTL(5×) and LN(7×) are close already. + tcp_snd_buf = "(4*TCP_MSS)" # BK: 10×MSS, RTL: 5×MSS, LN: 7×MSS - # lwIP heap — BK72XX has less headroom than RTL/LN (SDK: 32KB for both) - mem_size = 12288 if CORE.is_bk72xx else 16384 + # TCP_WND: receive window. 4×MSS matches ESP32. + # RTL SDK uses only 2×MSS; increasing to 4× is safe and improves throughput. + tcp_wnd = "(4*TCP_MSS)" # BK: 10×MSS, RTL: 2×MSS, LN: 3×MSS # TCP_SND_QUEUELEN: max pbufs queued for send buffer # ESP-IDF formula: (4 * TCP_SND_BUF + (TCP_MSS - 1)) / TCP_MSS # With 4×MSS: (4*5840 + 1459) / 1460 = 17 — match ESP32 - tcp_snd_queuelen = 17 # SDK: 20, ESP32: 17 + tcp_snd_queuelen = 17 # BK: 20, RTL: 13, LN: 35 # MEMP_NUM_TCP_SEG: segment pool, must be >= TCP_SND_QUEUELEN (lwIP sanity check) - memp_num_tcp_seg = tcp_snd_queuelen # SDK: 40 + memp_num_tcp_seg = tcp_snd_queuelen # BK: 40, RTL: 20, LN: =qlen lwip_opts: list[str] = [ # Disable statistics — not needed for production, saves RAM - "LWIP_STATS=0", # SDK: 1 - "MEM_STATS=0", # SDK: 1 - "MEMP_STATS=0", # SDK: 1 - # TCP send buffer — ESPAsyncWebServer allocates malloc(tcp_sndbuf()) - # per response chunk. At 14.6KB this causes OOM on BK7231N (#14095) - f"TCP_SND_BUF={tcp_snd_buf}", # SDK: 10×MSS (14,600) - # TCP receive window — match send buffer ratio (ESP8266: 4×MSS) - f"TCP_WND={tcp_wnd}", # SDK: 10×MSS (14,600) + "LWIP_STATS=0", # BK: 1, RTL: 0 already, LN: 0 already + "MEM_STATS=0", + "MEMP_STATS=0", + # TCP send buffer — 4×MSS matches ESP32 + f"TCP_SND_BUF={tcp_snd_buf}", + # TCP receive window — 4×MSS matches ESP32 + f"TCP_WND={tcp_wnd}", # Socket counts — auto-calculated from component registrations - # API=4 TCP, web_server=6 TCP, OTA=1 TCP, mDNS=2 UDP, etc. - f"MAX_SOCKETS_TCP={tcp_sockets}", # SDK: 12 - f"MAX_SOCKETS_UDP={udp_sockets}", # SDK: 22 - # Listening sockets — ESPHome needs API + web_server + OTA at most - f"MAX_LISTENING_SOCKETS_TCP={listening_tcp}", # SDK: 4 - # lwIP heap — SDK allocates 32KB, ESPHome needs far less - f"MEM_SIZE={mem_size}", # SDK: 32,768 + f"MAX_SOCKETS_TCP={tcp_sockets}", + f"MAX_SOCKETS_UDP={udp_sockets}", + # Listening sockets — API + web_server + OTA at most + f"MAX_LISTENING_SOCKETS_TCP={listening_tcp}", # Queued segment limits — derived from 4×MSS buffer size - f"TCP_SND_QUEUELEN={tcp_snd_queuelen}", # SDK: 20, match ESP32 - f"MEMP_NUM_TCP_SEG={memp_num_tcp_seg}", # SDK: 40, must be >= queuelen + f"TCP_SND_QUEUELEN={tcp_snd_queuelen}", + f"MEMP_NUM_TCP_SEG={memp_num_tcp_seg}", # must be >= queuelen # PCB pools — 1:1 with socket counts - f"MEMP_NUM_TCP_PCB={tcp_sockets}", # SDK: 12 + f"MEMP_NUM_TCP_PCB={tcp_sockets}", # BK: 12, RTL: 10, LN: 8 # UDP PCB pool — +2 for lwIP internal use (DHCP, DNS) - f"MEMP_NUM_UDP_PCB={udp_sockets + 2}", # SDK: 24 (22+2) + f"MEMP_NUM_UDP_PCB={udp_sockets + 2}", # all SDKs →7 via LT # Netconn pool — sum of all socket types - f"MEMP_NUM_NETCONN={tcp_sockets + listening_tcp + udp_sockets}", # SDK: 38 - # Netbuf pool — ESP8266 uses 0, conservative value - "MEMP_NUM_NETBUF=4", # SDK: 16 - # Inbound message pool — between ESP8266 (4) and SDK (16) - "MEMP_NUM_TCPIP_MSG_INPKT=8", # SDK: 16 + f"MEMP_NUM_NETCONN={tcp_sockets + listening_tcp + udp_sockets}", + # Netbuf pool + "MEMP_NUM_NETBUF=4", # BK/RTL: lwIP default(2), LN: 8 + # Inbound message pool + "MEMP_NUM_TCPIP_MSG_INPKT=8", # BK/RTL: lwIP default(8), LN: 12 ] + + # MEM_SIZE: lwIP dedicated heap pool. + # Only BK72XX needs this — its SDK reserves a 32KB pool, way oversized. + # RTL87XX SDK default is 5KB (already reasonable). + # LN882H uses MEM_LIBC_MALLOC=1 (system heap), so MEM_SIZE is irrelevant. + if CORE.is_bk72xx: + lwip_opts.append("MEM_SIZE=5120") # BK SDK: 32,768, RTL SDK: 5,120 + cg.add_platformio_option("custom_options.lwip", lwip_opts) From 38984b61c97c880f44d2fc85b198e02ecdf59f73 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 13:33:50 -0600 Subject: [PATCH 03/17] [wifi] Track lwIP-internal DHCP+DNS UDP PCBs via socket consumer API Register the 2 UDP PCBs that lwIP allocates internally for DHCP and DNS as a wifi.lwip_internal socket consumer, eliminating the magic +2 in libretiny's MEMP_NUM_UDP_PCB calculation. Also corrects SDK default values in the comparison table after line-by-line verification against all three SDK source files. Co-Authored-By: Claude Opus 4.6 --- esphome/components/libretiny/__init__.py | 25 ++++++++++++------------ esphome/components/wifi/__init__.py | 12 ++++++++++++ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 7064e911c7d..e6715cdf236 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -294,18 +294,19 @@ def _configure_lwip(config: dict) -> None: MEM_SIZE 1.6KB N/A* 32KB 5KB N/A* 5KB BK MAX_SOCKETS_TCP 5 16 12 —** —** dynamic MAX_SOCKETS_UDP 4 16 22 —** —** dynamic - TCP_SND_QUEUELEN ~8 17 20 13 35 17 + TCP_SND_QUEUELEN ~8 17 20 20 35 17 MEMP_NUM_TCP_SEG 10 16 40 20 =qlen 17 MEMP_NUM_TCP_PCB 5 16 12 10 8 =TCP - MEMP_NUM_UDP_PCB 4 16 24→7*** 6→7*** 4→7*** =UDP+2 - MEMP_NUM_NETCONN 0 10 —**** —**** =sum =sum - MEMP_NUM_NETBUF 0 2 —**** —**** 8 4 - MEMP_NUM_TCPIP_MSG_INPKT 4 8 —**** —**** 12 8 + MEMP_NUM_UDP_PCB 4 16 25*** 7**** 7**** =UDP + MEMP_NUM_NETCONN 0 10 38 4***** =sum =sum + MEMP_NUM_NETBUF 0 2 16 2***** 8 4 + MEMP_NUM_TCPIP_MSG_INPKT 4 8 16 8***** 12 8 * ESP32 and LN882H use MEM_LIBC_MALLOC=1 (system heap, no dedicated pool). ** RTL/LN SDKs don't define MAX_SOCKETS_TCP/UDP (LibreTiny-specific). - *** MEMP_NUM_UDP_PCB: SDK base values, all overridden to 7 by LT platform. - **** Not defined in SDK — lwIP opt.h defaults apply. + *** BK LT overlay: MAX_SOCKETS_UDP+2+1 = 25. + **** RTL/LN LT overlay overrides to flat 7. + ***** Not defined in RTL SDK — lwIP opt.h defaults shown. "dynamic" = auto-calculated from component socket registrations via socket.get_socket_counts() with minimums of 10 TCP / 6 UDP. """ @@ -333,7 +334,7 @@ def _configure_lwip(config: dict) -> None: # TCP_SND_QUEUELEN: max pbufs queued for send buffer # ESP-IDF formula: (4 * TCP_SND_BUF + (TCP_MSS - 1)) / TCP_MSS # With 4×MSS: (4*5840 + 1459) / 1460 = 17 — match ESP32 - tcp_snd_queuelen = 17 # BK: 20, RTL: 13, LN: 35 + tcp_snd_queuelen = 17 # BK: 20, RTL: 20, LN: 35 # MEMP_NUM_TCP_SEG: segment pool, must be >= TCP_SND_QUEUELEN (lwIP sanity check) memp_num_tcp_seg = tcp_snd_queuelen # BK: 40, RTL: 20, LN: =qlen @@ -356,14 +357,14 @@ def _configure_lwip(config: dict) -> None: f"MEMP_NUM_TCP_SEG={memp_num_tcp_seg}", # must be >= queuelen # PCB pools — 1:1 with socket counts f"MEMP_NUM_TCP_PCB={tcp_sockets}", # BK: 12, RTL: 10, LN: 8 - # UDP PCB pool — +2 for lwIP internal use (DHCP, DNS) - f"MEMP_NUM_UDP_PCB={udp_sockets + 2}", # all SDKs →7 via LT + # UDP PCB pool — includes wifi.lwip_internal (DHCP + DNS) + f"MEMP_NUM_UDP_PCB={udp_sockets}", # BK: 25, RTL/LN: 7 via LT # Netconn pool — sum of all socket types f"MEMP_NUM_NETCONN={tcp_sockets + listening_tcp + udp_sockets}", # Netbuf pool - "MEMP_NUM_NETBUF=4", # BK/RTL: lwIP default(2), LN: 8 + "MEMP_NUM_NETBUF=4", # BK: 16, RTL: 2 (opt.h), LN: 8 # Inbound message pool - "MEMP_NUM_TCPIP_MSG_INPKT=8", # BK/RTL: lwIP default(8), LN: 12 + "MEMP_NUM_TCPIP_MSG_INPKT=8", # BK: 16, RTL: 8 (opt.h), LN: 12 ] # MEM_SIZE: lwIP dedicated heap pool. diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 540d0a0ab18..a40f04cc455 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -58,6 +58,7 @@ from esphome.const import ( ) from esphome.core import CORE, CoroPriority, HexInt, coroutine_with_priority import esphome.final_validate as fv +from esphome.types import ConfigType from . import wpa2_eap @@ -269,9 +270,20 @@ def final_validate(config): ) +def _consume_wifi_sockets(config: ConfigType) -> ConfigType: + """Register UDP PCBs used internally by lwIP for DHCP and DNS.""" + from esphome.components import socket + + # lwIP allocates UDP PCBs for DHCP client and DNS resolver internally. + # These are not application sockets but consume MEMP_NUM_UDP_PCB pool entries. + socket.consume_sockets(2, "wifi.lwip_internal", socket.SOCKET_UDP)(config) + return config + + FINAL_VALIDATE_SCHEMA = cv.All( final_validate, validate_variant, + _consume_wifi_sockets, ) From f55387b323a62ad02b1fa9678083bd0e444cf86b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 14:11:19 -0600 Subject: [PATCH 04/17] [wifi] Guard lwIP-internal socket registration to LibreTiny only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DHCP/DNS raw UDP PCBs only need tracking on LibreTiny where we directly set MEMP_NUM_UDP_PCB. On ESP32, CONFIG_LWIP_MAX_SOCKETS controls the POSIX socket layer — DHCP/DNS use raw udp_new() bypassing it entirely. Co-Authored-By: Claude Opus 4.6 --- esphome/components/wifi/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index a40f04cc455..6edd84e5896 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -271,7 +271,15 @@ def final_validate(config): def _consume_wifi_sockets(config: ConfigType) -> ConfigType: - """Register UDP PCBs used internally by lwIP for DHCP and DNS.""" + """Register UDP PCBs used internally by lwIP for DHCP and DNS. + + Only needed on LibreTiny where we directly set MEMP_NUM_UDP_PCB (the raw + PCB pool shared by both application sockets and lwIP internals like DHCP/DNS). + On ESP32, CONFIG_LWIP_MAX_SOCKETS only controls the POSIX socket layer — + DHCP/DNS use raw udp_new() which bypasses it entirely. + """ + if not (CORE.is_bk72xx or CORE.is_rtl87xx or CORE.is_ln882x): + return config from esphome.components import socket # lwIP allocates UDP PCBs for DHCP client and DNS resolver internally. From 5d44a556a4663f03ca1f8ad0f500a2613657f61b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 14:15:52 -0600 Subject: [PATCH 05/17] [socket] Increase MIN_UDP_SOCKETS from 6 to 8 Now that DHCP+DNS are tracked as wifi.lwip_internal UDP consumers, the old minimum of 6 only left 4 free PCBs. Bumping to 8 restores the same effective headroom as before (6 free after DHCP+DNS). Co-Authored-By: Claude Opus 4.6 --- esphome/components/libretiny/__init__.py | 2 +- esphome/components/socket/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index e6715cdf236..aeb29668f80 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -308,7 +308,7 @@ def _configure_lwip(config: dict) -> None: **** RTL/LN LT overlay overrides to flat 7. ***** Not defined in RTL SDK — lwIP opt.h defaults shown. "dynamic" = auto-calculated from component socket registrations via - socket.get_socket_counts() with minimums of 10 TCP / 6 UDP. + socket.get_socket_counts() with minimums of 10 TCP / 8 UDP. """ from esphome.components.socket import ( MIN_TCP_SOCKETS, diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index 5ece7a97951..fdecda8c4a3 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -23,7 +23,7 @@ KEY_SOCKET_CONSUMERS_UDP = "socket_consumers_udp" # Recommended minimum socket counts to ensure headroom # Platforms should apply these (or their own) on top of get_socket_counts() MIN_TCP_SOCKETS = 10 -MIN_UDP_SOCKETS = 6 +MIN_UDP_SOCKETS = 8 # Wake loop threadsafe support tracking KEY_WAKE_LOOP_THREADSAFE_REQUIRED = "wake_loop_threadsafe_required" From 2b00a809c827a84cfc261ed674b8c0154115414a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 14:42:30 -0600 Subject: [PATCH 06/17] [libretiny] Fix BK72XX lwIP sanity check for reduced-plan boards BK SDK boards with CFG_LWIP_MEM_POLICY=LWIP_REDUCE_THE_PLAN (e.g. CB3S/ BK7231N) set PBUF_POOL_SIZE=3, which is too small for TCP_WND=4*MSS: 3*(1580-54)=4578 < 5840. Set PBUF_POOL_SIZE=4 on BK72XX so the lwIP compile-time sanity check passes: 4*1526=6104 > 5840. RTL(20) and LN(20) already have large enough PBUF pools. Co-Authored-By: Claude Opus 4.6 --- esphome/components/libretiny/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index aeb29668f80..02c4aad03d6 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -373,6 +373,11 @@ def _configure_lwip(config: dict) -> None: # LN882H uses MEM_LIBC_MALLOC=1 (system heap), so MEM_SIZE is irrelevant. if CORE.is_bk72xx: lwip_opts.append("MEM_SIZE=5120") # BK SDK: 32,768, RTL SDK: 5,120 + # PBUF_POOL_SIZE: BK SDK "reduced plan" sets this to only 3, which is + # too small for TCP_WND=4×MSS (5840 > 3*1526=4578). Set to 4 so the + # lwIP sanity check passes: 4*1526=6104 > 5840. + # RTL(20) and LN(20) are already large enough. + lwip_opts.append("PBUF_POOL_SIZE=4") cg.add_platformio_option("custom_options.lwip", lwip_opts) From 18c89a1cce0b12f074e4a7287b0aa8b8cdacdcd2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 14:58:11 -0600 Subject: [PATCH 07/17] [libretiny] Set BK72XX PBUF_POOL_SIZE to 10 to match default plan PBUF_POOL_SIZE=4 was the minimum to pass lwIP's sanity check but caused pbuf exhaustion during concurrent connections (API + web_server + OTA), resulting in dropped log messages. Set to 10 to match the BK SDK default plan and ESP8266. RTL/LN already default to 20 and need no override. Co-Authored-By: Claude Opus 4.6 --- esphome/components/libretiny/__init__.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 02c4aad03d6..6bba2e20f05 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -292,6 +292,7 @@ def _configure_lwip(config: dict) -> None: TCP_SND_BUF 2×MSS 4×MSS 10×MSS 5×MSS 7×MSS 4×MSS TCP_WND 4×MSS 4×MSS 10×MSS 2×MSS 3×MSS 4×MSS MEM_SIZE 1.6KB N/A* 32KB 5KB N/A* 5KB BK + PBUF_POOL_SIZE 10 16 3/10 20 20 10 BK MAX_SOCKETS_TCP 5 16 12 —** —** dynamic MAX_SOCKETS_UDP 4 16 22 —** —** dynamic TCP_SND_QUEUELEN ~8 17 20 20 35 17 @@ -373,11 +374,11 @@ def _configure_lwip(config: dict) -> None: # LN882H uses MEM_LIBC_MALLOC=1 (system heap), so MEM_SIZE is irrelevant. if CORE.is_bk72xx: lwip_opts.append("MEM_SIZE=5120") # BK SDK: 32,768, RTL SDK: 5,120 - # PBUF_POOL_SIZE: BK SDK "reduced plan" sets this to only 3, which is - # too small for TCP_WND=4×MSS (5840 > 3*1526=4578). Set to 4 so the - # lwIP sanity check passes: 4*1526=6104 > 5840. - # RTL(20) and LN(20) are already large enough. - lwip_opts.append("PBUF_POOL_SIZE=4") + # PBUF_POOL_SIZE: BK SDK "reduced plan" sets this to only 3 — too few + # for multiple concurrent connections (API + web_server + OTA). + # BK default plan uses 10; match that. RTL(20) and LN(20) need no override. + # Cost: 10 × 1580 = 15.8KB static RAM (vs 3 × 1580 = 4.7KB before). + lwip_opts.append("PBUF_POOL_SIZE=10") cg.add_platformio_option("custom_options.lwip", lwip_opts) From 6adbb34fbbcb1ae12ab2ed123441aadb764f9174 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 15:05:18 -0600 Subject: [PATCH 08/17] [libretiny] Show BK72XX reduced/default plan values for MEM_SIZE BK SDK has two plans: reduced (16KB) and default (32KB). Show both in the docstring table for accuracy. Co-Authored-By: Claude Opus 4.6 --- esphome/components/libretiny/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 6bba2e20f05..83f11cc8e59 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -291,7 +291,7 @@ def _configure_lwip(config: dict) -> None: ──────────────────────────────────────────────────────────────────────────── TCP_SND_BUF 2×MSS 4×MSS 10×MSS 5×MSS 7×MSS 4×MSS TCP_WND 4×MSS 4×MSS 10×MSS 2×MSS 3×MSS 4×MSS - MEM_SIZE 1.6KB N/A* 32KB 5KB N/A* 5KB BK + MEM_SIZE 1.6KB N/A* 16/32KB 5KB N/A* 5KB BK PBUF_POOL_SIZE 10 16 3/10 20 20 10 BK MAX_SOCKETS_TCP 5 16 12 —** —** dynamic MAX_SOCKETS_UDP 4 16 22 —** —** dynamic @@ -373,7 +373,7 @@ def _configure_lwip(config: dict) -> None: # RTL87XX SDK default is 5KB (already reasonable). # LN882H uses MEM_LIBC_MALLOC=1 (system heap), so MEM_SIZE is irrelevant. if CORE.is_bk72xx: - lwip_opts.append("MEM_SIZE=5120") # BK SDK: 32,768, RTL SDK: 5,120 + lwip_opts.append("MEM_SIZE=5120") # BK SDK: 16,384/32,768, RTL SDK: 5,120 # PBUF_POOL_SIZE: BK SDK "reduced plan" sets this to only 3 — too few # for multiple concurrent connections (API + web_server + OTA). # BK default plan uses 10; match that. RTL(20) and LN(20) need no override. From e463dc331ebcbf536c1b06cb0c6899b34b642cf8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 15:37:03 -0600 Subject: [PATCH 09/17] [libretiny] Use system heap for BK72XX lwIP (MEM_LIBC_MALLOC=1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the dedicated lwIP heap (MEM_SIZE=16/32KB) with system malloc on BK72XX, matching what LN882H and ESP8266 already do. The BK SDK's malloc wraps FreeRTOS pvPortMalloc which is thread-safe. This eliminates the dedicated pool bottleneck that caused OTA slowness when MEM_SIZE was reduced to 5KB, and frees ~5.9KB heap on BK7231N (61,264 B → 67,144 B). Co-Authored-By: Claude Opus 4.6 --- esphome/components/libretiny/__init__.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 83f11cc8e59..8095f6e341c 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -291,7 +291,7 @@ def _configure_lwip(config: dict) -> None: ──────────────────────────────────────────────────────────────────────────── TCP_SND_BUF 2×MSS 4×MSS 10×MSS 5×MSS 7×MSS 4×MSS TCP_WND 4×MSS 4×MSS 10×MSS 2×MSS 3×MSS 4×MSS - MEM_SIZE 1.6KB N/A* 16/32KB 5KB N/A* 5KB BK + MEM_SIZE N/A* N/A* 16/32KB 5KB N/A* N/A* BK PBUF_POOL_SIZE 10 16 3/10 20 20 10 BK MAX_SOCKETS_TCP 5 16 12 —** —** dynamic MAX_SOCKETS_UDP 4 16 22 —** —** dynamic @@ -368,16 +368,17 @@ def _configure_lwip(config: dict) -> None: "MEMP_NUM_TCPIP_MSG_INPKT=8", # BK: 16, RTL: 8 (opt.h), LN: 12 ] - # MEM_SIZE: lwIP dedicated heap pool. - # Only BK72XX needs this — its SDK reserves a 32KB pool, way oversized. - # RTL87XX SDK default is 5KB (already reasonable). - # LN882H uses MEM_LIBC_MALLOC=1 (system heap), so MEM_SIZE is irrelevant. + # BK72XX-specific overrides: + # - MEM_LIBC_MALLOC=1: Use system heap instead of dedicated lwIP heap. + # BK SDK defaults to a 16/32KB dedicated pool (MEM_LIBC_MALLOC=0). + # LN882H already uses MEM_LIBC_MALLOC=1. This eliminates the dedicated + # pool bottleneck that caused OTA slowness at MEM_SIZE=5KB. + # - PBUF_POOL_SIZE: BK SDK "reduced plan" sets this to only 3 — too few + # for multiple concurrent connections (API + web_server + OTA). + # BK default plan uses 10; match that. RTL(20) and LN(20) need no override. + # Cost: 10 × 1580 = 15.8KB static RAM (vs 3 × 1580 = 4.7KB before). if CORE.is_bk72xx: - lwip_opts.append("MEM_SIZE=5120") # BK SDK: 16,384/32,768, RTL SDK: 5,120 - # PBUF_POOL_SIZE: BK SDK "reduced plan" sets this to only 3 — too few - # for multiple concurrent connections (API + web_server + OTA). - # BK default plan uses 10; match that. RTL(20) and LN(20) need no override. - # Cost: 10 × 1580 = 15.8KB static RAM (vs 3 × 1580 = 4.7KB before). + lwip_opts.append("MEM_LIBC_MALLOC=1") lwip_opts.append("PBUF_POOL_SIZE=10") cg.add_platformio_option("custom_options.lwip", lwip_opts) From 770e3092a5cfaff89d4e7de31aa660b5de8ca82c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 15:54:42 -0600 Subject: [PATCH 10/17] [libretiny] Fix MEMP_NUM_NETCONN double-counting listening sockets Listening sockets are already included in the TCP socket count from component registrations (e.g. api registers 1 listening + 3 clients). Remove the extra listening_tcp from MEMP_NUM_NETCONN to avoid over-provisioning. Co-Authored-By: Claude Opus 4.6 --- esphome/components/libretiny/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 8095f6e341c..4e73ac9542c 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -360,8 +360,8 @@ def _configure_lwip(config: dict) -> None: f"MEMP_NUM_TCP_PCB={tcp_sockets}", # BK: 12, RTL: 10, LN: 8 # UDP PCB pool — includes wifi.lwip_internal (DHCP + DNS) f"MEMP_NUM_UDP_PCB={udp_sockets}", # BK: 25, RTL/LN: 7 via LT - # Netconn pool — sum of all socket types - f"MEMP_NUM_NETCONN={tcp_sockets + listening_tcp + udp_sockets}", + # Netconn pool — listening sockets are already counted in tcp_sockets + f"MEMP_NUM_NETCONN={tcp_sockets + udp_sockets}", # Netbuf pool "MEMP_NUM_NETBUF=4", # BK: 16, RTL: 2 (opt.h), LN: 8 # Inbound message pool From 30d694e62e9f501cba99732b6af396f0d94df50c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 16:19:18 -0600 Subject: [PATCH 11/17] [libretiny] Enable MEMP_MEM_MALLOC=1 on BK72XX for dynamic MEMP pools Allocate all MEMP pools (TCP PCBs, UDP PCBs, netconns, TCP segments, pbufs, etc.) from the heap on demand instead of pre-allocated static arrays. Saves ~20KB RAM on BK7231N (87,312 B free vs 67,144 B before). Safe because the BK WiFi driver's receive path runs in task context (core_thread pops from g_wifi_core.io_queue), not ISR context. ESP32 and ESP8266 both ship with MEMP_MEM_MALLOC=1. Co-Authored-By: Claude Opus 4.6 --- esphome/components/libretiny/__init__.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 4e73ac9542c..26be46dcd0c 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -291,6 +291,8 @@ def _configure_lwip(config: dict) -> None: ──────────────────────────────────────────────────────────────────────────── TCP_SND_BUF 2×MSS 4×MSS 10×MSS 5×MSS 7×MSS 4×MSS TCP_WND 4×MSS 4×MSS 10×MSS 2×MSS 3×MSS 4×MSS + MEM_LIBC_MALLOC 1 1 0 0 1 1 BK + MEMP_MEM_MALLOC 1 1 0 0 0 1 BK MEM_SIZE N/A* N/A* 16/32KB 5KB N/A* N/A* BK PBUF_POOL_SIZE 10 16 3/10 20 20 10 BK MAX_SOCKETS_TCP 5 16 12 —** —** dynamic @@ -303,7 +305,8 @@ def _configure_lwip(config: dict) -> None: MEMP_NUM_NETBUF 0 2 16 2***** 8 4 MEMP_NUM_TCPIP_MSG_INPKT 4 8 16 8***** 12 8 - * ESP32 and LN882H use MEM_LIBC_MALLOC=1 (system heap, no dedicated pool). + * ESP8266/ESP32/LN882H use MEM_LIBC_MALLOC=1 (system heap, no dedicated pool). + ESP8266/ESP32 also use MEMP_MEM_MALLOC=1 (MEMP pools from heap, not static). ** RTL/LN SDKs don't define MAX_SOCKETS_TCP/UDP (LibreTiny-specific). *** BK LT overlay: MAX_SOCKETS_UDP+2+1 = 25. **** RTL/LN LT overlay overrides to flat 7. @@ -372,13 +375,21 @@ def _configure_lwip(config: dict) -> None: # - MEM_LIBC_MALLOC=1: Use system heap instead of dedicated lwIP heap. # BK SDK defaults to a 16/32KB dedicated pool (MEM_LIBC_MALLOC=0). # LN882H already uses MEM_LIBC_MALLOC=1. This eliminates the dedicated - # pool bottleneck that caused OTA slowness at MEM_SIZE=5KB. + # pool bottleneck that caused OTA slowness due to heap fragmentation. + # Safe because BK SDK wires mem_clib_malloc → os_malloc → pvPortMalloc + # (FreeRTOS thread-safe heap). + # - MEMP_MEM_MALLOC=1: Allocate MEMP pools from heap on demand instead + # of static arrays. Saves ~20KB RAM for unused pool entries. Safe because + # the BK WiFi driver's receive path runs in task context (core_thread), + # not ISR context — the ISR only posts to a queue. ESP32 and ESP8266 + # both ship with MEMP_MEM_MALLOC=1. # - PBUF_POOL_SIZE: BK SDK "reduced plan" sets this to only 3 — too few # for multiple concurrent connections (API + web_server + OTA). # BK default plan uses 10; match that. RTL(20) and LN(20) need no override. - # Cost: 10 × 1580 = 15.8KB static RAM (vs 3 × 1580 = 4.7KB before). + # With MEMP_MEM_MALLOC=1, this is a max count (allocated on demand). if CORE.is_bk72xx: lwip_opts.append("MEM_LIBC_MALLOC=1") + lwip_opts.append("MEMP_MEM_MALLOC=1") lwip_opts.append("PBUF_POOL_SIZE=10") cg.add_platformio_option("custom_options.lwip", lwip_opts) From 5b5bc28ba3ecade3c70f50187ca96a796f15daae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 16:37:56 -0600 Subject: [PATCH 12/17] [libretiny] Enable MEM_LIBC_MALLOC + MEMP_MEM_MALLOC on all platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move MEM_LIBC_MALLOC=1 and MEMP_MEM_MALLOC=1 from BK72XX-only to all LibreTiny platforms. All three SDKs (BK/RTL/LN) wire malloc to FreeRTOS pvPortMalloc (thread-safe), and WiFi receive paths run in task context. Tested on RTL87xx (BW15): 84,184 B → 103,744 B free heap (~19.6KB freed). OTA and web server confirmed working. Co-Authored-By: Claude Opus 4.6 --- esphome/components/libretiny/__init__.py | 33 ++++++++++++------------ 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 26be46dcd0c..cc9b26f0d88 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -291,8 +291,8 @@ def _configure_lwip(config: dict) -> None: ──────────────────────────────────────────────────────────────────────────── TCP_SND_BUF 2×MSS 4×MSS 10×MSS 5×MSS 7×MSS 4×MSS TCP_WND 4×MSS 4×MSS 10×MSS 2×MSS 3×MSS 4×MSS - MEM_LIBC_MALLOC 1 1 0 0 1 1 BK - MEMP_MEM_MALLOC 1 1 0 0 0 1 BK + MEM_LIBC_MALLOC 1 1 0 0 1 1 + MEMP_MEM_MALLOC 1 1 0 0 0 1 MEM_SIZE N/A* N/A* 16/32KB 5KB N/A* N/A* BK PBUF_POOL_SIZE 10 16 3/10 20 20 10 BK MAX_SOCKETS_TCP 5 16 12 —** —** dynamic @@ -371,25 +371,24 @@ def _configure_lwip(config: dict) -> None: "MEMP_NUM_TCPIP_MSG_INPKT=8", # BK: 16, RTL: 8 (opt.h), LN: 12 ] - # BK72XX-specific overrides: + # Use system heap for all lwIP allocations on all LibreTiny platforms. # - MEM_LIBC_MALLOC=1: Use system heap instead of dedicated lwIP heap. - # BK SDK defaults to a 16/32KB dedicated pool (MEM_LIBC_MALLOC=0). - # LN882H already uses MEM_LIBC_MALLOC=1. This eliminates the dedicated - # pool bottleneck that caused OTA slowness due to heap fragmentation. - # Safe because BK SDK wires mem_clib_malloc → os_malloc → pvPortMalloc - # (FreeRTOS thread-safe heap). + # LN882H already ships with this. BK SDK defaults to a 16/32KB dedicated + # pool that fragments during OTA. RTL SDK defaults to a 5KB pool. + # All three SDKs wire malloc → pvPortMalloc (FreeRTOS thread-safe heap). # - MEMP_MEM_MALLOC=1: Allocate MEMP pools from heap on demand instead - # of static arrays. Saves ~20KB RAM for unused pool entries. Safe because - # the BK WiFi driver's receive path runs in task context (core_thread), - # not ISR context — the ISR only posts to a queue. ESP32 and ESP8266 + # of static arrays. Saves ~20KB RAM on BK72XX. Safe because WiFi + # receive paths run in task context, not ISR context. ESP32 and ESP8266 # both ship with MEMP_MEM_MALLOC=1. - # - PBUF_POOL_SIZE: BK SDK "reduced plan" sets this to only 3 — too few - # for multiple concurrent connections (API + web_server + OTA). - # BK default plan uses 10; match that. RTL(20) and LN(20) need no override. - # With MEMP_MEM_MALLOC=1, this is a max count (allocated on demand). + lwip_opts.append("MEM_LIBC_MALLOC=1") + lwip_opts.append("MEMP_MEM_MALLOC=1") + + # BK72XX-specific: PBUF_POOL_SIZE override + # BK SDK "reduced plan" sets this to only 3 — too few for multiple + # concurrent connections (API + web_server + OTA). BK default plan + # uses 10; match that. RTL(20) and LN(20) need no override. + # With MEMP_MEM_MALLOC=1, this is a max count (allocated on demand). if CORE.is_bk72xx: - lwip_opts.append("MEM_LIBC_MALLOC=1") - lwip_opts.append("MEMP_MEM_MALLOC=1") lwip_opts.append("PBUF_POOL_SIZE=10") cg.add_platformio_option("custom_options.lwip", lwip_opts) From d63016628050009952da8aaef881e0fc1a615d64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 17:00:40 -0600 Subject: [PATCH 13/17] [socket] Track TCP listening sockets separately via SocketType.TCP_LISTEN Components now register their listening sockets explicitly instead of relying on a hardcoded count. web_server_base registers the shared HTTP listener (used by both web_server and captive_portal). The libretiny lwIP config derives MEMP_NUM_TCP_PCB_LISTEN dynamically from these registrations. Also fixes captive_portal to correctly register its DNS socket as UDP instead of TCP. Co-Authored-By: Claude Opus 4.6 --- esphome/components/api/__init__.py | 4 +-- esphome/components/captive_portal/__init__.py | 6 +++-- esphome/components/esp32/__init__.py | 5 +++- .../esp32_camera_web_server/__init__.py | 6 +++-- esphome/components/esphome/ota/__init__.py | 2 +- esphome/components/libretiny/__init__.py | 11 +++++--- esphome/components/socket/__init__.py | 24 +++++++++++++----- esphome/components/web_server/__init__.py | 6 ++--- .../components/web_server_base/__init__.py | 25 ++++++++++++++++--- 9 files changed, 64 insertions(+), 25 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 9bff9f56355..3f7cafb4851 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -233,8 +233,8 @@ def _consume_api_sockets(config: ConfigType) -> ConfigType: # API needs 1 listening socket + typically 3 concurrent client connections # (not max_connections, which is the upper limit rarely reached) - sockets_needed = 1 + 3 - socket.consume_sockets(sockets_needed, "api")(config) + socket.consume_sockets(3, "api")(config) + socket.consume_sockets(1, "api", socket.SocketType.TCP_LISTEN)(config) return config diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 049618219e1..6c190814c03 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -76,13 +76,15 @@ def _final_validate(config: ConfigType) -> ConfigType: # Register socket needs for DNS server and additional HTTP connections # - 1 UDP socket for DNS server - # - 3 additional TCP sockets for captive portal detection probes + configuration requests + # - 3 TCP sockets for captive portal detection probes + configuration requests # OS captive portal detection makes multiple probe requests that stay in TIME_WAIT. # Need headroom for actual user configuration requests. # LRU purging will reclaim idle sockets to prevent exhaustion from repeated attempts. + # The listening socket is registered by web_server_base (shared HTTP server). from esphome.components import socket - socket.consume_sockets(4, "captive_portal")(config) + socket.consume_sockets(3, "captive_portal")(config) + socket.consume_sockets(1, "captive_portal", socket.SocketType.UDP)(config) return config diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ac73bf449e5..5483be5c4f9 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1263,7 +1263,10 @@ def _configure_lwip_max_sockets(conf: dict) -> None: # Check if user manually specified CONFIG_LWIP_MAX_SOCKETS user_max_sockets = conf[CONF_SDKCONFIG_OPTIONS].get("CONFIG_LWIP_MAX_SOCKETS") - tcp_sockets, udp_sockets = get_socket_counts() + # tcp_listen not used on ESP32 — ESP-IDF defaults MEMP_NUM_TCP_PCB_LISTEN + # to 16 which is already generous, and CONFIG_LWIP_MAX_SOCKETS is a single + # combined VFS socket pool with no separate listening socket limit. + tcp_sockets, udp_sockets, _tcp_listen = get_socket_counts() total_sockets = tcp_sockets + udp_sockets # User specified their own value - respect it but warn if insufficient diff --git a/esphome/components/esp32_camera_web_server/__init__.py b/esphome/components/esp32_camera_web_server/__init__.py index ed1aaa2e076..da260ad7a1c 100644 --- a/esphome/components/esp32_camera_web_server/__init__.py +++ b/esphome/components/esp32_camera_web_server/__init__.py @@ -20,8 +20,10 @@ def _consume_camera_web_server_sockets(config: ConfigType) -> ConfigType: from esphome.components import socket # Each camera web server instance needs 1 listening socket + 2 client connections - sockets_needed = 3 - socket.consume_sockets(sockets_needed, "esp32_camera_web_server")(config) + socket.consume_sockets(2, "esp32_camera_web_server")(config) + socket.consume_sockets(1, "esp32_camera_web_server", socket.SocketType.TCP_LISTEN)( + config + ) return config diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 2f637d714d1..589f44f85c3 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -98,7 +98,7 @@ def _consume_ota_sockets(config: ConfigType) -> ConfigType: from esphome.components import socket # OTA needs 1 listening socket (client connections are temporary during updates) - socket.consume_sockets(1, "ota")(config) + socket.consume_sockets(1, "ota", socket.SocketType.TCP_LISTEN)(config) return config diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index cc9b26f0d88..e2b3335491a 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -300,6 +300,7 @@ def _configure_lwip(config: dict) -> None: TCP_SND_QUEUELEN ~8 17 20 20 35 17 MEMP_NUM_TCP_SEG 10 16 40 20 =qlen 17 MEMP_NUM_TCP_PCB 5 16 12 10 8 =TCP + MEMP_NUM_TCP_PCB_LISTEN 4 16 4 5 3 dynamic MEMP_NUM_UDP_PCB 4 16 25*** 7**** 7**** =UDP MEMP_NUM_NETCONN 0 10 38 4***** =sum =sum MEMP_NUM_NETBUF 0 2 16 2***** 8 4 @@ -320,11 +321,12 @@ def _configure_lwip(config: dict) -> None: get_socket_counts, ) - raw_tcp, raw_udp = get_socket_counts() + raw_tcp, raw_udp, raw_tcp_listen = get_socket_counts() # Apply platform minimums — ensure headroom for ESPHome's needs tcp_sockets = max(MIN_TCP_SOCKETS, raw_tcp) udp_sockets = max(MIN_UDP_SOCKETS, raw_udp) - listening_tcp = 4 + # Listening sockets — registered by components (api, ota, web_server_base, etc.) + listening_tcp = max(raw_tcp_listen, 2) # at least 2 (api + ota) # TCP_SND_BUF: ESPAsyncWebServer allocates malloc(tcp_sndbuf()) per # response chunk. At 10×MSS=14.6KB (BK default) this causes OOM (#14095). @@ -354,13 +356,14 @@ def _configure_lwip(config: dict) -> None: # Socket counts — auto-calculated from component registrations f"MAX_SOCKETS_TCP={tcp_sockets}", f"MAX_SOCKETS_UDP={udp_sockets}", - # Listening sockets — API + web_server + OTA at most + # Listening sockets — auto-calculated from component registrations f"MAX_LISTENING_SOCKETS_TCP={listening_tcp}", # Queued segment limits — derived from 4×MSS buffer size f"TCP_SND_QUEUELEN={tcp_snd_queuelen}", f"MEMP_NUM_TCP_SEG={memp_num_tcp_seg}", # must be >= queuelen - # PCB pools — 1:1 with socket counts + # PCB pools — active connections + listening sockets f"MEMP_NUM_TCP_PCB={tcp_sockets}", # BK: 12, RTL: 10, LN: 8 + f"MEMP_NUM_TCP_PCB_LISTEN={listening_tcp}", # BK: =MAX_LISTENING, RTL: 5, LN: 3 # UDP PCB pool — includes wifi.lwip_internal (DHCP + DNS) f"MEMP_NUM_UDP_PCB={udp_sockets}", # BK: 25, RTL/LN: 7 via LT # Netconn pool — listening sockets are already counted in tcp_sockets diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index fdecda8c4a3..8fbc0ce302e 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -19,9 +19,12 @@ IMPLEMENTATION_BSD_SOCKETS = "bsd_sockets" # Components register their socket needs and platforms read this to configure appropriately KEY_SOCKET_CONSUMERS_TCP = "socket_consumers_tcp" KEY_SOCKET_CONSUMERS_UDP = "socket_consumers_udp" +KEY_SOCKET_CONSUMERS_TCP_LISTEN = "socket_consumers_tcp_listen" -# Recommended minimum socket counts to ensure headroom -# Platforms should apply these (or their own) on top of get_socket_counts() +# Recommended minimum socket counts to ensure headroom. +# Platforms should apply these (or their own) on top of get_socket_counts(). +# TCP: api(4) + ota(1) = 5 base, +5 headroom for web_server/other components. +# UDP: dhcp(1) + dns(1) + mdns(2) + wake_loop(1) = 5 base, +3 headroom. MIN_TCP_SOCKETS = 10 MIN_UDP_SOCKETS = 8 @@ -32,6 +35,7 @@ KEY_WAKE_LOOP_THREADSAFE_REQUIRED = "wake_loop_threadsafe_required" class SocketType(StrEnum): TCP = "tcp" UDP = "udp" + TCP_LISTEN = "tcp_listen" # Legacy aliases @@ -41,6 +45,7 @@ SOCKET_UDP = SocketType.UDP _SOCKET_TYPE_KEYS = { SocketType.TCP: KEY_SOCKET_CONSUMERS_TCP, SocketType.UDP: KEY_SOCKET_CONSUMERS_UDP, + SocketType.TCP_LISTEN: KEY_SOCKET_CONSUMERS_TCP_LISTEN, } @@ -67,8 +72,8 @@ def consume_sockets( return _consume_sockets -def get_socket_counts() -> tuple[int, int]: - """Return (tcp_count, udp_count) of raw registered socket needs. +def get_socket_counts() -> tuple[int, int, int]: + """Return (tcp_count, udp_count, tcp_listen_count) of raw registered socket needs. Platforms call this during code generation to configure lwIP socket limits. All components will have registered their needs by then. @@ -77,8 +82,10 @@ def get_socket_counts() -> tuple[int, int]: """ tcp_consumers = CORE.data.get(KEY_SOCKET_CONSUMERS_TCP, {}) udp_consumers = CORE.data.get(KEY_SOCKET_CONSUMERS_UDP, {}) + tcp_listen_consumers = CORE.data.get(KEY_SOCKET_CONSUMERS_TCP_LISTEN, {}) tcp = sum(tcp_consumers.values()) udp = sum(udp_consumers.values()) + tcp_listen = sum(tcp_listen_consumers.values()) tcp_list = ", ".join( f"{name}={count}" for name, count in sorted(tcp_consumers.items()) @@ -86,14 +93,19 @@ def get_socket_counts() -> tuple[int, int]: udp_list = ", ".join( f"{name}={count}" for name, count in sorted(udp_consumers.items()) ) + tcp_listen_list = ", ".join( + f"{name}={count}" for name, count in sorted(tcp_listen_consumers.items()) + ) _LOGGER.debug( - "Socket counts: TCP=%d (%s), UDP=%d (%s)", + "Socket counts: TCP=%d (%s), UDP=%d (%s), TCP_LISTEN=%d (%s)", tcp, tcp_list or "none", udp, udp_list or "none", + tcp_listen, + tcp_listen_list or "none", ) - return tcp, udp + return tcp, udp, tcp_listen def require_wake_loop_threadsafe() -> None: diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 9305a2de613..84910b6f90f 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -144,11 +144,11 @@ def _consume_web_server_sockets(config: ConfigType) -> ConfigType: """Register socket needs for web_server component.""" from esphome.components import socket - # Web server needs 1 listening socket + typically 5 concurrent client connections + # Web server needs typically 5 concurrent client connections # (browser opens connections for page resources, SSE event stream, and POST # requests for entity control which may linger before closing) - sockets_needed = 6 - socket.consume_sockets(sockets_needed, "web_server")(config) + # The listening socket is registered by web_server_base (shared with captive_portal) + socket.consume_sockets(5, "web_server")(config) return config diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index 7986ac964df..3191f1013ac 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -23,10 +23,27 @@ web_server_base_ns = cg.esphome_ns.namespace("web_server_base") WebServerBase = web_server_base_ns.class_("WebServerBase", cg.Component) CONF_WEB_SERVER_BASE_ID = "web_server_base_id" -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(WebServerBase), - } + + +def _consume_web_server_base_sockets(config): + """Register the shared listening socket for the HTTP server. + + web_server_base is the shared HTTP server used by web_server and captive_portal. + The listening socket is registered here rather than in each consumer. + """ + from esphome.components import socket + + socket.consume_sockets(1, "web_server_base", socket.SocketType.TCP_LISTEN)(config) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(WebServerBase), + } + ), + _consume_web_server_base_sockets, ) From c90ab6f72f2c9341be1feba0da3d8e7b6a56bdaa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 17:07:46 -0600 Subject: [PATCH 14/17] [socket] Remove legacy aliases, fix NETCONN count, add comments - Remove SOCKET_TCP/SOCKET_UDP aliases, use SocketType.UDP everywhere - Fix MEMP_NUM_NETCONN to include listening_tcp (listeners need netconns) - Add comment on listening_tcp minimum (not all components register yet) - Add comment on MAX_LISTENING_SOCKETS_TCP (BK-specific, RTL/LN use MEMP_NUM_TCP_PCB_LISTEN directly) Co-Authored-By: Claude Opus 4.6 --- esphome/components/libretiny/__init__.py | 10 ++++++---- esphome/components/mdns/__init__.py | 2 +- esphome/components/socket/__init__.py | 6 +----- esphome/components/udp/__init__.py | 2 +- esphome/components/wifi/__init__.py | 2 +- 5 files changed, 10 insertions(+), 12 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index e2b3335491a..0daf9733b81 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -326,7 +326,8 @@ def _configure_lwip(config: dict) -> None: tcp_sockets = max(MIN_TCP_SOCKETS, raw_tcp) udp_sockets = max(MIN_UDP_SOCKETS, raw_udp) # Listening sockets — registered by components (api, ota, web_server_base, etc.) - listening_tcp = max(raw_tcp_listen, 2) # at least 2 (api + ota) + # Not all components register yet, so ensure a minimum of 2 (api + ota baseline). + listening_tcp = max(raw_tcp_listen, 2) # TCP_SND_BUF: ESPAsyncWebServer allocates malloc(tcp_sndbuf()) per # response chunk. At 10×MSS=14.6KB (BK default) this causes OOM (#14095). @@ -356,7 +357,8 @@ def _configure_lwip(config: dict) -> None: # Socket counts — auto-calculated from component registrations f"MAX_SOCKETS_TCP={tcp_sockets}", f"MAX_SOCKETS_UDP={udp_sockets}", - # Listening sockets — auto-calculated from component registrations + # Listening sockets — BK SDK uses this to derive MEMP_NUM_TCP_PCB_LISTEN; + # RTL/LN don't use it, but we set MEMP_NUM_TCP_PCB_LISTEN explicitly below. f"MAX_LISTENING_SOCKETS_TCP={listening_tcp}", # Queued segment limits — derived from 4×MSS buffer size f"TCP_SND_QUEUELEN={tcp_snd_queuelen}", @@ -366,8 +368,8 @@ def _configure_lwip(config: dict) -> None: f"MEMP_NUM_TCP_PCB_LISTEN={listening_tcp}", # BK: =MAX_LISTENING, RTL: 5, LN: 3 # UDP PCB pool — includes wifi.lwip_internal (DHCP + DNS) f"MEMP_NUM_UDP_PCB={udp_sockets}", # BK: 25, RTL/LN: 7 via LT - # Netconn pool — listening sockets are already counted in tcp_sockets - f"MEMP_NUM_NETCONN={tcp_sockets + udp_sockets}", + # Netconn pool — each socket (active + listening) needs a netconn + f"MEMP_NUM_NETCONN={tcp_sockets + udp_sockets + listening_tcp}", # Netbuf pool "MEMP_NUM_NETBUF=4", # BK: 16, RTL: 2 (opt.h), LN: 8 # Inbound message pool diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index c7fb28e7665..420e6a60e30 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -56,7 +56,7 @@ def _consume_mdns_sockets(config: ConfigType) -> ConfigType: from esphome.components import socket # mDNS needs 2 sockets (IPv4 + IPv6 multicast) - socket.consume_sockets(2, "mdns", socket.SOCKET_UDP)(config) + socket.consume_sockets(2, "mdns", socket.SocketType.UDP)(config) return config diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index 8fbc0ce302e..4204a147475 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -38,10 +38,6 @@ class SocketType(StrEnum): TCP_LISTEN = "tcp_listen" -# Legacy aliases -SOCKET_TCP = SocketType.TCP -SOCKET_UDP = SocketType.UDP - _SOCKET_TYPE_KEYS = { SocketType.TCP: KEY_SOCKET_CONSUMERS_TCP, SocketType.UDP: KEY_SOCKET_CONSUMERS_UDP, @@ -134,7 +130,7 @@ def require_wake_loop_threadsafe() -> None: CORE.data[KEY_WAKE_LOOP_THREADSAFE_REQUIRED] = True cg.add_define("USE_WAKE_LOOP_THREADSAFE") # Consume 1 socket for the shared wake notification socket - consume_sockets(1, "socket.wake_loop_threadsafe", SOCKET_UDP)({}) + consume_sockets(1, "socket.wake_loop_threadsafe", SocketType.UDP)({}) CONFIG_SCHEMA = cv.Schema( diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index 74a890076ed..37dd871a6c1 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -73,7 +73,7 @@ def _consume_udp_sockets(config: ConfigType) -> ConfigType: # UDP uses up to 2 sockets: 1 broadcast + 1 listen # Whether each is used depends on code generation, so register worst case - socket.consume_sockets(2, "udp", socket.SOCKET_UDP)(config) + socket.consume_sockets(2, "udp", socket.SocketType.UDP)(config) return config diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 6edd84e5896..5fbf76a22dc 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -284,7 +284,7 @@ def _consume_wifi_sockets(config: ConfigType) -> ConfigType: # lwIP allocates UDP PCBs for DHCP client and DNS resolver internally. # These are not application sockets but consume MEMP_NUM_UDP_PCB pool entries. - socket.consume_sockets(2, "wifi.lwip_internal", socket.SOCKET_UDP)(config) + socket.consume_sockets(2, "wifi.lwip_internal", socket.SocketType.UDP)(config) return config From bfde66f7f4c299d1e5b135cd64a2d9fb538f5d32 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 17:10:29 -0600 Subject: [PATCH 15/17] [socket] Update comments for new TCP_LISTEN registration model - OTA: note that active transfer uses a TCP PCB from general pool - MIN_TCP_SOCKETS: update base count to api(3) after TCP_LISTEN split Co-Authored-By: Claude Opus 4.6 --- esphome/components/esphome/ota/__init__.py | 3 ++- esphome/components/socket/__init__.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 589f44f85c3..337064dd271 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -97,7 +97,8 @@ def _consume_ota_sockets(config: ConfigType) -> ConfigType: """Register socket needs for OTA component.""" from esphome.components import socket - # OTA needs 1 listening socket (client connections are temporary during updates) + # OTA needs 1 listening socket. The active transfer connection during an update + # uses a TCP PCB from the general pool, covered by MIN_TCP_SOCKETS headroom. socket.consume_sockets(1, "ota", socket.SocketType.TCP_LISTEN)(config) return config diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index 4204a147475..3369779942e 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -23,7 +23,7 @@ KEY_SOCKET_CONSUMERS_TCP_LISTEN = "socket_consumers_tcp_listen" # Recommended minimum socket counts to ensure headroom. # Platforms should apply these (or their own) on top of get_socket_counts(). -# TCP: api(4) + ota(1) = 5 base, +5 headroom for web_server/other components. +# TCP: api(3) = 3 base, +7 headroom for web_server(5)/ota-transfer/other. # UDP: dhcp(1) + dns(1) + mdns(2) + wake_loop(1) = 5 base, +3 headroom. MIN_TCP_SOCKETS = 10 MIN_UDP_SOCKETS = 8 From 7a1448ed1956c0ac159b872eb35ff91c3473f725 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 17:18:21 -0600 Subject: [PATCH 16/17] Update esphome/components/socket/__init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/socket/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index 3369779942e..db65635a892 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -23,7 +23,7 @@ KEY_SOCKET_CONSUMERS_TCP_LISTEN = "socket_consumers_tcp_listen" # Recommended minimum socket counts to ensure headroom. # Platforms should apply these (or their own) on top of get_socket_counts(). -# TCP: api(3) = 3 base, +7 headroom for web_server(5)/ota-transfer/other. +# TCP: Typical setup: api(3) + web_server(5) = 8 registered, +2 headroom for ota-transfer/other = 10 total. # UDP: dhcp(1) + dns(1) + mdns(2) + wake_loop(1) = 5 base, +3 headroom. MIN_TCP_SOCKETS = 10 MIN_UDP_SOCKETS = 8 From 8aa77c306f89eb274a0115fe81a1b6f6aa1a5a84 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 17:18:27 -0600 Subject: [PATCH 17/17] [socket] Fix docstring and comment accuracy - Update MIN_TCP_SOCKETS comment: api(3) + web_server(5) = 8 typical - Add TCP_LISTEN to consume_sockets docstring Co-Authored-By: Claude Opus 4.6 --- esphome/components/socket/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index db65635a892..de5c6d2dd6b 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -53,7 +53,7 @@ def consume_sockets( Args: value: Number of sockets needed by the component consumer: Name of the component consuming the sockets - socket_type: Type of socket (SocketType.TCP or SocketType.UDP) + socket_type: Type of socket (SocketType.TCP, SocketType.UDP, or SocketType.TCP_LISTEN) Returns: A validator function that records the socket usage