Compare commits

...
46 Commits
Author SHA1 Message Date
Jesse Hills 4ea26ed65f Merge pull request #21707 from esphome/bump-2026.10.1
2026.10.1
2026-10-28 07:33:05 +13:00
J. Nick Koston 1e8f084741 [ci] Stop saving an empty apt cache when the install fails (#21713) 2026-10-27 19:34:58 +13:00
Jesse Hills 6595df0cab Bump version to 2026.10.1 2026-10-27 16:17:54 +13:00
J. Nick Koston 73894b2490 [core] Turn off the precompiled header with ccache older than 4.7 (#21627) 2026-10-27 16:17:53 +13:00
Jonathan Swoboda 9bef20d8dc [core] Avoid division by zero in Color::random_color() (#21623) 2026-10-27 16:17:53 +13:00
Jonathan Swoboda 0e440500a2 [core] Generate valid C++ for infinite float values (#21625) 2026-10-27 16:17:53 +13:00
minimicro34 badfa84fe0 [esp32_ble] Ignore unhandled BLE key GAP events (#21457) 2026-10-27 16:17:53 +13:00
J. Nick Koston e686ecf76e [core] Resolve version-less library dependencies like PlatformIO (#21317) 2026-10-27 16:17:53 +13:00
Jonathan Swoboda 64fb4e06b5 [ledc] Reset the peripheral through the HAL on targets without the legacy periph API (#21397) 2026-10-27 16:17:53 +13:00
Jonathan Swoboda cb82c9031a [core] Keep the color codes of a filtered build output line (#21396) 2026-10-27 16:17:53 +13:00
J. Nick KostonandJonathan Swoboda ca74ab9873 [climate] Add StringRef overloads for ClimateCall::set_fan_mode and set_preset (#21359)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-10-27 16:17:53 +13:00
J. Nick Koston 86fc740b65 [http_request] Copy a manifest URL set at run time and keep the config literal by pointer (#21373) 2026-10-27 16:17:53 +13:00
J. Nick Koston 450bb52f65 [mqtt] Resend the fan state when its first publish fails (#21328) 2026-10-27 16:17:35 +13:00
J. Nick Koston ed3615ec69 [light] Add a StringRef overload for LightCall::set_effect (#21358) 2026-10-27 16:15:01 +13:00
J. Nick Koston a5f25ebe2d [core] Emit generated lambda code after the user includes (#21307) 2026-10-27 16:12:58 +13:00
Jonathan Swoboda 7a4775de3d [esp32_hosted] Keep the OTA watchdog timeout through the coprocessor end call (#21300) 2026-10-27 15:48:16 +13:00
Jesse Hills 704551a6c9 Merge pull request #21268 from esphome/bump-2026.10.0
2026.10.0
2026-10-22 00:42:40 +13:00
Jesse Hills f30e70acfa Bump version to 2026.10.0 2026-10-21 23:53:12 +13:00
Jeroen 594a691d9f [web_server_idf] Increase HTTP server task stack to 4512 bytes (#21038) 2026-10-21 23:53:09 +13:00
Jesse Hills 6c90b5c549 Merge pull request #21254 from esphome/bump-2026.10.0b6
2026.10.0b6
2026-10-21 16:39:54 +13:00
Jesse Hills c8df23a9cb Bump version to 2026.10.0b6 2026-10-21 16:17:51 +13:00
4fdce9d96b [web_server_idf] Reduce loopTask stack usage for state SSE (#21109)
Co-authored-by: Jeroen Jansen <jeroen@Jeroens-MacBook-Air.local>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-21 16:17:49 +13:00
J. Nick Koston 9d296a5748 [ota] Accept an unknown image size in the RP2040 backend so web_server OTA works (#21178) 2026-10-21 16:17:49 +13:00
esphome[bot] f2e013d71a Bump bundled esphome-device-builder to 1.25.0 (#21175) 2026-10-21 16:17:49 +13:00
Jesse Hills 2001dd2be8 Merge pull request #21097 from esphome/bump-2026.10.0b5
2026.10.0b5
2026-10-19 14:03:02 +13:00
Jesse Hills a6a4b49e75 Bump version to 2026.10.0b5 2026-10-19 11:29:05 +13:00
Bonne EgglestonandJ. Nick Koston 649f2c91f5 [api] Poll the outgoing dial every loop (#21085)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-19 11:29:02 +13:00
Bonne Eggleston ffa20abe96 [api] Poll the outgoing dial every loop on deep sleep builds (#21077) 2026-10-19 11:29:02 +13:00
J. Nick Koston f979ddcf0d [bl0906] Read stale receive bytes instead of spinning on flush() (#21035) 2026-10-19 11:29:02 +13:00
J. Nick Koston 26064f1415 [uart_tcp] Expose the connection entities as platforms (#21008) 2026-10-19 11:29:02 +13:00
J. Nick Koston c50ee215f2 [tcp_uart] Expose the connection entities as platforms (#21009) 2026-10-19 10:36:23 +13:00
Jonathan Swoboda 0fbba03522 [debug] Log uptime in dump_config (#20989) 2026-10-19 10:33:13 +13:00
Jonathan Swoboda 7ccaedfb9e [esp32] Bump recommended ESP-IDF to 5.5.6 (#20987) 2026-10-19 10:33:13 +13:00
esphome[bot] 0173127239 Bump bundled esphome-device-builder to 1.24.0 (#20851)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-10-19 10:33:13 +13:00
Clyde Stubbs 235c614e68 [epaper_spi] Ensure test card gets drawn (#20804) 2026-10-19 10:33:13 +13:00
Jonathan Swoboda 79823c89ab [mipi_rgb] Rotate pure RGB panels in software (#20757) 2026-10-19 10:33:13 +13:00
J. Nick Koston e09cb64756 [bluetooth_connection] Value-initialize the connection parameters for ESP-IDF 6.1 (#20774) 2026-10-19 10:33:13 +13:00
J. Nick Koston bd5b3fa641 [esp32_ble_client] Value-initialize the connection parameters for ESP-IDF 6.1 (#20776) 2026-10-19 10:33:13 +13:00
Kevin AhrendtandJ. Nick Koston f464994f85 [api][sendspin] Add the Sendspin client id to the capabilities message (#20775)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-19 10:33:13 +13:00
esphome[bot] 4552dbad7a Bump aioesphomeapi from 46.11.0 to 46.12.0 (#20781)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-10-19 10:33:13 +13:00
J. Nick Koston ad8cd0dd3e [noise] Initialize the protocol id's reserved field for ESP-IDF 6.1 (#20779) 2026-10-19 10:33:13 +13:00
puddlyandJ. Nick Koston dd2582bdfd [usb_uart] Expose interface_class and port_number (#20659)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-19 10:33:13 +13:00
J. Nick Koston ba575600dc [mqtt] Match subscription topics in a loop instead of recursing per character (#20767) 2026-10-19 10:33:13 +13:00
esphome[bot] cd6c0dd402 Bump aioesphomeapi from 46.10.0 to 46.11.0 (#20766)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-10-19 10:33:13 +13:00
4645d34e1a [web_server_idf] Keep active SSE sessions out of the LRU purge (#17383)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-19 10:33:13 +13:00
dff1422e36 [mdns] Advertise the connected interface in the network TXT record (#20673)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-19 10:33:13 +13:00
120 changed files with 2851 additions and 571 deletions

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
name: Install apt packages (cached)
description: >-
Installs libsdl2-dev, libmbedtls-dev and ccache from one shared cache. The cache key comes
only from the package list and version, so both live here and nowhere else.
runs:
using: "composite"
steps:
- name: Keep a failed install out of the cache
# cache-apt-pkgs ignores install failures and saved an empty cache when a stale package
# list got a 404 (upstream awalsh128/cache-apt-pkgs-action#221). It uses an apt-fast
# already on PATH: this one refreshes the lists and retries when the install fails and,
# if that fails too, removes the folder the action would save. Only a cache miss installs.
shell: bash
run: |
sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF'
Acquire::Retries "1";
Acquire::http::Timeout "15";
Acquire::https::Timeout "15";
EOF
sudo tee /usr/local/bin/apt-fast > /dev/null <<'EOF'
#!/bin/bash
if [[ " $* " != *" install "* ]]; then
exec apt-get "$@"
fi
touch /tmp/apt-fast-standin-ran
apt-get "$@" && exit 0
apt-get update -qq >&2
# The action pins each package to the version the stale lists named
args=()
for arg in "$@"; do
if [[ "${arg}" == -* ]]; then args+=("${arg}"); else args+=("${arg%%=*}"); fi
done
apt-get "${args[@]}" && exit 0
# The action writes our output to install.log inside the folder it saves
log="$(readlink -f "/proc/$$/fd/1")"
if [ "$(basename "${log}")" = install.log ]; then
rm -rf "$(dirname "${log}")"
else
touch /tmp/apt-fast-cleanup-missed
fi
exit 1
EOF
sudo chmod +x /usr/local/bin/apt-fast
- name: Install apt packages (cached)
id: apt
# Pinned on purpose and not watched by Dependabot: the step above relies on how this
# release calls apt-fast and where it writes install.log.
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev libmbedtls-dev ccache
# 1.3 drops the empty caches saved before the step above existed
version: 1.3
- name: Check the packages are installed
shell: bash
env:
CACHE_HIT: ${{ steps.apt.outputs.cache-hit }}
run: |
if [ "${CACHE_HIT}" != "true" ] && [ ! -e /tmp/apt-fast-standin-ran ]; then
echo "::error::cache-apt-pkgs did not use the apt-fast stand-in; check the pinned release"
exit 1
fi
if [ -e /tmp/apt-fast-cleanup-missed ]; then
echo "::error::the failed install may have been cached; check the pinned release and bump the version"
exit 1
fi
# The action restores files without registering them with dpkg
for cmd in ccache sdl2-config; do
if ! command -v "${cmd}" > /dev/null; then
echo "::error::${cmd} is missing after the cached install"
exit 1
fi
done
if [ ! -f /usr/include/mbedtls/version.h ]; then
echo "::error::libmbedtls-dev is missing after the cached install"
exit 1
fi
+22 -23
View File
@@ -73,16 +73,17 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
# PR-branch cache saves are invisible to other PRs, so dev/beta/release # PR-branch cache saves are invisible to other PRs, so dev/beta/release
# pushes seed the one shared entry PR jobs restore. The key is derived # pushes seed the one shared entry PR jobs restore. The key is derived
# only from the package list and version; keep both identical in every # only from the package list and version, kept in the apt-packages action.
# step that restores it. In ci-status needs so a broken seed fails dev. # In ci-status needs so a broken seed fails dev.
if: github.event_name == 'push' if: github.event_name == 'push'
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- name: Install apt packages (cached) - name: Check out code from GitHub
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
packages: libsdl2-dev ccache sparse-checkout: .github
version: 1.1 - name: Install apt packages (cached)
uses: ./.github/actions/apt-packages
determine-jobs: determine-jobs:
name: Determine which jobs to run name: Determine which jobs to run
@@ -409,14 +410,10 @@ jobs:
- name: Install apt packages (cached) - name: Install apt packages (cached)
# ccache speeds up the host compiles. A cache hit never touches apt # ccache speeds up the host compiles. A cache hit never touches apt
# (mirror outages cannot hang the job); the timeout bounds the cold # (mirror outages cannot hang the job); the timeout bounds the cold
# path. Packages and version must match seed-apt-cache exactly. # path. libsdl2-dev is needed by the headless display tests, which
# libsdl2-dev is needed by the headless display tests, which capture # capture screenshots.
# screenshots.
timeout-minutes: 10 timeout-minutes: 10
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 uses: ./.github/actions/apt-packages
with:
packages: libsdl2-dev ccache
version: 1.1
- name: Set up Python 3.13 - name: Set up Python 3.13
id: python id: python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
@@ -707,6 +704,12 @@ jobs:
with: with:
# Need history for HEAD~1 to work for checking changed files # Need history for HEAD~1 to work for checking changed files
fetch-depth: 2 fetch-depth: 2
- name: Install apt packages (cached)
if: matrix.pio_cache_key == 'tidyhost'
# libsdl2-dev is needed by the sdl component on the host, libmbedtls-dev
# by dsmr and dlms_meter.
timeout-minutes: 10
uses: ./.github/actions/apt-packages
- name: Restore Python - name: Restore Python
id: restore-python id: restore-python
@@ -1093,20 +1096,16 @@ jobs:
- name: List components - name: List components
run: echo ${{ matrix.batch.components }} run: echo ${{ matrix.batch.components }}
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install apt packages (cached) - name: Install apt packages (cached)
# A cache hit (seeded on dev by seed-apt-cache) never touches apt, # A cache hit (seeded on dev by seed-apt-cache) never touches apt,
# so mirror outages cannot hang this PR-only job; the timeout bounds # so mirror outages cannot hang this PR-only job; the timeout bounds
# the cold path. Packages and version must match seed-apt-cache # the cold path. The action has no --no-install-recommends; same
# exactly. The action has no --no-install-recommends; same package # package set this job used before #17463.
# set this job used before #17463.
timeout-minutes: 10 timeout-minutes: 10
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 uses: ./.github/actions/apt-packages
with:
packages: libsdl2-dev ccache
version: 1.1
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python - name: Restore Python
uses: ./.github/actions/restore-python uses: ./.github/actions/restore-python
with: with:
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version # could be handy for archiving the generated documentation or if some version
# control system is used. # control system is used.
PROJECT_NUMBER = 2026.10.0b4 PROJECT_NUMBER = 2026.10.1
# Using the PROJECT_BRIEF tag one can provide an optional one line description # Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a # for a project that appears at the top of each page and should give viewer a
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt -r /requirements.txt
# Install the ESPHome Device Builder dashboard. # Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.23.1 RUN uv pip install --no-cache-dir esphome-device-builder==1.25.0
RUN \ RUN \
platformio settings set enable_telemetry No \ platformio settings set enable_telemetry No \
-2
View File
@@ -44,7 +44,6 @@ from esphome.platformio.library import (
normalize_dependencies, normalize_dependencies,
parse_library_json, parse_library_json,
parse_library_properties, parse_library_properties,
warn_properties_depends,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -283,7 +282,6 @@ def _bundled_library(framework_path: Path, name: str) -> ArduinoLibrary:
"resolved automatically; add them with add_library() if needed", "resolved automatically; add them with add_library() if needed",
name, name,
) )
warn_properties_depends(name, data)
build = data.get("build") build = data.get("build")
if isinstance(build, dict) and build.get("extraScript"): if isinstance(build, dict) and build.get("extraScript"):
# Scripts only run on the converted path; building without # Scripts only run on the converted path; building without
+29 -12
View File
@@ -7,6 +7,7 @@ import re
import textwrap import textwrap
from esphome.build_helpers import pch from esphome.build_helpers import pch
from esphome.build_helpers.ccache import CCACHE_PCH_MIN_VERSION
from esphome.components.esp32 import ( from esphome.components.esp32 import (
get_esp32_variant, get_esp32_variant,
get_excluded_builtin_components, get_excluded_builtin_components,
@@ -474,7 +475,7 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC
# Where CMake puts the .gch of the src component; ccache reads the checksum # Where CMake puts the .gch of the src component; ccache reads the checksum
# next to it in place of the .gch # next to it in place of the .gch
_PCH_SUM_PATH = "build/esp-idf/src/CMakeFiles/__idf_src.dir/cmake_pch.hxx.gch.sum" _PCH_SUM_PATH = "build/esp-idf/src/CMakeFiles/__idf_src.dir/cmake_pch.hxx.gch.sum"
# Where the Windows gate records its choice # Where the CMake gate records its choice
_PCH_CHOICE_VAR = "ESPHOME_PCH" _PCH_CHOICE_VAR = "ESPHOME_PCH"
@@ -490,16 +491,32 @@ def _pch_cmake_block() -> str:
block = f"""target_precompile_headers(${{COMPONENT_LIB}} PRIVATE block = f"""target_precompile_headers(${{COMPONENT_LIB}} PRIVATE
{headers} {headers}
)""" )"""
if not pch.pch_needs_gcc_check(): if pch.pch_forced():
return f"\n# ESPHome precompiled header\n{block}\n" return f"\n# ESPHome precompiled header\n{block}\n"
# Before the first configure only CMake knows the compiler version # Only CMake knows the compiler version before the first configure, and
return f""" # the ccache IDF found is the one the build runs
# ESPHome precompiled header, unless GCC bug 14940 keeps it from loading gcc_gate = ""
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND ({pch.PCH_WINDOWS_CMAKE_OLD_GCC})) if pch.pch_needs_gcc_check():
gcc_gate = f"""if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND ({pch.PCH_WINDOWS_CMAKE_OLD_GCC}))
message(STATUS "ESPHome: GCC ${{CMAKE_CXX_COMPILER_VERSION}} cannot load a precompiled header on Windows; compiling without it") message(STATUS "ESPHome: GCC ${{CMAKE_CXX_COMPILER_VERSION}} cannot load a precompiled header on Windows; compiling without it")
set({_PCH_CHOICE_VAR} OFF CACHE BOOL "ESPHome precompiled header in use" FORCE) set(_esphome_pch OFF)
else() endif()
set({_PCH_CHOICE_VAR} ON CACHE BOOL "ESPHome precompiled header in use" FORCE) """
min_ccache = ".".join(map(str, CCACHE_PCH_MIN_VERSION))
return f"""
# ESPHome precompiled header, unless a check below rules it out
set(_esphome_pch ON)
{gcc_gate}if(CCACHE_ENABLE AND CCACHE_FOUND)
execute_process(COMMAND "${{CCACHE_FOUND}}" --version OUTPUT_VARIABLE _esphome_ccache ERROR_QUIET)
if(_esphome_ccache MATCHES "ccache version ([0-9.]+)")
if(CMAKE_MATCH_1 VERSION_LESS {min_ccache})
message(STATUS "ESPHome: ccache ${{CMAKE_MATCH_1}} can reuse stale objects with a precompiled header; compiling without it (upgrade ccache to {min_ccache} or newer, or set ESPHOME_PCH_ENABLE=1 to force)")
set(_esphome_pch OFF)
endif()
endif()
endif()
set({_PCH_CHOICE_VAR} ${{_esphome_pch}} CACHE BOOL "ESPHome precompiled header in use" FORCE)
if(_esphome_pch)
{textwrap.indent(block, " ")} {textwrap.indent(block, " ")}
endif() endif()
""" """
@@ -516,9 +533,9 @@ def write_pch_checksum() -> None:
if not pch.pch_enabled(): if not pch.pch_enabled():
return return
# The gate's choice, cached by configure # Written whatever the gate chose last time: a build tree without that
if pch.pch_needs_gcc_check() and get_cmake_cache_value(_PCH_CHOICE_VAR) != "ON": # choice, or a reconfigure that turns the pch on, must not see a stale sum
return if pch.pch_forced() or get_cmake_cache_value(_PCH_CHOICE_VAR) == "ON":
pch.log_pch_in_use() pch.log_pch_in_use()
checksum = pch.pch_checksum( checksum = pch.pch_checksum(
CORE.relative_src_path(), CORE.relative_src_path(),
+48 -1
View File
@@ -3,21 +3,68 @@ resolution, and default ``CCACHE_*`` values."""
from __future__ import annotations from __future__ import annotations
import functools
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
import re
from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs from esphome.framework_helpers import (
strip_win_long_path_prefix,
tool_version_probe,
tool_version_runs,
)
from esphome.helpers import FALSY_ENV_STRINGS, TRUTHY_ENV_STRINGS from esphome.helpers import FALSY_ENV_STRINGS, TRUTHY_ENV_STRINGS
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Before 4.7 ccache stores a pch consumer whose header is newer than one
# second without that header, so a later build reuses it after the header
# changed (#21621)
CCACHE_PCH_MIN_VERSION = (4, 7)
_VERSION_RE = re.compile(r"ccache version (\d+(?:\.\d+)*)")
@functools.cache
def _ccache_probe(ccache: str) -> str | Exception:
"""``ccache --version``, run once per binary per process."""
return tool_version_probe(ccache)
def _ccache_runs(ccache: str) -> bool: def _ccache_runs(ccache: str) -> bool:
"""Return True when the ``ccache`` found on PATH actually runs.""" """Return True when the ``ccache`` found on PATH actually runs."""
return tool_version_runs( return tool_version_runs(
ccache, ccache,
"Ignoring ccache at %s because it failed to run; compiling without ccache", "Ignoring ccache at %s because it failed to run; compiling without ccache",
probe=_ccache_probe,
)
@functools.cache
def _ccache_too_old_for_pch(ccache: str) -> bool:
output = _ccache_probe(ccache)
if not isinstance(output, str) or (found := _VERSION_RE.search(output)) is None:
return False
if tuple(map(int, found[1].split("."))) >= CCACHE_PCH_MIN_VERSION:
return False
_LOGGER.info(
"ccache %s at %s can reuse stale objects with a precompiled header; "
"compiling without it (upgrade ccache to %s or newer, or set "
"ESPHOME_PCH_ENABLE=1 to force)",
found[1],
ccache,
".".join(map(str, CCACHE_PCH_MIN_VERSION)),
)
return True
def ccache_breaks_pch() -> bool:
"""Whether the ccache this build uses is too old for a precompiled header.
ESP-IDF builds check the ccache IDF finds in CMake instead (build_gen.espidf)."""
return (ccache := resolve_ccache_path()) is not None and _ccache_too_old_for_pch(
ccache
) )
+19 -4
View File
@@ -12,7 +12,11 @@ import re
import subprocess import subprocess
import sys import sys
from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env from esphome.build_helpers.ccache import (
ccache_breaks_pch,
effective_ccache_basedir,
parse_enable_env,
)
from esphome.const import PLATFORM_NRF52 from esphome.const import PLATFORM_NRF52
from esphome.helpers import write_file_if_changed from esphome.helpers import write_file_if_changed
@@ -117,9 +121,16 @@ def pch_needs_gcc_check() -> bool:
return sys.platform == "win32" and parse_enable_env("ESPHOME_PCH_ENABLE") is None return sys.platform == "win32" and parse_enable_env("ESPHOME_PCH_ENABLE") is None
def _ccache_rules_out_pch() -> bool:
"""An old ccache, unless ``ESPHOME_PCH_ENABLE=1`` forces the pch on.
ESP-IDF runs the same check in CMake (build_gen.espidf)."""
return not pch_forced() and ccache_breaks_pch()
def pch_usable(cxx: Sequence[Path | str]) -> bool: def pch_usable(cxx: Sequence[Path | str]) -> bool:
"""The knob plus the host rule; ``ESPHOME_PCH_ENABLE=1`` skips the rule.""" """The knob plus the ccache and host rules; ``ESPHOME_PCH_ENABLE=1``
if not pch_enabled(): skips the rules."""
if not pch_enabled() or _ccache_rules_out_pch():
return False return False
if not pch_needs_gcc_check(): if not pch_needs_gcc_check():
return True return True
@@ -162,7 +173,11 @@ def pch_script_enabled() -> bool:
"""Whether this PlatformIO build takes the pch script.""" """Whether this PlatformIO build takes the pch script."""
from esphome.core import CORE from esphome.core import CORE
return pch_enabled() and CORE.target_platform not in PCH_SCRIPT_EXCLUDED_PLATFORMS return (
pch_enabled()
and CORE.target_platform not in PCH_SCRIPT_EXCLUDED_PLATFORMS
and not _ccache_rules_out_pch()
)
def pch_header_text(include_headers: Iterable[str]) -> str: def pch_header_text(include_headers: Iterable[str]) -> str:
+3 -1
View File
@@ -111,7 +111,9 @@ class ToolOutput(RedirectText):
def _emit_line(self, line: str) -> None: def _emit_line(self, line: str) -> None:
if self._progress and _PROGRESS.match(line): if self._progress and _PROGRESS.match(line):
if not self._is_filtered(line): if self._is_filtered(line):
self._drop_line(line)
else:
text = _fit_terminal(line.strip("\r\n")) text = _fit_terminal(line.strip("\r\n"))
self._write_color_replace(f"\r{text}\x1b[K") self._write_color_replace(f"\r{text}\x1b[K")
self._on_progress_line = True self._on_progress_line = True
+8 -2
View File
@@ -403,6 +403,9 @@ message WizardCapabilities {
message SendspinCapabilities { message SendspinCapabilities {
// Bitmask of the Sendspin features this device supports // Bitmask of the Sendspin features this device supports
uint32 feature_flags = 1; uint32 feature_flags = 1;
// The Sendspin client_id: base64url of the client's static public key, 43 characters.
// Empty until the Sendspin client has started once since boot.
string client_id = 2 [(max_data_length) = 43];
} }
message DeviceCapabilitiesResponse { message DeviceCapabilitiesResponse {
@@ -3043,12 +3046,15 @@ enum SerialProxyIdentityFlag {
// IDs below are empty // IDs below are empty
} }
// The descriptor fields of a USB device as seen by the host stack // The descriptor fields of a USB device as seen by the host stack, and the port's index
// among those a vendor driver exposes on the interface
message UsbDeviceDescriptor { message UsbDeviceDescriptor {
uint32 vendor_id = 1; uint32 vendor_id = 1;
uint32 product_id = 2; uint32 product_id = 2;
uint32 bcd_device = 3; uint32 bcd_device = 3;
uint32 interface_number = 4; // bInterfaceNumber the host driver binds to uint32 interface_number = 4; // bInterfaceNumber the port is bound through
uint32 interface_class = 5; // bInterfaceClass of that interface
uint32 port_number = 6;
} }
// The identity of the device behind a port. Identifies one physical device among others of // The identity of the device behind a port. Identifies one physical device among others of
@@ -2128,6 +2128,7 @@ bool APIConnection::send_device_capabilities_response_() {
#endif #endif
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) #if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
resp.sendspin.feature_flags = sendspin_::global_sendspin_hub->get_feature_flags(); resp.sendspin.feature_flags = sendspin_::global_sendspin_hub->get_feature_flags();
resp.sendspin.client_id = sendspin_::global_sendspin_hub->get_client_id();
#endif #endif
return this->send_message(resp); return this->send_message(resp);
} }
@@ -152,10 +152,7 @@ void OutgoingConnectionManager::loop(APIServer *server) {
void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) { void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) {
if (!network::is_connected()) { if (!network::is_connected()) {
// Flips within seconds of boot; recheck fast so a deep sleep wake return; // still WAITING with the wait elapsed, so the next loop rechecks
// window is not spent waiting
this->schedule_wait_(now, NETWORK_RETRY_MS);
return;
} }
struct sockaddr_storage addr; struct sockaddr_storage addr;
socklen_t addr_len = this->target_sockaddr_(&addr); socklen_t addr_len = this->target_sockaddr_(&addr);
@@ -197,7 +194,6 @@ void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) {
} }
this->state_ = DialState::DIAL_STATE_CONNECTING; this->state_ = DialState::DIAL_STATE_CONNECTING;
this->state_ts_ = now; this->state_ts_ = now;
this->last_poll_ = now;
} }
void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) {
@@ -206,10 +202,6 @@ void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) {
this->schedule_retry_(now); this->schedule_retry_(now);
return; return;
} }
if (now - this->last_poll_ < CONNECT_POLL_INTERVAL_MS) {
return;
}
this->last_poll_ = now;
int err = 0; int err = 0;
switch (socket::poll_connect(*this->dial_socket_, err)) { switch (socket::poll_connect(*this->dial_socket_, err)) {
case socket::ConnectPollResult::CONNECT_POLL_RESULT_PENDING: case socket::ConnectPollResult::CONNECT_POLL_RESULT_PENDING:
@@ -59,8 +59,6 @@ class OutgoingConnectionManager {
static constexpr uint32_t BACKOFF_MIN_MS = 5000; static constexpr uint32_t BACKOFF_MIN_MS = 5000;
static constexpr uint32_t BACKOFF_MAX_MS = 300000; static constexpr uint32_t BACKOFF_MAX_MS = 300000;
static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000; static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000;
static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250;
static constexpr uint32_t NETWORK_RETRY_MS = 500;
static constexpr uint32_t PRECONDITION_RETRY_MS = 5000; static constexpr uint32_t PRECONDITION_RETRY_MS = 5000;
// A deep sleep wake window is too short to spend on the delay, so those // A deep sleep wake window is too short to spend on the delay, so those
// builds dial out as soon as the target is gone // builds dial out as soon as the target is gone
@@ -104,7 +102,6 @@ class OutgoingConnectionManager {
uint32_t backoff_{BACKOFF_MIN_MS}; uint32_t backoff_{BACKOFF_MIN_MS};
uint32_t wait_{BOOT_WAIT_MS}; uint32_t wait_{BOOT_WAIT_MS};
uint32_t state_ts_{0}; uint32_t state_ts_{0};
uint32_t last_poll_{0};
// Byte-aligned types last // Byte-aligned types last
#ifndef API_OUTGOING_CONNECTION_HOST #ifndef API_OUTGOING_CONNECTION_HOST
+6
View File
@@ -310,12 +310,14 @@ uint32_t WizardCapabilities::calc_size_msg(const void *self) {
uint8_t *SendspinCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { uint8_t *SendspinCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const SendspinCapabilities *>(self); const auto &msg = *static_cast<const SendspinCapabilities *>(self);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags);
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.client_id);
return pos; return pos;
} }
uint32_t SendspinCapabilities::calc_size_msg(const void *self) { uint32_t SendspinCapabilities::calc_size_msg(const void *self) {
const auto &msg = *static_cast<const SendspinCapabilities *>(self); const auto &msg = *static_cast<const SendspinCapabilities *>(self);
uint32_t size = 0; uint32_t size = 0;
size += ProtoSize::calc_uint32(1, msg.feature_flags); size += ProtoSize::calc_uint32(1, msg.feature_flags);
size += !msg.client_id.empty() ? 2 + msg.client_id.size() : 0;
return size; return size;
} }
#endif #endif
@@ -4175,6 +4177,8 @@ uint8_t *UsbDeviceDescriptor::encode_msg(const void *self, uint8_t *__restrict__
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.product_id); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.product_id);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.bcd_device); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.bcd_device);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.interface_number); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.interface_number);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.interface_class);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.port_number);
return pos; return pos;
} }
uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) { uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) {
@@ -4184,6 +4188,8 @@ uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) {
size += ProtoSize::calc_uint32(1, msg.product_id); size += ProtoSize::calc_uint32(1, msg.product_id);
size += ProtoSize::calc_uint32(1, msg.bcd_device); size += ProtoSize::calc_uint32(1, msg.bcd_device);
size += ProtoSize::calc_uint32(1, msg.interface_number); size += ProtoSize::calc_uint32(1, msg.interface_number);
size += ProtoSize::calc_uint32(1, msg.interface_class);
size += ProtoSize::calc_uint32(1, msg.port_number);
return size; return size;
} }
uint8_t *SerialProxyIdentity::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { uint8_t *SerialProxyIdentity::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
+3
View File
@@ -750,6 +750,7 @@ class WizardCapabilities final : public ProtoMessage {
class SendspinCapabilities final : public ProtoMessage { class SendspinCapabilities final : public ProtoMessage {
public: public:
uint32_t feature_flags{0}; uint32_t feature_flags{0};
StringRef client_id{nullptr, 0}; // null until set, encode only
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
@@ -4083,6 +4084,8 @@ class UsbDeviceDescriptor final : public ProtoMessage {
uint32_t product_id{0}; uint32_t product_id{0};
uint32_t bcd_device{0}; uint32_t bcd_device{0};
uint32_t interface_number{0}; uint32_t interface_number{0};
uint32_t interface_class{0};
uint32_t port_number{0};
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
+3
View File
@@ -1119,6 +1119,7 @@ const char *WizardCapabilities::dump_to(DumpBuffer &out) const {
const char *SendspinCapabilities::dump_to(DumpBuffer &out) const { const char *SendspinCapabilities::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinCapabilities")); MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinCapabilities"));
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
dump_field(out, ESPHOME_PSTR("client_id"), this->client_id);
return out.c_str(); return out.c_str();
} }
#endif #endif
@@ -2942,6 +2943,8 @@ const char *UsbDeviceDescriptor::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("product_id"), this->product_id); dump_field(out, ESPHOME_PSTR("product_id"), this->product_id);
dump_field(out, ESPHOME_PSTR("bcd_device"), this->bcd_device); dump_field(out, ESPHOME_PSTR("bcd_device"), this->bcd_device);
dump_field(out, ESPHOME_PSTR("interface_number"), this->interface_number); dump_field(out, ESPHOME_PSTR("interface_number"), this->interface_number);
dump_field(out, ESPHOME_PSTR("interface_class"), this->interface_class);
dump_field(out, ESPHOME_PSTR("port_number"), this->port_number);
return out.c_str(); return out.c_str();
} }
const char *SerialProxyIdentity::dump_to(DumpBuffer &out) const { const char *SerialProxyIdentity::dump_to(DumpBuffer &out) const {
+2 -2
View File
@@ -20,7 +20,7 @@ constexpr uint8_t bl0906_checksum(const uint8_t address, const DataPacket *data)
void BL0906::loop() { void BL0906::loop() {
while (this->available()) while (this->available())
this->flush(); this->read();
if (this->current_stage_ == STAGE_IDLE) { if (this->current_stage_ == STAGE_IDLE) {
// Woken up between cycles to drain the action queue. Go back to sleep. // Woken up between cycles to drain the action queue. Go back to sleep.
@@ -92,7 +92,7 @@ void BL0906::advance_stage_() {
void BL0906::setup() { void BL0906::setup() {
while (this->available()) while (this->available())
this->flush(); this->read();
this->write_array(USR_WRPROT_WITABLE, sizeof(USR_WRPROT_WITABLE)); this->write_array(USR_WRPROT_WITABLE, sizeof(USR_WRPROT_WITABLE));
// Calibration (1: register address; 2: value before calibration; 3: value after calibration) // Calibration (1: register address; 2: value before calibration; 3: value after calibration)
this->bias_correction_(BL0906_RMSOS_1, 0.01600, 0); // Calibration current_1 this->bias_correction_(BL0906_RMSOS_1, 0.01600, 0); // Calibration current_1
@@ -382,7 +382,7 @@ void BluedroidGattClient::set_disconnecting_() {
esp_err_t BluedroidGattClient::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, esp_err_t BluedroidGattClient::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
uint16_t timeout, const char *param_type) { uint16_t timeout, const char *param_type) {
esp_ble_conn_update_params_t conn_params = {{0}}; esp_ble_conn_update_params_t conn_params = {};
memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t));
conn_params.min_int = min_interval; conn_params.min_int = min_interval;
conn_params.max_int = max_interval; conn_params.max_int = max_interval;
+8
View File
@@ -82,6 +82,10 @@ class ClimateCall {
ClimateCall &set_fan_mode(optional<std::string> fan_mode); ClimateCall &set_fan_mode(optional<std::string> fan_mode);
/// Set the custom fan mode of the climate device. /// Set the custom fan mode of the climate device.
ClimateCall &set_fan_mode(const char *custom_fan_mode); ClimateCall &set_fan_mode(const char *custom_fan_mode);
/// Set the custom fan mode of the climate device (StringRef overload to resolve ambiguity, zero-copy).
ClimateCall &set_fan_mode(StringRef custom_fan_mode) {
return this->set_fan_mode(custom_fan_mode.c_str(), custom_fan_mode.size());
}
/// Set the custom fan mode of the climate device (zero-copy API path). /// Set the custom fan mode of the climate device (zero-copy API path).
ClimateCall &set_fan_mode(const char *custom_fan_mode, size_t len); ClimateCall &set_fan_mode(const char *custom_fan_mode, size_t len);
/// Set the swing mode of the climate device. /// Set the swing mode of the climate device.
@@ -102,6 +106,10 @@ class ClimateCall {
ClimateCall &set_preset(optional<std::string> preset); ClimateCall &set_preset(optional<std::string> preset);
/// Set the custom preset of the climate device. /// Set the custom preset of the climate device.
ClimateCall &set_preset(const char *custom_preset); ClimateCall &set_preset(const char *custom_preset);
/// Set the custom preset of the climate device (StringRef overload to resolve ambiguity, zero-copy).
ClimateCall &set_preset(StringRef custom_preset) {
return this->set_preset(custom_preset.c_str(), custom_preset.size());
}
/// Set the custom preset of the climate device (zero-copy API path). /// Set the custom preset of the climate device (zero-copy API path).
ClimateCall &set_preset(const char *custom_preset, size_t len); ClimateCall &set_preset(const char *custom_preset, size_t len);
@@ -34,6 +34,15 @@ void DebugComponent::dump_config() {
this->free_heap_ = get_free_heap_(); this->free_heap_ = get_free_heap_();
ESP_LOGD(TAG, "Free Heap Size: %" PRIu32 " bytes", this->free_heap_); ESP_LOGD(TAG, "Free Heap Size: %" PRIu32 " bytes", this->free_heap_);
uint32_t uptime = static_cast<uint32_t>(millis_64() / 1000);
uint32_t days = uptime / 86400;
uptime -= days * 86400;
uint32_t hours = uptime / 3600;
uptime -= hours * 3600;
uint32_t minutes = uptime / 60;
uptime -= minutes * 60;
ESP_LOGD(TAG, "Uptime: %" PRIu32 "d %02" PRIu32 "h %02" PRIu32 "m %02" PRIu32 "s", days, hours, minutes, uptime);
pos = get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>(device_info_buffer), pos); pos = get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>(device_info_buffer), pos);
#ifdef USE_TEXT_SENSOR #ifdef USE_TEXT_SENSOR
+1
View File
@@ -208,6 +208,7 @@ def _final_validate(config) -> None:
else: else:
# If no drawing methods are configured, and LVGL is not enabled, show a test card # If no drawing methods are configured, and LVGL is not enabled, show a test card
config[CONF_SHOW_TEST_CARD] = True config[CONF_SHOW_TEST_CARD] = True
config[CONF_UPDATE_INTERVAL] = update_interval("1min")
elif CONF_UPDATE_INTERVAL not in config: elif CONF_UPDATE_INTERVAL not in config:
config[CONF_UPDATE_INTERVAL] = update_interval("1min") config[CONF_UPDATE_INTERVAL] = update_interval("1min")
+24 -5
View File
@@ -1099,7 +1099,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = {
# See: https://github.com/pioarduino/esp-idf/releases # See: https://github.com/pioarduino/esp-idf/releases
ARDUINO_IDF_VERSION_LOOKUP = { ARDUINO_IDF_VERSION_LOOKUP = {
cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1), cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1),
cv.Version(3, 3, 12): cv.Version(5, 5, 5), cv.Version(3, 3, 12): cv.Version(5, 5, 6),
cv.Version(3, 3, 11): cv.Version(5, 5, 5), cv.Version(3, 3, 11): cv.Version(5, 5, 5),
cv.Version(3, 3, 10): cv.Version(5, 5, 5), cv.Version(3, 3, 10): cv.Version(5, 5, 5),
cv.Version(3, 3, 9): cv.Version(5, 5, 4), cv.Version(3, 3, 9): cv.Version(5, 5, 4),
@@ -1123,9 +1123,9 @@ ARDUINO_IDF_VERSION_LOOKUP = {
# The default/recommended esp-idf framework version # The default/recommended esp-idf framework version
# - https://github.com/espressif/esp-idf/releases # - https://github.com/espressif/esp-idf/releases
ESP_IDF_FRAMEWORK_VERSION_LOOKUP = { ESP_IDF_FRAMEWORK_VERSION_LOOKUP = {
"recommended": cv.Version(5, 5, 5), "recommended": cv.Version(5, 5, 6),
"latest": cv.Version(5, 5, 5), "latest": cv.Version(5, 5, 6),
"dev": cv.Version(5, 5, 5), "dev": cv.Version(5, 5, 6),
} }
ESP_IDF_PLATFORM_VERSION_LOOKUP = { ESP_IDF_PLATFORM_VERSION_LOOKUP = {
@@ -1135,6 +1135,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = {
cv.Version( cv.Version(
6, 0, 0 6, 0, 0
): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6",
cv.Version(5, 5, 6): cv.Version(55, 3, 312),
cv.Version(5, 5, 5): cv.Version(55, 3, 312), cv.Version(5, 5, 5): cv.Version(55, 3, 312),
cv.Version(5, 5, 4): cv.Version(55, 3, 39), cv.Version(5, 5, 4): cv.Version(55, 3, 39),
cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37), cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37),
@@ -3682,6 +3683,20 @@ def _write_sdkconfig():
clean_build(clear_pio_cache=False) clean_build(clear_pio_cache=False)
def _arduino_provides(name: str) -> bool:
"""Whether arduino-esp32 bundles the library a manifest depends on, so it is
never fetched by name; it still has to be enabled to build."""
if name not in ARDUINO_DISABLED_LIBRARIES:
return False
if name not in CORE.data[KEY_ESP32][KEY_ARDUINO_LIBRARIES]:
_LOGGER.warning(
"A library depends on the Arduino library %s, which is not enabled; "
"add it under esphome: libraries:",
name,
)
return True
def _write_idf_component_yml(): def _write_idf_component_yml():
yml_path = CORE.relative_build_path("src/idf_component.yml") yml_path = CORE.relative_build_path("src/idf_component.yml")
dependencies: dict[str, dict] = {} dependencies: dict[str, dict] = {}
@@ -3703,7 +3718,11 @@ def _write_idf_component_yml():
# IDF sees the same requirement twice; converted components reach it through # IDF sees the same requirement twice; converted components reach it through
# ${ESPHOME_PROJECT_MANAGED_COMPONENTS}. # ${ESPHOME_PROJECT_MANAGED_COMPONENTS}.
managed = set(CORE.data[KEY_ESP32].get(KEY_COMPONENTS, {})) managed = set(CORE.data[KEY_ESP32].get(KEY_COMPONENTS, {}))
converted = generate_idf_components(libraries, managed=managed) converted = generate_idf_components(
libraries,
managed=managed,
provides=_arduino_provides if CORE.using_arduino else None,
)
# IDF names a component after its directory and a later registration of the # IDF names a component after its directory and a later registration of the
# same name replaces the earlier one, so a stub beside a converted library of # same name replaces the earlier one, so a stub beside a converted library of
# the same name (espressif/libsodium vs esphome/libsodium) would win or lose # the same name (espressif/libsodium vs esphome/libsodium) would win or lose
+1
View File
@@ -719,6 +719,7 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa
} }
// Ignore these GAP events as they are not relevant for our use case // Ignore these GAP events as they are not relevant for our use case
case ESP_GAP_BLE_KEY_EVT:
case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT: case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT:
case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete
case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm
@@ -300,7 +300,7 @@ void BLEClientBase::log_warning_(const char *message) {
esp_err_t BLEClientBase::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, esp_err_t BLEClientBase::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
uint16_t timeout, const char *param_type) { uint16_t timeout, const char *param_type) {
esp_ble_conn_update_params_t conn_params = {{0}}; esp_ble_conn_update_params_t conn_params = {};
memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t));
conn_params.min_int = min_interval; conn_params.min_int = min_interval;
conn_params.max_int = max_interval; conn_params.max_int = max_interval;
@@ -26,9 +26,14 @@ static const char *const TAG = "esp32_hosted.update";
// Older coprocessor firmware versions have a 1500-byte limit per RPC call // Older coprocessor firmware versions have a 1500-byte limit per RPC call
constexpr size_t CHUNK_SIZE = 1500; constexpr size_t CHUNK_SIZE = 1500;
// OTA begin blocks while the coprocessor erases its partition // OTA begin blocks while the coprocessor erases its partition, end while it validates the image
constexpr uint32_t OTA_WDT_TIMEOUT_MS = 60000; constexpr uint32_t OTA_WDT_TIMEOUT_MS = 60000;
static esp_err_t ota_end_guarded() {
watchdog::WatchdogManager wdt(OTA_WDT_TIMEOUT_MS);
return esp_hosted_slave_ota_end(); // NOLINT
}
#ifdef USE_ESP32_HOSTED_HTTP_UPDATE #ifdef USE_ESP32_HOSTED_HTTP_UPDATE
// Interval/timeout IDs (uint32_t to avoid string comparison) // Interval/timeout IDs (uint32_t to avoid string comparison)
constexpr uint32_t INITIAL_CHECK_INTERVAL_ID = 0; constexpr uint32_t INITIAL_CHECK_INTERVAL_ID = 0;
@@ -380,7 +385,7 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() {
} else { } else {
ESP_LOGE(TAG, "Error reading firmware data: %d", read_or_error); ESP_LOGE(TAG, "Error reading firmware data: %d", read_or_error);
} }
esp_hosted_slave_ota_end(); // NOLINT ota_end_guarded();
container->end(); container->end();
this->status_set_error(LOG_STR("Download failed")); this->status_set_error(LOG_STR("Download failed"));
return false; return false;
@@ -390,7 +395,7 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() {
err = esp_hosted_slave_ota_write(buffer, read_or_error); // NOLINT err = esp_hosted_slave_ota_write(buffer, read_or_error); // NOLINT
if (err != ESP_OK) { if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to write OTA data: %s", esp_err_to_name(err)); ESP_LOGE(TAG, "Failed to write OTA data: %s", esp_err_to_name(err));
esp_hosted_slave_ota_end(); // NOLINT ota_end_guarded();
container->end(); container->end();
this->status_set_error(LOG_STR("Failed to write OTA data")); this->status_set_error(LOG_STR("Failed to write OTA data"));
return false; return false;
@@ -402,7 +407,7 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() {
hasher.calculate(); hasher.calculate();
if (!hasher.equals_bytes(this->firmware_sha256_.data())) { if (!hasher.equals_bytes(this->firmware_sha256_.data())) {
ESP_LOGE(TAG, "SHA256 mismatch"); ESP_LOGE(TAG, "SHA256 mismatch");
esp_hosted_slave_ota_end(); // NOLINT ota_end_guarded();
this->status_set_error(LOG_STR("SHA256 verification failed")); this->status_set_error(LOG_STR("SHA256 verification failed"));
return false; return false;
} }
@@ -451,7 +456,7 @@ bool Esp32HostedUpdate::write_embedded_firmware_to_coprocessor_() {
err = esp_hosted_slave_ota_write(chunk, chunk_size); // NOLINT err = esp_hosted_slave_ota_write(chunk, chunk_size); // NOLINT
if (err != ESP_OK) { if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to write OTA data: %s", esp_err_to_name(err)); ESP_LOGE(TAG, "Failed to write OTA data: %s", esp_err_to_name(err));
esp_hosted_slave_ota_end(); // NOLINT ota_end_guarded();
this->status_set_error(LOG_STR("Failed to write OTA data")); this->status_set_error(LOG_STR("Failed to write OTA data"));
return false; return false;
} }
@@ -494,7 +499,7 @@ void Esp32HostedUpdate::perform(bool force) {
} }
// End OTA and activate new firmware // End OTA and activate new firmware
esp_err_t end_err = esp_hosted_slave_ota_end(); // NOLINT esp_err_t end_err = ota_end_guarded();
if (end_err != ESP_OK) { if (end_err != ESP_OK) {
ESP_LOGE(TAG, "Failed to end OTA: %s", esp_err_to_name(end_err)); ESP_LOGE(TAG, "Failed to end OTA: %s", esp_err_to_name(end_err));
this->state_ = prev_state; this->state_ = prev_state;
@@ -37,7 +37,7 @@ async def to_code(config: ConfigType) -> None:
request_parent = await cg.get_variable(config[CONF_HTTP_REQUEST_ID]) request_parent = await cg.get_variable(config[CONF_HTTP_REQUEST_ID])
cg.add(var.set_request_parent(request_parent)) cg.add(var.set_request_parent(request_parent))
cg.add(var.set_source_url(config[CONF_SOURCE])) cg.add(var.set_source_url_static(config[CONF_SOURCE]))
ota.request_ota_state_listeners() ota.request_ota_state_listeners()
@@ -24,12 +24,6 @@ namespace esphome::http_request {
static const char *const TAG = "http_request.update"; static const char *const TAG = "http_request.update";
// Wraps UpdateInfo + error for the task→main-loop handoff.
struct TaskResult {
update::UpdateInfo info;
const LogString *error_str{nullptr};
};
static const size_t MAX_READ_SIZE = 256; static const size_t MAX_READ_SIZE = 256;
static constexpr uint32_t INITIAL_CHECK_INTERVAL_ID = 0; static constexpr uint32_t INITIAL_CHECK_INTERVAL_ID = 0;
static constexpr uint32_t INITIAL_CHECK_INTERVAL_MS = 10000; static constexpr uint32_t INITIAL_CHECK_INTERVAL_MS = 10000;
@@ -68,6 +62,37 @@ void HttpRequestUpdate::on_ota_state(ota::OTAState state, float progress, uint8_
} }
} }
void HttpRequestUpdate::set_source_url(const char *source_url, size_t len) {
// Copy before releasing, so a caller passing the current URL back in stays valid.
char *copy = new char[len + 1];
memcpy(copy, source_url, len);
copy[len] = '\0';
this->release_source_url_();
this->source_url_ = copy;
this->source_url_owned_ = true;
}
void HttpRequestUpdate::release_source_url_() {
if (this->source_url_owned_)
delete[] this->source_url_;
this->source_url_owned_ = false;
}
void HttpRequestUpdate::begin_check_(TaskResult *result) {
result->update = this;
result->source_url = this->source_url_;
result->owns_source_url = this->source_url_owned_;
this->source_url_owned_ = false;
}
void HttpRequestUpdate::end_check_(TaskResult *result) {
if (this->source_url_ == result->source_url) {
this->source_url_owned_ = result->owns_source_url;
} else if (result->owns_source_url) {
delete[] result->source_url; // replaced while the check ran
}
}
void HttpRequestUpdate::update() { void HttpRequestUpdate::update() {
if (!network::is_connected()) { if (!network::is_connected()) {
ESP_LOGD(TAG, "Network not connected, skipping update check"); ESP_LOGD(TAG, "Network not connected, skipping update check");
@@ -75,28 +100,37 @@ void HttpRequestUpdate::update() {
} }
this->cancel_interval(INITIAL_CHECK_INTERVAL_ID); this->cancel_interval(INITIAL_CHECK_INTERVAL_ID);
#ifdef USE_ESP32 #ifdef USE_ESP32
if (this->update_task_handle_ != nullptr) { if (this->task_result_ != nullptr) {
ESP_LOGW(TAG, "Update check already in progress"); ESP_LOGW(TAG, "Update check already in progress");
return; return;
} }
xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, (void *) this, 1, &this->update_task_handle_); #endif
// Allocated once per check; every path in the task returns via its single defer, which frees it.
auto *result = new TaskResult();
this->begin_check_(result);
#ifdef USE_ESP32
this->task_result_ = result;
if (xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, result, 1, nullptr) != pdPASS) {
this->task_result_ = nullptr;
this->end_check_(result);
delete result;
}
#else #else
this->update_task(this); this->update_task(result);
this->end_check_(result);
#endif #endif
} }
void HttpRequestUpdate::update_task(void *params) { void HttpRequestUpdate::update_task(void *params) {
HttpRequestUpdate *this_update = (HttpRequestUpdate *) params; auto *result = static_cast<TaskResult *>(params);
HttpRequestUpdate *this_update = result->update;
// Allocate once — every path below returns via the single defer at the end.
// On failure, error_str is set; on success it is nullptr.
auto *result = new TaskResult();
auto *info = &result->info; auto *info = &result->info;
const char *source_url = result->source_url;
auto container = this_update->request_parent_->get(this_update->source_url_); auto container = this_update->request_parent_->get(source_url);
if (container == nullptr || container->status_code != HTTP_STATUS_OK) { if (container == nullptr || container->status_code != HTTP_STATUS_OK) {
ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_); ESP_LOGE(TAG, "Failed to fetch manifest from %s", source_url);
if (container != nullptr) if (container != nullptr)
container->end(); container->end();
result->error_str = LOG_STR("Failed to fetch manifest"); result->error_str = LOG_STR("Failed to fetch manifest");
@@ -176,14 +210,14 @@ void HttpRequestUpdate::update_task(void *params) {
allocator.deallocate(data, content_length); allocator.deallocate(data, content_length);
if (!valid) { if (!valid) {
ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_); ESP_LOGE(TAG, "Failed to parse JSON from %s", source_url);
result->error_str = LOG_STR("Failed to parse manifest JSON"); result->error_str = LOG_STR("Failed to parse manifest JSON");
goto defer; // NOLINT(cppcoreguidelines-avoid-goto) goto defer; // NOLINT(cppcoreguidelines-avoid-goto)
} }
// Merge source_url_ and firmware_url // Merge source_url_ and firmware_url
if (!info->firmware_url.empty() && info->firmware_url.find("http") == std::string::npos) { if (!info->firmware_url.empty() && info->firmware_url.find("http") == std::string::npos) {
const char *source = this_update->source_url_; const char *source = source_url;
const size_t source_len = strlen(source); const size_t source_len = strlen(source);
size_t prefix_len; size_t prefix_len;
if (info->firmware_url[0] == '/') { if (info->firmware_url[0] == '/') {
@@ -215,7 +249,8 @@ defer:
// Lambda captures only 2 pointers (8 bytes) — fits in std::function SBO on supported toolchains. // Lambda captures only 2 pointers (8 bytes) — fits in std::function SBO on supported toolchains.
this_update->defer([this_update, result]() { this_update->defer([this_update, result]() {
#ifdef USE_ESP32 #ifdef USE_ESP32
this_update->update_task_handle_ = nullptr; this_update->task_result_ = nullptr;
this_update->end_check_(result);
#endif #endif
if (result->error_str != nullptr) { if (result->error_str != nullptr) {
this_update->status_set_error(result->error_str); this_update->status_set_error(result->error_str);
@@ -13,6 +13,21 @@
namespace esphome::http_request { namespace esphome::http_request {
namespace testing {
class HttpRequestUpdateTest;
} // namespace testing
class HttpRequestUpdate;
// One manifest check: handed from update() to the task and back to the main loop.
struct TaskResult {
HttpRequestUpdate *update{nullptr};
const char *source_url{nullptr}; // the URL this check reads, fixed before it starts
update::UpdateInfo info;
const LogString *error_str{nullptr}; // set on failure, nullptr on success
bool owns_source_url{false}; // the check holds the buffer until it ends, see begin_check_()
};
class HttpRequestUpdate final : public update::UpdateEntity, public PollingComponent, public ota::OTAStateListener { class HttpRequestUpdate final : public update::UpdateEntity, public PollingComponent, public ota::OTAStateListener {
public: public:
void setup() override; void setup() override;
@@ -21,7 +36,18 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo
void perform(bool force) override; void perform(bool force) override;
void check() override { this->update(); } void check() override { this->update(); }
void set_source_url(const char *source_url) { this->source_url_ = source_url; } ~HttpRequestUpdate() override { this->release_source_url_(); }
/// Codegen only: the config literal lives in flash for the life of the program, so it is kept by pointer.
void set_source_url_static(const char *source_url) {
this->release_source_url_();
this->source_url_ = source_url;
}
/// Set the manifest URL at run time; the string is copied, so any storage may be passed.
void set_source_url(const char *source_url) { this->set_source_url(source_url, strlen(source_url)); }
void set_source_url(const std::string &source_url) { this->set_source_url(source_url.data(), source_url.size()); }
void set_source_url(const char *source_url, size_t len);
const char *get_source_url() const { return this->source_url_; }
void set_request_parent(HttpRequestComponent *request_parent) { this->request_parent_ = request_parent; } void set_request_parent(HttpRequestComponent *request_parent) { this->request_parent_ = request_parent; }
void set_ota_parent(OtaHttpRequestComponent *ota_parent) { this->ota_parent_ = ota_parent; } void set_ota_parent(OtaHttpRequestComponent *ota_parent) { this->ota_parent_ = ota_parent; }
@@ -36,12 +62,21 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo
static void update_task(void *params); static void update_task(void *params);
#ifdef USE_ESP32 #ifdef USE_ESP32
TaskHandle_t update_task_handle_{nullptr}; TaskResult *task_result_{nullptr}; // set while a check runs on the update task
#endif #endif
uint8_t initial_check_remaining_{0}; uint8_t initial_check_remaining_{0};
private: private:
const char *source_url_{nullptr}; // literal from codegen friend class testing::HttpRequestUpdateTest;
void release_source_url_();
// A check reads the URL it started with until it ends, on the update task or inline through an
// automation it triggers. Ownership of that buffer moves to the check, so a set_source_url in the
// meantime cannot free it; end_check_() hands it back or frees it on the main loop.
void begin_check_(TaskResult *result);
void end_check_(TaskResult *result);
bool source_url_owned_{false}; // source_url_ was copied by a run-time setter; sits in the padding above
const char *source_url_{nullptr};
}; };
} // namespace esphome::http_request } // namespace esphome::http_request
+3 -1
View File
@@ -169,7 +169,9 @@ void LEDCOutput::setup() {
#if !defined(CLANG_TIDY) #if !defined(CLANG_TIDY)
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0) #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0)
PERIPH_RCC_ATOMIC() { ledc_ll_reset_register(0); } PERIPH_RCC_ATOMIC() { ledc_ll_reset_register(0); }
#elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) #elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) || \
(ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) && !defined(__PERIPH_CTRL_ALLOW_LEGACY_API))
// Also on the targets whose periph_module_reset() is an empty stub in ESP-IDF 5.x (P4, C5, C61, H4)
PERIPH_RCC_ATOMIC() { PERIPH_RCC_ATOMIC() {
ledc_ll_enable_reset_reg(true); ledc_ll_enable_reset_reg(true);
ledc_ll_enable_reset_reg(false); ledc_ll_enable_reset_reg(false);
+3
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include "light_color_values.h" #include "light_color_values.h"
#include "esphome/core/string_ref.h"
namespace esphome { namespace esphome {
@@ -133,6 +134,8 @@ class LightCall {
LightCall &set_effect(const std::string &effect) { return this->set_effect(effect.data(), effect.size()); } LightCall &set_effect(const std::string &effect) { return this->set_effect(effect.data(), effect.size()); }
/// Set the effect of the light by its name (const char * overload to resolve ambiguity). /// Set the effect of the light by its name (const char * overload to resolve ambiguity).
LightCall &set_effect(const char *effect) { return this->set_effect(effect, strlen(effect)); } LightCall &set_effect(const char *effect) { return this->set_effect(effect, strlen(effect)); }
/// Set the effect of the light by its name (StringRef overload to resolve ambiguity, zero-copy).
LightCall &set_effect(StringRef effect) { return this->set_effect(effect.c_str(), effect.size()); }
/// Set the effect of the light by its name and length (zero-copy from API). /// Set the effect of the light by its name and length (zero-copy from API).
LightCall &set_effect(const char *effect, size_t len); LightCall &set_effect(const char *effect, size_t len);
/// Set the effect of the light by its internal index number (only for internal use). /// Set the effect of the light by its internal index number (only for internal use).
+52 -1
View File
@@ -20,6 +20,10 @@
#ifdef USE_DASHBOARD_IMPORT #ifdef USE_DASHBOARD_IMPORT
#include "esphome/components/dashboard_import/dashboard_import.h" #include "esphome/components/dashboard_import/dashboard_import.h"
#endif #endif
#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT
#include "esphome/components/ethernet/ethernet_component.h"
#include "esphome/components/wifi/wifi_component.h"
#endif
namespace esphome::mdns { namespace esphome::mdns {
@@ -39,6 +43,45 @@ MDNS_STATIC_CONST_CHAR(SERVICE_TCP, "_tcp");
// Wrap build-time defines into flash storage // Wrap build-time defines into flash storage
MDNS_STATIC_CONST_CHAR(VALUE_VERSION, ESPHOME_VERSION); MDNS_STATIC_CONST_CHAR(VALUE_VERSION, ESPHOME_VERSION);
#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT
MDNS_STATIC_CONST_CHAR(NETWORK_WIFI, "wifi");
MDNS_STATIC_CONST_CHAR(NETWORK_ETHERNET, "ethernet");
// Same order as the default route in NetworkComponent::loop()
const char *MDNSComponent::connected_network_() const {
const bool ethernet = ethernet::global_eth_component->is_connected();
const bool wifi = wifi::global_wifi_component->is_connected();
#ifdef USE_NETWORK_PRIMARY_INTERFACE_WIFI
if (wifi)
return NETWORK_WIFI;
#endif
if (ethernet)
return NETWORK_ETHERNET;
if (wifi)
return NETWORK_WIFI;
return nullptr;
}
void MDNSComponent::update_network_txt_() {
if (this->network_txt_ == nullptr)
return;
// With no interface connected, keep the last value instead of flapping to wifi
const char *network = this->connected_network_();
if (network == nullptr || network == this->network_txt_)
return;
if (!this->publish_network_txt_(network)) {
// The _esphomelib service is missing, so a retry would fail too
this->network_txt_ = nullptr;
return;
}
ESP_LOGD(TAG, "Network TXT: %s", network);
this->network_txt_ = network;
#ifdef USE_MDNS_STORE_SERVICES
this->network_record_->value = MDNS_STR(network);
#endif
}
#endif
void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_register) { void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_register) {
#ifdef USE_MDNS_STORE_SERVICES #ifdef USE_MDNS_STORE_SERVICES
auto &services = this->services_; auto &services = this->services_;
@@ -160,7 +203,15 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
txt_records.push_back({MDNS_STR(TXT_BOARD), MDNS_STR(VALUE_BOARD)}); txt_records.push_back({MDNS_STR(TXT_BOARD), MDNS_STR(VALUE_BOARD)});
#if defined(USE_WIFI) #ifdef USE_MDNS_DYNAMIC_NETWORK_TXT
const char *network = this->connected_network_();
this->network_txt_ = network != nullptr ? network : NETWORK_WIFI;
txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(this->network_txt_)});
#ifdef USE_MDNS_STORE_SERVICES
// services is services_ here, which never reallocates
this->network_record_ = &txt_records.back();
#endif
#elif defined(USE_WIFI)
MDNS_STATIC_CONST_CHAR(NETWORK_WIFI, "wifi"); MDNS_STATIC_CONST_CHAR(NETWORK_WIFI, "wifi");
txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(NETWORK_WIFI)}); txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(NETWORK_WIFI)});
#elif defined(USE_ETHERNET) #elif defined(USE_ETHERNET)
+22
View File
@@ -32,6 +32,12 @@
#define USE_MDNS_DEVICE_INFO_TXT #define USE_MDNS_DEVICE_INFO_TXT
#endif #endif
// With both wifi and ethernet, the _esphomelib "network" TXT value follows the connected interface.
// OpenThread hands its services to the SRP client once, so it keeps the fixed value.
#if defined(USE_API) && defined(USE_WIFI) && defined(USE_ETHERNET) && !defined(USE_OPENTHREAD)
#define USE_MDNS_DYNAMIC_NETWORK_TXT
#endif
namespace esphome::mdns { namespace esphome::mdns {
// Helper struct that identifies strings that may be stored in flash storage (similar to LogString) // Helper struct that identifies strings that may be stored in flash storage (similar to LogString)
@@ -150,6 +156,22 @@ class MDNSComponent final : public Component
void setup_buffers_and_register_(PlatformRegisterFn platform_register); void setup_buffers_and_register_(PlatformRegisterFn platform_register);
#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT
static constexpr uint32_t NETWORK_TXT_CHECK_INTERVAL_MS = 1000;
/// The first connected interface in network: priority order ("ethernet" or "wifi"), else nullptr.
const char *connected_network_() const;
/// Check the connected interface and publish a changed "network" TXT value.
void update_network_txt_();
/// Platform-specific: replace the "network" TXT value of the registered _esphomelib service.
bool publish_network_txt_(const char *network);
/// Advertised value; nullptr until the _esphomelib service is registered, and after a failed publish.
const char *network_txt_{nullptr};
#ifdef USE_MDNS_STORE_SERVICES
/// The "network" record in services_, kept in step with network_txt_.
MDNSTXTRecord *network_record_{nullptr};
#endif
#endif
#ifdef USE_MDNS_DYNAMIC_TXT #ifdef USE_MDNS_DYNAMIC_TXT
/// Storage for runtime-generated TXT values from user lambdas /// Storage for runtime-generated TXT values from user lambdas
/// Pre-sized at compile time via MDNS_DYNAMIC_TXT_COUNT to avoid heap allocations. /// Pre-sized at compile time via MDNS_DYNAMIC_TXT_COUNT to avoid heap allocations.
+18 -1
View File
@@ -89,7 +89,24 @@ bool MDNSComponent::set_service_enabled(const char *service_type, const char *pr
} }
#endif // USE_MDNS_SUPPORTS_ENABLE_DISABLE && !USE_OPENTHREAD #endif // USE_MDNS_SUPPORTS_ENABLE_DISABLE && !USE_OPENTHREAD
void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp32); } #ifdef USE_MDNS_DYNAMIC_NETWORK_TXT
bool MDNSComponent::publish_network_txt_(const char *network) {
// ESP-IDF copies the value and announces the changed record
esp_err_t err = mdns_service_txt_item_set("_esphomelib", "_tcp", "network", network);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Failed to update network TXT: %s", esp_err_to_name(err));
return false;
}
return true;
}
#endif
void MDNSComponent::setup() {
this->setup_buffers_and_register_(register_esp32);
#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT
this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); });
#endif
}
void MDNSComponent::on_shutdown() { void MDNSComponent::on_shutdown() {
#ifndef USE_OPENTHREAD #ifndef USE_OPENTHREAD
+19
View File
@@ -46,7 +46,26 @@ void MDNSComponent::start_polling_window_() {
} }
#endif #endif
#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT
ESPHOME_LOG_TAG(TAG, "mdns");
bool MDNSComponent::publish_network_txt_(const char *network) {
// LEAmDNS replaces the value of an existing key; announce() sends the change
if (!MDNS.addServiceTxt("esphomelib", "tcp", "network", network)) {
ESP_LOGW(TAG, "Failed to update network TXT");
return false;
}
MDNS.announce();
this->start_polling_window_();
return true;
}
#endif
void MDNSComponent::setup() { void MDNSComponent::setup() {
#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT
// No-op until the first IP-up event registers the services
this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); });
#endif
// arduino-pico stubs out LwipIntf::stateUpCB (the netif status callback LEAmDNS uses // arduino-pico stubs out LwipIntf::stateUpCB (the netif status callback LEAmDNS uses
// on ESP8266 for auto-restart), so we must drive begin()/notifyAPChange() from our // on ESP8266 for auto-restart), so we must drive begin()/notifyAPChange() from our
// own IP state listener. Both WiFi and Ethernet have the same listener signature — // own IP state listener. Both WiFi and Ethernet have the same listener signature —
@@ -9,6 +9,10 @@ class RgbDriverChip(DriverChip):
def transforms(self) -> set[str]: def transforms(self) -> set[str]:
""" """
Return the set of transformations supported by this driver chip. Return the set of transformations supported by this driver chip.
RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY.
A model with an empty init sequence has no command interface at all, so it
cannot apply any transform in hardware.
""" """
if self.initsequence is not None and len(self.initsequence) == 0:
return set()
return super().transforms - {CONF_SWAP_XY} return super().transforms - {CONF_SWAP_XY}
+1 -57
View File
@@ -16,6 +16,7 @@
#include "lwip/dns.h" #include "lwip/dns.h"
#include "lwip/err.h" #include "lwip/err.h"
#include "mqtt_component.h" #include "mqtt_component.h"
#include "mqtt_topic_match.h"
#ifdef USE_API #ifdef USE_API
#include "esphome/components/api/api_server.h" #include "esphome/components/api/api_server.h"
@@ -581,63 +582,6 @@ void MQTTClientComponent::disable() {
this->on_shutdown(); this->on_shutdown();
} }
/** Check if the message topic matches the given subscription topic
*
* INFO: MQTT spec mandates that topics must not be empty and must be valid NULL-terminated UTF-8 strings.
*
* @param message The message topic that was received from the MQTT server. Note: this must not contain
* wildcard characters as mandated by the MQTT spec.
* @param subscription The subscription topic we are matching against.
* @param is_normal Is this a "normal" topic - Does the message topic not begin with a "$".
* @param past_separator Are we past the first '/' topic separator.
* @return true if the subscription topic matches the message topic, false otherwise.
*/
static bool topic_match(const char *message, const char *subscription, bool is_normal, bool past_separator) {
// Reached end of both strings at the same time, this means we have a successful match
if (*message == '\0' && *subscription == '\0')
return true;
// Either the message or the subscribe are at the end. This means they don't match.
if (*message == '\0' || *subscription == '\0')
return false;
bool do_wildcards = is_normal || past_separator;
if (*subscription == '+' && do_wildcards) {
// single level wildcard
// consume + from subscription
subscription++;
// consume everything from message until '/' found or end of string
while (*message != '\0' && *message != '/') {
message++;
}
// after this, both pointers will point to a '/' or to the end of the string
return topic_match(message, subscription, is_normal, true);
}
if (*subscription == '#' && do_wildcards) {
// multilevel wildcard - MQTT mandates that this must be at end of subscribe topic
return true;
}
// this handles '/' and normal characters at the same time.
if (*message != *subscription)
return false;
past_separator = past_separator || *subscription == '/';
// consume characters
subscription++;
message++;
return topic_match(message, subscription, is_normal, past_separator);
}
static bool topic_match(const char *message, const char *subscription) {
return topic_match(message, subscription, *message != '\0' && *message != '$', false);
}
void MQTTClientComponent::on_message(const std::string &topic, const std::string &payload) { void MQTTClientComponent::on_message(const std::string &topic, const std::string &payload) {
#ifdef USE_ESP8266 #ifdef USE_ESP8266
// IMPORTANT: This defer is REQUIRED to prevent stack overflow crashes on ESP8266. // IMPORTANT: This defer is REQUIRED to prevent stack overflow crashes on ESP8266.
+1 -2
View File
@@ -169,8 +169,7 @@ bool MQTTFanComponent::publish_state() {
char topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; char topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN];
const char *state_s = this->state_->state ? "ON" : "OFF"; const char *state_s = this->state_->state ? "ON" : "OFF";
ESP_LOGD(TAG, "'%s' Sending state %s.", this->state_->get_name().c_str(), state_s); ESP_LOGD(TAG, "'%s' Sending state %s.", this->state_->get_name().c_str(), state_s);
this->publish(this->get_state_topic_to_(topic_buf), state_s); bool failed = !this->publish(this->get_state_topic_to_(topic_buf), state_s);
bool failed = false;
if (this->state_->get_traits().supports_direction()) { if (this->state_->get_traits().supports_direction()) {
bool success = this->publish(this->get_direction_state_topic_to(topic_buf), bool success = this->publish(this->get_direction_state_topic_to(topic_buf),
fan_direction_to_mqtt_str(this->state_->direction)); fan_direction_to_mqtt_str(this->state_->direction));
@@ -0,0 +1,28 @@
#pragma once
namespace esphome::mqtt {
/// True if the message topic matches the subscription, with + and # wildcards.
/// A loop on purpose: recursing per character overflowed the ESP32 loop task's stack on long topics.
inline bool topic_match(const char *message, const char *subscription) {
// First-level wildcards do not match topics that begin with '$'
bool wildcards = *message != '$';
while (*message != '\0' && *subscription != '\0') {
if (wildcards && *subscription == '#')
return true;
if (wildcards && *subscription == '+') {
subscription++;
while (*message != '\0' && *message != '/')
message++;
continue;
}
if (*message != *subscription)
return false;
wildcards |= *subscription == '/';
message++;
subscription++;
}
return *message == *subscription; // a match only if both ended
}
} // namespace esphome::mqtt
@@ -37,6 +37,7 @@ int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prolog
.cipher_id = NOISE_CIPHER_CHACHAPOLY, .cipher_id = NOISE_CIPHER_CHACHAPOLY,
.hash_id = NOISE_HASH_SHA256, .hash_id = NOISE_HASH_SHA256,
.hybrid_id = NOISE_DH_NONE, .hybrid_id = NOISE_DH_NONE,
.reserved = {},
}; };
int err = noise_handshakestate_new_by_id(&this->handshake_, &nid, NOISE_ROLE_RESPONDER); int err = noise_handshakestate_new_by_id(&this->handshake_, &nid, NOISE_ROLE_RESPONDER);
+3 -2
View File
@@ -98,8 +98,9 @@ enum OTAType : uint8_t {
// The OTA backend method surface. Exactly one backend exists per build, // The OTA backend method surface. Exactly one backend exists per build,
// selected in ota_backend_factory.h where this concept is asserted on // selected in ota_backend_factory.h where this concept is asserted on
// make_ota_backend()'s return type. Semantics beyond the signatures: // make_ota_backend()'s return type. Semantics beyond the signatures:
// - begin: prepare for an image of the given size; ota_type defaults to an // - begin: prepare for an image of the given size; 0 means the size is unknown
// app update, so both call forms must be accepted. // (web_server upload) and the backend allows the largest image it can take.
// ota_type defaults to an app update, so both call forms must be accepted.
// - set_update_md5: expected digest of the incoming image, hex string. // - set_update_md5: expected digest of the incoming image, hex string.
// - write: consume the next chunk; end: finalize and mark bootable. // - write: consume the next chunk; end: finalize and mark bootable.
// - abort: safe to call in any state, including after end(). // - abort: safe to call in any state, including after end().
@@ -9,6 +9,12 @@
#include <Updater.h> #include <Updater.h>
// LittleFS region bounds, provided by the arduino-pico linker script.
// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp)
extern "C" uint8_t _FS_start;
// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp)
extern "C" uint8_t _FS_end;
namespace esphome::ota { namespace esphome::ota {
static const char *const TAG = "ota"; static const char *const TAG = "ota";
@@ -19,8 +25,11 @@ OTAResponseTypes ArduinoRP2OTABackend::begin(size_t image_size, OTAType ota_type
if (ota_type != OTA_TYPE_UPDATE_APP) { if (ota_type != OTA_TYPE_UPDATE_APP) {
return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE;
} }
// OTA size of 0 is not currently handled, but // 0 means the size is unknown (web_server upload). The updater only checks the size
// web_server is not supported for RP2040, so this is not an issue. // against the LittleFS region it stages the image in, so ask for all of it.
if (image_size == 0) {
image_size = reinterpret_cast<uintptr_t>(&_FS_end) - reinterpret_cast<uintptr_t>(&_FS_start);
}
bool ret = Update.begin(image_size, U_FLASH); bool ret = Update.begin(image_size, U_FLASH);
if (ret) { if (ret) {
rp2::preferences_prevent_write(true); rp2::preferences_prevent_write(true);
@@ -34,7 +43,7 @@ OTAResponseTypes ArduinoRP2OTABackend::begin(size_t image_size, OTAType ota_type
return OTA_RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG; return OTA_RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG;
if (error == UPDATE_ERROR_FLASH_CONFIG) if (error == UPDATE_ERROR_FLASH_CONFIG)
return OTA_RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG; return OTA_RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG;
if (error == UPDATE_ERROR_SPACE) if (error == UPDATE_ERROR_SPACE || error == UPDATE_ERROR_SIZE)
return OTA_RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE; return OTA_RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE;
ESP_LOGE(TAG, "Begin error: %d", error); ESP_LOGE(TAG, "Begin error: %d", error);
@@ -243,6 +243,14 @@ std::optional<std::string> SendspinHub::get_pairing_token() const {
return this->client_->pairing_token(); return this->client_->pairing_token();
} }
// THREAD CONTEXT: Main loop
StringRef SendspinHub::get_client_id() const {
if (this->client_ == nullptr) {
return StringRef();
}
return StringRef(this->client_->client_id());
}
// THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components) // THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components)
void SendspinHub::set_unpaired_access_enabled(bool enabled) { void SendspinHub::set_unpaired_access_enabled(bool enabled) {
this->unpaired_access_ = enabled; this->unpaired_access_ = enabled;
@@ -198,6 +198,12 @@ class SendspinHub final : public Component,
/// started once; it stays available after the client stops. Main loop only. /// started once; it stays available after the client stops. Main loop only.
std::optional<std::string> get_pairing_token() const; std::optional<std::string> get_pairing_token() const;
/// @brief Returns the Sendspin client_id: base64url of the client's static public key.
///
/// Empty until the client has started once; it stays set after the client stops. The returned StringRef points into
/// the client's string, which start() may rewrite, so do not keep it past the current call. Main loop only.
StringRef get_client_id() const;
/// @brief Sets the device information reported to the server in the `client/hello` message. /// @brief Sets the device information reported to the server in the `client/hello` message.
/// ///
/// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the /// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the
@@ -412,6 +412,8 @@ void SerialProxy::fill_identity_([[maybe_unused]] IdentityScratch &scratch, api:
msg.usb.product_id = scratch.product_id; msg.usb.product_id = scratch.product_id;
msg.usb.bcd_device = scratch.bcd_device; msg.usb.bcd_device = scratch.bcd_device;
msg.usb.interface_number = this->usb_channel_->get_interface_number(); msg.usb.interface_number = this->usb_channel_->get_interface_number();
msg.usb.interface_class = this->usb_channel_->get_interface_class();
msg.usb.port_number = this->usb_channel_->get_port_number();
msg.manufacturer = StringRef(scratch.manufacturer); msg.manufacturer = StringRef(scratch.manufacturer);
msg.product = StringRef(scratch.product); msg.product = StringRef(scratch.product);
msg.serial_number = StringRef(scratch.serial_number); msg.serial_number = StringRef(scratch.serial_number);
+5 -25
View File
@@ -1,8 +1,7 @@
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import binary_sensor, sensor, socket, uart from esphome.components import socket, uart
from esphome.components.const import ( from esphome.components.const import (
CONF_ALLOWED_IPS, CONF_ALLOWED_IPS,
CONF_CONNECTED,
CONF_DATA_BITS, CONF_DATA_BITS,
CONF_HOST, CONF_HOST,
CONF_PARITY, CONF_PARITY,
@@ -11,22 +10,16 @@ from esphome.components.const import (
CONF_STOP_BITS, CONF_STOP_BITS,
) )
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import ( from esphome.const import CONF_BAUD_RATE, CONF_ID, CONF_PORT
CONF_BAUD_RATE,
CONF_ID,
CONF_PORT,
DEVICE_CLASS_CONNECTIVITY,
ENTITY_CATEGORY_DIAGNOSTIC,
STATE_CLASS_TOTAL_INCREASING,
)
from esphome.types import ConfigType from esphome.types import ConfigType
CODEOWNERS = ["@Bascht74"] CODEOWNERS = ["@Bascht74"]
DEPENDENCIES = ["network"] DEPENDENCIES = ["network"]
AUTO_LOAD = ["uart", "binary_sensor", "sensor", "socket"] AUTO_LOAD = ["uart", "socket"]
DOMAIN = "tcp_uart"
MULTI_CONF = True MULTI_CONF = True
CONF_DISCONNECTS = "disconnects" CONF_TCP_UART_ID = "tcp_uart_id"
tcp_uart_ns = cg.esphome_ns.namespace("tcp_uart") tcp_uart_ns = cg.esphome_ns.namespace("tcp_uart")
TcpUart = tcp_uart_ns.class_("TcpUart", uart.UARTComponent, cg.Component) TcpUart = tcp_uart_ns.class_("TcpUart", uart.UARTComponent, cg.Component)
@@ -45,15 +38,6 @@ BASE_SCHEMA = cv.Schema(
cv.Optional( cv.Optional(
CONF_RECONNECT_INTERVAL, default="5s" CONF_RECONNECT_INTERVAL, default="5s"
): cv.positive_time_period_milliseconds, ): cv.positive_time_period_milliseconds,
cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema(
device_class=DEVICE_CLASS_CONNECTIVITY,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema(
accuracy_decimals=0,
state_class=STATE_CLASS_TOTAL_INCREASING,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
} }
).extend(cv.COMPONENT_SCHEMA) ).extend(cv.COMPONENT_SCHEMA)
@@ -99,7 +83,3 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_parity(config[CONF_PARITY])) cg.add(var.set_parity(config[CONF_PARITY]))
if (host := config.get(CONF_HOST)) is not None: if (host := config.get(CONF_HOST)) is not None:
cg.add(var.set_host(host)) cg.add(var.set_host(host))
binary_sensors = binary_sensor.sub_binary_sensors(config)
await binary_sensors(CONF_CONNECTED, var.set_connected_sensor)
sensors = sensor.sub_sensors(config)
await sensors(CONF_DISCONNECTS, var.set_disconnects_sensor)
@@ -0,0 +1,25 @@
import esphome.codegen as cg
from esphome.components import binary_sensor
from esphome.components.const import CONF_CONNECTED
import esphome.config_validation as cv
from esphome.const import CONF_ID, DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC
from esphome.types import ConfigType
from . import CONF_TCP_UART_ID, TcpUart
DEPENDENCIES = ["tcp_uart"]
CONFIG_SCHEMA = {
cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase),
cv.GenerateID(CONF_TCP_UART_ID): cv.use_id(TcpUart),
cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema(
device_class=DEVICE_CLASS_CONNECTIVITY,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_TCP_UART_ID])
binary_sensors = binary_sensor.sub_binary_sensors(config)
await binary_sensors(CONF_CONNECTED, hub.set_connected_sensor)
+31
View File
@@ -0,0 +1,31 @@
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
ENTITY_CATEGORY_DIAGNOSTIC,
STATE_CLASS_TOTAL_INCREASING,
)
from esphome.types import ConfigType
from . import CONF_TCP_UART_ID, TcpUart
DEPENDENCIES = ["tcp_uart"]
CONF_DISCONNECTS = "disconnects"
CONFIG_SCHEMA = {
cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase),
cv.GenerateID(CONF_TCP_UART_ID): cv.use_id(TcpUart),
cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema(
accuracy_decimals=0,
state_class=STATE_CLASS_TOTAL_INCREASING,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_TCP_UART_ID])
sensors = sensor.sub_sensors(config)
await sensors(CONF_DISCONNECTS, hub.set_disconnects_sensor)
+6
View File
@@ -17,9 +17,11 @@ void TcpUart::setup() {
#ifdef USE_SOCKET_TCP_LISTENER #ifdef USE_SOCKET_TCP_LISTENER
this->listener_.begin(TAG); this->listener_.begin(TAG);
#endif #endif
#ifdef USE_BINARY_SENSOR
if (this->connected_sensor_ != nullptr) { if (this->connected_sensor_ != nullptr) {
this->connected_sensor_->publish_state(false); this->connected_sensor_->publish_state(false);
} }
#endif
#ifdef USE_SENSOR #ifdef USE_SENSOR
if (this->disconnects_sensor_ != nullptr) { if (this->disconnects_sensor_ != nullptr) {
this->disconnects_sensor_->publish_state(0); this->disconnects_sensor_->publish_state(0);
@@ -38,7 +40,9 @@ void TcpUart::dump_config() {
#ifdef USE_SOCKET_TCP_LISTENER #ifdef USE_SOCKET_TCP_LISTENER
this->listener_.dump_config(); this->listener_.dump_config();
#endif #endif
#ifdef USE_BINARY_SENSOR
LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_); LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_);
#endif
#ifdef USE_SENSOR #ifdef USE_SENSOR
LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_); LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_);
#endif #endif
@@ -58,9 +62,11 @@ void TcpUart::sync_link_() {
// Unread bytes of the last session stay readable while down, never into the next one. // Unread bytes of the last session stay readable while down, never into the next one.
this->rx_start_ = this->rx_end_ = 0; this->rx_start_ = this->rx_end_ = 0;
} }
#ifdef USE_BINARY_SENSOR
if (this->connected_sensor_ != nullptr) { if (this->connected_sensor_ != nullptr) {
this->connected_sensor_->publish_state(up); this->connected_sensor_->publish_state(up);
} }
#endif
#ifdef USE_SENSOR #ifdef USE_SENSOR
// Only edges get here, so down is the falling edge. // Only edges get here, so down is the falling edge.
if (!up && this->disconnects_sensor_ != nullptr) { if (!up && this->disconnects_sensor_ != nullptr) {
+7
View File
@@ -1,6 +1,9 @@
#pragma once #pragma once
#include "esphome/core/defines.h"
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h" #include "esphome/components/binary_sensor/binary_sensor.h"
#endif
#ifdef USE_SENSOR #ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h" #include "esphome/components/sensor/sensor.h"
#endif #endif
@@ -23,7 +26,9 @@ class TcpUart : public uart::UARTComponent, public Component {
void set_host(const char *host) { this->link_.set_host(host); } void set_host(const char *host) { this->link_.set_host(host); }
void set_port(uint16_t port) { this->link_.set_port(port); } void set_port(uint16_t port) { this->link_.set_port(port); }
void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); } void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); }
#ifdef USE_BINARY_SENSOR
void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; } void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; }
#endif
#ifdef USE_SENSOR #ifdef USE_SENSOR
void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; } void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; }
#endif #endif
@@ -64,7 +69,9 @@ class TcpUart : public uart::UARTComponent, public Component {
#ifdef USE_SOCKET_TCP_LISTENER #ifdef USE_SOCKET_TCP_LISTENER
socket::TcpListener listener_; socket::TcpListener listener_;
#endif #endif
#ifdef USE_BINARY_SENSOR
binary_sensor::BinarySensor *connected_sensor_{nullptr}; binary_sensor::BinarySensor *connected_sensor_{nullptr};
#endif
#ifdef USE_SENSOR #ifdef USE_SENSOR
sensor::Sensor *disconnects_sensor_{nullptr}; sensor::Sensor *disconnects_sensor_{nullptr};
uint32_t disconnects_{0}; uint32_t disconnects_{0};
+3 -20
View File
@@ -1,8 +1,7 @@
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import binary_sensor, sensor, socket, uart from esphome.components import socket, uart
from esphome.components.const import ( from esphome.components.const import (
CONF_ALLOWED_IPS, CONF_ALLOWED_IPS,
CONF_CONNECTED,
CONF_HOST, CONF_HOST,
CONF_RECONNECT_INTERVAL, CONF_RECONNECT_INTERVAL,
CONF_ROLE, CONF_ROLE,
@@ -14,9 +13,6 @@ from esphome.const import (
CONF_ID, CONF_ID,
CONF_PORT, CONF_PORT,
CONF_UART_ID, CONF_UART_ID,
DEVICE_CLASS_CONNECTIVITY,
ENTITY_CATEGORY_DIAGNOSTIC,
STATE_CLASS_TOTAL_INCREASING,
) )
from esphome.core import CORE from esphome.core import CORE
import esphome.final_validate as fv import esphome.final_validate as fv
@@ -25,10 +21,10 @@ from esphome.types import ConfigType
CODEOWNERS = ["@Bascht74"] CODEOWNERS = ["@Bascht74"]
DOMAIN = "uart_tcp" DOMAIN = "uart_tcp"
DEPENDENCIES = ["network", "uart"] DEPENDENCIES = ["network", "uart"]
AUTO_LOAD = ["binary_sensor", "sensor", "socket"] AUTO_LOAD = ["socket"]
MULTI_CONF = True MULTI_CONF = True
CONF_DISCONNECTS = "disconnects" CONF_UART_TCP_ID = "uart_tcp_id"
uart_tcp_ns = cg.esphome_ns.namespace("uart_tcp") uart_tcp_ns = cg.esphome_ns.namespace("uart_tcp")
UartTcp = uart_tcp_ns.class_("UartTcp", cg.Component, uart.UARTDevice) UartTcp = uart_tcp_ns.class_("UartTcp", cg.Component, uart.UARTDevice)
@@ -42,15 +38,6 @@ BASE_SCHEMA = cv.Schema(
cv.Optional( cv.Optional(
CONF_RECONNECT_INTERVAL, default="5s" CONF_RECONNECT_INTERVAL, default="5s"
): cv.positive_time_period_milliseconds, ): cv.positive_time_period_milliseconds,
cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema(
device_class=DEVICE_CLASS_CONNECTIVITY,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema(
accuracy_decimals=0,
state_class=STATE_CLASS_TOTAL_INCREASING,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
} }
).extend(cv.COMPONENT_SCHEMA) ).extend(cv.COMPONENT_SCHEMA)
@@ -155,7 +142,3 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL])) cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL]))
if (host := config.get(CONF_HOST)) is not None: if (host := config.get(CONF_HOST)) is not None:
cg.add(var.set_host(host)) cg.add(var.set_host(host))
binary_sensors = binary_sensor.sub_binary_sensors(config)
await binary_sensors(CONF_CONNECTED, var.set_connected_sensor)
sensors = sensor.sub_sensors(config)
await sensors(CONF_DISCONNECTS, var.set_disconnects_sensor)
@@ -0,0 +1,25 @@
import esphome.codegen as cg
from esphome.components import binary_sensor
from esphome.components.const import CONF_CONNECTED
import esphome.config_validation as cv
from esphome.const import CONF_ID, DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC
from esphome.types import ConfigType
from . import CONF_UART_TCP_ID, UartTcp
DEPENDENCIES = ["uart_tcp"]
CONFIG_SCHEMA = {
cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase),
cv.GenerateID(CONF_UART_TCP_ID): cv.use_id(UartTcp),
cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema(
device_class=DEVICE_CLASS_CONNECTIVITY,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_UART_TCP_ID])
binary_sensors = binary_sensor.sub_binary_sensors(config)
await binary_sensors(CONF_CONNECTED, hub.set_connected_sensor)
+31
View File
@@ -0,0 +1,31 @@
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
ENTITY_CATEGORY_DIAGNOSTIC,
STATE_CLASS_TOTAL_INCREASING,
)
from esphome.types import ConfigType
from . import CONF_UART_TCP_ID, UartTcp
DEPENDENCIES = ["uart_tcp"]
CONF_DISCONNECTS = "disconnects"
CONFIG_SCHEMA = {
cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase),
cv.GenerateID(CONF_UART_TCP_ID): cv.use_id(UartTcp),
cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema(
accuracy_decimals=0,
state_class=STATE_CLASS_TOTAL_INCREASING,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_UART_TCP_ID])
sensors = sensor.sub_sensors(config)
await sensors(CONF_DISCONNECTS, hub.set_disconnects_sensor)
+6
View File
@@ -19,9 +19,11 @@ void UartTcp::setup() {
#ifdef USE_SOCKET_TCP_LISTENER #ifdef USE_SOCKET_TCP_LISTENER
this->listener_.begin(TAG); this->listener_.begin(TAG);
#endif #endif
#ifdef USE_BINARY_SENSOR
if (this->connected_sensor_ != nullptr) { if (this->connected_sensor_ != nullptr) {
this->connected_sensor_->publish_state(false); this->connected_sensor_->publish_state(false);
} }
#endif
#ifdef USE_SENSOR #ifdef USE_SENSOR
if (this->disconnects_sensor_ != nullptr) { if (this->disconnects_sensor_ != nullptr) {
this->disconnects_sensor_->publish_state(0); this->disconnects_sensor_->publish_state(0);
@@ -40,7 +42,9 @@ void UartTcp::dump_config() {
#ifdef USE_SOCKET_TCP_LISTENER #ifdef USE_SOCKET_TCP_LISTENER
this->listener_.dump_config(); this->listener_.dump_config();
#endif #endif
#ifdef USE_BINARY_SENSOR
LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_); LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_);
#endif
#ifdef USE_SENSOR #ifdef USE_SENSOR
LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_); LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_);
#endif #endif
@@ -60,9 +64,11 @@ void UartTcp::sync_link_() {
// The driver kept whatever arrived while the link was down. // The driver kept whatever arrived while the link was down.
this->discard_uart_(); this->discard_uart_();
} }
#ifdef USE_BINARY_SENSOR
if (this->connected_sensor_ != nullptr) { if (this->connected_sensor_ != nullptr) {
this->connected_sensor_->publish_state(up); this->connected_sensor_->publish_state(up);
} }
#endif
#ifdef USE_SENSOR #ifdef USE_SENSOR
// Only edges get here, so down is the falling edge. // Only edges get here, so down is the falling edge.
if (!up && this->disconnects_sensor_ != nullptr) { if (!up && this->disconnects_sensor_ != nullptr) {
+7
View File
@@ -1,6 +1,9 @@
#pragma once #pragma once
#include "esphome/core/defines.h"
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h" #include "esphome/components/binary_sensor/binary_sensor.h"
#endif
#ifdef USE_SENSOR #ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h" #include "esphome/components/sensor/sensor.h"
#endif #endif
@@ -22,7 +25,9 @@ class UartTcp final : public Component, public uart::UARTDevice {
void set_host(const char *host) { this->link_.set_host(host); } void set_host(const char *host) { this->link_.set_host(host); }
void set_port(uint16_t port) { this->link_.set_port(port); } void set_port(uint16_t port) { this->link_.set_port(port); }
void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); } void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); }
#ifdef USE_BINARY_SENSOR
void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; } void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; }
#endif
#ifdef USE_SENSOR #ifdef USE_SENSOR
void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; } void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; }
#endif #endif
@@ -53,7 +58,9 @@ class UartTcp final : public Component, public uart::UARTDevice {
#ifdef USE_SOCKET_TCP_LISTENER #ifdef USE_SOCKET_TCP_LISTENER
socket::TcpListener listener_; socket::TcpListener listener_;
#endif #endif
#ifdef USE_BINARY_SENSOR
binary_sensor::BinarySensor *connected_sensor_{nullptr}; binary_sensor::BinarySensor *connected_sensor_{nullptr};
#endif
// Loop start time of the last socket-to-UART write; sizes the next paced write. // Loop start time of the last socket-to-UART write; sizes the next paced write.
uint32_t last_write_ms_{0}; uint32_t last_write_ms_{0};
#ifdef USE_SENSOR #ifdef USE_SENSOR
-8
View File
@@ -152,14 +152,6 @@ bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bo
} }
} }
std::vector<CdcEps> USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_hdl) {
auto result = USBUartTypeCdcAcm::parse_descriptors(dev_hdl);
// ch34x doesn't use the interrupt endpoint, and we don't have endpoints to spare
for (auto &cdc_dev : result) {
cdc_dev.interrupt_interface_number = 0xFF;
}
return result;
}
} // namespace esphome::usb_uart } // namespace esphome::usb_uart
#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 ||
+5 -5
View File
@@ -88,11 +88,11 @@ std::vector<CdcEps> USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev
ESP_LOGE(TAG, "in_ep: usb_parse_endpoint_descriptor_by_index failed"); ESP_LOGE(TAG, "in_ep: usb_parse_endpoint_descriptor_by_index failed");
continue; continue;
} }
if (in_ep->bEndpointAddress & usb_host::USB_DIR_IN) { CdcEps eps = in_ep->bEndpointAddress & usb_host::USB_DIR_IN
cdc_devs.push_back({CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber}}); ? CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber}
} else { : CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber};
cdc_devs.push_back({CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber}}); eps.bulk_interface_class = data_desc->bInterfaceClass;
} cdc_devs.push_back(eps);
} }
return cdc_devs; return cdc_devs;
} }
+1
View File
@@ -217,6 +217,7 @@ static optional<CdcEps> get_uart(const usb_config_desc_t *config_desc, uint8_t i
} }
eps.bulk_interface_number = intf_desc->bInterfaceNumber; eps.bulk_interface_number = intf_desc->bInterfaceNumber;
eps.bulk_interface_class = intf_desc->bInterfaceClass;
return eps; return eps;
} }
+4 -1
View File
@@ -189,7 +189,10 @@ std::vector<CdcEps> USBUartTypePL2303::parse_descriptors(usb_device_handle_t dev
} }
if (in_ep && out_ep) { if (in_ep && out_ep) {
cdc_devs.push_back(CdcEps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber}); CdcEps eps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber};
eps.bulk_interface_class = intf->bInterfaceClass;
eps.interrupt_interface_class = intf->bInterfaceClass;
cdc_devs.push_back(eps);
break; // PL2303 is single-port break; // PL2303 is single-port
} }
} }
+4 -2
View File
@@ -41,14 +41,17 @@ static optional<CdcEps> get_cdc(const usb_config_desc_t *config_desc, uint8_t in
if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_INT) { if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_INT) {
eps.notify_ep = ep; eps.notify_ep = ep;
eps.interrupt_interface_number = intf_desc->bInterfaceNumber; eps.interrupt_interface_number = intf_desc->bInterfaceNumber;
eps.interrupt_interface_class = intf_desc->bInterfaceClass;
} else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && ep->bEndpointAddress & usb_host::USB_DIR_IN && } else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && ep->bEndpointAddress & usb_host::USB_DIR_IN &&
(eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) { (eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) {
eps.in_ep = ep; eps.in_ep = ep;
eps.bulk_interface_number = intf_desc->bInterfaceNumber; eps.bulk_interface_number = intf_desc->bInterfaceNumber;
eps.bulk_interface_class = intf_desc->bInterfaceClass;
} else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && !(ep->bEndpointAddress & usb_host::USB_DIR_IN) && } else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && !(ep->bEndpointAddress & usb_host::USB_DIR_IN) &&
(eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) { (eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) {
eps.out_ep = ep; eps.out_ep = ep;
eps.bulk_interface_number = intf_desc->bInterfaceNumber; eps.bulk_interface_number = intf_desc->bInterfaceNumber;
eps.bulk_interface_class = intf_desc->bInterfaceClass;
} else { } else {
ESP_LOGE(TAG, "Unexpected endpoint attributes: %02X", ep->bmAttributes); ESP_LOGE(TAG, "Unexpected endpoint attributes: %02X", ep->bmAttributes);
continue; continue;
@@ -431,8 +434,7 @@ void USBUartTypeCdcAcm::on_connected() {
if (channel->cdc_dev_.interrupt_interface_number != 0xFF && if (channel->cdc_dev_.interrupt_interface_number != 0xFF &&
channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) { channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) {
if (!this->claim_comm_interface_) { if (!this->claim_comm_interface_) {
ESP_LOGD(TAG, "Skipping comm interface %d (claim_comm_interface: false)", ESP_LOGD(TAG, "Comm interface %d left unclaimed", channel->cdc_dev_.interrupt_interface_number);
channel->cdc_dev_.interrupt_interface_number);
} else { } else {
auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_, auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_,
channel->cdc_dev_.interrupt_interface_number, 0); channel->cdc_dev_.interrupt_interface_number, 0);
+19 -2
View File
@@ -39,6 +39,9 @@ struct CdcEps {
// must remain valid even when the interface itself is not claimed. // must remain valid even when the interface itself is not claimed.
uint8_t interrupt_interface_number{0xFF}; uint8_t interrupt_interface_number{0xFF};
bool interrupt_interface_claimed{false}; bool interrupt_interface_claimed{false};
// bInterfaceClass of each interface above, which decides how a host binds the port
uint8_t bulk_interface_class{0};
uint8_t interrupt_interface_class{0};
}; };
enum CH34xChipType : uint8_t { enum CH34xChipType : uint8_t {
@@ -186,6 +189,16 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented<USBUartCo
: this->cdc_dev_.bulk_interface_number; : this->cdc_dev_.bulk_interface_number;
} }
/// bInterfaceClass of the interface `get_interface_number()` names
uint8_t get_interface_class() const {
return this->cdc_dev_.interrupt_interface_number != 0xFF ? this->cdc_dev_.interrupt_interface_class
: this->cdc_dev_.bulk_interface_class;
}
/// Index of this port among the ports on its interface. Every driver here exposes one
/// port per interface; a multiplexed driver would index its channels.
uint8_t get_port_number() const { return 0; }
protected: protected:
// Not directly instantiable; construct a concrete channel type instead. // Not directly instantiable; construct a concrete channel type instead.
USBUartChannelBase(uint8_t index, uint16_t buffer_size) : input_buffer_(RingBuffer(buffer_size)), index_(index) {} USBUartChannelBase(uint8_t index, uint16_t buffer_size) : input_buffer_(RingBuffer(buffer_size)), index_(index) {}
@@ -317,13 +330,17 @@ class USBUartTypeCP210X : public USBUartTypeCdcAcm {
}; };
class USBUartTypeCH34X : public USBUartTypeCdcAcm { class USBUartTypeCH34X : public USBUartTypeCdcAcm {
public: public:
USBUartTypeCH34X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {} USBUartTypeCH34X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {
// ch34x doesn't use the interrupt endpoint, and we don't have endpoints to spare. The
// comm interface is left in place unclaimed: on the CDC-style chips it is still the
// interface a host binds, and so the one the port is identified by.
this->claim_comm_interface_ = false;
}
void dump_config() override; void dump_config() override;
protected: protected:
bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override;
bool config_device_step(uint8_t step, bool ok, const uint8_t *response) override; bool config_device_step(uint8_t step, bool ok, const uint8_t *response) override;
std::vector<CdcEps> parse_descriptors(usb_device_handle_t dev_hdl) override;
private: private:
CH34xChipType chiptype_{CHIP_UNKNOWN}; CH34xChipType chiptype_{CHIP_UNKNOWN};
@@ -57,7 +57,7 @@ namespace esphome::web_server_idf {
static const char *const TAG = "web_server_idf"; static const char *const TAG = "web_server_idf";
// Only send_json_() may hold the JSON arena: every other frame in this file is capped below one // Only prepare_json_() may hold the JSON arena: every other frame in this file is capped below one
// arena. Measured at -Os on GCC 14; newer toolchains stay checked on purpose, older ones skip it. // arena. Measured at -Os on GCC 14; newer toolchains stay checked on purpose, older ones skip it.
#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__) #if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__)
#pragma GCC diagnostic error "-Wstack-usage=2048" #pragma GCC diagnostic error "-Wstack-usage=2048"
@@ -148,10 +148,9 @@ void AsyncWebServer::begin() {
if (this->server_) { if (this->server_) {
this->end(); this->end();
} }
// Default httpd stack is defined by ESP-IDF. Increase to accommodate SerializationBuffer's // Allow room for SerializationBuffer's 640-byte stack buffer and HTTP request handling.
// 640-byte stack buffer used by web_server JSON request handlers.
httpd_config_t config = HTTPD_DEFAULT_CONFIG(); httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.stack_size = config.stack_size + 256; config.stack_size = 4512;
config.server_port = this->port_; config.server_port = this->port_;
config.uri_match_fn = [](const char * /*unused*/, const char * /*unused*/, size_t /*unused*/) { return true; }; config.uri_match_fn = [](const char * /*unused*/, const char * /*unused*/, size_t /*unused*/) { return true; };
// Always enable LRU purging to handle socket exhaustion gracefully. // Always enable LRU purging to handle socket exhaustion gracefully.
@@ -955,10 +954,39 @@ void AsyncEventSourceResponse::process_close_() {
} }
} }
void AsyncEventSourceResponse::refresh_lru_() {
// httpd only advances a session's LRU counter when a request arrives, and an event stream receives one
// request ever, so the purge that frees a socket for a new client would evict the live streams first.
// The HTTPD task owns the session table, so the refresh runs there.
// A stalled stream is the socket the purge should take, and a zeroed fd has no session left to refresh.
if (this->send_failure_started_ms_ != 0 || this->fd_.load() == 0)
return;
const uint32_t now = App.get_loop_component_start_time();
if (now - this->last_lru_refresh_ms_ < LRU_REFRESH_INTERVAL_MS ||
this->lru_work_queued_.load(std::memory_order_acquire)) {
return;
}
this->last_lru_refresh_ms_ = now; // a full queue also waits for the next interval
this->lru_work_queued_.store(true, std::memory_order_release);
if (httpd_queue_work(this->hd_, &AsyncEventSourceResponse::refresh_lru_work, this) != ESP_OK) {
this->lru_work_queued_.store(false, std::memory_order_release);
}
}
void AsyncEventSourceResponse::refresh_lru_work(void *arg) {
auto *response = static_cast<AsyncEventSourceResponse *>(arg);
const int fd = response->fd_.load();
if (response->owns_session_(fd)) {
httpd_sess_update_lru_counter(response->hd_, fd);
}
// Release self only after the HTTPD-task callback has finished every access.
response->lru_work_queued_.store(false, std::memory_order_release);
}
void AsyncEventSourceResponse::close_session_work(void *arg) { void AsyncEventSourceResponse::close_session_work(void *arg) {
auto *response = static_cast<AsyncEventSourceResponse *>(arg); auto *response = static_cast<AsyncEventSourceResponse *>(arg);
const int fd = response->fd_.load(); const int fd = response->fd_.load();
if (fd != 0 && httpd_sess_get_ctx(response->hd_, fd) == response) { if (response->owns_session_(fd)) {
// The HTTPD task remains the session owner. Shutting the socket down makes // The HTTPD task remains the session owner. Shutting the socket down makes
// its next select/recv path delete the session and invoke destroy(). // its next select/recv path delete the session and invoke destroy().
shutdown(fd, SHUT_RDWR); shutdown(fd, SHUT_RDWR);
@@ -972,7 +1000,7 @@ ssize_t AsyncEventSourceResponse::send_(struct iovec *iov, int iovcnt) {
// httpd frees a session before closing its socket, so the fd may already be a new client's. // httpd frees a session before closing its socket, so the fd may already be a new client's.
// Treated as would-block; the stall timer ends a session that never becomes ours again. // Treated as would-block; the stall timer ends a session that never becomes ours again.
const int fd = this->fd_.load(); const int fd = this->fd_.load();
if (httpd_sess_get_ctx(this->hd_, fd) != this) { if (!this->owns_session_(fd)) {
return 0; return 0;
} }
struct msghdr msg {}; struct msghdr msg {};
@@ -1081,6 +1109,7 @@ void AsyncEventSourceResponse::loop() {
this->process_close_(); this->process_close_();
return; return;
} }
this->refresh_lru_();
drain_tail_(); drain_tail_();
process_deferred_queue_(); process_deferred_queue_();
if (this->close_requested_) if (this->close_requested_)
@@ -1091,37 +1120,32 @@ void AsyncEventSourceResponse::loop() {
#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__) #if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__)
#pragma GCC diagnostic push #pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wstack-usage=" // the one frame that holds the arena and the JSON buffer #pragma GCC diagnostic ignored "-Wstack-usage=" // the one frame that holds the JSON arena
#endif #endif
bool AsyncEventSourceResponse::send_json_(void *source, message_generator_t *generator) { AsyncEventSourceResponse::JsonPrepareResult AsyncEventSourceResponse::prepare_json_(void *source,
// The arena lives only in this frame, so no call chain ever holds two of them message_generator_t *generator,
char *buffer, size_t *size_out) {
// Log lines emitted while the generator runs are dropped for this session: on the large path they would fill
// tail_ before it is overwritten, and on the small path they would go out ahead of the event being built.
SendGuard guard{*this};
json::JsonArena<json::JSON_ARENA_SIZE> arena; json::JsonArena<json::JSON_ARENA_SIZE> arena;
json::JsonBuilder builder(&arena); json::JsonBuilder builder(&arena);
generator(this->web_server_, source, builder); generator(this->web_server_, source, builder);
char buf[JSON_BUF_SIZE]; *size_out = builder.serialize_to(buffer, JSON_BUF_SIZE);
const size_t len = builder.serialize_to(buf, sizeof(buf)); if (*size_out < JSON_BUF_SIZE) {
if (len < sizeof(buf)) { return JsonPrepareResult::JSON_PREPARE_RESULT_BUFFER;
return this->try_send_nodefer(buf, len, "state");
} }
// Too large for the stack: the tail holds the whole chunk and loop() drains it. Serialized
// JSON has no raw line break, so the body is one data line.
if (!this->ready_to_send_()) {
return false;
}
{
SendGuard guard{*this};
char prefix[PREFIX_BUF_SIZE]; char prefix[PREFIX_BUF_SIZE];
const size_t prefix_len = build_chunk_prefix(prefix, sizeof(prefix), "state", 0, 0, true); const size_t prefix_len = build_chunk_prefix(prefix, sizeof(prefix), "state", 0, 0, true);
// Grow the tail until the document fits. Nothing has reached the wire, so a document that // Nothing reaches the wire until the complete document and its framing are owned by the caller.
// cannot be held costs only this event, and the tail grown for it is released.
size_t json_len = 0; size_t json_len = 0;
size_t cap = std::max<size_t>(JSON_BUF_SIZE * 2, this->tail_cap_); size_t cap = std::max<size_t>(JSON_BUF_SIZE * 2, this->tail_cap_);
for (;;) { for (;;) {
if (!this->reserve_tail_(cap)) { if (!this->reserve_tail_(cap)) {
this->tail_alloc_failed_(cap); *size_out = cap;
return false; // stays deferred, retried on a later pass return JsonPrepareResult::JSON_PREPARE_RESULT_NO_MEMORY;
} }
const size_t room = cap - prefix_len - SSE_SUFFIX_LEN; const size_t room = cap - prefix_len - SSE_SUFFIX_LEN;
json_len = builder.serialize_to(reinterpret_cast<char *>(this->tail_.get()) + prefix_len, room); json_len = builder.serialize_to(reinterpret_cast<char *>(this->tail_.get()) + prefix_len, room);
@@ -1129,11 +1153,8 @@ bool AsyncEventSourceResponse::send_json_(void *source, message_generator_t *gen
break; break;
} }
if (cap >= TAIL_MAX_SIZE) { if (cap >= TAIL_MAX_SIZE) {
ESP_LOGW(TAG, "State event does not fit %zu bytes, dropped", room); *size_out = room;
this->tail_.reset(); return JsonPrepareResult::JSON_PREPARE_RESULT_TOO_LARGE;
this->tail_cap_ = 0;
this->send_failure_started_ms_ = 0;
return true; // would never fit, reported as sent
} }
cap = std::min<size_t>(cap * 2, TAIL_MAX_SIZE); cap = std::min<size_t>(cap * 2, TAIL_MAX_SIZE);
} }
@@ -1145,14 +1166,42 @@ bool AsyncEventSourceResponse::send_json_(void *source, message_generator_t *gen
std::memcpy(dst + prefix_len + json_len, SSE_SUFFIX, SSE_SUFFIX_LEN); std::memcpy(dst + prefix_len + json_len, SSE_SUFFIX, SSE_SUFFIX_LEN);
this->tail_len_ = total; this->tail_len_ = total;
this->tail_sent_ = 0; this->tail_sent_ = 0;
} return JsonPrepareResult::JSON_PREPARE_RESULT_TAIL;
drain_tail_();
return true;
} }
#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__) #if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__)
#pragma GCC diagnostic pop #pragma GCC diagnostic pop
#endif #endif
bool AsyncEventSourceResponse::send_json_(void *source, message_generator_t *generator) {
if (!this->ready_to_send_()) {
return false;
}
char buffer[JSON_BUF_SIZE];
size_t size;
const auto result = this->prepare_json_(source, generator, buffer, &size);
switch (result) {
case JsonPrepareResult::JSON_PREPARE_RESULT_BUFFER:
return this->try_send_nodefer(buffer, size, "state");
case JsonPrepareResult::JSON_PREPARE_RESULT_TAIL:
this->drain_tail_();
return true;
case JsonPrepareResult::JSON_PREPARE_RESULT_NO_MEMORY: {
SendGuard guard{*this};
this->tail_alloc_failed_(size);
return false;
}
case JsonPrepareResult::JSON_PREPARE_RESULT_TOO_LARGE: {
SendGuard guard{*this};
ESP_LOGW(TAG, "State event does not fit %zu bytes, dropped", size);
this->tail_.reset();
this->tail_cap_ = 0;
this->send_failure_started_ms_ = 0;
return true;
}
}
return false;
}
void AsyncEventSourceResponse::tail_alloc_failed_(size_t cap) { void AsyncEventSourceResponse::tail_alloc_failed_(size_t cap) {
// Same stall clock as a socket that stops draining, so a session cannot retry forever // Same stall clock as a socket that stops draining, so a session cannot retry forever
const uint32_t now = App.get_loop_component_start_time(); const uint32_t now = App.get_loop_component_start_time();
@@ -317,6 +317,17 @@ class AsyncEventSourceResponse {
size_t sent); size_t sent);
// Send a state event; JSON too large for the stack buffer is serialized into tail_ instead // Send a state event; JSON too large for the stack buffer is serialized into tail_ instead
bool send_json_(void *source, message_generator_t *generator); bool send_json_(void *source, message_generator_t *generator);
enum class JsonPrepareResult : uint8_t {
JSON_PREPARE_RESULT_BUFFER,
JSON_PREPARE_RESULT_TAIL,
JSON_PREPARE_RESULT_NO_MEMORY,
JSON_PREPARE_RESULT_TOO_LARGE,
};
// Keep the arena out of the caller's frame so it is gone before the state send.
// size_out: BUFFER = JSON length, NO_MEMORY = requested tail capacity, TOO_LARGE = available JSON room;
// unused for TAIL.
JsonPrepareResult __attribute__((noinline))
prepare_json_(void *source, message_generator_t *generator, char *buffer, size_t *size_out);
// Warn once, and close the session once the stall timeout passes with no memory for the tail // Warn once, and close the session once the stall timeout passes with no memory for the tail
void tail_alloc_failed_(size_t cap); void tail_alloc_failed_(size_t cap);
void request_close_(); void request_close_();
@@ -329,10 +340,15 @@ class AsyncEventSourceResponse {
}; };
void process_close_(); void process_close_();
static void close_session_work(void *arg); static void close_session_work(void *arg);
void refresh_lru_();
static void refresh_lru_work(void *arg);
// httpd frees a session before closing its socket, so the fd may already belong to a new client
bool owns_session_(int fd) const { return fd != 0 && httpd_sess_get_ctx(this->hd_, fd) == this; }
// Deletable only after destroy() zeroed fd_ and no queued HTTPD close work still references this object. // Deletable only after destroy() zeroed fd_ and no queued HTTPD work still references this object.
bool safe_to_delete_() const { bool safe_to_delete_() const {
return this->fd_.load() == 0 && !this->close_work_queued_.load(std::memory_order_acquire); return this->fd_.load() == 0 && !this->close_work_queued_.load(std::memory_order_acquire) &&
!this->lru_work_queued_.load(std::memory_order_acquire);
} }
static void destroy(void *p); static void destroy(void *p);
@@ -347,11 +363,13 @@ class AsyncEventSourceResponse {
RAMUniquePtr<uint8_t[]> tail_; RAMUniquePtr<uint8_t[]> tail_;
uint32_t send_failure_started_ms_{0}; // Zero means no send stall in progress. uint32_t send_failure_started_ms_{0}; // Zero means no send stall in progress.
uint32_t next_close_attempt_ms_{0}; uint32_t next_close_attempt_ms_{0};
uint32_t last_lru_refresh_ms_{0};
uint16_t tail_cap_{0}; uint16_t tail_cap_{0};
uint16_t tail_len_{0}; // Zero means nothing pending uint16_t tail_len_{0}; // Zero means nothing pending
uint16_t tail_sent_{0}; uint16_t tail_sent_{0};
// Set on the main loop before queueing close work, cleared by the HTTPD-task callback when done. // Set on the main loop before queueing close work, cleared by the HTTPD-task callback when done.
std::atomic<bool> close_work_queued_{false}; std::atomic<bool> close_work_queued_{false};
std::atomic<bool> lru_work_queued_{false};
// Main-loop only; the HTTPD task never reads or writes these flags. // Main-loop only; the HTTPD task never reads or writes these flags.
bool close_requested_{false}; bool close_requested_{false};
bool close_retry_warning_logged_{false}; bool close_retry_warning_logged_{false};
@@ -373,6 +391,7 @@ class AsyncEventSourceResponse {
static constexpr uint32_t SEND_STALL_TIMEOUT_MS = 20000; static constexpr uint32_t SEND_STALL_TIMEOUT_MS = 20000;
static constexpr uint32_t CLOSE_RETRY_INTERVAL_MS = 250; static constexpr uint32_t CLOSE_RETRY_INTERVAL_MS = 250;
static constexpr uint32_t CLOSE_CONFIRM_INTERVAL_MS = 1000; static constexpr uint32_t CLOSE_CONFIRM_INTERVAL_MS = 1000;
static constexpr uint32_t LRU_REFRESH_INTERVAL_MS = 1000;
}; };
using AsyncEventSourceClient = AsyncEventSourceResponse; using AsyncEventSourceClient = AsyncEventSourceResponse;
+1 -1
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum from esphome.enum import StrEnum
__version__ = "2026.10.0b4" __version__ = "2026.10.1"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = ( VALID_SUBSTITUTIONS_CHARACTERS = (
+18 -11
View File
@@ -648,6 +648,8 @@ class EsphomeCore:
self.main_statements: list[Statement] = [] self.main_statements: list[Statement] = []
# A list of statements to insert in the global block (includes and global variables) # A list of statements to insert in the global block (includes and global variables)
self.global_statements: list[Statement] = [] self.global_statements: list[Statement] = []
# Generated code emitted after the user includes, so it can use what they declare
self.global_statements_after_includes: list[Statement] = []
# A map of platformio libraries to add to the project (shortname: (name, version, repository)) # A map of platformio libraries to add to the project (shortname: (name, version, repository))
self.platformio_libraries: dict[str, Library] = {} self.platformio_libraries: dict[str, Library] = {}
# A set of build flags to set in the platformio project # A set of build flags to set in the platformio project
@@ -716,6 +718,7 @@ class EsphomeCore:
self.variables = {} self.variables = {}
self.main_statements = [] self.main_statements = []
self.global_statements = [] self.global_statements = []
self.global_statements_after_includes = []
self.platformio_libraries = {} self.platformio_libraries = {}
self.build_flags = set() self.build_flags = set()
self.cmake_args = {} self.cmake_args = {}
@@ -1032,8 +1035,8 @@ class EsphomeCore:
except RuntimeError as e: except RuntimeError as e:
raise EsphomeError(str(e)) from e raise EsphomeError(str(e)) from e
def add(self, expression, prepend=False) -> "Statement": @staticmethod
"""Add an expression or statement to the main setup() block.""" def _as_statement(expression) -> "Statement":
from esphome.cpp_generator import Expression, Statement, statement from esphome.cpp_generator import Expression, Statement, statement
if isinstance(expression, Expression): if isinstance(expression, Expression):
@@ -1042,7 +1045,11 @@ class EsphomeCore:
raise ValueError( raise ValueError(
f"Add '{expression}' must be expression or statement, not {type(expression)}" f"Add '{expression}' must be expression or statement, not {type(expression)}"
) )
return expression
def add(self, expression, prepend=False) -> "Statement":
"""Add an expression or statement to the main setup() block."""
expression = self._as_statement(expression)
if prepend: if prepend:
self.main_statements.insert(0, expression) self.main_statements.insert(0, expression)
else: else:
@@ -1051,14 +1058,7 @@ class EsphomeCore:
return expression return expression
def add_global(self, expression, prepend=False) -> "Statement": def add_global(self, expression, prepend=False) -> "Statement":
from esphome.cpp_generator import Expression, Statement, statement expression = self._as_statement(expression)
if isinstance(expression, Expression):
expression = statement(expression)
if not isinstance(expression, Statement):
raise ValueError(
f"Add '{expression}' must be expression or statement, not {type(expression)}"
)
if prepend: if prepend:
self.global_statements.insert(0, expression) self.global_statements.insert(0, expression)
else: else:
@@ -1066,6 +1066,13 @@ class EsphomeCore:
_LOGGER.debug("Adding global: %s", expression) _LOGGER.debug("Adding global: %s", expression)
return expression return expression
def add_global_after_includes(self, expression) -> "Statement":
"""Add a global after the user includes, for generated code that may use them."""
expression = self._as_statement(expression)
self.global_statements_after_includes.append(expression)
_LOGGER.debug("Adding global after includes: %s", expression)
return expression
def add_library(self, library: Library): def add_library(self, library: Library):
if not isinstance(library, Library): if not isinstance(library, Library):
raise TypeError( raise TypeError(
@@ -1245,7 +1252,7 @@ class EsphomeCore:
from esphome.cpp_generator import statement from esphome.cpp_generator import statement
global_code = [] global_code = []
for exp in self.global_statements: for exp in (*self.global_statements, *self.global_statements_after_includes):
text = str(statement(exp)) text = str(statement(exp))
text = text.rstrip() text = text.rstrip()
global_code.append(text) global_code.append(text)
+1 -1
View File
@@ -169,7 +169,7 @@ struct Color {
uint8_t r = rand >> 16; uint8_t r = rand >> 16;
uint8_t g = rand >> 8; uint8_t g = rand >> 8;
uint8_t b = rand >> 0; uint8_t b = rand >> 0;
const uint16_t max_rgb = std::max({r, g, b}); const uint16_t max_rgb = std::max({r, g, b, uint8_t(1)});
return Color(uint8_t((uint16_t(r) * 255U / max_rgb)), uint8_t((uint16_t(g) * 255U / max_rgb)), return Color(uint8_t((uint16_t(r) * 255U / max_rgb)), uint8_t((uint16_t(g) * 255U / max_rgb)),
uint8_t((uint16_t(b) * 255U / max_rgb)), w); uint8_t((uint16_t(b) * 255U / max_rgb)), w);
} }
+2 -1
View File
@@ -577,7 +577,8 @@ async def add_arduino_global_workaround():
@coroutine_with_priority(CoroPriority.FINAL) @coroutine_with_priority(CoroPriority.FINAL)
async def add_includes(includes: list[str], is_c_header: bool = False) -> None: async def add_includes(includes: list[str], is_c_header: bool = False) -> None:
# Add includes at the very end, so that the included files can access global variables # After the id declarations, so the headers can use id(x); the generated function bodies
# and flash tables come later still, so their lambdas can use the headers.
for include in includes: for include in includes:
path = CORE.relative_config_path(include) path = CORE.relative_config_path(include)
if path.is_dir(): if path.is_dir():
+19 -9
View File
@@ -318,6 +318,8 @@ class FloatLiteral(Literal):
def __str__(self): def __str__(self):
if math.isnan(self.f): if math.isnan(self.f):
return "NAN" return "NAN"
if math.isinf(self.f):
return "INFINITY" if self.f > 0 else "-INFINITY"
return f"{self.f}f" return f"{self.f}f"
@@ -506,8 +508,8 @@ def shared_progmem_array(
used |= {str(i) for i in CORE.variables} used |= {str(i) for i in CORE.variables}
used |= set(RESERVED_IDS) | CORE.loaded_integrations used |= set(RESERVED_IDS) | CORE.loaded_integrations
id_ = ID(ensure_unique_string(name, used), is_declaration=True, type=type_) id_ = ID(ensure_unique_string(name, used), is_declaration=True, type=type_)
# Global, so any scope can use it; anything a lambda references is already declared. # After the user includes, so a lambda in the table can use what their headers declare.
CORE.add_global(ProgmemAssignmentExpression(type_, id_, rhs)) CORE.add_global_after_includes(ProgmemAssignmentExpression(type_, id_, rhs))
array = MockObj(id_, ".") array = MockObj(id_, ".")
CORE.register_variable(id_, array) CORE.register_variable(id_, array)
if share: if share:
@@ -731,15 +733,16 @@ def static_function(
"""Emit ``static <return_type> <name>(parameters) { body }`` at global scope and return an """Emit ``static <return_type> <name>(parameters) { body }`` at global scope and return an
expression naming it, for use as a template argument or a function pointer. expression naming it, for use as a template argument or a function pointer.
Every id the body names must already be declared, which holds when the statements were The declaration goes with the other globals, so a template argument can name the function
rendered through ``get_variable`` or ``process_lambda``. at once; the body goes after the user includes, where every id and everything those
headers declare is in scope.
""" """
params = ParameterListExpression(*parameters) signature = (
add_global( f"static {safe_exp(return_type)} {name}({ParameterListExpression(*parameters)})"
RawStatement(
f"static {safe_exp(return_type)} {name}({params}) {{\n"
f"{indent(chr(10).join(body))}\n}}"
) )
add_global(RawStatement(f"{signature};"))
add_global_after_includes(
RawStatement(f"{signature} {{\n{indent(chr(10).join(body))}\n}}")
) )
return RawExpression(name) return RawExpression(name)
@@ -758,6 +761,13 @@ def add_global(expression: SafeExpType | Statement, prepend: bool = False):
CORE.add_global(expression, prepend) CORE.add_global(expression, prepend)
def add_global_after_includes(expression: SafeExpType | Statement):
"""Add a global after the user's ``includes:`` headers, for generated code whose
lambdas may use what those headers declare.
"""
CORE.add_global_after_includes(expression)
def add_library(name: str, version: str | None, repository: str | None = None): def add_library(name: str, version: str | None, repository: str | None = None):
"""Add a library to the codegen library storage. """Add a library to the codegen library storage.
+12 -2
View File
@@ -266,14 +266,24 @@ def _convert_pio_libs(
in the real esp32 build; converting them too would give IDF the same requirement in the real esp32 build; converting them too would give IDF the same requirement
twice. Arduino below IDF 6.0 has them rule-gated off, so there they convert. twice. Arduino below IDF 6.0 has them rule-gated off, so there they convert.
""" """
from esphome.components.esp32 import arduino_bundles_libsodium from esphome.components.esp32 import (
ARDUINO_DISABLED_LIBRARIES,
arduino_bundles_libsodium,
)
from esphome.espidf.component import generate_idf_components from esphome.espidf.component import generate_idf_components
libraries = _parse_lib_deps(platformio_ini, framework) libraries = _parse_lib_deps(platformio_ini, framework)
# Same rule as noise._use_managed_components and the manifest # Same rule as noise._use_managed_components and the manifest
managed = set() if arduino_bundles_libsodium() else _esphome_manifest_deps() managed = set() if arduino_bundles_libsodium() else _esphome_manifest_deps()
deps: dict[str, dict[str, str]] = {} deps: dict[str, dict[str, str]] = {}
for component in generate_idf_components(libraries, managed=managed): # As in the real esp32 build, a dependency on an Arduino-bundled library is
# left to arduino-esp32 instead of being fetched from the registry
provides = (
ARDUINO_DISABLED_LIBRARIES.__contains__ if framework == "arduino" else None
)
for component in generate_idf_components(
libraries, managed=managed, provides=provides
):
deps[component.get_sanitized_name()] = {"override_path": str(component.path)} deps[component.get_sanitized_name()] = {"override_path": str(component.path)}
return deps return deps
+7 -1
View File
@@ -7,6 +7,7 @@ for each resolved library, running any PlatformIO ``extraScript``, and the
ESP-IDF platform/framework compatibility defaults. ESP-IDF platform/framework compatibility defaults.
""" """
from collections.abc import Callable
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
@@ -288,18 +289,23 @@ def _emit_idf_component(component: IDFComponent) -> None:
def generate_idf_components( def generate_idf_components(
libraries: list[Library], managed: set[str] | None = None libraries: list[Library],
managed: set[str] | None = None,
provides: Callable[[str], bool] | None = None,
) -> list[IDFComponent]: ) -> list[IDFComponent]:
"""Resolve and convert a batch of PlatformIO libraries to IDF components. """Resolve and convert a batch of PlatformIO libraries to IDF components.
``managed`` names registry components already in the project manifest; they ``managed`` names registry components already in the project manifest; they
are not converted, since IDF rejects the same requirement from two components. are not converted, since IDF rejects the same requirement from two components.
Converted components reach them through ``${ESPHOME_PROJECT_MANAGED_COMPONENTS}``. Converted components reach them through ``${ESPHOME_PROJECT_MANAGED_COMPONENTS}``.
``provides`` names the libraries the framework bundles, so a manifest dependency on
one is never fetched from the registry.
""" """
backend = LibraryBackend( backend = LibraryBackend(
platform=ESP32_PLATFORM, platform=ESP32_PLATFORM,
framework=_idf_framework(), framework=_idf_framework(),
emit=_emit_idf_component, emit=_emit_idf_component,
cache_key="idf", cache_key="idf",
provides=provides,
) )
return convert_libraries(libraries, backend, provided=managed) return convert_libraries(libraries, backend, provided=managed)
+22 -8
View File
@@ -205,28 +205,42 @@ def run_command(
return False, None, None return False, None, None
def tool_version_runs(binary: str, warning: str) -> bool: def tool_version_probe(binary: str) -> str | Exception:
"""Probe ``binary --version``; on failure warn with ``warning`` % binary. """Run ``binary --version``: its stdout, or the error when it fails to run.
``shutil.which`` proves existence, not runnability (Windows .bat/.cmd ``shutil.which`` proves existence, not runnability (Windows .bat/.cmd
shims, stale package-manager shims). shims, stale package-manager shims).
""" """
try: try:
subprocess.run( result = subprocess.run(
[binary, "--version"], [binary, "--version"],
check=True, check=True,
stdout=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
text=True,
errors="replace",
timeout=15, timeout=15,
# Repo-wide convention (posix_spawn fast path) # Repo-wide convention (posix_spawn fast path)
close_fds=False, close_fds=False,
) )
except (OSError, subprocess.SubprocessError) as err: except (OSError, subprocess.SubprocessError) as err:
# The cause (permission denied, missing DLL, timeout) is the one return err
# detail the user needs to fix it return result.stdout
_LOGGER.warning("%s (%s)", warning % binary, err)
return False
def tool_version_runs(
binary: str,
warning: str,
probe: Callable[[str], str | Exception] = tool_version_probe,
) -> bool:
"""Probe ``binary --version``; on failure warn with ``warning`` % binary.
``probe`` lets a caller supply a cached probe."""
if not isinstance(result := probe(binary), Exception):
return True return True
# The cause (permission denied, missing DLL, timeout) is the one detail
# the user needs to fix it
_LOGGER.warning("%s (%s)", warning % binary, result)
return False
def run_command_ok(*args, **kwargs) -> bool: def run_command_ok(*args, **kwargs) -> bool:
+248 -98
View File
@@ -609,9 +609,34 @@ def parse_library_properties(library_properties_path: PathType):
if not value.strip(): if not value.strip():
continue continue
data[key.strip()] = value.strip() data[key.strip()] = value.strip()
if depends := data.pop("depends", None):
data["dependencies"] = parse_properties_depends(depends)
return data return data
def parse_properties_depends(depends: str) -> list[dict[str, str]]:
"""The Arduino ``depends=Name, Other (>=1.0)`` line as name-only dependency entries.
An Arduino constraint is not PlatformIO syntax, so it is dropped; the name is
satisfied by a listed library or the registry's current version, as the walk
does for every version-less dependency.
"""
entries: list[dict[str, str]] = []
for item in depends.split(","):
name, _, constraint = item.strip().partition("(")
if not (name := name.strip()):
continue
if constraint:
# A plain lower bound is met by the registry's current version; an
# exact or capped one may not be, so say so
log = (
_LOGGER.debug if constraint.lstrip().startswith(">=") else _LOGGER.info
)
log("Ignoring version constraint on depends=%s", item.strip())
entries.append({"name": name})
return entries
def _make_registry_client() -> Any: def _make_registry_client() -> Any:
"""Create a minimal PlatformIO registry client with no system filtering. """Create a minimal PlatformIO registry client with no system filtering.
@@ -730,20 +755,6 @@ def join_flag_args(tokens: Iterable[str], owner: str) -> list[str]:
return out return out
def warn_properties_depends(name: str, data: object) -> None:
"""Warn for ``depends=``-only manifests; the walk reads only the JSON
``dependencies`` key, so they would otherwise drop silently."""
if isinstance(data, dict) and not data.get("dependencies") and data.get("depends"):
# INFO: common and unactionable for transitive libraries; a WARNING
# on every build would train users to ignore the stream
_LOGGER.info(
"Library %s declares dependencies via library.properties "
"depends=, which are not resolved automatically; add them with "
"add_library() if needed",
name,
)
def dependency_is_usable( def dependency_is_usable(
dep: dict, platform: str | None, framework: str, requester: str dep: dict, platform: str | None, framework: str, requester: str
) -> bool: ) -> bool:
@@ -762,6 +773,20 @@ def dependency_is_usable(
return True return True
def _split_owner(entry: dict) -> dict:
"""``owner/Name`` written as one name becomes owner and name, as in the dict form,
so the name matches the package the registry resolves it to."""
name, owner = entry.get("name"), entry.get("owner")
if isinstance(name, str) and owner is None and "://" not in name:
head, sep, tail = name.partition("/")
# A registry owner is a plain identifier; anything else is left to the
# backend's own checks
owner_ok = head.replace("-", "").replace("_", "").isalnum()
if sep and owner_ok and tail and "/" not in tail:
return {**entry, "owner": head, "name": tail}
return entry
def _valid_dependency_entry(entry: dict, manifest_name: str) -> bool: def _valid_dependency_entry(entry: dict, manifest_name: str) -> bool:
"""Whether a normalized entry carries a usable name (non-empty string), """Whether a normalized entry carries a usable name (non-empty string),
version (string, if present), and owner (string, if present); invalid version (string, if present), and owner (string, if present); invalid
@@ -790,16 +815,12 @@ def normalize_dependencies(
so callers see a uniform list. ``manifest_name`` names the manifest in the so callers see a uniform list. ``manifest_name`` names the manifest in the
warning for entries that cannot be normalized. warning for entries that cannot be normalized.
Bare-name spellings carry no version; the dependency walk later drops Bare-name spellings carry no version; the walk settles them once the graph is known.
version-less entries at DEBUG, since they are usually names of bundled
framework libraries (Wire, SPI) that need no registry install.
""" """
if dependencies is None: if dependencies is None:
return [] return []
if isinstance(dependencies, str): if isinstance(dependencies, str):
# A plain string is one or more comma-separated names; iterating it return [_split_owner(entry) for entry in parse_properties_depends(dependencies)]
# as a list would shred it into one-character "libraries"
return [{"name": n.strip()} for n in dependencies.split(",") if n.strip()]
if isinstance(dependencies, dict): if isinstance(dependencies, dict):
normalized = [] normalized = []
for raw_name, spec in dependencies.items(): for raw_name, spec in dependencies.items():
@@ -826,10 +847,10 @@ def normalize_dependencies(
for entry in dependencies: for entry in dependencies:
if isinstance(entry, dict): if isinstance(entry, dict):
if _valid_dependency_entry(entry, manifest_name): if _valid_dependency_entry(entry, manifest_name):
normalized.append(entry) normalized.append(_split_owner(entry))
elif isinstance(entry, str) and entry: elif isinstance(entry, str) and entry:
# PIO also accepts a bare list of names ("dependencies": ["Wire"]) # PIO also accepts a bare list of names ("dependencies": ["Wire"])
normalized.append({"name": entry}) normalized.append(_split_owner({"name": entry}))
else: else:
_LOGGER.warning( _LOGGER.warning(
"Ignoring unrecognized dependency entry %r of %s", "Ignoring unrecognized dependency entry %r of %s",
@@ -971,54 +992,61 @@ def is_lib_ignored(name: str | None, lib_ignore: set[str]) -> bool:
) )
def _reconcile_versionless_skips( def _short_name(node: _LibNode) -> str:
skipped_versionless: list[tuple[Any, Any, str]], """The package name a node goes by: the registry name, else the last segment
of its key (the repository or directory name of a git or local source)."""
return node.pkgname or node.key.rpartition("/")[2]
def _satisfying_key(
name: str,
owner: str | None,
requester: str,
nodes: dict[str, _LibNode],
components: dict[str, ConvertedLibrary], components: dict[str, ConvertedLibrary],
backend: LibraryBackend, ) -> tuple[str, bool] | None:
) -> None: """The key of a resolved library a version-less dependency names, and whether
"""Warn for version-less deps nothing satisfied, and record the only its manifest name matched: the registry package name (with the owner,
backend-provided ones in ``backend.provided_requests`` for its when the dependency gives one) is checked first; never the requester itself."""
post-emit reconciliation; a silent drop surfaces as link errors far wanted = name.lower()
from the cause."""
resolved_manifest_names = {c.data.get("name") for c in components.values()} def owner_of(key: str, component: ConvertedLibrary) -> str:
# A treeless backend can never supply a bundled name; noise for it # The owner as requested, else the one the registry (or a git path) gave
log = _LOGGER.warning if backend.provides is not None else _LOGGER.debug head, sep, _ = component.name.partition("/")
warned: set[str] = set() return nodes[key].owner or (head if sep else "")
for dep_name, dep_owner, requester in skipped_versionless:
if not isinstance(dep_name, str) or not dep_name or dep_name in warned: candidates = [
continue (key, component)
if dep_name in components: for key, component in components.items()
# A version-less dep's request key is the name itself if key != requester
continue and (not owner or owner_of(key, component).lower() == owner.lower())
if ( ]
not dep_owner for key, _ in candidates:
and backend.provides is not None if _short_name(nodes[key]).lower() == wanted:
and backend.provides(dep_name) return key, False
): if owner:
# provides() only satisfies owner-less names (same guard as return None
# the walk's skip); record for the post-emit reconciliation. for key, component in candidates:
# Checked before the manifest-name evidence so the overlap manifest_name = component.data.get("name")
# case warns once, in the backend's own suppression loop if isinstance(manifest_name, str) and manifest_name.lower() == wanted:
backend.provided_requests.add(dep_name) return key, True
continue return None
if dep_name in resolved_manifest_names:
# Name-only evidence: a coincidental collision must stay
# visible where the user could pin it def _own_names(
warned.add(dep_name) key: str, nodes: dict[str, _LibNode], components: dict[str, ConvertedLibrary]
log( ) -> set[str]:
"Version-less dependency %s of %s assumed satisfied by a " """The lower-case key, requested and resolved package names and the manifest
"resolved library's manifest name only", name a resolved library goes by."""
dep_name, names = {
requester, key.lower(),
) _short_name(nodes[key]).lower(),
continue components[key].name.lower(),
warned.add(dep_name) }
log( manifest_name = components[key].data.get("name")
"Dependency %s of %s has no version to resolve and nothing " if isinstance(manifest_name, str):
"provides it; skipping", names.add(manifest_name.lower())
dep_name, return names
requester,
)
def _fetch_source( def _fetch_source(
@@ -1147,7 +1175,12 @@ def convert_libraries(
managed components from ``add_idf_component``); unlike ``backend.provides`` they managed components from ``add_idf_component``); unlike ``backend.provides`` they
carry an owner. They are excluded like ``lib_ignore`` so nothing is both carry an owner. They are excluded like ``lib_ignore`` so nothing is both
converted and managed, which ESP-IDF refuses to build. converted and managed, which ESP-IDF refuses to build.
A dependency named without a version is settled once the graph is known: wired
to a library already in it, left to ``backend.provides``, or fetched by name.
""" """
from platformio.package.exception import UnknownPackageError
nodes: dict[str, _LibNode] = {} nodes: dict[str, _LibNode] = {}
# Folded into one set so every is_lib_ignored() call site honors both. # Folded into one set so every is_lib_ignored() call site honors both.
@@ -1165,10 +1198,16 @@ def convert_libraries(
else "" else ""
) )
resolved_requirements: dict[str, frozenset[str]] = {}
def add_spec(name: str | None, version: str | None, repository: str | None) -> str: def add_spec(name: str | None, version: str | None, repository: str | None) -> str:
key, kind, locator = _node_key(name, version, repository) key, kind, locator = _node_key(name, version, repository)
node = nodes.get(key) or _LibNode(key=key, is_git=kind == "git") node = nodes.get(key) or _LibNode(key=key, is_git=kind == "git")
nodes[key] = node nodes[key] = node
if kind != "registry" and not (node.is_git or node.is_local):
# A registry node (perhaps one the registry did not know) becomes a
# git or local source: resolve it again from that source
resolved_requirements.pop(key, None)
# The same key requested from two different kinds of source (or two # The same key requested from two different kinds of source (or two
# different local paths) is a config mistake: one silently wins. Warn so # different local paths) is a config mistake: one silently wins. Warn so
# it isn't a surprise. (git-vs-registry is reported separately below.) # it isn't a surprise. (git-vs-registry is reported separately below.)
@@ -1220,10 +1259,73 @@ def convert_libraries(
# requirement set has grown since the last time, so every requirement in the # requirement set has grown since the last time, so every requirement in the
# graph is accounted for before conversion. # graph is accounted for before conversion.
components: dict[str, ConvertedLibrary] = {} components: dict[str, ConvertedLibrary] = {}
resolved_requirements: dict[str, frozenset[str]] = {}
top_level_keys = set(top_level) top_level_keys = set(top_level)
# (name, owner, requester) reconciled against the final resolution set
skipped_versionless: list[tuple[Any, Any, str]] = [] def fetched_by_name(key: str) -> bool:
"""A registry node only version-less manifest dependencies asked for: a
miss skips it, and an explicit library of the same name replaces it."""
node = nodes[key]
return (
key not in top_level_keys
and not (node.is_git or node.is_local)
and not node.requirements
)
def backend_provides(name: str, owner: str | None) -> bool:
"""Whether the framework bundles an owner-less name; asked once per name,
since the backend may warn, and recorded in ``provided_requests``."""
if owner or backend.provides is None:
return False
if name in backend.provided_requests or backend.provides(name):
backend.provided_requests.add(name)
return True
return False
# Version-less dependencies per requester key, kept for the whole walk and
# wired once the graph is final; a re-walked requester replaces its entries
pending_versionless: dict[str, list[dict]] = {}
# Names fetched by name so far: each is asked for once
fetched: dict[tuple[str, str], str] = {}
def spelling_of(name: str, owner: str | None) -> tuple[str, str]:
"""Registry names fold case; a URL names its source as written."""
return (name if "://" in name else name.lower(), (owner or "").lower())
def settle_versionless() -> list[str]:
"""Fetch by name the deferred dependencies nothing in the graph or the
framework satisfies yet; return the new keys to walk. Only a dependency
on the requester itself or with a bad name needs no wiring later."""
new_keys: list[str] = []
for requester, dependencies in pending_versionless.items():
kept: list[dict] = []
for dependency in dependencies:
name, owner = dependency["name"], dependency.get("owner")
if _owner_pkgname_to_name(owner, name).lower() in _own_names(
requester, nodes, components
):
_LOGGER.debug("Dependency %s of %s names itself", name, requester)
continue
# A library in the graph beats a bundled copy, as on the Arduino backend
if _satisfying_key(
name, owner, requester, nodes, components
) is None and not backend_provides(name, owner):
spelling = spelling_of(name, owner)
if spelling not in fetched:
try:
dep_key = add_spec(
_owner_pkgname_to_name(owner, name), None, None
)
except RuntimeError as e:
_LOGGER.warning(
"Skipping dependency %s of %s: %s", name, requester, e
)
continue
fetched[spelling] = dep_key
new_keys.append(dep_key)
kept.append(dependency)
dependencies[:] = kept
return new_keys
worklist = deque(dict.fromkeys(top_level)) worklist = deque(dict.fromkeys(top_level))
while worklist: while worklist:
# Drain the frontier sequentially (spec resolution mutates shared # Drain the frontier sequentially (spec resolution mutates shared
@@ -1245,9 +1347,17 @@ def convert_libraries(
elif node.is_local: elif node.is_local:
component = ConvertedLibrary(key, "*", LocalSource(node.local_path)) component = ConvertedLibrary(key, "*", LocalSource(node.local_path))
else: else:
try:
owner, name, version, url, size = _resolve_registry_version( owner, name, version, url, size = _resolve_registry_version(
node.owner, node.pkgname, node.requirements node.owner, node.pkgname, node.requirements
) )
except UnknownPackageError as e:
# A requirement conflict is a RuntimeError and always raises
if not fetched_by_name(key):
raise
# Reported per requester once the graph is final
_LOGGER.debug("Library %s is not in the registry: %s", key, e)
continue
component = ConvertedLibrary( component = ConvertedLibrary(
_owner_pkgname_to_name(owner, name), version, URLSource(url, size) _owner_pkgname_to_name(owner, name), version, URLSource(url, size)
) )
@@ -1255,9 +1365,9 @@ def convert_libraries(
_prefetch_wave(wave, salt, backend.cache_key) _prefetch_wave(wave, salt, backend.cache_key)
for key, component in wave: for key, component in wave:
node = nodes[key] node = nodes[key]
if frozenset(node.requirements) != resolved_requirements[key]: if frozenset(node.requirements) != resolved_requirements.get(key):
# Requirements grew mid-wave: skip parsing a manifest the # Requirements grew (or the source changed) mid-wave: skip parsing
# next wave will re-resolve and replace # a manifest the next wave will re-resolve and replace
worklist.append(key) worklist.append(key)
continue continue
component.download(salt=salt, namespace=backend.cache_key) component.download(salt=salt, namespace=backend.cache_key)
@@ -1313,8 +1423,6 @@ def convert_libraries(
raise EsphomeError(f"Library {key} has a malformed manifest") raise EsphomeError(f"Library {key} has a malformed manifest")
_LOGGER.warning("Skipping dependency %s: malformed manifest", key) _LOGGER.warning("Skipping dependency %s: malformed manifest", key)
continue continue
warn_properties_depends(component.name, component.data)
try: try:
check_library_data(component.data, backend.platform, backend.framework) check_library_data(component.data, backend.platform, backend.framework)
except InvalidLibrary as e: except InvalidLibrary as e:
@@ -1337,27 +1445,19 @@ def convert_libraries(
# Requirements changed (we got past the short-circuit above), so # Requirements changed (we got past the short-circuit above), so
# (re)walk this component's dependencies. # (re)walk this component's dependencies.
node.edges = set() node.edges = set()
pending = pending_versionless[key] = []
for dependency in normalize_dependencies( for dependency in normalize_dependencies(
component.data.get("dependencies"), component.name component.data.get("dependencies"), component.name
): ):
if "version" not in dependency: if not dependency.get("version"):
# Cannot resolve from the registry; the post-emit # An empty version is as good as none; it settles the same way
# reconciliation owns the drop warning
dep_name = dependency.get("name")
_LOGGER.debug(
"Skip version-less dependency %r of %s",
dep_name,
component.name,
)
if not is_lib_ignored( if not is_lib_ignored(
dep_name, lib_ignore dependency.get("name"), lib_ignore
) and dependency_is_usable( ) and dependency_is_usable(
dependency, backend.platform, backend.framework, component.name dependency, backend.platform, backend.framework, component.name
): ):
# Filtered or ignored deps are deliberately absent # Filtered or ignored deps are deliberately absent
skipped_versionless.append( pending.append(dependency)
(dep_name, dependency.get("owner"), component.name)
)
continue continue
if not dependency_is_usable( if not dependency_is_usable(
dependency, backend.platform, backend.framework, component.name dependency, backend.platform, backend.framework, component.name
@@ -1375,11 +1475,7 @@ def convert_libraries(
dep_url = _url_or_none(dep_version) dep_url = _url_or_none(dep_version)
if dep_url is not None: if dep_url is not None:
dep_version = None dep_version = None
elif ( elif backend_provides(dep_name, dependency.get("owner")):
backend.provides is not None
and not dependency.get("owner")
and backend.provides(dep_name)
):
# The backend adds it from its own tree; resolving here # The backend adds it from its own tree; resolving here
# would fetch a same-named registry package # would fetch a same-named registry package
if dep_version and dep_version != "*": if dep_version and dep_version != "*":
@@ -1392,11 +1488,12 @@ def convert_libraries(
) )
else: else:
_LOGGER.debug("Skip backend-provided dependency %s", dep_name) _LOGGER.debug("Skip backend-provided dependency %s", dep_name)
backend.provided_requests.add(dep_name)
continue continue
dep_key = add_spec(dep_name, dep_version, dep_url) dep_key = add_spec(dep_name, dep_version, dep_url)
node.edges.add(dep_key) node.edges.add(dep_key)
worklist.append(dep_key) worklist.append(dep_key)
if not worklist:
worklist.extend(settle_versionless())
# A git or local source wins over the same component requested from the # A git or local source wins over the same component requested from the
# registry. That's intentional, but warn so the dropped registry spec isn't # registry. That's intentional, but warn so the dropped registry spec isn't
@@ -1420,6 +1517,61 @@ def convert_libraries(
source, source,
) )
# Every version-less request resolves to the explicit library that goes by
# its name if the final graph has one, else to the node fetched by name for
# that package, else to the framework's bundled copy, else to nothing
explicit = {k: c for k, c in components.items() if not fetched_by_name(k)}
by_name = {k: c for k, c in components.items() if fetched_by_name(k)}
unresolved: dict[tuple[str, str], None] = {}
for requester, dependencies in pending_versionless.items():
for dependency in dependencies:
name, owner = dependency["name"], dependency.get("owner")
found = _satisfying_key(
name, owner, requester, nodes, explicit
) or _satisfying_key(name, owner, requester, nodes, by_name)
if found is None:
# A dependency written as a URL goes by the key its source gave it
key = fetched.get(spelling_of(name, owner))
if key in components and key != requester:
found = (key, False)
if found is None:
if not backend_provides(name, owner):
unresolved[(requester, name)] = None
continue
key, by_manifest_name = found
if by_manifest_name:
# Visible, so a chance name collision can be pinned to the right package
_LOGGER.info(
"Dependency %s of %s satisfied by %s through its manifest name",
name,
requester,
key,
)
else:
_LOGGER.debug(
"Dependency %s of %s satisfied by %s", name, requester, key
)
nodes[requester].edges.add(key)
# What no listed library needs any more is not built: a node fetched by name
# that an explicit library replaced, and whatever only it pulled in
reachable: set[str] = set()
stack = [key for key in top_level if key in components]
while stack:
if (key := stack.pop()) in reachable:
continue
reachable.add(key)
stack.extend(edge for edge in nodes[key].edges if edge in components)
for key in [key for key in components if key not in reachable]:
_LOGGER.debug("Library %s is not needed; dropping it", key)
del components[key]
for requester, name in unresolved:
if requester in components:
_LOGGER.warning(
"Dependency %s of %s was not resolved; skipping", name, requester
)
# Two graph nodes that resolve to the same component name (e.g. a package # Two graph nodes that resolve to the same component name (e.g. a package
# referenced both bare and as ``owner/name``) are not deduplicated and can # referenced both bare and as ``owner/name``) are not deduplicated and can
# produce conflicting component definitions. Warn so it's not silent. # produce conflicting component definitions. Warn so it's not silent.
@@ -1447,6 +1599,4 @@ def convert_libraries(
for component in components.values(): for component in components.values():
backend.emit(component) backend.emit(component)
_reconcile_versionless_skips(skipped_versionless, components, backend)
return [components[key] for key in top_level if key in components] return [components[key] for key in top_level if key in components]
+11
View File
@@ -190,8 +190,19 @@ class RedirectText:
"""Whether output is handled line by line rather than passed through.""" """Whether output is handled line by line rather than passed through."""
return self._filter_pattern is not None or bool(self._line_callbacks) return self._filter_pattern is not None or bool(self._line_callbacks)
def _drop_line(self, line: str) -> None:
"""Leave out a filtered line, keeping the color codes it carried.
cmake ends a warning with the reset code on a line of its own; dropped
with the blank lines, it would leave everything after it in the
warning's color.
"""
if codes := "".join(ANSI_ESCAPE.findall(line)):
self._write_color_replace(codes)
def _emit_line(self, line: str) -> None: def _emit_line(self, line: str) -> None:
if self._is_filtered(line): if self._is_filtered(line):
self._drop_line(line)
return return
line_without_end = ANSI_ESCAPE.sub("", line).rstrip() line_without_end = ANSI_ESCAPE.sub("", line).rstrip()
+2 -2
View File
@@ -147,7 +147,7 @@ extends = common:arduino
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip
platform_packages = platform_packages =
pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.12/esp32-core-3.3.12.tar.xz pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.12/esp32-core-3.3.12.tar.xz
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.6/esp-idf-v5.5.6.tar.xz
framework = arduino, espidf ; Arduino as an ESP-IDF component framework = arduino, espidf ; Arduino as an ESP-IDF component
lib_deps = lib_deps =
@@ -183,7 +183,7 @@ extra_scripts =
extends = common:idf extends = common:idf
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip
platform_packages = platform_packages =
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.6/esp-idf-v5.5.6.tar.xz
framework = espidf framework = espidf
lib_deps = lib_deps =
+1 -1
View File
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.2.0 platformio==6.2.0
esptool==5.4.0 esptool==5.4.0
click==8.5.0 click==8.5.0
aioesphomeapi==46.10.0 aioesphomeapi==46.12.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.151.5 zeroconf==0.151.5
puremagic==2.2.0 puremagic==2.2.0
@@ -0,0 +1,22 @@
esphome:
name: test
esp32:
board: esp32dev
spi:
clk_pin: GPIO18
mosi_pin: GPIO19
display:
- platform: epaper_spi
id: epaper_display
model: ssd1677
dc_pin: GPIO21
busy_pin: GPIO22
reset_pin: GPIO23
cs_pin: GPIO5
update_interval: never
dimensions:
width: 200
height: 200
@@ -580,3 +580,15 @@ def test_model_with_no_default_init_sequence_generates(
# init_sequence_length, ...); a length of 0 confirms the empty init # init_sequence_length, ...); a length of 0 confirms the empty init
# sequence array was generated instead of raising during code generation. # sequence array was generated instead of raising during code generation.
assert re.search(r"epaper_spi::EPaperT133A01\([^;]*,\s*\w+,\s*0\);", main_cpp) assert re.search(r"epaper_spi::EPaperT133A01\([^;]*,\s*\w+,\s*0\);", main_cpp)
def test_test_card_overrides_update_interval(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""With nothing else to draw, update_interval is not left at never for the test card."""
main_cpp = generate_main(component_config_path("test_card_update_interval.yaml"))
match = re.search(r"epaper_display->set_update_interval\((\d+)\);", main_cpp)
assert match is not None
assert int(match.group(1)) != 4294967295
@@ -93,12 +93,12 @@ def test_transform_matches_model_support(
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS
# ESP32-8048S070 supports both mirror axes but not swap_xy (RGB displays # The full-height ST7701S supports both mirror axes but not swap_xy (RGB displays
# never support axis swapping). # never support axis swapping).
model = MODELS["ESP32-8048S070"] model = MODELS["ST7701S"]
assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y} assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y}
base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21} base = {"model": "ST7701S", "data_pins": DATA_PINS, "pclk_pin": 21}
CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": False}}) CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": False}})
# An unsupported axis may be explicitly disabled (a harmless no-op)... # An unsupported axis may be explicitly disabled (a harmless no-op)...
@@ -139,6 +139,35 @@ def test_st7701s_only_supports_mirror_x(
CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": True}}) CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": True}})
def test_pure_rgb_model_has_no_hardware_transform(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A model with no init sequence has no command interface, so no transform
can be applied in hardware and every rotation must be done in software.
Previously such models claimed mirror_x/mirror_y support, so a rotation of
180 was handed to the (nonexistent) hardware and the display was not rotated.
"""
_set_s3(set_core_config)
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS
model = MODELS["ESP32-8048S070"]
assert model.initsequence == ()
assert model.transforms == set()
base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21}
for rotation in (90, 180, 270):
config = CONFIG_SCHEMA({**base, "id": f"rot_{rotation}", "rotation": rotation})
assert not model.rotation_as_transform(config)
metadata = get_display_metadata(config["id"])
assert metadata.rotation == rotation
assert not metadata.has_hardware_rotation
with pytest.raises(cv.Invalid, match="'mirror_x' is not supported by this model"):
CONFIG_SCHEMA({**base, "transform": {"mirror_x": True}})
def test_metadata_records_rotation( def test_metadata_records_rotation(
set_core_config: SetCoreConfigCallable, set_core_config: SetCoreConfigCallable,
) -> None: ) -> None:
+8
View File
@@ -100,6 +100,14 @@ button:
id: climate_test_thermostat id: climate_test_thermostat
custom_fan_mode: !lambda return "quiet"; custom_fan_mode: !lambda return "quiet";
custom_preset: !lambda return "Default"; custom_preset: !lambda return "Default";
# Inlined lambdas returning a StringRef, the shape an api action string variable takes (issue #21327)
- platform: template
name: "Climate Control StringRef Lambdas"
on_press:
- climate.control:
id: climate_test_thermostat
custom_fan_mode: !lambda return id(climate_test_thermostat).get_custom_fan_mode();
custom_preset: !lambda return id(climate_test_thermostat).get_custom_preset();
# Exercise climate.control inside a trigger with non-empty Ts (number on_value # Exercise climate.control inside a trigger with non-empty Ts (number on_value
# passes float). # passes float).
+15
View File
@@ -43,6 +43,21 @@ button:
url: http://my.ha.net:8123/local/esphome/firmware.bin url: http://my.ha.net:8123/local/esphome/firmware.bin
- logger.log: "This message should be not displayed (reboot)" - logger.log: "This message should be not displayed (reboot)"
# Device firmware switches the manifest from lambdas; every shape below must compile and
# the stored URL must outlive the lambda (EverythingSmartHome/everything-presence-one#341).
- platform: template
name: Switch manifest
on_press:
then:
- lambda: |-
id(ota_update).set_source_url("http://my.ha.net:8123/local/esphome/beta-manifest.json");
std::string url = "http://my.ha.net:8123/local/esphome/";
url += "manifest.json";
id(ota_update).set_source_url(url);
id(ota_update).set_source_url(url.c_str());
id(ota_update).set_source_url(std::string("http://my.ha.net:8123/local/esphome/manifest.json"));
ESP_LOGI("test", "Manifest is now %s", id(ota_update).get_source_url());
- component.update: ota_update
update: update:
- platform: http_request - platform: http_request
@@ -0,0 +1,8 @@
import esphome.config_validation as cv
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The real schema rejects the host target by framework version; the update platform's
# unit tests only need this platform's sources on the host.
manifest.config_schema = cv.Schema({}, extra=cv.ALLOW_EXTRA)
@@ -0,0 +1,141 @@
#include <gtest/gtest.h>
#include <string>
#include "esphome/components/http_request/update/http_request_update.h"
#if defined(__SANITIZE_ADDRESS__)
#define HAS_ASAN
#elif defined(__has_feature)
#if __has_feature(address_sanitizer)
#define HAS_ASAN
#endif
#endif
#ifdef HAS_ASAN
#include <sanitizer/asan_interface.h>
#endif
namespace esphome::http_request::testing {
namespace {
constexpr char LITERAL[] = "http://example.invalid/manifest.json";
// Freed heap memory is poisoned under AddressSanitizer, which CI builds with; without it the
// freed-exactly-once checks cannot be observed and are skipped.
void expect_freed(const char *buffer) {
#ifdef HAS_ASAN
EXPECT_TRUE(__asan_address_is_poisoned(buffer));
#endif
}
void expect_live(const char *buffer, const char *content) {
#ifdef HAS_ASAN
EXPECT_FALSE(__asan_address_is_poisoned(buffer));
#endif
EXPECT_STREQ(buffer, content);
}
} // namespace
// Friend of HttpRequestUpdate, so a check can be begun and ended without an HTTP fetch.
class HttpRequestUpdateTest : public ::testing::Test {
protected:
static void begin_check(HttpRequestUpdate &update, TaskResult &result) { update.begin_check_(&result); }
static void end_check(HttpRequestUpdate &update, TaskResult &result) { update.end_check_(&result); }
};
TEST(HttpRequestUpdateSourceUrl, ConfigLiteralIsKeptByPointer) {
HttpRequestUpdate update;
update.set_source_url_static(LITERAL);
EXPECT_EQ(update.get_source_url(), LITERAL);
}
TEST(HttpRequestUpdateSourceUrl, RuntimeStringIsCopied) {
HttpRequestUpdate update;
std::string url = "http://example.invalid/from-string-manifest.json";
update.set_source_url(url);
EXPECT_NE(update.get_source_url(), url.c_str());
EXPECT_STREQ(update.get_source_url(), url.c_str());
}
// Device firmware passes base_url.c_str() of a local and runs the update after the lambda returned.
TEST(HttpRequestUpdateSourceUrl, PointerIntoDeadLocalStaysReadable) {
HttpRequestUpdate update;
{
std::string local = "http://example.invalid/from-local-manifest.json";
update.set_source_url(local.c_str());
}
EXPECT_STREQ(update.get_source_url(), "http://example.invalid/from-local-manifest.json");
}
TEST(HttpRequestUpdateSourceUrl, PassingTheCurrentUrlBackIsSafe) {
HttpRequestUpdate update;
update.set_source_url("http://example.invalid/current-manifest.json");
const char *previous = update.get_source_url();
update.set_source_url(update.get_source_url());
EXPECT_STREQ(update.get_source_url(), "http://example.invalid/current-manifest.json");
expect_freed(previous);
}
TEST(HttpRequestUpdateSourceUrl, ConfigLiteralAfterRuntimeUrlDropsTheCopy) {
HttpRequestUpdate update;
update.set_source_url("http://example.invalid/runtime-manifest.json");
const char *copy = update.get_source_url();
update.set_source_url_static(LITERAL);
EXPECT_EQ(update.get_source_url(), LITERAL);
expect_freed(copy);
}
// The URL a check started with stays readable until the check ends, however often it is replaced in
// the meantime; the replacements it never saw are freed at once and the original exactly once.
TEST_F(HttpRequestUpdateTest, UrlReplacedDuringACheckOutlivesIt) {
HttpRequestUpdate update;
update.set_source_url("http://example.invalid/first-manifest.json");
const char *first = update.get_source_url();
TaskResult result;
begin_check(update, result);
EXPECT_EQ(result.source_url, first);
update.set_source_url("http://example.invalid/second-manifest.json");
const char *second = update.get_source_url();
expect_live(first, "http://example.invalid/first-manifest.json");
update.set_source_url("http://example.invalid/third-manifest.json");
const char *third = update.get_source_url();
expect_live(first, "http://example.invalid/first-manifest.json");
expect_freed(second);
end_check(update, result);
expect_freed(first);
expect_live(third, "http://example.invalid/third-manifest.json");
// The entity owns the URL set during the check: switching away frees it.
update.set_source_url_static(LITERAL);
expect_freed(third);
}
TEST_F(HttpRequestUpdateTest, UrlUnchangedDuringACheckReturnsToTheEntity) {
HttpRequestUpdate update;
update.set_source_url("http://example.invalid/kept-manifest.json");
const char *kept = update.get_source_url();
TaskResult result;
begin_check(update, result);
end_check(update, result);
expect_live(kept, "http://example.invalid/kept-manifest.json");
// Ownership came back, so the next change frees it instead of leaking it.
update.set_source_url_static(LITERAL);
expect_freed(kept);
}
TEST_F(HttpRequestUpdateTest, ConfigLiteralDuringACheckIsNeverFreed) {
HttpRequestUpdate update;
update.set_source_url_static(LITERAL);
TaskResult result;
begin_check(update, result);
update.set_source_url("http://example.invalid/runtime-manifest.json");
end_check(update, result);
EXPECT_STREQ(LITERAL, "http://example.invalid/manifest.json");
EXPECT_STREQ(update.get_source_url(), "http://example.invalid/runtime-manifest.json");
}
} // namespace esphome::http_request::testing
@@ -0,0 +1,11 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# setup() registers the entity as an OTA state listener, which the real to_code
# requests through ota.request_ota_state_listeners().
async def to_code_testing(config):
cg.add_define("USE_OTA_STATE_LISTENER")
manifest.to_code = to_code_testing
@@ -0,0 +1,2 @@
packages:
ledc: !include common.yaml
+4
View File
@@ -138,6 +138,10 @@ esphome:
- light.turn_on: - light.turn_on:
id: test_monochromatic_light id: test_monochromatic_light
effect: !lambda 'return "Strobe";' effect: !lambda 'return "Strobe";'
# Inlined lambda returning a StringRef, the shape an api action string variable takes (issue #21327)
- light.turn_on:
id: test_monochromatic_light
effect: !lambda 'return light::EFFECT_NONE_REF;'
# Test effect lambda with non-empty args (repeat passes uint32_t iteration) # Test effect lambda with non-empty args (repeat passes uint32_t iteration)
- repeat: - repeat:
count: 3 count: 3
@@ -0,0 +1,9 @@
# wifi and ethernet together: the _esphomelib "network" TXT value follows
# the connected interface, so the update path is compiled.
wifi:
ssid: MySSID
password: password1
api:
mdns:
@@ -0,0 +1,17 @@
packages:
mdns: !include common-dual-interface.yaml
ethernet:
type: W5500
clk_pin: GPIO19
mosi_pin: GPIO21
miso_pin: GPIO23
cs_pin: GPIO18
interrupt_pin: GPIO36
reset_pin: GPIO22
clock_speed: 10Mhz
network:
priority:
- ethernet
- wifi
@@ -0,0 +1,17 @@
packages:
mdns: !include common-dual-interface.yaml
ethernet:
type: W5500
clk_pin: 18
mosi_pin: 19
miso_pin: 16
cs_pin: 17
interrupt_pin: 21
reset_pin: 20
# wifi first, so the USE_NETWORK_PRIMARY_INTERFACE_WIFI order is compiled
network:
priority:
- wifi
- ethernet
+103
View File
@@ -0,0 +1,103 @@
#include <gtest/gtest.h>
#include <random>
#include <string>
#include "esphome/components/mqtt/mqtt_topic_match.h"
namespace esphome::mqtt::testing {
// The previous recursive implementation, kept to check the loop matches it
static bool reference_match(const char *message, const char *subscription, bool is_normal, bool past_separator) {
if (*message == '\0' && *subscription == '\0')
return true;
if (*message == '\0' || *subscription == '\0')
return false;
bool do_wildcards = is_normal || past_separator;
if (*subscription == '+' && do_wildcards) {
subscription++;
while (*message != '\0' && *message != '/')
message++;
return reference_match(message, subscription, is_normal, true);
}
if (*subscription == '#' && do_wildcards)
return true;
if (*message != *subscription)
return false;
past_separator = past_separator || *subscription == '/';
return reference_match(message + 1, subscription + 1, is_normal, past_separator);
}
static bool reference_match(const char *message, const char *subscription) {
return reference_match(message, subscription, *message != '\0' && *message != '$', false);
}
TEST(MQTTTopicMatch, Exact) {
EXPECT_TRUE(topic_match("a/b/c", "a/b/c"));
EXPECT_FALSE(topic_match("a/b/c", "a/b"));
EXPECT_FALSE(topic_match("a/b", "a/b/c"));
EXPECT_FALSE(topic_match("a/b/c", "a/b/d"));
}
TEST(MQTTTopicMatch, SingleLevelWildcard) {
EXPECT_TRUE(topic_match("a/b/c", "a/+/c"));
EXPECT_TRUE(topic_match("a/b/c", "+/+/+"));
EXPECT_TRUE(topic_match("a//c", "a/+/c"));
EXPECT_FALSE(topic_match("a/b/c", "a/+"));
EXPECT_FALSE(topic_match("a/b/c/d", "a/+/c"));
}
TEST(MQTTTopicMatch, MultiLevelWildcard) {
EXPECT_TRUE(topic_match("a/b/c", "a/#"));
EXPECT_TRUE(topic_match("a/b/c", "#"));
EXPECT_TRUE(topic_match("a/b/c", "a/+/#"));
EXPECT_FALSE(topic_match("b/c", "a/#"));
}
TEST(MQTTTopicMatch, DollarTopicsSkipFirstLevelWildcards) {
EXPECT_FALSE(topic_match("$SYS/broker", "#"));
EXPECT_FALSE(topic_match("$SYS/broker", "+/broker"));
EXPECT_TRUE(topic_match("$SYS/broker", "$SYS/#"));
EXPECT_TRUE(topic_match("$SYS/broker", "$SYS/+"));
}
TEST(MQTTTopicMatch, EdgeCases) {
EXPECT_TRUE(topic_match("", ""));
EXPECT_FALSE(topic_match("a", ""));
// Kept from the previous code; the MQTT spec has "#" match the parent level and "+" an empty last level
EXPECT_FALSE(topic_match("a/b", "a/b/#"));
EXPECT_FALSE(topic_match("a/", "a/+"));
EXPECT_TRUE(topic_match("a/$x", "a/+"));
}
// Recursion overflowed the ESP32 loop stack at about 250 characters
TEST(MQTTTopicMatch, LongTopic) {
std::string topic(4096, 'x');
topic[100] = '/';
EXPECT_TRUE(topic_match(topic.c_str(), topic.c_str()));
std::string other = topic;
other.back() = 'y';
EXPECT_FALSE(topic_match(topic.c_str(), other.c_str()));
}
TEST(MQTTTopicMatch, MatchesPreviousImplementation) {
// Small alphabet so wildcards, separators and '$' meet often
static constexpr char MESSAGE_CHARS[] = {'a', 'b', '/', '$'};
static constexpr char SUBSCRIPTION_CHARS[] = {'a', 'b', '/', '$', '+', '#'};
std::mt19937 rng(1234); // NOLINT(cert-msc32-c,cert-msc51-cpp,bugprone-random-generator-seed) reproducible
auto random_string = [&rng](const char *chars, size_t count) {
std::string s(rng() % 8, ' ');
for (char &c : s)
c = chars[rng() % count];
return s;
};
for (int i = 0; i < 20000; i++) {
std::string message = random_string(MESSAGE_CHARS, sizeof(MESSAGE_CHARS));
std::string subscription = random_string(SUBSCRIPTION_CHARS, sizeof(SUBSCRIPTION_CHARS));
ASSERT_EQ(topic_match(message.c_str(), subscription.c_str()),
reference_match(message.c_str(), subscription.c_str()))
<< "message='" << message << "' subscription='" << subscription << "'";
}
}
} // namespace esphome::mqtt::testing
+16
View File
@@ -0,0 +1,16 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The harness gives a multi-instance component an empty list and never calls its to_code,
# so take a single empty config instead and emit what the proxy messages and handlers
# need. No proxy is configured, so the count is 0, which keeps the capability messages as
# the api tests expect them; the USB identity stays out, since the USB channel exists on the ESP32 only.
manifest.multi_conf = False
async def to_code_testing(config):
cg.add_define("USE_SERIAL_PROXY")
cg.add_define("SERIAL_PROXY_COUNT", 0)
manifest.to_code = to_code_testing
@@ -0,0 +1,51 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <vector>
#include "esphome/components/api/api_buffer.h"
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/api/proto.h"
namespace esphome::api::testing {
// Encodes into a buffer of exactly the calculated size, so any overrun trips the debug bounds check.
static std::vector<uint8_t> encode(const UsbDeviceDescriptor &msg) {
const uint32_t size = msg.calculate_size();
APIBuffer buf;
EXPECT_TRUE(buf.resize(size == 0 ? 1 : size));
uint8_t *pos = buf.data();
#ifdef ESPHOME_DEBUG_API
uint8_t *proto_debug_end_ = buf.data() + buf.size();
#endif
uint8_t *end = UsbDeviceDescriptor::encode_msg(&msg, pos PROTO_ENCODE_DEBUG_ARG);
EXPECT_EQ(static_cast<uint32_t>(end - buf.data()), size);
return {buf.data(), end};
}
TEST(UsbDeviceDescriptor, EncodesTheInterfaceClassAndPort) {
UsbDeviceDescriptor msg;
msg.vendor_id = 0x10C4;
msg.product_id = 0xEA60;
msg.bcd_device = 0x0100;
msg.interface_number = 1;
msg.interface_class = 0xFF;
msg.port_number = 2;
const std::vector<uint8_t> expected = {0x08, 0xC4, 0x21, 0x10, 0xE0, 0xD4, 0x03, 0x18, 0x80,
0x02, 0x20, 0x01, 0x28, 0xFF, 0x01, 0x30, 0x02};
EXPECT_EQ(encode(msg), expected);
}
TEST(UsbDeviceDescriptor, ZeroClassAndPortLeaveTheWireAsBefore) {
// The new fields are additive with zero defaults, so an old client sees the message it always did
UsbDeviceDescriptor msg;
msg.vendor_id = 0x303A;
msg.product_id = 0x1001;
msg.interface_number = 1;
const std::vector<uint8_t> expected = {0x08, 0xBA, 0x60, 0x10, 0x81, 0x20, 0x20, 0x01};
EXPECT_EQ(encode(msg), expected);
}
} // namespace esphome::api::testing
+3 -2
View File
@@ -1,3 +1,4 @@
# The disconnects counter is compiled only with USE_SENSOR; declaring the # The connection entities are compiled only with USE_BINARY_SENSOR and
# sensor domain makes the C++ unit test build define it. # USE_SENSOR; declaring the domains makes the C++ unit test build define them.
binary_sensor:
sensor: sensor:
+8
View File
@@ -10,8 +10,16 @@ tcp_uart:
allowed_ips: allowed_ips:
- 192.0.2.20 - 192.0.2.20
- 192.0.2.0/24 - 192.0.2.0/24
binary_sensor:
- platform: tcp_uart
tcp_uart_id: tcp_uart_server
connected: connected:
name: TCP UART Server Connected name: TCP UART Server Connected
sensor:
- platform: tcp_uart
tcp_uart_id: tcp_uart_server
disconnects: disconnects:
name: TCP UART Server Disconnects name: TCP UART Server Disconnects
@@ -7,7 +7,15 @@ tcp_uart:
host: 192.0.2.10 host: 192.0.2.10
port: 502 port: 502
reconnect_interval: 10s reconnect_interval: 10s
binary_sensor:
- platform: tcp_uart
tcp_uart_id: tcp_uart_1
connected: connected:
name: TCP UART Connected name: TCP UART Connected
sensor:
- platform: tcp_uart
tcp_uart_id: tcp_uart_1
disconnects: disconnects:
name: TCP UART Disconnects name: TCP UART Disconnects
+3 -2
View File
@@ -1,3 +1,4 @@
# The disconnects counter is compiled only with USE_SENSOR; declaring the # The connection entities are compiled only with USE_BINARY_SENSOR and
# sensor domain makes the C++ unit test build define it. # USE_SENSOR; declaring the domains makes the C++ unit test build define them.
binary_sensor:
sensor: sensor:
+8
View File
@@ -10,7 +10,15 @@ uart_tcp:
allowed_ips: allowed_ips:
- 192.168.1.10 - 192.168.1.10
- 192.168.1.0/24 - 192.168.1.0/24
binary_sensor:
- platform: uart_tcp
uart_tcp_id: bridge
connected: connected:
name: UART TCP Connected name: UART TCP Connected
sensor:
- platform: uart_tcp
uart_tcp_id: bridge
disconnects: disconnects:
name: UART TCP Disconnects name: UART TCP Disconnects
@@ -12,7 +12,15 @@ uart_tcp:
host: 192.0.2.20 host: 192.0.2.20
port: 502 port: 502
reconnect_interval: 10s reconnect_interval: 10s
binary_sensor:
- platform: uart_tcp
uart_tcp_id: bridge
connected: connected:
name: UART TCP Connected name: UART TCP Connected
sensor:
- platform: uart_tcp
uart_tcp_id: bridge
disconnects: disconnects:
name: UART TCP Disconnects name: UART TCP Disconnects
+8
View File
@@ -8,7 +8,15 @@ uart_tcp:
uart_id: uart_bus uart_id: uart_bus
host: 127.0.0.1 host: 127.0.0.1
port: 44502 port: 44502
binary_sensor:
- platform: uart_tcp
uart_tcp_id: bridge
connected: connected:
name: UART TCP Connected name: UART TCP Connected
sensor:
- platform: uart_tcp
uart_tcp_id: bridge
disconnects: disconnects:
name: UART TCP Disconnects name: UART TCP Disconnects
@@ -6,3 +6,6 @@ web_server:
username: admin username: admin
password: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA password: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
type: basic type: basic
ota:
- platform: web_server
+4
View File
@@ -236,6 +236,10 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s
external_components_path = str(FIXTURES_DIR / "external_components") external_components_path = str(FIXTURES_DIR / "external_components")
content = content.replace("EXTERNAL_COMPONENT_PATH", external_components_path) content = content.replace("EXTERNAL_COMPONENT_PATH", external_components_path)
# Replace the user includes path placeholder if present
if "INCLUDES_PATH" in content:
content = content.replace("INCLUDES_PATH", str(FIXTURES_DIR / "includes"))
if shared_name is not None: if shared_name is not None:
# _compile verifies the marked test compiles this content unmodified # _compile verifies the marked test compiles this content unmodified
request.node._shared_yaml_content = content request.node._shared_yaml_content = content
@@ -0,0 +1,6 @@
#pragma once
static constexpr int HEADER_CONSTANT = 42;
// Names the global by its id, so the header must come after the id declarations.
inline int header_reads_global() { return header_value->value() * 2; }
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more