From b63794b53c8310b42e0d2a77a7cbfd482be81727 Mon Sep 17 00:00:00 2001 From: Jeroen Date: Tue, 6 Oct 2026 17:00:50 +0200 Subject: [PATCH 01/55] [web_server_idf] Respect explicit HTTP header limit (#18657) Co-authored-by: jeroen85 <26403565+jeroen85@users.noreply.github.com> Co-authored-by: Jeroen Jansen --- esphome/components/esp32/__init__.py | 7 ++-- esphome/components/web_server_idf/__init__.py | 4 +-- .../web_server/test_httpd_sdkconfig.py | 32 +++++++++++++++++++ .../web_server_header_limit_override.yaml | 16 ++++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 tests/component_tests/web_server/test_httpd_sdkconfig.py create mode 100644 tests/component_tests/web_server/web_server_header_limit_override.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index be6b2c32663..ce1a66a4d22 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -742,9 +742,10 @@ def is_idf_sdkconfig_option_enabled(name: str) -> bool: def set_idf_sdkconfig_default(name: str, value: SdkconfigValueType) -> None: """Set an sdkconfig option unless it is already set. - For the FINAL priority reconcile jobs: they run after every to_code, - including the user's sdkconfig_options, and must not override an - existing value. + User sdkconfig_options take precedence regardless of to_code order: + esp32.to_code applies them unconditionally, and this helper preserves + values that are already set. FINAL priority reconcile jobs use the same + guard because they run after every to_code, including the user's options. """ if name not in CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]: add_idf_sdkconfig_option(name, value) diff --git a/esphome/components/web_server_idf/__init__.py b/esphome/components/web_server_idf/__init__.py index 5a400dfbf3e..5eac63212f2 100644 --- a/esphome/components/web_server_idf/__init__.py +++ b/esphome/components/web_server_idf/__init__.py @@ -1,6 +1,6 @@ from esphome.components.esp32 import ( - add_idf_sdkconfig_option, include_builtin_idf_component, + set_idf_sdkconfig_default, ) from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv @@ -16,7 +16,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: # Increase the maximum supported size of headers section in HTTP request packet to be processed by the server - add_idf_sdkconfig_option("CONFIG_HTTPD_MAX_REQ_HDR_LEN", 1024) + set_idf_sdkconfig_default("CONFIG_HTTPD_MAX_REQ_HDR_LEN", 1024) # Re-enable esp-tls (excluded by default to save compile time); # web_server_idf.cpp includes for digest auth include_builtin_idf_component("esp-tls") diff --git a/tests/component_tests/web_server/test_httpd_sdkconfig.py b/tests/component_tests/web_server/test_httpd_sdkconfig.py new file mode 100644 index 00000000000..d3b0acb384a --- /dev/null +++ b/tests/component_tests/web_server/test_httpd_sdkconfig.py @@ -0,0 +1,32 @@ +"""Tests for ESP-IDF web server sdkconfig defaults.""" + +from collections.abc import Callable + +from esphome.components.esp32 import RawSdkconfigValue +from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS +from esphome.core import CORE + + +def test_web_server_idf_uses_default_header_limit( + generate_main: Callable[[str], str], +) -> None: + """The web server retains its existing 1024-byte default.""" + generate_main("tests/component_tests/web_server/web_server_no_auth.yaml") + + assert ( + CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]["CONFIG_HTTPD_MAX_REQ_HDR_LEN"] + == 1024 + ) + + +def test_web_server_idf_respects_explicit_header_limit( + generate_main: Callable[[str], str], +) -> None: + """An explicit sdkconfig option takes precedence over the component default.""" + generate_main( + "tests/component_tests/web_server/web_server_header_limit_override.yaml" + ) + + value = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]["CONFIG_HTTPD_MAX_REQ_HDR_LEN"] + assert isinstance(value, RawSdkconfigValue) + assert value.value == "4096" diff --git a/tests/component_tests/web_server/web_server_header_limit_override.yaml b/tests/component_tests/web_server/web_server_header_limit_override.yaml new file mode 100644 index 00000000000..eb1c8a87ddc --- /dev/null +++ b/tests/component_tests/web_server/web_server_header_limit_override.yaml @@ -0,0 +1,16 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + sdkconfig_options: + CONFIG_HTTPD_MAX_REQ_HDR_LEN: "4096" + +wifi: + ssid: MySSID + password: password1 + +web_server: From 1f53ccb5725601a4088f02ae1ee0f5410fc163b2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 6 Oct 2026 10:04:45 -0500 Subject: [PATCH 02/55] [api] Cover Home Assistant action fields in the api test config (#20236) --- tests/components/api/common-base.yaml | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index f55e7b176dd..1513d537c21 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -149,6 +149,38 @@ api: args: - error.c_str() - name.c_str() + # Constant and lambda fields mixed in one action, with lambdas of several + # return types reading the trigger args, and a templated action name + - action: test_homeassistant_fields + variables: + room: string + level: int + then: + - homeassistant.action: + action: !lambda 'return std::string("light.") + (level > 0 ? "turn_on" : "turn_off");' + data: + entity_id: light.living_room + brightness: !lambda 'return level;' + data_template: + message: "{{ room }} is at {{ level }}" + variables: + room: !lambda 'return room.c_str();' + level: !lambda 'static char buf[12]; snprintf(buf, sizeof(buf), "%d", level); return buf;' + - homeassistant.action: + action: notify.html5 + data: + message: Button was pressed + # Same constant fields as above, so codegen can share them + - homeassistant.action: + action: notify.html5 + data: + message: Button was pressed + - homeassistant.event: + event: esphome.room_level + data: + room: !lambda 'return room;' + ratio: !lambda 'return level / 100.0f;' + - homeassistant.tag_scanned: !lambda 'return room;' # Test ContinuationAction (IfAction with then/else branches) - action: test_if_action variables: From e66ef45df7fd390cd7663f85d426edec96084b2b Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:08:01 +0200 Subject: [PATCH 03/55] [zigbee] add missing tests (#20234) --- tests/components/zigbee/common.yaml | 4 ++++ tests/components/zigbee/test.nrf52-mcumgr.yaml | 2 +- tests/components/zigbee/test.nrf52-xiao-ble.yaml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/components/zigbee/common.yaml b/tests/components/zigbee/common.yaml index 4518e390602..ce59f4a6ae1 100644 --- a/tests/components/zigbee/common.yaml +++ b/tests/components/zigbee/common.yaml @@ -14,9 +14,12 @@ sensor: name: "Analog 1" lambda: return 10.0; accuracy_decimals: 0 + device_class: temperature + unit_of_measurement: "°C" - platform: template name: "Analog 2" lambda: return 11.0; + unit_of_measurement: "°C" - platform: template name: "Analog 3" lambda: return 12.0; @@ -41,6 +44,7 @@ number: min_value: 2 max_value: 100 step: 1 + unit_of_measurement: "°C" time: - platform: zigbee diff --git a/tests/components/zigbee/test.nrf52-mcumgr.yaml b/tests/components/zigbee/test.nrf52-mcumgr.yaml index a81feea0691..cdead0db905 100644 --- a/tests/components/zigbee/test.nrf52-mcumgr.yaml +++ b/tests/components/zigbee/test.nrf52-mcumgr.yaml @@ -1,4 +1,4 @@ -<<: !include common_nrf52.yaml +<<: !include common.yaml zigbee: router: true diff --git a/tests/components/zigbee/test.nrf52-xiao-ble.yaml b/tests/components/zigbee/test.nrf52-xiao-ble.yaml index acfbc9e9961..64134e536d1 100644 --- a/tests/components/zigbee/test.nrf52-xiao-ble.yaml +++ b/tests/components/zigbee/test.nrf52-xiao-ble.yaml @@ -1,4 +1,4 @@ -<<: !include common_nrf52.yaml +<<: !include common.yaml zigbee: wipe_on_boot: once From fef3804aa5400a7f4ac003002141df680e0d664c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 6 Oct 2026 10:09:47 -0500 Subject: [PATCH 04/55] [espidf] Fix V1 ECDSA signed builds when skipping the bootloader (#20244) --- esphome/build_gen/espidf.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 42a1de5a4b9..e7a2dc1536b 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -160,7 +160,7 @@ def _cmake_quote(value: str) -> str: # CONFIG_APP_BUILD_BOOTLOADER is hidden and force-selected, so it can only be # cleared at the CMake level (the same state IDF's RAM-app build type uses). -# The macro is IDF's __build_process_project_includes plus two added lines; +# The macro is IDF's __build_process_project_includes plus a few added lines; # the flag is ignored and the bootloader builds as usual if IDF changes it. IDF_BOOTLOADER_OVERRIDE = """\ # ESPHome bootloader skip switch; see esphome/espidf/toolchain.py. @@ -173,6 +173,9 @@ if(ESPHOME_SKIP_BOOTLOADER) # skipped) bootloader project_include leaks; keep it defined, or # its empty TARGET_SRC_NAME sends file(GLOB_RECURSE) across /. idf_build_get_property(idf_target IDF_TARGET) + # partition_table's V1 ECDSA signing reads this key, which the + # skipped bootloader project_include also sets. + get_filename_component(SECURE_BOOT_SIGNING_KEY "${CONFIG_SECURE_BOOT_SIGNING_KEY}" ABSOLUTE BASE_DIR "${project_dir}") idf_build_get_property(build_properties __BUILD_PROPERTIES) foreach(build_property ${build_properties}) idf_build_get_property(val ${build_property}) @@ -198,6 +201,10 @@ endif() BOOTLOADER_OVERRIDE_ADDED_LINES = ( 'set(CONFIG_APP_BUILD_BOOTLOADER "")', "idf_build_get_property(idf_target IDF_TARGET)", + ( + "get_filename_component(SECURE_BOOT_SIGNING_KEY" + ' "${CONFIG_SECURE_BOOT_SIGNING_KEY}" ABSOLUTE BASE_DIR "${project_dir}")' + ), ) _MACRO = re.compile( From 74d0a90308357f9f58a199888f2a0926ffd24341 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:11:25 +1100 Subject: [PATCH 05/55] [esphome] Revise warning text for password use (#20230) --- esphome/components/esphome/ota/__init__.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index ffd7c509497..0cb2657ea1f 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -133,20 +133,12 @@ def ota_esphome_final_validate(config: ConfigType) -> None: _resolve_encryption_key(encryption_conf, api_conf) elif CONF_PASSWORD in ota_conf and static_encryption_key(api_conf) is not None: _LOGGER.warning( - "'%s' %s wastes significant flash and RAM (about 3.5 KB and 60 " - "bytes plus the password on the heap): the device already offers " - "encryption with the '%s' %s %s, which authenticates any uploader " - "that takes it, and a password only matters for uploaders without " - "encryption support; remove '%s' and add '%s' under '%s' so " - "uploads use the key and encryption is required", + "'%s' %s wastes significant flash and RAM; " + "using '%s' instead is recommended - " + "see https://esphome.io/components/ota/esphome/#configuration-variables", CONF_OTA, CONF_PASSWORD, - CONF_API, CONF_ENCRYPTION, - CONF_KEY, - CONF_PASSWORD, - CONF_ENCRYPTION, - CONF_OTA, ) elif ( CONF_PASSWORD in ota_conf From fb20966353e1b004a11cd837fc622220509990d0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:16:57 +0100 Subject: [PATCH 06/55] [wireguard] Restart the connection while the peer is offline so the endpoint is re-resolved (#20231) --- esphome/components/wireguard/wireguard.cpp | 5 +++++ esphome/components/wireguard/wireguard.h | 2 ++ 2 files changed, 7 insertions(+) diff --git a/esphome/components/wireguard/wireguard.cpp b/esphome/components/wireguard/wireguard.cpp index fc06569fbab..1fd610f4e88 100644 --- a/esphome/components/wireguard/wireguard.cpp +++ b/esphome/components/wireguard/wireguard.cpp @@ -101,8 +101,13 @@ void Wireguard::update() { if (this->wg_peer_offline_time_ == 0) { ESP_LOGW(TAG, LOGMSG_PEER_STATUS, LOGMSG_OFFLINE, latest_handshake.c_str()); this->wg_peer_offline_time_ = millis(); + this->wg_reconnect_time_ = this->wg_peer_offline_time_; } else if (this->enabled_) { ESP_LOGD(TAG, LOGMSG_PEER_STATUS, LOGMSG_OFFLINE, latest_handshake.c_str()); + if (millis() - this->wg_reconnect_time_ >= RECONNECT_INTERVAL_MS) { + this->wg_reconnect_time_ = millis(); + this->stop_connection_(); + } this->start_connection_(); } diff --git a/esphome/components/wireguard/wireguard.h b/esphome/components/wireguard/wireguard.h index 68885f3a351..faf63cd444c 100644 --- a/esphome/components/wireguard/wireguard.h +++ b/esphome/components/wireguard/wireguard.h @@ -140,6 +140,7 @@ class Wireguard final : public PollingComponent { /// The last time the remote peer become offline. uint32_t wg_peer_offline_time_ = 0; + uint32_t wg_reconnect_time_ = 0; /** \brief The latest saved handshake. * @@ -159,6 +160,7 @@ void resume_wdt(); /// Size of buffer required for mask_key_to: 5 chars + "[...]=" + null = 12 static constexpr size_t MASK_KEY_BUFFER_SIZE = 12; +static constexpr uint32_t RECONNECT_INTERVAL_MS = 30000; /// Strip most part of the key only for secure printing void mask_key_to(char *buffer, size_t len, const char *key); From f670993fe92313b523afddabfadf0ba702259100 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:40:53 +0100 Subject: [PATCH 07/55] [ethernet] Pin the IDF version in the KSZ8851SNL test fixtures (#20245) --- tests/component_tests/ethernet/config/ksz8851snl_idf5.yaml | 2 ++ tests/component_tests/ethernet/config/ksz8851snl_idf6.yaml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/component_tests/ethernet/config/ksz8851snl_idf5.yaml b/tests/component_tests/ethernet/config/ksz8851snl_idf5.yaml index 7c8f3336844..eb83960f67d 100644 --- a/tests/component_tests/ethernet/config/ksz8851snl_idf5.yaml +++ b/tests/component_tests/ethernet/config/ksz8851snl_idf5.yaml @@ -3,6 +3,8 @@ esphome: esp32: board: esp32dev + framework: + version: 5.5.5 ethernet: id: eth_component diff --git a/tests/component_tests/ethernet/config/ksz8851snl_idf6.yaml b/tests/component_tests/ethernet/config/ksz8851snl_idf6.yaml index bc11c6db5b8..3ca9c801b77 100644 --- a/tests/component_tests/ethernet/config/ksz8851snl_idf6.yaml +++ b/tests/component_tests/ethernet/config/ksz8851snl_idf6.yaml @@ -4,7 +4,7 @@ esphome: esp32: board: esp32dev framework: - version: 6.0.0 + version: 6.1.0 ethernet: id: eth_component From 980fed9e964676f563a61256f92ad8ca8c897712 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 6 Oct 2026 13:23:57 -0400 Subject: [PATCH 08/55] [sendspin] Require a type on the switch platform (#20189) --- .../components/sendspin/switch/__init__.py | 24 ++++++++++++------- tests/components/sendspin/common-switch.yaml | 1 + 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/esphome/components/sendspin/switch/__init__.py b/esphome/components/sendspin/switch/__init__.py index 63f5f7ad28b..1f896640bfc 100644 --- a/esphome/components/sendspin/switch/__init__.py +++ b/esphome/components/sendspin/switch/__init__.py @@ -1,7 +1,8 @@ import esphome.codegen as cg from esphome.components import switch +from esphome.components.const import CONF_ENABLED import esphome.config_validation as cv -from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.const import CONF_TYPE, ENTITY_CATEGORY_CONFIG from esphome.types import ConfigType from .. import CONF_SENDSPIN_ID, SendspinHub, sendspin_ns @@ -12,14 +13,19 @@ DEPENDENCIES = ["sendspin"] SendspinSwitch = sendspin_ns.class_("SendspinSwitch", switch.Switch, cg.Component) CONFIG_SCHEMA = cv.All( - switch.switch_schema( - SendspinSwitch, - block_inverted=True, - default_restore_mode="RESTORE_DEFAULT_ON", - entity_category=ENTITY_CATEGORY_CONFIG, - ) - .extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) - .extend(cv.COMPONENT_SCHEMA), + cv.typed_schema( + { + CONF_ENABLED: switch.switch_schema( + SendspinSwitch, + block_inverted=True, + default_restore_mode="RESTORE_DEFAULT_ON", + entity_category=ENTITY_CATEGORY_CONFIG, + ) + .extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) + .extend(cv.COMPONENT_SCHEMA), + }, + key=CONF_TYPE, + ), cv.only_on_esp32, ) diff --git a/tests/components/sendspin/common-switch.yaml b/tests/components/sendspin/common-switch.yaml index d332cb0dde0..01e2f0b02b1 100644 --- a/tests/components/sendspin/common-switch.yaml +++ b/tests/components/sendspin/common-switch.yaml @@ -4,3 +4,4 @@ packages: switch: - platform: sendspin name: "Sendspin Enabled" + type: enabled From cad670785b6db78db1ce7a9aea87c9b2381fd5b4 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:28:06 +0200 Subject: [PATCH 09/55] [socket] Add host tests for TcpClientLink and TcpListener (#20167) Co-authored-by: J. Nick Koston --- esphome/components/socket/tcp_client_link.cpp | 20 +-- tests/components/socket/__init__.py | 3 +- .../socket/test_ipv4_allow_host.cpp | 35 +++++ .../socket/test_tcp_client_link_host.cpp | 134 ++++++++++++++++++ .../socket/test_tcp_listener_host.cpp | 132 +++++++++++++++++ 5 files changed, 315 insertions(+), 9 deletions(-) create mode 100644 tests/components/socket/test_tcp_listener_host.cpp diff --git a/esphome/components/socket/tcp_client_link.cpp b/esphome/components/socket/tcp_client_link.cpp index 141695a4bb6..9812f1835e1 100644 --- a/esphome/components/socket/tcp_client_link.cpp +++ b/esphome/components/socket/tcp_client_link.cpp @@ -17,19 +17,23 @@ static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000; // Non-blocking options and TCP keepalive for a bridged stream socket. // Keepalive is best-effort: the raw lwIP implementation (ESP8266, RP2040) // rejects it, so a half-open link there is only detected by a failed write. -static void set_stream_options(Socket *sock) { +static void set_stream_options(Socket *sock, const char *tag) { int yes = 1; + // Fails only on an invalid descriptor, or on raw lwIP after a peer reset that the next read() reports. sock->setblocking(false); - sock->setsockopt(IPPROTO_TCP, TCP_NODELAY, &yes, sizeof(yes)); - sock->setsockopt(SOL_SOCKET, SO_KEEPALIVE, &yes, sizeof(yes)); + int err = sock->setsockopt(IPPROTO_TCP, TCP_NODELAY, &yes, sizeof(yes)); + err |= sock->setsockopt(SOL_SOCKET, SO_KEEPALIVE, &yes, sizeof(yes)); #ifdef TCP_KEEPIDLE int idle = 30; int interval = 10; int count = 3; - sock->setsockopt(IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle)); - sock->setsockopt(IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval)); - sock->setsockopt(IPPROTO_TCP, TCP_KEEPCNT, &count, sizeof(count)); + err |= sock->setsockopt(IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle)); + err |= sock->setsockopt(IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval)); + err |= sock->setsockopt(IPPROTO_TCP, TCP_KEEPCNT, &count, sizeof(count)); #endif + if (err != 0) { + ESP_LOGV(tag, "Nodelay/keepalive not fully applied"); + } } void TcpClientLink::begin(const char *tag) { @@ -84,7 +88,7 @@ void TcpClientLink::try_connect_() { this->drop_(LOG_STR("Connect failed"), errno); return; } - set_stream_options(this->sock_.get()); + set_stream_options(this->sock_.get(), this->tag_); // Starts the pending-connect clock that poll() times out against. this->note_attempt(); // An immediate success is reported by the next poll(); poll_connect() sees it writable. @@ -95,7 +99,7 @@ void TcpClientLink::try_connect_() { void TcpClientLink::adopt(std::unique_ptr sock) { this->close(); - set_stream_options(sock.get()); + set_stream_options(sock.get(), this->tag_); this->sock_ = std::move(sock); this->connected_ = true; } diff --git a/tests/components/socket/__init__.py b/tests/components/socket/__init__.py index 6c1d2b1c080..3cd8cd839d7 100644 --- a/tests/components/socket/__init__.py +++ b/tests/components/socket/__init__.py @@ -14,6 +14,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: await real_to_code(config) cg.add_define("USE_NETWORK_IPV6", True) # The gtests link against the filtered helper files. - socket_component.require_tcp_client_link() + socket_component.require_tcp_listener() + cg.add_define("USE_SOCKET_IPV4_ALLOW") manifest.to_code = to_code_testing diff --git a/tests/components/socket/test_ipv4_allow_host.cpp b/tests/components/socket/test_ipv4_allow_host.cpp index e4257b41c4d..a8924de3713 100644 --- a/tests/components/socket/test_ipv4_allow_host.cpp +++ b/tests/components/socket/test_ipv4_allow_host.cpp @@ -70,6 +70,41 @@ TEST(Ipv4Allow, InstancesKeepIndependentLists) { EXPECT_FALSE(first.allows(htonl(0x0A00002A))); } +TEST(Ipv4Allow, HostEntryMatchesOnlyThatAddress) { + static const Ipv4AllowEntry HOST[] = {{htonl(0xC0A8AF14), htonl(0xFFFFFFFF)}}; + Ipv4Allow list; + list.set(HOST, std::size(HOST)); + EXPECT_TRUE(allows_peer(list, "192.168.175.20")); + EXPECT_FALSE(allows_peer(list, "192.168.175.21")); + EXPECT_FALSE(allows_peer(list, "192.168.175.19")); +} + +TEST(Ipv4Allow, CatchAllAllowsEveryV4PeerOnly) { + static const Ipv4AllowEntry ANY[] = {{0, 0}}; + Ipv4Allow list; + list.set(ANY, std::size(ANY)); + EXPECT_TRUE(allows_peer(list, "0.0.0.0")); + EXPECT_TRUE(allows_peer(list, "255.255.255.255")); + EXPECT_TRUE(allows_peer(list, "::ffff:10.1.2.3")); + // Unlike an empty list, 0.0.0.0/0 still turns a native IPv6 peer away. + EXPECT_FALSE(allows_peer(list, "fe80::1")); +} + +TEST(Ipv4Allow, LastEntryOfAFullListMatches) { + // 255 is the schema's cap: 10.0.0.1/32 to 10.0.0.255/32. + static Ipv4AllowEntry full[255]; + for (uint32_t i = 0; i < std::size(full); i++) { + full[i] = {htonl(0x0A000001 + i), htonl(0xFFFFFFFF)}; + } + Ipv4Allow list; + list.set(full, std::size(full)); + EXPECT_EQ(list.size(), 255u); + EXPECT_TRUE(allows_peer(list, "10.0.0.1")); + EXPECT_TRUE(allows_peer(list, "10.0.0.255")); + EXPECT_FALSE(allows_peer(list, "10.0.1.0")); + EXPECT_FALSE(allows_peer(list, "10.0.0.0")); +} + } // namespace esphome::socket::testing #endif diff --git a/tests/components/socket/test_tcp_client_link_host.cpp b/tests/components/socket/test_tcp_client_link_host.cpp index 9a6d388b725..98be8190b00 100644 --- a/tests/components/socket/test_tcp_client_link_host.cpp +++ b/tests/components/socket/test_tcp_client_link_host.cpp @@ -1,15 +1,22 @@ #include +#include #include #include +#include #include +#include #include #include #include "esphome/components/socket/tcp_client_link.h" +#include "esphome/core/application.h" #ifdef USE_HOST +// Host only: ESP-IDF has no poll.h. +#include + namespace esphome::socket::testing { class LinkPeer { @@ -40,6 +47,133 @@ class LinkPeer { int peer_fd_{-1}; }; +// Sets the cached loop time the link's clock reads, as the main loop does. +static void set_loop_time(uint32_t now) { LoopBlockingGuard guard(nullptr, LOG_STR("test"), now); } + +class LinkUnderTest : public TcpClientLink { + public: + void set_socket(std::unique_ptr sock) { this->sock_ = std::move(sock); } + bool has_socket() const { return this->sock_ != nullptr; } + int fd() const { return this->sock_->get_fd(); } +}; + +class TcpClientLinkClock : public ::testing::Test { + protected: + void SetUp() override { + signal(SIGPIPE, SIG_IGN); + set_loop_time(1000); + } + void TearDown() override { + this->link_.close(); + if (this->peer_fd_ >= 0) { + ::close(this->peer_fd_); + } + set_loop_time(0); + } + // A connected socket whose send buffer is full selects as not writable, + // which poll_connect() reports as a connect still in progress. + void set_pending_socket() { + int fds[2]; + ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0); + this->peer_fd_ = fds[1]; + ASSERT_EQ(fcntl(fds[0], F_SETFL, fcntl(fds[0], F_GETFL, 0) | O_NONBLOCK), 0); + char fill[1024]{}; + while (::write(fds[0], fill, sizeof(fill)) > 0) { + } + ASSERT_EQ(errno, EAGAIN); + this->link_.set_socket(std::make_unique(fds[0])); + } + + LinkUnderTest link_; + int peer_fd_{-1}; +}; + +TEST_F(TcpClientLinkClock, PendingConnectTimesOutIntoBackoff) { + this->link_.begin("link_test"); + this->set_pending_socket(); + this->link_.note_attempt(); + set_loop_time(1000 + 9999); + this->link_.poll(); + ASSERT_TRUE(this->link_.has_socket()); + EXPECT_FALSE(this->link_.connected()); + set_loop_time(1000 + 10000); + this->link_.poll(); + EXPECT_FALSE(this->link_.has_socket()); + EXPECT_TRUE(this->link_.in_backoff()); +} + +TEST_F(TcpClientLinkClock, ConnectTimeoutFollowsALongerInterval) { + this->link_.set_reconnect_interval(20000); + this->link_.begin("link_test"); + this->set_pending_socket(); + this->link_.note_attempt(); + set_loop_time(1000 + 19999); + this->link_.poll(); + ASSERT_TRUE(this->link_.has_socket()); + set_loop_time(1000 + 20000); + this->link_.poll(); + EXPECT_FALSE(this->link_.has_socket()); +} + +TEST_F(TcpClientLinkClock, ResolveFailureBacksOff) { + // An IPv6 literal fails the IPv4 lookup without DNS. + this->link_.set_host("::1"); + this->link_.set_port(1); + this->link_.begin("link_test"); + this->link_.poll(); + ASSERT_FALSE(this->link_.in_backoff()); + // The failure is consumed by the next attempt and restarts the clock. + this->link_.poll(); + EXPECT_FALSE(this->link_.has_socket()); + EXPECT_TRUE(this->link_.in_backoff()); + set_loop_time(1000 + 4999); + EXPECT_TRUE(this->link_.in_backoff()); + set_loop_time(1000 + 5000); + EXPECT_FALSE(this->link_.in_backoff()); +} + +TEST_F(TcpClientLinkClock, RefusedConnectBacksOff) { + // A loopback port that was just free refuses the connect. + int probe = ::socket(AF_INET, SOCK_STREAM, 0); + ASSERT_GE(probe, 0); + struct sockaddr_in addr {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + socklen_t len = sizeof(addr); + ASSERT_EQ(::bind(probe, reinterpret_cast(&addr), len), 0); + ASSERT_EQ(::getsockname(probe, reinterpret_cast(&addr), &len), 0); + ::close(probe); + + this->link_.set_host("127.0.0.1"); + this->link_.set_port(ntohs(addr.sin_port)); + this->link_.begin("link_test"); + // The refusal comes back from connect() itself or from a later poll. + this->link_.poll(); + if (this->link_.has_socket()) { + // Wait for the stack to finish the connect, then let poll() read the result. + struct pollfd pfd { + this->link_.fd(), POLLOUT, 0 + }; + ASSERT_EQ(::poll(&pfd, 1, 1000), 1); + this->link_.poll(); + } + EXPECT_FALSE(this->link_.connected()); + EXPECT_FALSE(this->link_.has_socket()); + EXPECT_TRUE(this->link_.in_backoff()); +} + +TEST_F(TcpClientLinkClock, BackoffSpansAMillisWrap) { + set_loop_time(UINT32_MAX - 999); + this->link_.begin("link_test"); + // begin() back-dates the clock so the first attempt is immediate. + EXPECT_FALSE(this->link_.in_backoff()); + this->link_.note_attempt(); + set_loop_time(3999); + EXPECT_TRUE(this->link_.in_backoff()); + set_loop_time(4000); + EXPECT_FALSE(this->link_.in_backoff()); +} + TEST(TcpClientLink, AdoptedSocketFlushesQueuedBytes) { LinkPeer p; ASSERT_TRUE(p.link_.connected()); diff --git a/tests/components/socket/test_tcp_listener_host.cpp b/tests/components/socket/test_tcp_listener_host.cpp new file mode 100644 index 00000000000..7c6e8392da9 --- /dev/null +++ b/tests/components/socket/test_tcp_listener_host.cpp @@ -0,0 +1,132 @@ +#include + +#include +#include +#include +#include +#include + +#include "esphome/components/socket/tcp_listener.h" + +#ifdef USE_HOST + +// Host only: ESP-IDF has no poll.h. +#include + +namespace esphome::socket::testing { + +class ListenerUnderTest : public TcpListener { + public: + void set_listen(std::unique_ptr sock) { this->listen_ = std::move(sock); } + bool listening() const { return this->listen_ != nullptr; } + void accept(TcpClientLink &link) { this->accept_(link); } +}; + +class TcpListenerAccept : public ::testing::Test { + protected: + void SetUp() override { + this->link_.set_port(1); + this->link_.begin("listener_test"); + this->listener_.begin("listener_test"); + } + void TearDown() override { + this->listener_.close(); + this->link_.close(); + if (this->client_fd_ >= 0) { + ::close(this->client_fd_); + } + } + // A non-blocking IPv4 listener on a free loopback port; returns the port. + uint16_t listen_on_loopback() { + int fd = ::socket(AF_INET, SOCK_STREAM, 0); + EXPECT_GE(fd, 0); + struct sockaddr_in addr {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + socklen_t len = sizeof(addr); + EXPECT_EQ(::bind(fd, reinterpret_cast(&addr), len), 0); + EXPECT_EQ(::listen(fd, 1), 0); + EXPECT_EQ(::getsockname(fd, reinterpret_cast(&addr), &len), 0); + EXPECT_EQ(fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK), 0); + this->listen_fd_ = fd; + this->listener_.set_listen(std::make_unique(fd)); + return ntohs(addr.sin_port); + } + // A blocking connect to the loopback listener; it completes into the backlog. + void connect_client(uint16_t port) { + this->client_fd_ = ::socket(AF_INET, SOCK_STREAM, 0); + ASSERT_GE(this->client_fd_, 0); + struct sockaddr_in addr {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = htons(port); + ASSERT_EQ(::connect(this->client_fd_, reinterpret_cast(&addr), sizeof(addr)), 0); + // The stack may queue the connection for accept() a moment after connect() returns. + struct pollfd pfd { + this->listen_fd_, POLLIN, 0 + }; + ASSERT_EQ(::poll(&pfd, 1, 1000), 1); + } + + TcpClientLink link_; + ListenerUnderTest listener_; + int listen_fd_{-1}; + int client_fd_{-1}; +}; + +TEST_F(TcpListenerAccept, AcceptErrorRebuildsAfterTheBackoff) { + // accept() on a bad descriptor fails with EBADF, which no retry fixes. + this->listener_.set_listen(std::make_unique(-1)); + this->listener_.accept(this->link_); + EXPECT_FALSE(this->listener_.listening()); + EXPECT_TRUE(this->link_.in_backoff()); + // No new listen socket until the backoff has passed. + this->listener_.poll(this->link_, true); + EXPECT_FALSE(this->listener_.listening()); +} + +TEST_F(TcpListenerAccept, NothingPendingKeepsTheListener) { + this->listen_on_loopback(); + this->listener_.accept(this->link_); + EXPECT_TRUE(this->listener_.listening()); + EXPECT_FALSE(this->link_.connected()); + EXPECT_FALSE(this->link_.in_backoff()); +} + +TEST_F(TcpListenerAccept, AcceptedClientIsAdopted) { + this->connect_client(this->listen_on_loopback()); + this->listener_.accept(this->link_); + EXPECT_TRUE(this->link_.connected()); + EXPECT_TRUE(this->listener_.listening()); +} + +#ifdef USE_SOCKET_IPV4_ALLOW +TEST_F(TcpListenerAccept, PeerOutsideTheAllowListIsClosed) { + static const Ipv4AllowEntry ONLY_TEN[] = {{htonl(0x0A000000), htonl(0xFF000000)}}; + this->listener_.set_allow(ONLY_TEN, 1); + this->connect_client(this->listen_on_loopback()); + this->listener_.accept(this->link_); + EXPECT_FALSE(this->link_.connected()); + EXPECT_TRUE(this->listener_.listening()); + EXPECT_FALSE(this->link_.in_backoff()); + // The peer sees the close. + struct pollfd pfd { + this->client_fd_, POLLIN, 0 + }; + ASSERT_EQ(::poll(&pfd, 1, 1000), 1); + char b; + EXPECT_EQ(::read(this->client_fd_, &b, 1), 0); +} + +TEST_F(TcpListenerAccept, PeerInsideTheAllowListIsAdopted) { + static const Ipv4AllowEntry LOOPBACK_ONLY[] = {{htonl(INADDR_LOOPBACK), htonl(0xFFFFFFFF)}}; + this->listener_.set_allow(LOOPBACK_ONLY, 1); + this->connect_client(this->listen_on_loopback()); + this->listener_.accept(this->link_); + EXPECT_TRUE(this->link_.connected()); +} +#endif + +} // namespace esphome::socket::testing + +#endif From 2b02602a49515fcf06bbac86309c4afcd4382c3a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:37:14 -0600 Subject: [PATCH 10/55] Bump smpclient from 7.2.0 to 7.3.0 (#17443) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 744c5c1e669..1b719701e48 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ resvg-py==0.5.0 freetype-py==2.5.1 jinja2==3.1.6 bleak==3.0.2 -smpclient==7.2.0 +smpclient==7.3.0 requests==2.34.2 py7zr==1.1.3 platformdirs==4.12.2 # native esp-idf toolchain global cache dir From 61ac0cb753e464dcee7bfa079a914033a5d7fc83 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:39:02 +0200 Subject: [PATCH 11/55] [tcp_uart] Keep received bytes readable after the connection drops (#20164) --- esphome/components/tcp_uart/tcp_uart.cpp | 7 +- esphome/components/tcp_uart/tcp_uart.h | 2 +- tests/components/tcp_uart/__init__.py | 8 + .../components/tcp_uart/test_buffers_host.cpp | 234 ++++++++++++++++++ 4 files changed, 247 insertions(+), 4 deletions(-) create mode 100644 tests/components/tcp_uart/test_buffers_host.cpp diff --git a/esphome/components/tcp_uart/tcp_uart.cpp b/esphome/components/tcp_uart/tcp_uart.cpp index 5913610ee84..3d3f8dfeba1 100644 --- a/esphome/components/tcp_uart/tcp_uart.cpp +++ b/esphome/components/tcp_uart/tcp_uart.cpp @@ -46,7 +46,8 @@ void TcpUart::on_shutdown() { void TcpUart::sync_link_() { bool up = this->link_.connected(); this->link_was_up_ = up; - if (!up) { + if (up) { + // Unread bytes of the last session stay readable while down, never into the next one. this->rx_start_ = this->rx_end_ = 0; } if (this->connected_sensor_ != nullptr) { @@ -67,7 +68,7 @@ void TcpUart::read_socket_() { } ssize_t count = this->link_.read(this->rx_ + this->rx_end_, room); if (count <= 0) { - // A dropped link (-1) is cleaned up by sync_link_() on the next loop. + // A dropped link (-1) is seen by sync_link_() on the next loop. if (count == 0) { this->rx_pending_ = false; } @@ -81,7 +82,7 @@ void TcpUart::loop() { #ifdef USE_SOCKET_TCP_LISTENER if (this->server_) { // link_was_up_ holds the accept until the previous drop's edge has run, - // so the sensor and the cleared RX buffer always see the disconnect. + // so the sensor sees the disconnect and the new session's edge clears RX. this->listener_.poll(this->link_, !this->link_was_up_); } else { this->link_.poll(); diff --git a/esphome/components/tcp_uart/tcp_uart.h b/esphome/components/tcp_uart/tcp_uart.h index e973c7a1891..4b2ff0aa228 100644 --- a/esphome/components/tcp_uart/tcp_uart.h +++ b/esphome/components/tcp_uart/tcp_uart.h @@ -64,7 +64,7 @@ class TcpUart : public uart::UARTComponent, public Component { uint16_t rx_start_{0}; uint16_t rx_end_{0}; bool server_{false}; - // The link state loop() saw last; edges clear rx_ and publish the sensor. + // The link state loop() saw last; edges publish the sensor, the up edge clears rx_. bool link_was_up_{false}; // A read stopped before EAGAIN. ready() stays false until new data arrives. bool rx_pending_{false}; diff --git a/tests/components/tcp_uart/__init__.py b/tests/components/tcp_uart/__init__.py index 1e4de448b74..7090910cb23 100644 --- a/tests/components/tcp_uart/__init__.py +++ b/tests/components/tcp_uart/__init__.py @@ -1,7 +1,15 @@ # This file's presence makes pytest treat this directory as a package named # "tcp_uart"; required for cpp unit testing. +from esphome.components import socket as socket_component +from esphome.types import ConfigType from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: manifest.multi_conf = False + + async def to_code_testing(config: ConfigType) -> None: + # The server-role gtest needs the listener. + socket_component.require_tcp_listener() + + manifest.to_code = to_code_testing diff --git a/tests/components/tcp_uart/test_buffers_host.cpp b/tests/components/tcp_uart/test_buffers_host.cpp new file mode 100644 index 00000000000..3ec233eba2e --- /dev/null +++ b/tests/components/tcp_uart/test_buffers_host.cpp @@ -0,0 +1,234 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/tcp_uart/tcp_uart.h" +#include "esphome/core/application.h" +#include "esphome/core/wake.h" + +#ifdef USE_HOST + +namespace esphome::tcp_uart::testing { + +class TcpUartLoopDriver : public TcpUart { + public: + TcpUartLoopDriver() { + this->set_host("peer"); + this->set_port(1); + this->link_.begin("buffers_test"); + } + socket::TcpClientLink &link() { return this->link_; } +}; + +class TcpUartBuffers : public ::testing::Test { + protected: + void SetUp() override { + // EPIPE must come back as an errno, not a signal. + signal(SIGPIPE, SIG_IGN); + this->connect_peer(); + } + void TearDown() override { + this->close_peer(); + this->uart_.link().close(); + } + // Hands the UART a fresh session, as an accept or a reconnect would. + void connect_peer() { + int fds[2]; + ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0); + this->peer_fd_ = fds[1]; + this->uart_.link().adopt(std::make_unique(fds[0])); + } + void close_peer() { + if (this->peer_fd_ >= 0) { + ::close(this->peer_fd_); + this->peer_fd_ = -1; + } + } + void send(const void *data, size_t len) { ASSERT_EQ(::write(this->peer_fd_, data, len), static_cast(len)); } + void loops(int count) { + for (int i = 0; i < count; i++) { + this->uart_.loop(); + } + } + + TcpUartLoopDriver uart_; + int peer_fd_{-1}; +}; + +TEST_F(TcpUartBuffers, BytesReceivedBeforeACloseStayReadable) { + this->loops(1); + this->send("HELLO-0123456789", 16); + this->close_peer(); + // Read the bytes, see the close, run the down edge, then idle. + this->loops(4); + ASSERT_FALSE(this->uart_.is_connected()); + ASSERT_EQ(this->uart_.available(), 16u); + uint8_t got[16]; + ASSERT_TRUE(this->uart_.read_array(got, sizeof(got))); + EXPECT_EQ(std::memcmp(got, "HELLO-0123456789", sizeof(got)), 0); + EXPECT_EQ(this->uart_.available(), 0u); +} + +TEST_F(TcpUartBuffers, UnreadBytesAreGoneWhenTheNextSessionStarts) { + this->loops(1); + this->send("OLD", 3); + this->close_peer(); + this->loops(4); + ASSERT_FALSE(this->uart_.is_connected()); + this->connect_peer(); + this->send("NEW", 3); + this->loops(1); + ASSERT_EQ(this->uart_.available(), 3u); + uint8_t got[3]; + ASSERT_TRUE(this->uart_.read_array(got, sizeof(got))); + EXPECT_EQ(std::memcmp(got, "NEW", sizeof(got)), 0); +} + +TEST_F(TcpUartBuffers, FullBufferWaitsAndCompactsAfterARead) { + static constexpr size_t TOTAL = 1500; + uint8_t data[TOTAL]; + for (size_t i = 0; i < TOTAL; i++) { + data[i] = static_cast(i % 251); + } + this->loops(1); + this->send(data, TOTAL); + this->loops(1); + ASSERT_EQ(this->uart_.available(), 1024u); + // Nothing read, no room: the rest stays in the socket. + this->loops(2); + ASSERT_EQ(this->uart_.available(), 1024u); + + uint8_t got[TOTAL]; + ASSERT_TRUE(this->uart_.read_array(got, 100)); + // The read freed the front; the next pass moves the rest down and refills. + this->loops(1); + ASSERT_EQ(this->uart_.available(), 1024u); + ASSERT_TRUE(this->uart_.read_array(got + 100, 1024)); + this->loops(1); + ASSERT_EQ(this->uart_.available(), TOTAL - 1124); + ASSERT_TRUE(this->uart_.read_array(got + 1124, TOTAL - 1124)); + EXPECT_EQ(std::memcmp(got, data, TOTAL), 0); + EXPECT_TRUE(this->uart_.is_connected()); +} + +TEST_F(TcpUartBuffers, WritesBeyondTheSendBufferAreDropped) { + static constexpr size_t TOTAL = 1500; + uint8_t data[TOTAL]; + for (size_t i = 0; i < TOTAL; i++) { + data[i] = static_cast(i % 251); + } + this->loops(1); + this->uart_.write_array(data, TOTAL); + EXPECT_EQ(this->uart_.available_for_write(), 0u); + EXPECT_EQ(this->uart_.flush(), uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS); + + uint8_t got[TOTAL]; + size_t total = 0; + while (total < 1024) { + ssize_t n = ::read(this->peer_fd_, got + total, sizeof(got) - total); + ASSERT_GT(n, 0); + total += static_cast(n); + } + EXPECT_EQ(total, 1024u); + EXPECT_EQ(std::memcmp(got, data, 1024), 0); + // The 476 bytes past the buffer never reach the peer. + EXPECT_EQ(::recv(this->peer_fd_, got, sizeof(got), MSG_DONTWAIT), -1); +} + +#ifdef USE_SOCKET_TCP_LISTENER +// Server role through the real listener on a loopback port. +class TcpUartServer : public ::testing::Test { + protected: + void SetUp() override { + signal(SIGPIPE, SIG_IGN); + // Find a free port for the listener. + int probe = ::socket(AF_INET, SOCK_STREAM, 0); + ASSERT_GE(probe, 0); + struct sockaddr_in addr = loopback(0); + ASSERT_EQ(::bind(probe, reinterpret_cast(&addr), sizeof(addr)), 0); + socklen_t len = sizeof(addr); + ASSERT_EQ(::getsockname(probe, reinterpret_cast(&addr), &len), 0); + ::close(probe); + this->port_ = ntohs(addr.sin_port); + this->uart_.set_server(true); + this->uart_.set_port(this->port_); + this->uart_.set_reconnect_interval(0); + this->uart_.setup(); + this->pass(); + } + void TearDown() override { + this->close_peer(); + this->uart_.on_shutdown(); + } + static struct sockaddr_in loopback(uint16_t port) { + struct sockaddr_in addr {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = htons(port); + return addr; + } + // One main loop pass: select() marks readable sockets, then the component runs. + void pass() { + internal::wakeable_delay(5); + this->now_ += 16; + LoopBlockingGuard dispatch{nullptr, nullptr, this->now_}; + this->uart_.loop(); + } + void connect_peer() { + this->peer_fd_ = ::socket(AF_INET, SOCK_STREAM, 0); + ASSERT_GE(this->peer_fd_, 0); + struct sockaddr_in addr = loopback(this->port_); + ASSERT_EQ(::connect(this->peer_fd_, reinterpret_cast(&addr), sizeof(addr)), 0); + for (int i = 0; i < 50 && !this->uart_.is_connected(); i++) + this->pass(); + ASSERT_TRUE(this->uart_.is_connected()); + } + void close_peer() { + if (this->peer_fd_ >= 0) { + ::close(this->peer_fd_); + this->peer_fd_ = -1; + } + } + void send(const void *data, size_t len) { ASSERT_EQ(::write(this->peer_fd_, data, len), static_cast(len)); } + void pass_until_available(size_t count) { + for (int i = 0; i < 50 && this->uart_.available() < count; i++) + this->pass(); + } + + TcpUart uart_; + uint16_t port_{0}; + int peer_fd_{-1}; + uint32_t now_{0}; +}; + +TEST_F(TcpUartServer, NextAcceptedClientStartsWithAnEmptyBuffer) { + this->connect_peer(); + this->send("OLD", 3); + this->pass_until_available(3); + ASSERT_EQ(this->uart_.available(), 3u); + this->close_peer(); + for (int i = 0; i < 50 && this->uart_.is_connected(); i++) + this->pass(); + ASSERT_FALSE(this->uart_.is_connected()); + // Unread bytes stay readable while no client is connected. + EXPECT_EQ(this->uart_.available(), 3u); + + this->connect_peer(); + this->send("NEW", 3); + this->pass_until_available(3); + ASSERT_EQ(this->uart_.available(), 3u); + uint8_t got[3]; + ASSERT_TRUE(this->uart_.read_array(got, sizeof(got))); + EXPECT_EQ(std::memcmp(got, "NEW", sizeof(got)), 0); +} +#endif + +} // namespace esphome::tcp_uart::testing + +#endif From 1520d228bc965d4ca447852f1091b3de715884e8 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 6 Oct 2026 12:43:49 -0500 Subject: [PATCH 12/55] [i2s_audio] Park the speaker data-out pin low at setup (#20226) Co-authored-by: Claude Fable 5.1 --- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 14 +++++++++++--- .../i2s_audio/speaker/i2s_audio_speaker.h | 3 +++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 7ae58b3e440..4b74b2e29d4 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -40,6 +40,16 @@ void I2SAudioSpeakerBase::setup() { // When no audio_dac is configured, this initializes software volume control. this->set_volume(this->volume_); this->set_mute_state(this->mute_state_); + + // Until the I2S driver first starts, dout sits in its reset state (often pulled high, or a JTAG + // function on the ESP32-S3), which keeps a SPDIF optical transmitter lit. Park it low now. + this->park_dout_pin_(); +} + +void I2SAudioSpeakerBase::park_dout_pin_() { + gpio_reset_pin(this->dout_pin_); + gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT); + gpio_set_level(this->dout_pin_, 0); } void I2SAudioSpeakerBase::dump_config() { @@ -303,9 +313,7 @@ void I2SAudioSpeakerBase::stop_i2s_driver_() { // setup installed. If another speaker reuses this port (shared bus), its audio still reaches our // dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a // SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise. - gpio_reset_pin(this->dout_pin_); - gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT); - gpio_set_level(this->dout_pin_, 0); + this->park_dout_pin_(); } this->parent_->unlock(); } diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index b443166ea1b..965eede0201 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -129,6 +129,9 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public /// @brief Stops the I2S driver and unlocks the I2S port void stop_i2s_driver_(); + /// @brief Detaches dout from the I2S signal and drives it low + void park_dout_pin_(); + /// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup. virtual void on_task_stopped() {} From 490d478e546b3a7328ceab102ea478d3eebdfd3c Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 7 Oct 2026 07:02:34 +1100 Subject: [PATCH 13/55] [mipi_spi] Light platform for AMOLED display brightness (#20212) Co-authored-by: Claude Opus 5.5 --- esphome/components/mipi/__init__.py | 5 +- esphome/components/mipi_spi/display.py | 4 +- esphome/components/mipi_spi/light/__init__.py | 68 ++++++++++ .../mipi_spi/light/mipi_spi_light.h | 36 +++++ esphome/components/mipi_spi/mipi_spi.h | 13 +- .../mipi_spi/test_get_sequence.py | 36 ++++- tests/component_tests/mipi_spi/test_light.py | 126 ++++++++++++++++++ tests/components/mipi_spi/common.yaml | 45 +++++++ 8 files changed, 327 insertions(+), 6 deletions(-) create mode 100644 esphome/components/mipi_spi/light/__init__.py create mode 100644 esphome/components/mipi_spi/light/mipi_spi_light.h create mode 100644 tests/component_tests/mipi_spi/test_light.py diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 50b84b630d1..526afa914b5 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -648,7 +648,10 @@ class DriverChip: sequence.append((INVON,)) else: sequence.append((INVOFF,)) - if brightness := config.get(CONF_BRIGHTNESS, self.get_default(CONF_BRIGHTNESS)): + brightness = config.get( + CONF_BRIGHTNESS, self.get_default(CONF_BRIGHTNESS, None) + ) + if brightness is not None: sequence.append((BRIGHTNESS, brightness)) # Add a SLPOUT command if required. if not self.skip_command("SLPOUT"): diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index b49675ca4c1..ff49979a244 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -225,10 +225,10 @@ def model_schema(config: ConfigType) -> cv.All | cv.Schema: ) .extend({model.option(x): cv.boolean for x in other_options}) ) - if brightness := model.get_default(CONF_BRIGHTNESS): + if model.get_default(CONF_BRIGHTNESS, None) is not None or model.name == "CUSTOM": schema = schema.extend( { - cv.Optional(CONF_BRIGHTNESS, default=brightness): cv.int_range( + model.option(CONF_BRIGHTNESS, cv.UNDEFINED): cv.int_range( 0, 0xFF, min_included=True, max_included=True ), } diff --git a/esphome/components/mipi_spi/light/__init__.py b/esphome/components/mipi_spi/light/__init__.py new file mode 100644 index 00000000000..aec5d9b4fdf --- /dev/null +++ b/esphome/components/mipi_spi/light/__init__.py @@ -0,0 +1,68 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.const import ( + CONF_BRIGHTNESS, + CONF_DISPLAY_ID, + CONF_GAMMA_CORRECT, + CONF_MAX_BRIGHTNESS, + CONF_MIN_BRIGHTNESS, + CONF_MODEL, + CONF_OUTPUT_ID, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from ..display import MipiSpi, mipi_spi_ns + +MipiSpiLight = mipi_spi_ns.class_("MipiSpiLight", light.LightOutput) + + +def _validate_brightness_range(config: ConfigType) -> ConfigType: + if config[CONF_MIN_BRIGHTNESS] >= config[CONF_MAX_BRIGHTNESS]: + raise cv.Invalid( + f"'{CONF_MIN_BRIGHTNESS}' must be less than '{CONF_MAX_BRIGHTNESS}'" + ) + return config + + +CONFIG_SCHEMA = cv.All( + light.BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(MipiSpiLight), + cv.GenerateID(CONF_DISPLAY_ID): cv.use_id(MipiSpi), + cv.Optional(CONF_GAMMA_CORRECT, default=1.0): cv.positive_float, + cv.Optional(CONF_MIN_BRIGHTNESS, default=0): cv.int_range(0, 255), + cv.Optional(CONF_MAX_BRIGHTNESS, default=255): cv.int_range(0, 255), + } + ), + _validate_brightness_range, +) + + +def _final_validate(config: ConfigType) -> None: + full_config = fv.full_config.get() + display_path = full_config.get_path_for_id(config[CONF_DISPLAY_ID])[:-1] + display_config = full_config.get_config_for_path(display_path) + if CONF_BRIGHTNESS not in display_config: + model = display_config[CONF_MODEL] + if model == "CUSTOM": + raise cv.Invalid( + f"The '{CONF_BRIGHTNESS}' option must be set in the display config" + ) + raise cv.Invalid( + f"Display model {model} does not support setting brightness with an SPI command" + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable( + config[CONF_OUTPUT_ID], + config[CONF_MIN_BRIGHTNESS], + config[CONF_MAX_BRIGHTNESS], + ) + await light.register_light(var, config) + await cg.register_parented(var, config[CONF_DISPLAY_ID]) diff --git a/esphome/components/mipi_spi/light/mipi_spi_light.h b/esphome/components/mipi_spi/light/mipi_spi_light.h new file mode 100644 index 00000000000..30bbdcd8ee0 --- /dev/null +++ b/esphome/components/mipi_spi/light/mipi_spi_light.h @@ -0,0 +1,36 @@ +#pragma once + +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" +#include "esphome/components/mipi_spi/mipi_spi.h" +#include "esphome/core/helpers.h" + +namespace esphome::mipi_spi { + +// Brightness-only light that sets the display brightness with an SPI command. +// Brightness is scaled into [min_brightness, max_brightness]; off writes min_brightness. +class MipiSpiLight final : public light::LightOutput, public Parented { + public: + MipiSpiLight(uint8_t min_brightness, uint8_t max_brightness) + : min_brightness_(min_brightness), max_brightness_(max_brightness) {} + + light::LightTraits get_traits() override { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::BRIGHTNESS}); + return traits; + } + + void write_state(light::LightState *state) override { + float brightness; + state->current_values_as_brightness(&brightness); + this->parent_->set_brightness( + this->min_brightness_ + + static_cast(roundf(brightness * (this->max_brightness_ - this->min_brightness_)))); + } + + protected: + uint8_t min_brightness_; + uint8_t max_brightness_; +}; + +} // namespace esphome::mipi_spi diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index e428a1f96e5..48a2cafbdd3 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -72,6 +72,12 @@ void internal_dump_config(const char *model, int width, int height, int offset_w GPIOPin *cs, GPIOPin *reset, GPIOPin *dc, int spi_mode, uint32_t data_rate, int bus_width, bool has_hardware_rotation); +// Lets a light set the display brightness without knowing the display's template parameters. +class MipiSpiBrightness { + public: + virtual void set_brightness(uint8_t brightness) = 0; +}; + /** * Base class for MIPI SPI displays. * All the methods are defined here in the header file, as it is not possible to define templated methods in a cpp file. @@ -94,6 +100,7 @@ template class MipiSpi : public display::Display, + public MipiSpiBrightness, public spi::SPIDevice { public: @@ -108,9 +115,11 @@ class MipiSpi : public display::Display, this->invert_colors_ = invert_colors; this->reset_params_(); } - void set_brightness(uint8_t brightness) { + void set_brightness(uint8_t brightness) override { this->brightness_ = brightness; - this->reset_params_(); + // Before setup the stored value is applied by reset_params_() + if (this->is_ready()) + this->write_command_(BRIGHTNESS, brightness); } void set_rotation(display::DisplayRotation rotation) override { this->rotation_ = rotation; diff --git a/tests/component_tests/mipi_spi/test_get_sequence.py b/tests/component_tests/mipi_spi/test_get_sequence.py index 263b567b9c9..f8614b17a3a 100644 --- a/tests/component_tests/mipi_spi/test_get_sequence.py +++ b/tests/component_tests/mipi_spi/test_get_sequence.py @@ -4,7 +4,13 @@ from collections.abc import Generator import pytest -from esphome.components.mipi import CONF_INVERT_COLORS, CONF_PIXEL_MODE, DriverChip +from esphome.components.mipi import ( + BRIGHTNESS, + CONF_INVERT_COLORS, + CONF_PIXEL_MODE, + DriverChip, +) +from esphome.const import CONF_BRIGHTNESS # A minimal config with no reset pin: enough for get_sequence(add_madctl=False) to run # without needing a full display configuration. @@ -62,3 +68,31 @@ def test_get_sequence_skips_reset_delay_validation_without_add_reset() -> None: chip = DriverChip("TEST-GET-SEQUENCE-NO-RESET", reset_delay=999) chip.get_sequence(_BASE_CONFIG, add_madctl=False, add_reset=False) + + +def _has_brightness_command(sequence: tuple[int, ...], value: int) -> bool: + """True if the flattened sequence holds a one-byte brightness (0x51) command.""" + return any( + sequence[i : i + 3] == (BRIGHTNESS, 1, value) for i in range(len(sequence) - 2) + ) + + +@pytest.mark.parametrize("brightness", [0, 0x80]) +def test_get_sequence_adds_configured_brightness(brightness: int) -> None: + """A configured brightness is sent at startup, including zero.""" + chip = DriverChip("TEST-GET-SEQUENCE-BRIGHTNESS") + + sequence = chip.get_sequence( + {**_BASE_CONFIG, CONF_BRIGHTNESS: brightness}, add_madctl=False + ) + + assert _has_brightness_command(sequence, brightness) + + +def test_get_sequence_omits_brightness_when_not_set() -> None: + """No brightness command is sent when neither config nor model sets one.""" + chip = DriverChip("TEST-GET-SEQUENCE-NO-BRIGHTNESS") + + sequence = chip.get_sequence(_BASE_CONFIG, add_madctl=False) + + assert not any(_has_brightness_command(sequence, v) for v in range(256)) diff --git a/tests/component_tests/mipi_spi/test_light.py b/tests/component_tests/mipi_spi/test_light.py new file mode 100644 index 00000000000..c42b56cf5ae --- /dev/null +++ b/tests/component_tests/mipi_spi/test_light.py @@ -0,0 +1,126 @@ +"""Tests for the mipi_spi light platform validation.""" + +from __future__ import annotations + +import pytest + +from esphome import final_validate +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.mipi_spi.display import CONFIG_SCHEMA as DISPLAY_SCHEMA +from esphome.components.mipi_spi.light import ( + CONFIG_SCHEMA as LIGHT_SCHEMA, + FINAL_VALIDATE_SCHEMA as LIGHT_FINAL_VALIDATE_SCHEMA, +) +from esphome.config import Config +import esphome.config_validation as cv +from esphome.const import ( + CONF_BRIGHTNESS, + CONF_DISPLAY_ID, + CONF_GAMMA_CORRECT, + CONF_ID, + CONF_MAX_BRIGHTNESS, + CONF_MIN_BRIGHTNESS, + PlatformFramework, +) +from esphome.core import ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +DISPLAY_ID = "test_display" + +_CUSTOM: ConfigType = { + "model": "custom", + "dimensions": {"width": 240, "height": 240}, + "init_sequence": [[0xA0, 0x01]], +} + + +@pytest.fixture(autouse=True) +def _esp32(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + +def _light(**extra: object) -> ConfigType: + return { + "name": "Display Brightness", + CONF_DISPLAY_ID: DISPLAY_ID, + **extra, + } + + +def _final_validate_with_display(display: ConfigType) -> None: + """Validate a display, place it in the full config, then final-validate a light on it.""" + display = DISPLAY_SCHEMA( + {"id": DISPLAY_ID, "dc_pin": 18, **display}, + ) + full = Config() + full["display"] = [display] + full.declare_ids.append((display[CONF_ID], ["display", 0, CONF_ID])) + final_validate.full_config.set(full) + LIGHT_FINAL_VALIDATE_SCHEMA({CONF_DISPLAY_ID: ID(DISPLAY_ID, is_declaration=False)}) + + +def test_light_defaults() -> None: + """Gamma defaults to 1.0 and the range to the full 0-255.""" + config = LIGHT_SCHEMA(_light()) + + assert config[CONF_GAMMA_CORRECT] == 1.0 + assert config[CONF_MIN_BRIGHTNESS] == 0 + assert config[CONF_MAX_BRIGHTNESS] == 255 + + +@pytest.mark.parametrize( + ("min_brightness", "max_brightness"), + [(100, 100), (200, 100)], + ids=["equal", "inverted"], +) +def test_light_rejects_bad_range(min_brightness: int, max_brightness: int) -> None: + with pytest.raises( + cv.Invalid, match="'min_brightness' must be less than 'max_brightness'" + ): + LIGHT_SCHEMA( + _light(min_brightness=min_brightness, max_brightness=max_brightness) + ) + + +@pytest.mark.parametrize("key", [CONF_MIN_BRIGHTNESS, CONF_MAX_BRIGHTNESS]) +def test_light_rejects_out_of_range_value(key: str) -> None: + with pytest.raises(cv.Invalid): + LIGHT_SCHEMA(_light(**{key: 256})) + + +@pytest.mark.parametrize( + "display", + [ + pytest.param({"model": "rm690b0"}, id="model_with_default_brightness"), + pytest.param({**_CUSTOM, CONF_BRIGHTNESS: 0}, id="custom_with_brightness"), + ], +) +def test_light_accepts_display_with_brightness(display: ConfigType) -> None: + _final_validate_with_display(display) + + +def test_light_rejects_model_without_brightness() -> None: + with pytest.raises( + cv.Invalid, + match="Display model ILI9488 does not support setting brightness", + ): + _final_validate_with_display({"model": "ili9488"}) + + +def test_light_rejects_custom_without_brightness() -> None: + with pytest.raises( + cv.Invalid, + match="The 'brightness' option must be set in the display config", + ): + _final_validate_with_display(_CUSTOM) + + +def test_brightness_not_allowed_on_model_without_default() -> None: + with pytest.raises(cv.Invalid, match=r"extra keys not allowed.*brightness"): + DISPLAY_SCHEMA( + {"id": DISPLAY_ID, "dc_pin": 18, "model": "ili9488", CONF_BRIGHTNESS: 10} + ) diff --git a/tests/components/mipi_spi/common.yaml b/tests/components/mipi_spi/common.yaml index 6bdee9e54a6..45815b1ba5e 100644 --- a/tests/components/mipi_spi/common.yaml +++ b/tests/components/mipi_spi/common.yaml @@ -53,3 +53,48 @@ display: reset_pin: allow_other_uses: true number: ${reset_pin} + + - platform: mipi_spi + id: amoled_display + model: RM690B0 + dc_pin: + allow_other_uses: true + number: ${dc_pin} + cs_pin: + allow_other_uses: true + number: ${cs_pin} + reset_pin: + allow_other_uses: true + number: ${reset_pin} + + - platform: mipi_spi + id: custom_display + model: custom + brightness: 0x80 + dimensions: + width: 240 + height: 240 + init_sequence: + - [0xd0, 1] + dc_pin: + allow_other_uses: true + number: ${dc_pin} + cs_pin: + allow_other_uses: true + number: ${cs_pin} + reset_pin: + allow_other_uses: true + number: ${reset_pin} + +light: + - platform: mipi_spi + id: display_brightness + name: Display Brightness + display_id: amoled_display + + - platform: mipi_spi + id: custom_display_brightness + name: Custom Display Brightness + display_id: custom_display + min_brightness: 16 + max_brightness: 200 From 194273955c62fc2b269d9c2889654a8d9885133c Mon Sep 17 00:00:00 2001 From: mrtoy-me <118446898+mrtoy-me@users.noreply.github.com> Date: Wed, 7 Oct 2026 06:22:00 +1000 Subject: [PATCH 14/55] [tas58xx] Add support for tas5825 dac (#19971) --- esphome/components/tas58xx/__init__.py | 9 ++ esphome/components/tas58xx/audio_dac.py | 15 ++- esphome/components/tas58xx/binary_sensor.py | 46 ++++++- esphome/components/tas58xx/model_tas5805m.cpp | 28 +++-- esphome/components/tas58xx/model_tas5825m.cpp | 119 ++++++++++++++++++ esphome/components/tas58xx/tas58xx.cpp | 7 +- esphome/components/tas58xx/tas58xx.h | 32 ++++- tests/components/tas58xx/common.yaml | 17 ++- 8 files changed, 247 insertions(+), 26 deletions(-) create mode 100644 esphome/components/tas58xx/model_tas5825m.cpp diff --git a/esphome/components/tas58xx/__init__.py b/esphome/components/tas58xx/__init__.py index b87036984dc..0931d7ba1f3 100644 --- a/esphome/components/tas58xx/__init__.py +++ b/esphome/components/tas58xx/__init__.py @@ -1 +1,10 @@ +from esphome.config_helpers import filter_source_files_from_defines + CODEOWNERS = ["@mrtoy-me", "@remcom"] + +FILTER_SOURCE_FILES = filter_source_files_from_defines( + { + "model_tas5805m.cpp": "USE_TAS58XX_TAS5805M", + "model_tas5825m.cpp": "USE_TAS58XX_TAS5825M", + } +) diff --git a/esphome/components/tas58xx/audio_dac.py b/esphome/components/tas58xx/audio_dac.py index 18a60a66455..7b1e3e239e8 100644 --- a/esphome/components/tas58xx/audio_dac.py +++ b/esphome/components/tas58xx/audio_dac.py @@ -11,6 +11,8 @@ from esphome.const import CONF_ENABLE_PIN, CONF_ID, CONF_MODEL from esphome.cpp_generator import MockObj from esphome.types import ConfigType +from . import FILTER_SOURCE_FILES # noqa: F401 pylint: disable=unused-import + _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ["i2c"] @@ -23,6 +25,9 @@ CONF_VOLUME_MIN_DB = "volume_min_db" CONF_VOLUME_MAX_DB = "volume_max_db" CONF_TAS58XX_ID = "tas58xx_id" +DAC_TAS5805M = "tas5805m" +DAC_TAS5825M = "tas5825m" + tas58xx_ns = cg.esphome_ns.namespace("tas58xx") TAS58xx = tas58xx_ns.class_("TAS58xx", AudioDac, cg.PollingComponent, i2c.I2CDevice) @@ -54,13 +59,20 @@ class Model: MODELS: dict[str, Model] = { - "tas5805m": Model( + DAC_TAS5805M: Model( model_info=tas58xx_ns.TAS5805M_MODEL, analog_gain_min_db=-15.5, volume_min_db=-103.0, volume_max_db=24.0, default_address=0x2D, ), + DAC_TAS5825M: Model( + model_info=tas58xx_ns.TAS5825M_MODEL, + analog_gain_min_db=-15.5, + volume_min_db=-103.0, + volume_max_db=24.0, + default_address=0x4C, + ), } @@ -162,6 +174,7 @@ for _name, _call in ( async def to_code(config: ConfigType) -> None: model = MODELS[config[CONF_MODEL]] + cg.add_define(f"USE_TAS58XX_{config[CONF_MODEL].upper()}") var = cg.new_Pvariable(config[CONF_ID], cg.RawExpression(f"&{model.model_info}")) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/tas58xx/binary_sensor.py b/esphome/components/tas58xx/binary_sensor.py index 398cfa2440a..d0577398741 100644 --- a/esphome/components/tas58xx/binary_sensor.py +++ b/esphome/components/tas58xx/binary_sensor.py @@ -1,15 +1,17 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv -from esphome.const import DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.const import CONF_MODEL, DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC +import esphome.final_validate as fv from esphome.types import ConfigType -from .audio_dac import CONF_TAS58XX_ID, TAS58xx, tas58xx_ns +from . import FILTER_SOURCE_FILES # noqa: F401 pylint: disable=unused-import +from .audio_dac import CONF_TAS58XX_ID, DAC_TAS5825M, TAS58xx, tas58xx_ns CONF_HAVE_FAULT = "have_fault" -# Each name matches a FaultSensor value in tas58xx.h -FAULT_SENSORS = ( +# Faults common to TAS5805M and TAS5825M +COMMON_FAULT_SENSORS = ( "left_channel_dc_fault", "right_channel_dc_fault", "left_channel_over_current", @@ -20,8 +22,22 @@ FAULT_SENSORS = ( "pvdd_over_voltage", "pvdd_under_voltage", "over_temp_shutdown", - "over_temp_warning", + "over_temp_warning", # tas582x OTW Level 3 - use tas5805 naming ) +# Additional faults available on TAS5825M +TAS5825M_ONLY_FAULT_SENSORS = ( + "load_eeprom_error", + "right_channel_cbc_over_current", + "left_channel_cbc_over_current", + "left_channel_cbc_over_current_warning", + "right_channel_cbc_over_current_warning", + "over_temp_146c_warning", # tas582x OTW Level 4 + # "over_temp_122c_warning", # tas582x OTW Level 2 - not currently included + # "over_temp_112c_warning", # tas582x OTW Level 1 - not currently included +) + +# Each name matches a FaultSensor value in tas58xx.h +FAULT_SENSORS = COMMON_FAULT_SENSORS + TAS5825M_ONLY_FAULT_SENSORS FaultSensor = tas58xx_ns.enum("FaultSensor") @@ -39,6 +55,26 @@ CONFIG_SCHEMA = cv.Schema( ) +def _final_validate(config: ConfigType) -> ConfigType: + fconf = fv.full_config.get() + hub_path = fconf.get_path_for_id(config[CONF_TAS58XX_ID]) + hub_conf = fconf.get_config_for_path(hub_path[:-1]) + + if hub_conf[CONF_MODEL] == DAC_TAS5825M: + return config + + unsupported = sorted(frozenset(TAS5825M_ONLY_FAULT_SENSORS).intersection(config)) + if unsupported: + raise cv.Invalid( + f"binary_sensor platform tas58xx: '{', '.join(unsupported)}' is only available for 'model: {DAC_TAS5825M}' " + f"- Remove from YAML for 'model: {hub_conf[CONF_MODEL]}'" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_TAS58XX_ID]) binary_sensors = binary_sensor.sub_binary_sensors(config) diff --git a/esphome/components/tas58xx/model_tas5805m.cpp b/esphome/components/tas58xx/model_tas5805m.cpp index e5eb5a837e1..de0a0c5d750 100644 --- a/esphome/components/tas58xx/model_tas5805m.cpp +++ b/esphome/components/tas58xx/model_tas5805m.cpp @@ -67,17 +67,23 @@ const ModelInfo TAS5805M_MODEL = { .fault_name = fault_name, .fault_sensor_bits = { - 3, // FAULT_SENSOR_LEFT_CHANNEL_DC_FAULT - 2, // FAULT_SENSOR_RIGHT_CHANNEL_DC_FAULT - 1, // FAULT_SENSOR_LEFT_CHANNEL_OVER_CURRENT - 0, // FAULT_SENSOR_RIGHT_CHANNEL_OVER_CURRENT - 15, // FAULT_SENSOR_OTP_CRC_CHECK - 14, // FAULT_SENSOR_BQ_WRITE_FAILED - 10, // FAULT_SENSOR_CLOCK_FAULT - 9, // FAULT_SENSOR_PVDD_OVER_VOLTAGE - 8, // FAULT_SENSOR_PVDD_UNDER_VOLTAGE - 16, // FAULT_SENSOR_OVER_TEMP_SHUTDOWN - 26, // FAULT_SENSOR_OVER_TEMP_WARNING + 3, // FAULT_SENSOR_LEFT_CHANNEL_DC_FAULT + 2, // FAULT_SENSOR_RIGHT_CHANNEL_DC_FAULT + 1, // FAULT_SENSOR_LEFT_CHANNEL_OVER_CURRENT + 0, // FAULT_SENSOR_RIGHT_CHANNEL_OVER_CURRENT + 15, // FAULT_SENSOR_OTP_CRC_CHECK + 14, // FAULT_SENSOR_BQ_WRITE_FAILED + 10, // FAULT_SENSOR_CLOCK_FAULT + 9, // FAULT_SENSOR_PVDD_OVER_VOLTAGE + 8, // FAULT_SENSOR_PVDD_UNDER_VOLTAGE + 16, // FAULT_SENSOR_OVER_TEMP_SHUTDOWN + 26, // FAULT_SENSOR_OVER_TEMP_WARNING + NO_BIT, // FAULT_SENSOR_LOAD_EEPROM_ERROR + NO_BIT, // FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT + NO_BIT, // FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT + NO_BIT, // FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT_WARNING + NO_BIT, // FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT_WARNING + NO_BIT, // FAULT_SENSOR_OVER_TEMP_146C_WARNING }, }; diff --git a/esphome/components/tas58xx/model_tas5825m.cpp b/esphome/components/tas58xx/model_tas5825m.cpp new file mode 100644 index 00000000000..3aa74c675e5 --- /dev/null +++ b/esphome/components/tas58xx/model_tas5825m.cpp @@ -0,0 +1,119 @@ +#include "tas58xx.h" + +#include "esphome/core/hal.h" + +namespace esphome::tas58xx { + +// Remainder of the startup sequence, run after the reset. Register 0x00 selects the page. +// All writes except starting and last three register writes are exactly the startup sequence from TI PurePath Console. +// Registers 0x46, 0x7D, 0x7E, page 1 register 0x51 and Page 2 registers 0x1D, 0x19 are not documented in the datasheet. + +// clang-format off +static const uint8_t STARTUP_SEQUENCE[][2] PROGMEM = { + {0x03, 0x00}, // Deep Sleep + {0x7D, 0x11}, + {0x7E, 0xFF}, + {0x00, 0x01}, // Page 1 + {0x51, 0x05}, + {0x00, 0x02}, // Page 2 + {0x1D, 0x00}, + {0x19, 0x80}, + {0x00, 0x00}, // Page 0 + {0x46, 0x11}, + {0x02, 0x00}, // DEVICE_CTRL_1 - BD MODE, Damp to PBL MODE + {0x53, 0x01}, // ANA_CTRL PWM Phase Control - in phase + {0x54, 0x00}, // AGAIN 0dB + {0x03, 0x02}, // Hi-Z + {0x61, 0x0B}, // GPIO0_SEL - GPIO0 as FAULTZ output + {0x60, 0x01}, // GPIO0_OE - Enable GPIO0 as output + {0x77, 0x07}, // CBC_CONTROL enabling CBC function for warnings and faults +}; +// clang-format on + +static const LogString *model_name() { return LOG_STR("TAS5825M"); } + +// An if chain rather than a switch: a switch table would land in rodata, which is RAM on ESP8266. +static const LogString *fault_name(uint8_t index) { + if (index == 0) + return LOG_STR("Right channel over current"); + if (index == 1) + return LOG_STR("Left channel over current"); + if (index == 2) + return LOG_STR("Right channel DC fault"); + if (index == 3) + return LOG_STR("Left channel DC fault"); + if (index == 8) + return LOG_STR("PVDD under voltage"); + if (index == 9) + return LOG_STR("PVDD over voltage"); + if (index == 10) + return LOG_STR("Clock fault"); + if (index == 13) + return LOG_STR("Load EEPROM error"); + if (index == 14) + return LOG_STR("BQ write failed"); + if (index == 15) + return LOG_STR("OTP CRC check error"); + if (index == 16) + return LOG_STR("Over temperature shutdown"); + if (index == 17) + return LOG_STR("Left channel CBC over current"); + if (index == 18) + return LOG_STR("Right channel CBC over current"); + // if (index == 24) + // return LOG_STR("Over temperature warning 112C"); // not currently included + // if (index == 25) + // return LOG_STR("Over temperature warning 122C"); // not currently included + if (index == 26) + return LOG_STR("Over temperature warning"); + if (index == 27) + return LOG_STR("Over temperature warning 146C"); + if (index == 28) + return LOG_STR("Right channel CBC over current warning"); + if (index == 29) + return LOG_STR("Left channel CBC over current warning"); + return LOG_STR("Unknown fault"); +} + +const ModelInfo TAS5825M_MODEL = { + .name = model_name, + .startup_sequence = STARTUP_SEQUENCE, + .startup_sequence_length = sizeof(STARTUP_SEQUENCE) / sizeof(STARTUP_SEQUENCE[0]), + .mixer_book = 0x8C, + .mixer_page = 0x0B, + .mixer_register = 0x14, + // The clock fault is left out of the log and have_fault: it is set whenever the I2S clock stops, which is normal + .fault_error_mask = 0x0007E30F, + .fault_warning_mask = + 0x3C000000, // 0x3F000000 if OVER_TEMP_122C_WARNING and OVER_TEMP_112C_WARNING included in future + // DC and over current faults keep the output off until cleared (datasheet 7.5.3.3.1, 7.5.3.3.2). They are not + // cleared automatically: a DC fault re-trips only after 570 ms, so a clear on every poll would pass DC to the + // speaker. + .fault_output_off_mask = 0x0000000F, + .fault_latched_mask = 0x3F07E70F, + .fault_name = fault_name, + .fault_sensor_bits = + { + 3, // FAULT_SENSOR_LEFT_CHANNEL_DC_FAULT + 2, // FAULT_SENSOR_RIGHT_CHANNEL_DC_FAULT + 1, // FAULT_SENSOR_LEFT_CHANNEL_OVER_CURRENT + 0, // FAULT_SENSOR_RIGHT_CHANNEL_OVER_CURRENT + 15, // FAULT_SENSOR_OTP_CRC_CHECK + 14, // FAULT_SENSOR_BQ_WRITE_FAILED + 10, // FAULT_SENSOR_CLOCK_FAULT + 9, // FAULT_SENSOR_PVDD_OVER_VOLTAGE + 8, // FAULT_SENSOR_PVDD_UNDER_VOLTAGE + 16, // FAULT_SENSOR_OVER_TEMP_SHUTDOWN + 26, // FAULT_SENSOR_OVER_TEMP_WARNING + 13, // FAULT_SENSOR_LOAD_EEPROM_ERROR + 18, // FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT + 17, // FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT + 29, // FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT_WARNING + 28, // FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT_WARNING + 27, // FAULT_SENSOR_OVER_TEMP_146C_WARNING + // 25, // FAULT_SENSOR_OVER_TEMP_122C_WARNING // not currently included + // 24, // FAULT_SENSOR_OVER_TEMP_112C_WARNING // not currently included + }, +}; + +} // namespace esphome::tas58xx diff --git a/esphome/components/tas58xx/tas58xx.cpp b/esphome/components/tas58xx/tas58xx.cpp index 458150a1254..d042d42e24e 100644 --- a/esphome/components/tas58xx/tas58xx.cpp +++ b/esphome/components/tas58xx/tas58xx.cpp @@ -196,8 +196,9 @@ bool TAS58xx::read_faults_() { if (this->have_fault_binary_sensor_ != nullptr) this->have_fault_binary_sensor_->publish_state((active & model.fault_error_mask) != 0); for (uint8_t fault = 0; fault < FAULT_SENSOR_COUNT; fault++) { - if (this->fault_binary_sensors_[fault] != nullptr) - this->fault_binary_sensors_[fault]->publish_state(faults & (uint32_t{1} << model.fault_sensor_bits[fault])); + const uint8_t bit = model.fault_sensor_bits[fault]; + if (this->fault_binary_sensors_[fault] != nullptr && bit != NO_BIT) + this->fault_binary_sensors_[fault]->publish_state(faults & (uint32_t{1} << bit)); } #endif @@ -264,7 +265,7 @@ void TAS58xx::dump_config() { " Analog Gain: %.1f dB\n" " DAC Mode: %s\n" " Mixer Mode: %s\n" - " Volume Range: %.1f dB - %.1f dB", + " Volume Range: %.1f dB to %.1f dB", this->analog_gain_db_, this->dac_mode_ == DAC_MODE_PBTL ? LOG_STR_LITERAL("PBTL") : LOG_STR_LITERAL("BTL"), LOG_STR_ARG(mixer_mode), this->volume_min_db_, this->volume_max_db_); diff --git a/esphome/components/tas58xx/tas58xx.h b/esphome/components/tas58xx/tas58xx.h index b1e9a531ddc..d0f97a5cd9f 100644 --- a/esphome/components/tas58xx/tas58xx.h +++ b/esphome/components/tas58xx/tas58xx.h @@ -27,9 +27,11 @@ enum MixerMode : uint8_t { MIXER_MODE_RIGHT, }; -/// Fault binary sensors that map to a single fault bit. The Python FAULT_SENSORS list uses the same names. +/// Fault binary sensors that map to a single fault bit. Note that ordering is important. +/// The Python FAULT_SENSORS list in binary_sensor.py uses the same names and ordering. enum FaultSensor : uint8_t { - FAULT_SENSOR_LEFT_CHANNEL_DC_FAULT = 0, + // Faults common to TAS5805M and TAS5825M + FAULT_SENSOR_LEFT_CHANNEL_DC_FAULT, FAULT_SENSOR_RIGHT_CHANNEL_DC_FAULT, FAULT_SENSOR_LEFT_CHANNEL_OVER_CURRENT, FAULT_SENSOR_RIGHT_CHANNEL_OVER_CURRENT, @@ -40,9 +42,26 @@ enum FaultSensor : uint8_t { FAULT_SENSOR_PVDD_UNDER_VOLTAGE, FAULT_SENSOR_OVER_TEMP_SHUTDOWN, FAULT_SENSOR_OVER_TEMP_WARNING, - FAULT_SENSOR_COUNT, + // Faults available for TAS5825M Only + FAULT_SENSOR_LOAD_EEPROM_ERROR, + FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT, + FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT, + FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT_WARNING, + FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT_WARNING, + FAULT_SENSOR_OVER_TEMP_146C_WARNING, + // FAULT_SENSOR_OVER_TEMP_122C_WARNING, + // FAULT_SENSOR_OVER_TEMP_112C_WARNING, + FAULT_SENSOR_COUNT, // keep last }; +// compile time check +static_assert( + FAULT_SENSOR_COUNT == 17, + "enum FaultSensor altered: Update FAULT_SENSORS in binary_sensor.py and fault_sensor_bits in each model's cpp"); + +/// ModelInfo::fault_sensor_bits value for a FaultSensor that the model does not have. +static constexpr uint8_t NO_BIT = 0xFF; + /// Everything that differs between models of the family. One constant instance exists per model, see /// model_*.cpp, and each TAS58xx instance points to the one for its model. /// @@ -67,11 +86,16 @@ struct ModelInfo { /// Faults that stay set after the condition is gone and are cleared after each read uint32_t fault_latched_mask; const LogString *(*fault_name)(uint8_t index); - /// Bit index for each FaultSensor + /// Bit index for each FaultSensor, NO_BIT used where the model does not have that fault bit uint8_t fault_sensor_bits[FAULT_SENSOR_COUNT]; }; +#ifdef USE_TAS58XX_TAS5805M extern const ModelInfo TAS5805M_MODEL; +#endif +#ifdef USE_TAS58XX_TAS5825M +extern const ModelInfo TAS5825M_MODEL; +#endif class TAS58xx : public audio_dac::AudioDac, public PollingComponent, public i2c::I2CDevice { public: diff --git a/tests/components/tas58xx/common.yaml b/tests/components/tas58xx/common.yaml index e12ddf75671..afe38fef21f 100644 --- a/tests/components/tas58xx/common.yaml +++ b/tests/components/tas58xx/common.yaml @@ -12,10 +12,9 @@ audio_dac: volume_max_db: 0dB update_interval: 1s - platform: tas58xx - model: TAS5805M + model: TAS5825M id: tas58xx_amp_2 i2c_id: i2c_bus - address: 0x2C ignore_enable_pin_warning: true esphome: @@ -59,3 +58,17 @@ binary_sensor: name: Second Amp Any Fault left_channel_dc_fault: name: Second Amp Left Channel DC Fault + left_channel_cbc_over_current: + name: Left Channel CBC Current Fault + right_channel_cbc_over_current: + name: Right Channel CBC Current Fault + over_temp_shutdown: + name: Over Temperature Shutdown Fault + left_channel_cbc_over_current_warning: + name: Left Channel CBC Current Warning + right_channel_cbc_over_current_warning: + name: Right Channel CBC Current Warning + over_temp_146c_warning: + name: Over Temperature 146C Warning + over_temp_warning: + name: Over Temperature 134C Warning From 4eeb110247c089e77658a899f2e972f11f42085d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 6 Oct 2026 15:26:46 -0500 Subject: [PATCH 15/55] [esp32] Extract prefetched ESP-IDF tool archives in parallel (#18841) --- esphome/espidf/_tool_resolution.py | 67 +++ esphome/espidf/framework.py | 78 +++- esphome/espidf/get_tool_downloads.py | 79 +--- esphome/espidf/install_tool_archives.py | 141 ++++++ .../fixtures/idf_tools_stub/idf_tools.py | 50 +- tests/unit_tests/test_espidf_framework.py | 434 +++++++++++++++++- 6 files changed, 757 insertions(+), 92 deletions(-) create mode 100644 esphome/espidf/_tool_resolution.py create mode 100644 esphome/espidf/install_tool_archives.py diff --git a/esphome/espidf/_tool_resolution.py b/esphome/espidf/_tool_resolution.py new file mode 100644 index 00000000000..624a07fa25b --- /dev/null +++ b/esphome/espidf/_tool_resolution.py @@ -0,0 +1,67 @@ +"""Shared tool resolution for the sibling idf_tools-backed scripts. + +Importable because ``python