mirror of
https://github.com/esphome/esphome.git
synced 2026-05-31 07:57:40 +08:00
[libretiny] Tune oversized lwIP defaults for ESPHome (#14186)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -233,8 +233,8 @@ def _consume_api_sockets(config: ConfigType) -> ConfigType:
|
|||||||
|
|
||||||
# API needs 1 listening socket + typically 3 concurrent client connections
|
# API needs 1 listening socket + typically 3 concurrent client connections
|
||||||
# (not max_connections, which is the upper limit rarely reached)
|
# (not max_connections, which is the upper limit rarely reached)
|
||||||
sockets_needed = 1 + 3
|
socket.consume_sockets(3, "api")(config)
|
||||||
socket.consume_sockets(sockets_needed, "api")(config)
|
socket.consume_sockets(1, "api", socket.SocketType.TCP_LISTEN)(config)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -76,13 +76,15 @@ def _final_validate(config: ConfigType) -> ConfigType:
|
|||||||
|
|
||||||
# Register socket needs for DNS server and additional HTTP connections
|
# Register socket needs for DNS server and additional HTTP connections
|
||||||
# - 1 UDP socket for DNS server
|
# - 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.
|
# OS captive portal detection makes multiple probe requests that stay in TIME_WAIT.
|
||||||
# Need headroom for actual user configuration requests.
|
# Need headroom for actual user configuration requests.
|
||||||
# LRU purging will reclaim idle sockets to prevent exhaustion from repeated attempts.
|
# 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
|
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
|
return config
|
||||||
|
|
||||||
|
|||||||
@@ -1258,21 +1258,15 @@ def _configure_lwip_max_sockets(conf: dict) -> None:
|
|||||||
This function runs in to_code() after all components have registered their socket needs.
|
This function runs in to_code() after all components have registered their socket needs.
|
||||||
User-provided sdkconfig_options take precedence.
|
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
|
# Check if user manually specified CONFIG_LWIP_MAX_SOCKETS
|
||||||
user_max_sockets = conf[CONF_SDKCONFIG_OPTIONS].get("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, {})
|
# CONFIG_LWIP_MAX_SOCKETS is a single VFS socket pool shared by all socket
|
||||||
total_sockets = sum(socket_consumers.values())
|
# types (TCP clients, TCP listeners, and UDP). Include all three counts.
|
||||||
|
sc = get_socket_counts()
|
||||||
# Early return if no sockets registered and no user override
|
total_sockets = sc.tcp + sc.udp + sc.tcp_listen
|
||||||
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())
|
|
||||||
)
|
|
||||||
|
|
||||||
# User specified their own value - respect it but warn if insufficient
|
# User specified their own value - respect it but warn if insufficient
|
||||||
if user_max_sockets is not None:
|
if user_max_sockets is not None:
|
||||||
@@ -1281,22 +1275,23 @@ def _configure_lwip_max_sockets(conf: dict) -> None:
|
|||||||
user_max_sockets,
|
user_max_sockets,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Warn if user's value is less than what components need
|
user_sockets_int = 0
|
||||||
if total_sockets > 0:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
user_sockets_int = 0
|
user_sockets_int = int(user_max_sockets)
|
||||||
with contextlib.suppress(ValueError, TypeError):
|
|
||||||
user_sockets_int = int(user_max_sockets)
|
|
||||||
|
|
||||||
if user_sockets_int < total_sockets:
|
if user_sockets_int < total_sockets:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"CONFIG_LWIP_MAX_SOCKETS is set to %d but your configuration "
|
"CONFIG_LWIP_MAX_SOCKETS is set to %d but your configuration "
|
||||||
"needs %d sockets (registered: %s). You may experience socket "
|
"needs %d sockets (%d TCP + %d UDP + %d TCP_LISTEN). You may "
|
||||||
"exhaustion errors. Consider increasing to at least %d.",
|
"experience socket exhaustion errors. Consider increasing to "
|
||||||
user_sockets_int,
|
"at least %d.",
|
||||||
total_sockets,
|
user_sockets_int,
|
||||||
components_list,
|
total_sockets,
|
||||||
total_sockets,
|
sc.tcp,
|
||||||
)
|
sc.udp,
|
||||||
|
sc.tcp_listen,
|
||||||
|
total_sockets,
|
||||||
|
)
|
||||||
# User's value already added via sdkconfig_options processing
|
# User's value already added via sdkconfig_options processing
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1305,11 +1300,19 @@ def _configure_lwip_max_sockets(conf: dict) -> None:
|
|||||||
max_sockets = max(DEFAULT_MAX_SOCKETS, total_sockets)
|
max_sockets = max(DEFAULT_MAX_SOCKETS, total_sockets)
|
||||||
|
|
||||||
log_level = logging.INFO if max_sockets > DEFAULT_MAX_SOCKETS else logging.DEBUG
|
log_level = logging.INFO if max_sockets > DEFAULT_MAX_SOCKETS else logging.DEBUG
|
||||||
|
sock_min = " (min)" if max_sockets > total_sockets else ""
|
||||||
_LOGGER.log(
|
_LOGGER.log(
|
||||||
log_level,
|
log_level,
|
||||||
"Setting CONFIG_LWIP_MAX_SOCKETS to %d (registered: %s)",
|
"Setting CONFIG_LWIP_MAX_SOCKETS to %d%s "
|
||||||
|
"(TCP=%d [%s], UDP=%d [%s], TCP_LISTEN=%d [%s])",
|
||||||
max_sockets,
|
max_sockets,
|
||||||
components_list,
|
sock_min,
|
||||||
|
sc.tcp,
|
||||||
|
sc.tcp_details,
|
||||||
|
sc.udp,
|
||||||
|
sc.udp_details,
|
||||||
|
sc.tcp_listen,
|
||||||
|
sc.tcp_listen_details,
|
||||||
)
|
)
|
||||||
|
|
||||||
add_idf_sdkconfig_option("CONFIG_LWIP_MAX_SOCKETS", max_sockets)
|
add_idf_sdkconfig_option("CONFIG_LWIP_MAX_SOCKETS", max_sockets)
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ def _consume_camera_web_server_sockets(config: ConfigType) -> ConfigType:
|
|||||||
from esphome.components import socket
|
from esphome.components import socket
|
||||||
|
|
||||||
# Each camera web server instance needs 1 listening socket + 2 client connections
|
# Each camera web server instance needs 1 listening socket + 2 client connections
|
||||||
sockets_needed = 3
|
socket.consume_sockets(2, "esp32_camera_web_server")(config)
|
||||||
socket.consume_sockets(sockets_needed, "esp32_camera_web_server")(config)
|
socket.consume_sockets(1, "esp32_camera_web_server", socket.SocketType.TCP_LISTEN)(
|
||||||
|
config
|
||||||
|
)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -97,8 +97,9 @@ def _consume_ota_sockets(config: ConfigType) -> ConfigType:
|
|||||||
"""Register socket needs for OTA component."""
|
"""Register socket needs for OTA component."""
|
||||||
from esphome.components import socket
|
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
|
||||||
socket.consume_sockets(1, "ota")(config)
|
# 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
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -275,6 +275,146 @@ BASE_SCHEMA.add_extra(_detect_variant)
|
|||||||
BASE_SCHEMA.add_extra(_update_core_data)
|
BASE_SCHEMA.add_extra(_update_core_data)
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_lwip(config: dict) -> None:
|
||||||
|
"""Configure lwIP options for LibreTiny platforms.
|
||||||
|
|
||||||
|
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 (TCP_MSS=1460 on all LT):
|
||||||
|
|
||||||
|
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 3/10×MSS 2×MSS 3×MSS 4×MSS
|
||||||
|
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
|
||||||
|
MAX_SOCKETS_UDP 4 16 22 —** —** dynamic
|
||||||
|
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
|
||||||
|
MEMP_NUM_TCPIP_MSG_INPKT 4 8 16 8***** 12 8
|
||||||
|
|
||||||
|
* 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.
|
||||||
|
***** 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 8 TCP / 6 UDP.
|
||||||
|
"""
|
||||||
|
from esphome.components.socket import (
|
||||||
|
MIN_TCP_LISTEN_SOCKETS,
|
||||||
|
MIN_TCP_SOCKETS,
|
||||||
|
MIN_UDP_SOCKETS,
|
||||||
|
get_socket_counts,
|
||||||
|
)
|
||||||
|
|
||||||
|
sc = get_socket_counts()
|
||||||
|
# Apply platform minimums — ensure headroom for ESPHome's needs
|
||||||
|
tcp_sockets = max(MIN_TCP_SOCKETS, sc.tcp)
|
||||||
|
udp_sockets = max(MIN_UDP_SOCKETS, sc.udp)
|
||||||
|
# Listening sockets — registered by components (api, ota, web_server_base, etc.)
|
||||||
|
# Not all components register yet, so ensure a minimum for baseline operation.
|
||||||
|
listening_tcp = max(MIN_TCP_LISTEN_SOCKETS, sc.tcp_listen)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# 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 # 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
|
||||||
|
|
||||||
|
lwip_opts: list[str] = [
|
||||||
|
# Disable statistics — not needed for production, saves RAM
|
||||||
|
"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
|
||||||
|
f"MAX_SOCKETS_TCP={tcp_sockets}",
|
||||||
|
f"MAX_SOCKETS_UDP={udp_sockets}",
|
||||||
|
# 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}",
|
||||||
|
f"MEMP_NUM_TCP_SEG={memp_num_tcp_seg}", # must be >= queuelen
|
||||||
|
# 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 — 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
|
||||||
|
"MEMP_NUM_TCPIP_MSG_INPKT=8", # BK: 16, RTL: 8 (opt.h), LN: 12
|
||||||
|
]
|
||||||
|
|
||||||
|
# Use system heap for all lwIP allocations on all LibreTiny platforms.
|
||||||
|
# - MEM_LIBC_MALLOC=1: Use system heap instead of dedicated lwIP 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 on BK72XX. Safe because WiFi
|
||||||
|
# receive paths run in task context, not ISR context. ESP32 and ESP8266
|
||||||
|
# both ship with MEMP_MEM_MALLOC=1.
|
||||||
|
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("PBUF_POOL_SIZE=10")
|
||||||
|
|
||||||
|
tcp_min = " (min)" if tcp_sockets > sc.tcp else ""
|
||||||
|
udp_min = " (min)" if udp_sockets > sc.udp else ""
|
||||||
|
listen_min = " (min)" if listening_tcp > sc.tcp_listen else ""
|
||||||
|
_LOGGER.info(
|
||||||
|
"Configuring lwIP: TCP=%d%s [%s], UDP=%d%s [%s], TCP_LISTEN=%d%s [%s]",
|
||||||
|
tcp_sockets,
|
||||||
|
tcp_min,
|
||||||
|
sc.tcp_details,
|
||||||
|
udp_sockets,
|
||||||
|
udp_min,
|
||||||
|
sc.udp_details,
|
||||||
|
listening_tcp,
|
||||||
|
listen_min,
|
||||||
|
sc.tcp_listen_details,
|
||||||
|
)
|
||||||
|
cg.add_platformio_option("custom_options.lwip", lwip_opts)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=use-dict-literal
|
# pylint: disable=use-dict-literal
|
||||||
async def component_to_code(config):
|
async def component_to_code(config):
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
@@ -389,11 +529,12 @@ async def component_to_code(config):
|
|||||||
"custom_options.sys_config#h", _BK7231N_SYS_CONFIG_OPTIONS
|
"custom_options.sys_config#h", _BK7231N_SYS_CONFIG_OPTIONS
|
||||||
)
|
)
|
||||||
|
|
||||||
# Disable LWIP statistics to save RAM - not needed in production
|
# Tune lwIP for ESPHome's actual needs.
|
||||||
# Must explicitly disable all sub-stats to avoid redefinition warnings
|
# The SDK defaults (TCP_SND_BUF=10*MSS, MAX_SOCKETS_TCP=12, MEM_SIZE=32KB)
|
||||||
cg.add_platformio_option(
|
# are wildly oversized for an IoT device. ESPAsyncWebServer allocates
|
||||||
"custom_options.lwip",
|
# malloc(tcp_sndbuf()) per response chunk — at 14.6KB this causes silent
|
||||||
["LWIP_STATS=0", "MEM_STATS=0", "MEMP_STATS=0"],
|
# OOM on memory-constrained chips like BK7231N.
|
||||||
)
|
# See https://github.com/esphome/esphome/issues/14095
|
||||||
|
_configure_lwip(config)
|
||||||
|
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ def _consume_mdns_sockets(config: ConfigType) -> ConfigType:
|
|||||||
from esphome.components import socket
|
from esphome.components import socket
|
||||||
|
|
||||||
# mDNS needs 2 sockets (IPv4 + IPv6 multicast)
|
# mDNS needs 2 sockets (IPv4 + IPv6 multicast)
|
||||||
socket.consume_sockets(2, "mdns")(config)
|
socket.consume_sockets(2, "mdns", socket.SocketType.UDP)(config)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
from collections.abc import Callable, MutableMapping
|
from collections.abc import Callable, MutableMapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import StrEnum
|
||||||
|
import logging
|
||||||
|
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.core import CORE
|
from esphome.core import CORE
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
CODEOWNERS = ["@esphome/core"]
|
CODEOWNERS = ["@esphome/core"]
|
||||||
|
|
||||||
CONF_IMPLEMENTATION = "implementation"
|
CONF_IMPLEMENTATION = "implementation"
|
||||||
@@ -13,33 +18,110 @@ IMPLEMENTATION_BSD_SOCKETS = "bsd_sockets"
|
|||||||
|
|
||||||
# Socket tracking infrastructure
|
# Socket tracking infrastructure
|
||||||
# Components register their socket needs and platforms read this to configure appropriately
|
# 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"
|
||||||
|
KEY_SOCKET_CONSUMERS_TCP_LISTEN = "socket_consumers_tcp_listen"
|
||||||
|
|
||||||
|
# Recommended minimum socket counts.
|
||||||
|
# Platforms should apply these (or their own) on top of get_socket_counts().
|
||||||
|
# These cover minimal configs (e.g. api-only without web_server).
|
||||||
|
# When web_server is present, its 5 registered sockets push past the TCP minimum.
|
||||||
|
MIN_TCP_SOCKETS = 8
|
||||||
|
MIN_UDP_SOCKETS = 6
|
||||||
|
# Minimum listening sockets — at least api + ota baseline.
|
||||||
|
MIN_TCP_LISTEN_SOCKETS = 2
|
||||||
|
|
||||||
# Wake loop threadsafe support tracking
|
# Wake loop threadsafe support tracking
|
||||||
KEY_WAKE_LOOP_THREADSAFE_REQUIRED = "wake_loop_threadsafe_required"
|
KEY_WAKE_LOOP_THREADSAFE_REQUIRED = "wake_loop_threadsafe_required"
|
||||||
|
|
||||||
|
|
||||||
|
class SocketType(StrEnum):
|
||||||
|
TCP = "tcp"
|
||||||
|
UDP = "udp"
|
||||||
|
TCP_LISTEN = "tcp_listen"
|
||||||
|
|
||||||
|
|
||||||
|
_SOCKET_TYPE_KEYS = {
|
||||||
|
SocketType.TCP: KEY_SOCKET_CONSUMERS_TCP,
|
||||||
|
SocketType.UDP: KEY_SOCKET_CONSUMERS_UDP,
|
||||||
|
SocketType.TCP_LISTEN: KEY_SOCKET_CONSUMERS_TCP_LISTEN,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def consume_sockets(
|
def consume_sockets(
|
||||||
value: int, consumer: str
|
value: int, consumer: str, socket_type: SocketType = SocketType.TCP
|
||||||
) -> Callable[[MutableMapping], MutableMapping]:
|
) -> Callable[[MutableMapping], MutableMapping]:
|
||||||
"""Register socket usage for a component.
|
"""Register socket usage for a component.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
value: Number of sockets needed by the component
|
value: Number of sockets needed by the component
|
||||||
consumer: Name of the component consuming the sockets
|
consumer: Name of the component consuming the sockets
|
||||||
|
socket_type: Type of socket (SocketType.TCP, SocketType.UDP, or SocketType.TCP_LISTEN)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A validator function that records the socket usage
|
A validator function that records the socket usage
|
||||||
"""
|
"""
|
||||||
|
typed_key = _SOCKET_TYPE_KEYS[socket_type]
|
||||||
|
|
||||||
def _consume_sockets(config: MutableMapping) -> MutableMapping:
|
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
|
consumers[consumer] = consumers.get(consumer, 0) + value
|
||||||
return config
|
return config
|
||||||
|
|
||||||
return _consume_sockets
|
return _consume_sockets
|
||||||
|
|
||||||
|
|
||||||
|
def _format_consumers(consumers: dict[str, int]) -> str:
|
||||||
|
"""Format consumer dict as 'name=count, ...' or 'none'."""
|
||||||
|
if not consumers:
|
||||||
|
return "none"
|
||||||
|
return ", ".join(f"{name}={count}" for name, count in sorted(consumers.items()))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SocketCounts:
|
||||||
|
"""Socket counts and component details for platform configuration."""
|
||||||
|
|
||||||
|
tcp: int
|
||||||
|
udp: int
|
||||||
|
tcp_listen: int
|
||||||
|
tcp_details: str
|
||||||
|
udp_details: str
|
||||||
|
tcp_listen_details: str
|
||||||
|
|
||||||
|
|
||||||
|
def get_socket_counts() -> SocketCounts:
|
||||||
|
"""Return socket counts and component details for platform configuration.
|
||||||
|
|
||||||
|
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_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_details = _format_consumers(tcp_consumers)
|
||||||
|
udp_details = _format_consumers(udp_consumers)
|
||||||
|
tcp_listen_details = _format_consumers(tcp_listen_consumers)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Socket counts: TCP=%d (%s), UDP=%d (%s), TCP_LISTEN=%d (%s)",
|
||||||
|
tcp,
|
||||||
|
tcp_details,
|
||||||
|
udp,
|
||||||
|
udp_details,
|
||||||
|
tcp_listen,
|
||||||
|
tcp_listen_details,
|
||||||
|
)
|
||||||
|
return SocketCounts(
|
||||||
|
tcp, udp, tcp_listen, tcp_details, udp_details, tcp_listen_details
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def require_wake_loop_threadsafe() -> None:
|
def require_wake_loop_threadsafe() -> None:
|
||||||
"""Mark that wake_loop_threadsafe support is required by a component.
|
"""Mark that wake_loop_threadsafe support is required by a component.
|
||||||
|
|
||||||
@@ -66,7 +148,7 @@ def require_wake_loop_threadsafe() -> None:
|
|||||||
CORE.data[KEY_WAKE_LOOP_THREADSAFE_REQUIRED] = True
|
CORE.data[KEY_WAKE_LOOP_THREADSAFE_REQUIRED] = True
|
||||||
cg.add_define("USE_WAKE_LOOP_THREADSAFE")
|
cg.add_define("USE_WAKE_LOOP_THREADSAFE")
|
||||||
# Consume 1 socket for the shared wake notification socket
|
# Consume 1 socket for the shared wake notification socket
|
||||||
consume_sockets(1, "socket.wake_loop_threadsafe")({})
|
consume_sockets(1, "socket.wake_loop_threadsafe", SocketType.UDP)({})
|
||||||
|
|
||||||
|
|
||||||
CONFIG_SCHEMA = cv.Schema(
|
CONFIG_SCHEMA = cv.Schema(
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ def _consume_udp_sockets(config: ConfigType) -> ConfigType:
|
|||||||
|
|
||||||
# UDP uses up to 2 sockets: 1 broadcast + 1 listen
|
# UDP uses up to 2 sockets: 1 broadcast + 1 listen
|
||||||
# Whether each is used depends on code generation, so register worst case
|
# Whether each is used depends on code generation, so register worst case
|
||||||
socket.consume_sockets(2, "udp")(config)
|
socket.consume_sockets(2, "udp", socket.SocketType.UDP)(config)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -144,11 +144,11 @@ def _consume_web_server_sockets(config: ConfigType) -> ConfigType:
|
|||||||
"""Register socket needs for web_server component."""
|
"""Register socket needs for web_server component."""
|
||||||
from esphome.components import socket
|
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
|
# (browser opens connections for page resources, SSE event stream, and POST
|
||||||
# requests for entity control which may linger before closing)
|
# requests for entity control which may linger before closing)
|
||||||
sockets_needed = 6
|
# The listening socket is registered by web_server_base (shared with captive_portal)
|
||||||
socket.consume_sockets(sockets_needed, "web_server")(config)
|
socket.consume_sockets(5, "web_server")(config)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,27 @@ web_server_base_ns = cg.esphome_ns.namespace("web_server_base")
|
|||||||
WebServerBase = web_server_base_ns.class_("WebServerBase")
|
WebServerBase = web_server_base_ns.class_("WebServerBase")
|
||||||
|
|
||||||
CONF_WEB_SERVER_BASE_ID = "web_server_base_id"
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ from esphome.const import (
|
|||||||
)
|
)
|
||||||
from esphome.core import CORE, CoroPriority, HexInt, coroutine_with_priority
|
from esphome.core import CORE, CoroPriority, HexInt, coroutine_with_priority
|
||||||
import esphome.final_validate as fv
|
import esphome.final_validate as fv
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
from . import wpa2_eap
|
from . import wpa2_eap
|
||||||
|
|
||||||
@@ -269,9 +270,28 @@ def final_validate(config):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _consume_wifi_sockets(config: ConfigType) -> ConfigType:
|
||||||
|
"""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.
|
||||||
|
# These are not application sockets but consume MEMP_NUM_UDP_PCB pool entries.
|
||||||
|
socket.consume_sockets(2, "wifi.lwip_internal", socket.SocketType.UDP)(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||||
final_validate,
|
final_validate,
|
||||||
validate_variant,
|
validate_variant,
|
||||||
|
_consume_wifi_sockets,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -66,12 +66,12 @@ def test_require_wake_loop_threadsafe__no_networking_does_not_consume_socket() -
|
|||||||
CORE.config = {"logger": {}}
|
CORE.config = {"logger": {}}
|
||||||
|
|
||||||
# Track initial socket consumer state
|
# 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
|
# Call require_wake_loop_threadsafe
|
||||||
socket.require_wake_loop_threadsafe()
|
socket.require_wake_loop_threadsafe()
|
||||||
|
|
||||||
# Verify no socket was consumed
|
# Verify no socket was consumed
|
||||||
consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS, {})
|
udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {})
|
||||||
assert "socket.wake_loop_threadsafe" not in consumers
|
assert "socket.wake_loop_threadsafe" not in udp_consumers
|
||||||
assert consumers == initial_consumers
|
assert udp_consumers == initial_udp
|
||||||
|
|||||||
Reference in New Issue
Block a user