mirror of
https://github.com/esphome/esphome.git
synced 2026-10-27 08:22:25 +00:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e8f084741 | ||
|
|
6595df0cab | ||
|
|
73894b2490 | ||
|
|
9bef20d8dc | ||
|
|
0e440500a2 | ||
|
|
badfa84fe0 | ||
|
|
e686ecf76e | ||
|
|
64fb4e06b5 | ||
|
|
cb82c9031a | ||
|
|
ca74ab9873 | ||
|
|
86fc740b65 | ||
|
|
450bb52f65 | ||
|
|
ed3615ec69 | ||
|
|
a5f25ebe2d | ||
|
|
7a4775de3d | ||
|
|
704551a6c9 | ||
|
|
f30e70acfa | ||
|
|
594a691d9f | ||
|
|
6c90b5c549 | ||
|
|
c8df23a9cb | ||
|
|
4fdce9d96b | ||
|
|
9d296a5748 | ||
|
|
f2e013d71a | ||
|
|
2001dd2be8 | ||
|
|
a6a4b49e75 | ||
|
|
649f2c91f5 | ||
|
|
ffa20abe96 | ||
|
|
f979ddcf0d | ||
|
|
26064f1415 | ||
|
|
c50ee215f2 | ||
|
|
0fbba03522 | ||
|
|
7ccaedfb9e | ||
|
|
0173127239 | ||
|
|
235c614e68 | ||
|
|
79823c89ab | ||
|
|
e09cb64756 | ||
|
|
bd5b3fa641 | ||
|
|
f464994f85 | ||
|
|
4552dbad7a | ||
|
|
ad8cd0dd3e | ||
|
|
dd2582bdfd | ||
|
|
ba575600dc | ||
|
|
cd6c0dd402 | ||
|
|
4645d34e1a | ||
|
|
dff1422e36 |
No files matched your search
@@ -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
@@ -73,16 +73,17 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
# 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
|
||||
# only from the package list and version; keep both identical in every
|
||||
# step that restores it. In ci-status needs so a broken seed fails dev.
|
||||
# only from the package list and version, kept in the apt-packages action.
|
||||
# In ci-status needs so a broken seed fails dev.
|
||||
if: github.event_name == 'push'
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Install apt packages (cached)
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
packages: libsdl2-dev ccache
|
||||
version: 1.1
|
||||
sparse-checkout: .github
|
||||
- name: Install apt packages (cached)
|
||||
uses: ./.github/actions/apt-packages
|
||||
|
||||
determine-jobs:
|
||||
name: Determine which jobs to run
|
||||
@@ -409,14 +410,10 @@ jobs:
|
||||
- name: Install apt packages (cached)
|
||||
# ccache speeds up the host compiles. A cache hit never touches apt
|
||||
# (mirror outages cannot hang the job); the timeout bounds the cold
|
||||
# path. Packages and version must match seed-apt-cache exactly.
|
||||
# libsdl2-dev is needed by the headless display tests, which capture
|
||||
# screenshots.
|
||||
# path. libsdl2-dev is needed by the headless display tests, which
|
||||
# capture screenshots.
|
||||
timeout-minutes: 10
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
packages: libsdl2-dev ccache
|
||||
version: 1.1
|
||||
uses: ./.github/actions/apt-packages
|
||||
- name: Set up Python 3.13
|
||||
id: python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
@@ -707,6 +704,12 @@ jobs:
|
||||
with:
|
||||
# Need history for HEAD~1 to work for checking changed files
|
||||
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
|
||||
id: restore-python
|
||||
@@ -1093,20 +1096,16 @@ jobs:
|
||||
- name: List components
|
||||
run: echo ${{ matrix.batch.components }}
|
||||
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install apt packages (cached)
|
||||
# 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
|
||||
# the cold path. Packages and version must match seed-apt-cache
|
||||
# exactly. The action has no --no-install-recommends; same package
|
||||
# set this job used before #17463.
|
||||
# the cold path. The action has no --no-install-recommends; same
|
||||
# package set this job used before #17463.
|
||||
timeout-minutes: 10
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
packages: libsdl2-dev ccache
|
||||
version: 1.1
|
||||
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: ./.github/actions/apt-packages
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
|
||||
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# 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
|
||||
# for a project that appears at the top of each page and should give viewer a
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ RUN \
|
||||
-r /requirements.txt
|
||||
|
||||
# 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 \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
@@ -44,7 +44,6 @@ from esphome.platformio.library import (
|
||||
normalize_dependencies,
|
||||
parse_library_json,
|
||||
parse_library_properties,
|
||||
warn_properties_depends,
|
||||
)
|
||||
|
||||
_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",
|
||||
name,
|
||||
)
|
||||
warn_properties_depends(name, data)
|
||||
build = data.get("build")
|
||||
if isinstance(build, dict) and build.get("extraScript"):
|
||||
# Scripts only run on the converted path; building without
|
||||
|
||||
+30
-13
@@ -7,6 +7,7 @@ import re
|
||||
import textwrap
|
||||
|
||||
from esphome.build_helpers import pch
|
||||
from esphome.build_helpers.ccache import CCACHE_PCH_MIN_VERSION
|
||||
from esphome.components.esp32 import (
|
||||
get_esp32_variant,
|
||||
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
|
||||
# next to it in place of the .gch
|
||||
_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"
|
||||
|
||||
|
||||
@@ -490,16 +491,32 @@ def _pch_cmake_block() -> str:
|
||||
block = f"""target_precompile_headers(${{COMPONENT_LIB}} PRIVATE
|
||||
{headers}
|
||||
)"""
|
||||
if not pch.pch_needs_gcc_check():
|
||||
if pch.pch_forced():
|
||||
return f"\n# ESPHome precompiled header\n{block}\n"
|
||||
# Before the first configure only CMake knows the compiler version
|
||||
return f"""
|
||||
# ESPHome precompiled header, unless GCC bug 14940 keeps it from loading
|
||||
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND ({pch.PCH_WINDOWS_CMAKE_OLD_GCC}))
|
||||
# Only CMake knows the compiler version before the first configure, and
|
||||
# the ccache IDF found is the one the build runs
|
||||
gcc_gate = ""
|
||||
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")
|
||||
set({_PCH_CHOICE_VAR} OFF CACHE BOOL "ESPHome precompiled header in use" FORCE)
|
||||
else()
|
||||
set({_PCH_CHOICE_VAR} ON CACHE BOOL "ESPHome precompiled header in use" FORCE)
|
||||
set(_esphome_pch OFF)
|
||||
endif()
|
||||
"""
|
||||
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, " ")}
|
||||
endif()
|
||||
"""
|
||||
@@ -516,10 +533,10 @@ def write_pch_checksum() -> None:
|
||||
|
||||
if not pch.pch_enabled():
|
||||
return
|
||||
# The gate's choice, cached by configure
|
||||
if pch.pch_needs_gcc_check() and get_cmake_cache_value(_PCH_CHOICE_VAR) != "ON":
|
||||
return
|
||||
pch.log_pch_in_use()
|
||||
# Written whatever the gate chose last time: a build tree without that
|
||||
# choice, or a reconfigure that turns the pch on, must not see a stale sum
|
||||
if pch.pch_forced() or get_cmake_cache_value(_PCH_CHOICE_VAR) == "ON":
|
||||
pch.log_pch_in_use()
|
||||
checksum = pch.pch_checksum(
|
||||
CORE.relative_src_path(),
|
||||
pch.PCH_DEFAULT_HEADERS,
|
||||
|
||||
@@ -3,21 +3,68 @@ resolution, and default ``CCACHE_*`` values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
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
|
||||
|
||||
_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:
|
||||
"""Return True when the ``ccache`` found on PATH actually runs."""
|
||||
return tool_version_runs(
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,11 @@ import re
|
||||
import subprocess
|
||||
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.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
|
||||
|
||||
|
||||
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:
|
||||
"""The knob plus the host rule; ``ESPHOME_PCH_ENABLE=1`` skips the rule."""
|
||||
if not pch_enabled():
|
||||
"""The knob plus the ccache and host rules; ``ESPHOME_PCH_ENABLE=1``
|
||||
skips the rules."""
|
||||
if not pch_enabled() or _ccache_rules_out_pch():
|
||||
return False
|
||||
if not pch_needs_gcc_check():
|
||||
return True
|
||||
@@ -162,7 +173,11 @@ def pch_script_enabled() -> bool:
|
||||
"""Whether this PlatformIO build takes the pch script."""
|
||||
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:
|
||||
|
||||
@@ -111,7 +111,9 @@ class ToolOutput(RedirectText):
|
||||
|
||||
def _emit_line(self, line: str) -> None:
|
||||
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"))
|
||||
self._write_color_replace(f"\r{text}\x1b[K")
|
||||
self._on_progress_line = True
|
||||
|
||||
@@ -403,6 +403,9 @@ message WizardCapabilities {
|
||||
message SendspinCapabilities {
|
||||
// Bitmask of the Sendspin features this device supports
|
||||
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 {
|
||||
@@ -3043,12 +3046,15 @@ enum SerialProxyIdentityFlag {
|
||||
// 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 {
|
||||
uint32 vendor_id = 1;
|
||||
uint32 product_id = 2;
|
||||
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
|
||||
|
||||
@@ -2128,6 +2128,7 @@ bool APIConnection::send_device_capabilities_response_() {
|
||||
#endif
|
||||
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
|
||||
resp.sendspin.feature_flags = sendspin_::global_sendspin_hub->get_feature_flags();
|
||||
resp.sendspin.client_id = sendspin_::global_sendspin_hub->get_client_id();
|
||||
#endif
|
||||
return this->send_message(resp);
|
||||
}
|
||||
|
||||
@@ -152,10 +152,7 @@ void OutgoingConnectionManager::loop(APIServer *server) {
|
||||
|
||||
void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) {
|
||||
if (!network::is_connected()) {
|
||||
// Flips within seconds of boot; recheck fast so a deep sleep wake
|
||||
// window is not spent waiting
|
||||
this->schedule_wait_(now, NETWORK_RETRY_MS);
|
||||
return;
|
||||
return; // still WAITING with the wait elapsed, so the next loop rechecks
|
||||
}
|
||||
struct sockaddr_storage 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_ts_ = now;
|
||||
this->last_poll_ = 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);
|
||||
return;
|
||||
}
|
||||
if (now - this->last_poll_ < CONNECT_POLL_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
this->last_poll_ = now;
|
||||
int err = 0;
|
||||
switch (socket::poll_connect(*this->dial_socket_, err)) {
|
||||
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_MAX_MS = 300000;
|
||||
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;
|
||||
// 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
|
||||
@@ -104,7 +102,6 @@ class OutgoingConnectionManager {
|
||||
uint32_t backoff_{BACKOFF_MIN_MS};
|
||||
uint32_t wait_{BOOT_WAIT_MS};
|
||||
uint32_t state_ts_{0};
|
||||
uint32_t last_poll_{0};
|
||||
|
||||
// Byte-aligned types last
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
|
||||
@@ -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) {
|
||||
const auto &msg = *static_cast<const SendspinCapabilities *>(self);
|
||||
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;
|
||||
}
|
||||
uint32_t SendspinCapabilities::calc_size_msg(const void *self) {
|
||||
const auto &msg = *static_cast<const SendspinCapabilities *>(self);
|
||||
uint32_t size = 0;
|
||||
size += ProtoSize::calc_uint32(1, msg.feature_flags);
|
||||
size += !msg.client_id.empty() ? 2 + msg.client_id.size() : 0;
|
||||
return size;
|
||||
}
|
||||
#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, 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, 5, msg.interface_class);
|
||||
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.port_number);
|
||||
return pos;
|
||||
}
|
||||
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.bcd_device);
|
||||
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;
|
||||
}
|
||||
uint8_t *SerialProxyIdentity::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
|
||||
|
||||
@@ -750,6 +750,7 @@ class WizardCapabilities final : public ProtoMessage {
|
||||
class SendspinCapabilities final : public ProtoMessage {
|
||||
public:
|
||||
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);
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
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 bcd_device{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);
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
|
||||
|
||||
@@ -1119,6 +1119,7 @@ const char *WizardCapabilities::dump_to(DumpBuffer &out) const {
|
||||
const char *SendspinCapabilities::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinCapabilities"));
|
||||
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
|
||||
dump_field(out, ESPHOME_PSTR("client_id"), this->client_id);
|
||||
return out.c_str();
|
||||
}
|
||||
#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("bcd_device"), this->bcd_device);
|
||||
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();
|
||||
}
|
||||
const char *SerialProxyIdentity::dump_to(DumpBuffer &out) const {
|
||||
|
||||
@@ -20,7 +20,7 @@ constexpr uint8_t bl0906_checksum(const uint8_t address, const DataPacket *data)
|
||||
|
||||
void BL0906::loop() {
|
||||
while (this->available())
|
||||
this->flush();
|
||||
this->read();
|
||||
|
||||
if (this->current_stage_ == STAGE_IDLE) {
|
||||
// Woken up between cycles to drain the action queue. Go back to sleep.
|
||||
@@ -92,7 +92,7 @@ void BL0906::advance_stage_() {
|
||||
|
||||
void BL0906::setup() {
|
||||
while (this->available())
|
||||
this->flush();
|
||||
this->read();
|
||||
this->write_array(USR_WRPROT_WITABLE, sizeof(USR_WRPROT_WITABLE));
|
||||
// Calibration (1: register address; 2: value before calibration; 3: value after calibration)
|
||||
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,
|
||||
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));
|
||||
conn_params.min_int = min_interval;
|
||||
conn_params.max_int = max_interval;
|
||||
|
||||
@@ -82,6 +82,10 @@ class ClimateCall {
|
||||
ClimateCall &set_fan_mode(optional<std::string> fan_mode);
|
||||
/// Set the custom fan mode of the climate device.
|
||||
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).
|
||||
ClimateCall &set_fan_mode(const char *custom_fan_mode, size_t len);
|
||||
/// Set the swing mode of the climate device.
|
||||
@@ -102,6 +106,10 @@ class ClimateCall {
|
||||
ClimateCall &set_preset(optional<std::string> preset);
|
||||
/// Set the custom preset of the climate device.
|
||||
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).
|
||||
ClimateCall &set_preset(const char *custom_preset, size_t len);
|
||||
|
||||
|
||||
@@ -34,6 +34,15 @@ void DebugComponent::dump_config() {
|
||||
this->free_heap_ = get_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);
|
||||
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
|
||||
@@ -208,6 +208,7 @@ def _final_validate(config) -> None:
|
||||
else:
|
||||
# If no drawing methods are configured, and LVGL is not enabled, show a test card
|
||||
config[CONF_SHOW_TEST_CARD] = True
|
||||
config[CONF_UPDATE_INTERVAL] = update_interval("1min")
|
||||
elif CONF_UPDATE_INTERVAL not in config:
|
||||
config[CONF_UPDATE_INTERVAL] = update_interval("1min")
|
||||
|
||||
|
||||
@@ -1099,7 +1099,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = {
|
||||
# See: https://github.com/pioarduino/esp-idf/releases
|
||||
ARDUINO_IDF_VERSION_LOOKUP = {
|
||||
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, 10): cv.Version(5, 5, 5),
|
||||
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
|
||||
# - https://github.com/espressif/esp-idf/releases
|
||||
ESP_IDF_FRAMEWORK_VERSION_LOOKUP = {
|
||||
"recommended": cv.Version(5, 5, 5),
|
||||
"latest": cv.Version(5, 5, 5),
|
||||
"dev": cv.Version(5, 5, 5),
|
||||
"recommended": cv.Version(5, 5, 6),
|
||||
"latest": cv.Version(5, 5, 6),
|
||||
"dev": cv.Version(5, 5, 6),
|
||||
}
|
||||
|
||||
ESP_IDF_PLATFORM_VERSION_LOOKUP = {
|
||||
@@ -1135,6 +1135,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = {
|
||||
cv.Version(
|
||||
6, 0, 0
|
||||
): "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, 4): cv.Version(55, 3, 39),
|
||||
cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37),
|
||||
@@ -3682,6 +3683,20 @@ def _write_sdkconfig():
|
||||
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():
|
||||
yml_path = CORE.relative_build_path("src/idf_component.yml")
|
||||
dependencies: dict[str, dict] = {}
|
||||
@@ -3703,7 +3718,11 @@ def _write_idf_component_yml():
|
||||
# IDF sees the same requirement twice; converted components reach it through
|
||||
# ${ESPHOME_PROJECT_MANAGED_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
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
case ESP_GAP_BLE_KEY_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_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,
|
||||
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));
|
||||
conn_params.min_int = min_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
|
||||
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;
|
||||
|
||||
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
|
||||
// Interval/timeout IDs (uint32_t to avoid string comparison)
|
||||
constexpr uint32_t INITIAL_CHECK_INTERVAL_ID = 0;
|
||||
@@ -380,7 +385,7 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() {
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Error reading firmware data: %d", read_or_error);
|
||||
}
|
||||
esp_hosted_slave_ota_end(); // NOLINT
|
||||
ota_end_guarded();
|
||||
container->end();
|
||||
this->status_set_error(LOG_STR("Download failed"));
|
||||
return false;
|
||||
@@ -390,7 +395,7 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() {
|
||||
err = esp_hosted_slave_ota_write(buffer, read_or_error); // NOLINT
|
||||
if (err != ESP_OK) {
|
||||
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();
|
||||
this->status_set_error(LOG_STR("Failed to write OTA data"));
|
||||
return false;
|
||||
@@ -402,7 +407,7 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() {
|
||||
hasher.calculate();
|
||||
if (!hasher.equals_bytes(this->firmware_sha256_.data())) {
|
||||
ESP_LOGE(TAG, "SHA256 mismatch");
|
||||
esp_hosted_slave_ota_end(); // NOLINT
|
||||
ota_end_guarded();
|
||||
this->status_set_error(LOG_STR("SHA256 verification failed"));
|
||||
return false;
|
||||
}
|
||||
@@ -451,7 +456,7 @@ bool Esp32HostedUpdate::write_embedded_firmware_to_coprocessor_() {
|
||||
err = esp_hosted_slave_ota_write(chunk, chunk_size); // NOLINT
|
||||
if (err != ESP_OK) {
|
||||
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"));
|
||||
return false;
|
||||
}
|
||||
@@ -494,7 +499,7 @@ void Esp32HostedUpdate::perform(bool force) {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
ESP_LOGE(TAG, "Failed to end OTA: %s", esp_err_to_name(end_err));
|
||||
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])
|
||||
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()
|
||||
|
||||
|
||||
@@ -24,12 +24,6 @@ namespace esphome::http_request {
|
||||
|
||||
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 constexpr uint32_t INITIAL_CHECK_INTERVAL_ID = 0;
|
||||
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() {
|
||||
if (!network::is_connected()) {
|
||||
ESP_LOGD(TAG, "Network not connected, skipping update check");
|
||||
@@ -75,28 +100,37 @@ void HttpRequestUpdate::update() {
|
||||
}
|
||||
this->cancel_interval(INITIAL_CHECK_INTERVAL_ID);
|
||||
#ifdef USE_ESP32
|
||||
if (this->update_task_handle_ != nullptr) {
|
||||
if (this->task_result_ != nullptr) {
|
||||
ESP_LOGW(TAG, "Update check already in progress");
|
||||
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
|
||||
this->update_task(this);
|
||||
this->update_task(result);
|
||||
this->end_check_(result);
|
||||
#endif
|
||||
}
|
||||
|
||||
void HttpRequestUpdate::update_task(void *params) {
|
||||
HttpRequestUpdate *this_update = (HttpRequestUpdate *) params;
|
||||
|
||||
// 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 *result = static_cast<TaskResult *>(params);
|
||||
HttpRequestUpdate *this_update = result->update;
|
||||
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) {
|
||||
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)
|
||||
container->end();
|
||||
result->error_str = LOG_STR("Failed to fetch manifest");
|
||||
@@ -176,14 +210,14 @@ void HttpRequestUpdate::update_task(void *params) {
|
||||
allocator.deallocate(data, content_length);
|
||||
|
||||
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");
|
||||
goto defer; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
|
||||
// Merge source_url_ and firmware_url
|
||||
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);
|
||||
size_t prefix_len;
|
||||
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.
|
||||
this_update->defer([this_update, result]() {
|
||||
#ifdef USE_ESP32
|
||||
this_update->update_task_handle_ = nullptr;
|
||||
this_update->task_result_ = nullptr;
|
||||
this_update->end_check_(result);
|
||||
#endif
|
||||
if (result->error_str != nullptr) {
|
||||
this_update->status_set_error(result->error_str);
|
||||
|
||||
@@ -13,6 +13,21 @@
|
||||
|
||||
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 {
|
||||
public:
|
||||
void setup() override;
|
||||
@@ -21,7 +36,18 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo
|
||||
void perform(bool force) override;
|
||||
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_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);
|
||||
#ifdef USE_ESP32
|
||||
TaskHandle_t update_task_handle_{nullptr};
|
||||
TaskResult *task_result_{nullptr}; // set while a check runs on the update task
|
||||
#endif
|
||||
uint8_t initial_check_remaining_{0};
|
||||
|
||||
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
|
||||
@@ -169,7 +169,9 @@ void LEDCOutput::setup() {
|
||||
#if !defined(CLANG_TIDY)
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 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() {
|
||||
ledc_ll_enable_reset_reg(true);
|
||||
ledc_ll_enable_reset_reg(false);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "light_color_values.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
namespace esphome {
|
||||
|
||||
@@ -133,6 +134,8 @@ class LightCall {
|
||||
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).
|
||||
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).
|
||||
LightCall &set_effect(const char *effect, size_t len);
|
||||
/// Set the effect of the light by its internal index number (only for internal use).
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
#ifdef USE_DASHBOARD_IMPORT
|
||||
#include "esphome/components/dashboard_import/dashboard_import.h"
|
||||
#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 {
|
||||
|
||||
@@ -39,6 +43,45 @@ MDNS_STATIC_CONST_CHAR(SERVICE_TCP, "_tcp");
|
||||
// Wrap build-time defines into flash storage
|
||||
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) {
|
||||
#ifdef USE_MDNS_STORE_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)});
|
||||
|
||||
#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");
|
||||
txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(NETWORK_WIFI)});
|
||||
#elif defined(USE_ETHERNET)
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
#define USE_MDNS_DEVICE_INFO_TXT
|
||||
#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 {
|
||||
|
||||
// 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);
|
||||
|
||||
#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
|
||||
/// Storage for runtime-generated TXT values from user lambdas
|
||||
/// Pre-sized at compile time via MDNS_DYNAMIC_TXT_COUNT to avoid heap allocations.
|
||||
|
||||
@@ -89,7 +89,24 @@ bool MDNSComponent::set_service_enabled(const char *service_type, const char *pr
|
||||
}
|
||||
#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() {
|
||||
#ifndef USE_OPENTHREAD
|
||||
|
||||
@@ -46,7 +46,26 @@ void MDNSComponent::start_polling_window_() {
|
||||
}
|
||||
#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() {
|
||||
#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
|
||||
// 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 —
|
||||
|
||||
@@ -9,6 +9,10 @@ class RgbDriverChip(DriverChip):
|
||||
def transforms(self) -> set[str]:
|
||||
"""
|
||||
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}
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "lwip/dns.h"
|
||||
#include "lwip/err.h"
|
||||
#include "mqtt_component.h"
|
||||
#include "mqtt_topic_match.h"
|
||||
|
||||
#ifdef USE_API
|
||||
#include "esphome/components/api/api_server.h"
|
||||
@@ -581,63 +582,6 @@ void MQTTClientComponent::disable() {
|
||||
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) {
|
||||
#ifdef USE_ESP8266
|
||||
// IMPORTANT: This defer is REQUIRED to prevent stack overflow crashes on ESP8266.
|
||||
|
||||
@@ -169,8 +169,7 @@ bool MQTTFanComponent::publish_state() {
|
||||
char topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN];
|
||||
const char *state_s = this->state_->state ? "ON" : "OFF";
|
||||
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 = false;
|
||||
bool failed = !this->publish(this->get_state_topic_to_(topic_buf), state_s);
|
||||
if (this->state_->get_traits().supports_direction()) {
|
||||
bool success = this->publish(this->get_direction_state_topic_to(topic_buf),
|
||||
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,
|
||||
.hash_id = NOISE_HASH_SHA256,
|
||||
.hybrid_id = NOISE_DH_NONE,
|
||||
.reserved = {},
|
||||
};
|
||||
|
||||
int err = noise_handshakestate_new_by_id(&this->handshake_, &nid, NOISE_ROLE_RESPONDER);
|
||||
|
||||
@@ -98,8 +98,9 @@ enum OTAType : uint8_t {
|
||||
// The OTA backend method surface. Exactly one backend exists per build,
|
||||
// selected in ota_backend_factory.h where this concept is asserted on
|
||||
// make_ota_backend()'s return type. Semantics beyond the signatures:
|
||||
// - begin: prepare for an image of the given size; ota_type defaults to an
|
||||
// app update, so both call forms must be accepted.
|
||||
// - begin: prepare for an image of the given size; 0 means the size is unknown
|
||||
// (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.
|
||||
// - write: consume the next chunk; end: finalize and mark bootable.
|
||||
// - abort: safe to call in any state, including after end().
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
|
||||
#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 {
|
||||
|
||||
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) {
|
||||
return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE;
|
||||
}
|
||||
// OTA size of 0 is not currently handled, but
|
||||
// web_server is not supported for RP2040, so this is not an issue.
|
||||
// 0 means the size is unknown (web_server upload). The updater only checks the size
|
||||
// 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);
|
||||
if (ret) {
|
||||
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;
|
||||
if (error == UPDATE_ERROR_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;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// 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)
|
||||
void SendspinHub::set_unpaired_access_enabled(bool 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.
|
||||
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.
|
||||
///
|
||||
/// 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.bcd_device = scratch.bcd_device;
|
||||
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.product = StringRef(scratch.product);
|
||||
msg.serial_number = StringRef(scratch.serial_number);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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 (
|
||||
CONF_ALLOWED_IPS,
|
||||
CONF_CONNECTED,
|
||||
CONF_DATA_BITS,
|
||||
CONF_HOST,
|
||||
CONF_PARITY,
|
||||
@@ -11,22 +10,16 @@ from esphome.components.const import (
|
||||
CONF_STOP_BITS,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BAUD_RATE,
|
||||
CONF_ID,
|
||||
CONF_PORT,
|
||||
DEVICE_CLASS_CONNECTIVITY,
|
||||
ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
STATE_CLASS_TOTAL_INCREASING,
|
||||
)
|
||||
from esphome.const import CONF_BAUD_RATE, CONF_ID, CONF_PORT
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@Bascht74"]
|
||||
DEPENDENCIES = ["network"]
|
||||
AUTO_LOAD = ["uart", "binary_sensor", "sensor", "socket"]
|
||||
AUTO_LOAD = ["uart", "socket"]
|
||||
DOMAIN = "tcp_uart"
|
||||
MULTI_CONF = True
|
||||
|
||||
CONF_DISCONNECTS = "disconnects"
|
||||
CONF_TCP_UART_ID = "tcp_uart_id"
|
||||
|
||||
tcp_uart_ns = cg.esphome_ns.namespace("tcp_uart")
|
||||
TcpUart = tcp_uart_ns.class_("TcpUart", uart.UARTComponent, cg.Component)
|
||||
@@ -45,15 +38,6 @@ BASE_SCHEMA = cv.Schema(
|
||||
cv.Optional(
|
||||
CONF_RECONNECT_INTERVAL, default="5s"
|
||||
): 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)
|
||||
|
||||
@@ -99,7 +83,3 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(var.set_parity(config[CONF_PARITY]))
|
||||
if (host := config.get(CONF_HOST)) is not None:
|
||||
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)
|
||||
@@ -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)
|
||||
@@ -17,9 +17,11 @@ void TcpUart::setup() {
|
||||
#ifdef USE_SOCKET_TCP_LISTENER
|
||||
this->listener_.begin(TAG);
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->connected_sensor_ != nullptr) {
|
||||
this->connected_sensor_->publish_state(false);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
if (this->disconnects_sensor_ != nullptr) {
|
||||
this->disconnects_sensor_->publish_state(0);
|
||||
@@ -38,7 +40,9 @@ void TcpUart::dump_config() {
|
||||
#ifdef USE_SOCKET_TCP_LISTENER
|
||||
this->listener_.dump_config();
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_);
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_);
|
||||
#endif
|
||||
@@ -58,9 +62,11 @@ void TcpUart::sync_link_() {
|
||||
// Unread bytes of the last session stay readable while down, never into the next one.
|
||||
this->rx_start_ = this->rx_end_ = 0;
|
||||
}
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->connected_sensor_ != nullptr) {
|
||||
this->connected_sensor_->publish_state(up);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
// Only edges get here, so down is the falling edge.
|
||||
if (!up && this->disconnects_sensor_ != nullptr) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#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_port(uint16_t port) { this->link_.set_port(port); }
|
||||
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; }
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; }
|
||||
#endif
|
||||
@@ -64,7 +69,9 @@ class TcpUart : public uart::UARTComponent, public Component {
|
||||
#ifdef USE_SOCKET_TCP_LISTENER
|
||||
socket::TcpListener listener_;
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
binary_sensor::BinarySensor *connected_sensor_{nullptr};
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
sensor::Sensor *disconnects_sensor_{nullptr};
|
||||
uint32_t disconnects_{0};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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 (
|
||||
CONF_ALLOWED_IPS,
|
||||
CONF_CONNECTED,
|
||||
CONF_HOST,
|
||||
CONF_RECONNECT_INTERVAL,
|
||||
CONF_ROLE,
|
||||
@@ -14,9 +13,6 @@ from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_PORT,
|
||||
CONF_UART_ID,
|
||||
DEVICE_CLASS_CONNECTIVITY,
|
||||
ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
STATE_CLASS_TOTAL_INCREASING,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
import esphome.final_validate as fv
|
||||
@@ -25,10 +21,10 @@ from esphome.types import ConfigType
|
||||
CODEOWNERS = ["@Bascht74"]
|
||||
DOMAIN = "uart_tcp"
|
||||
DEPENDENCIES = ["network", "uart"]
|
||||
AUTO_LOAD = ["binary_sensor", "sensor", "socket"]
|
||||
AUTO_LOAD = ["socket"]
|
||||
MULTI_CONF = True
|
||||
|
||||
CONF_DISCONNECTS = "disconnects"
|
||||
CONF_UART_TCP_ID = "uart_tcp_id"
|
||||
|
||||
uart_tcp_ns = cg.esphome_ns.namespace("uart_tcp")
|
||||
UartTcp = uart_tcp_ns.class_("UartTcp", cg.Component, uart.UARTDevice)
|
||||
@@ -42,15 +38,6 @@ BASE_SCHEMA = cv.Schema(
|
||||
cv.Optional(
|
||||
CONF_RECONNECT_INTERVAL, default="5s"
|
||||
): 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)
|
||||
|
||||
@@ -155,7 +142,3 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL]))
|
||||
if (host := config.get(CONF_HOST)) is not None:
|
||||
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)
|
||||
@@ -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)
|
||||
@@ -19,9 +19,11 @@ void UartTcp::setup() {
|
||||
#ifdef USE_SOCKET_TCP_LISTENER
|
||||
this->listener_.begin(TAG);
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->connected_sensor_ != nullptr) {
|
||||
this->connected_sensor_->publish_state(false);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
if (this->disconnects_sensor_ != nullptr) {
|
||||
this->disconnects_sensor_->publish_state(0);
|
||||
@@ -40,7 +42,9 @@ void UartTcp::dump_config() {
|
||||
#ifdef USE_SOCKET_TCP_LISTENER
|
||||
this->listener_.dump_config();
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_);
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_);
|
||||
#endif
|
||||
@@ -60,9 +64,11 @@ void UartTcp::sync_link_() {
|
||||
// The driver kept whatever arrived while the link was down.
|
||||
this->discard_uart_();
|
||||
}
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->connected_sensor_ != nullptr) {
|
||||
this->connected_sensor_->publish_state(up);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
// Only edges get here, so down is the falling edge.
|
||||
if (!up && this->disconnects_sensor_ != nullptr) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#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_port(uint16_t port) { this->link_.set_port(port); }
|
||||
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; }
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; }
|
||||
#endif
|
||||
@@ -53,7 +58,9 @@ class UartTcp final : public Component, public uart::UARTDevice {
|
||||
#ifdef USE_SOCKET_TCP_LISTENER
|
||||
socket::TcpListener listener_;
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
binary_sensor::BinarySensor *connected_sensor_{nullptr};
|
||||
#endif
|
||||
// Loop start time of the last socket-to-UART write; sizes the next paced write.
|
||||
uint32_t last_write_ms_{0};
|
||||
#ifdef USE_SENSOR
|
||||
|
||||
@@ -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
|
||||
|
||||
#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 ||
|
||||
|
||||
@@ -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");
|
||||
continue;
|
||||
}
|
||||
if (in_ep->bEndpointAddress & usb_host::USB_DIR_IN) {
|
||||
cdc_devs.push_back({CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber}});
|
||||
} else {
|
||||
cdc_devs.push_back({CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber}});
|
||||
}
|
||||
CdcEps eps = in_ep->bEndpointAddress & usb_host::USB_DIR_IN
|
||||
? CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber}
|
||||
: CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber};
|
||||
eps.bulk_interface_class = data_desc->bInterfaceClass;
|
||||
cdc_devs.push_back(eps);
|
||||
}
|
||||
return cdc_devs;
|
||||
}
|
||||
|
||||
@@ -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_class = intf_desc->bInterfaceClass;
|
||||
return eps;
|
||||
}
|
||||
|
||||
|
||||
@@ -189,7 +189,10 @@ std::vector<CdcEps> USBUartTypePL2303::parse_descriptors(usb_device_handle_t dev
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
eps.notify_ep = ep;
|
||||
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 &&
|
||||
(eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) {
|
||||
eps.in_ep = ep;
|
||||
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) &&
|
||||
(eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) {
|
||||
eps.out_ep = ep;
|
||||
eps.bulk_interface_number = intf_desc->bInterfaceNumber;
|
||||
eps.bulk_interface_class = intf_desc->bInterfaceClass;
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Unexpected endpoint attributes: %02X", ep->bmAttributes);
|
||||
continue;
|
||||
@@ -431,8 +434,7 @@ void USBUartTypeCdcAcm::on_connected() {
|
||||
if (channel->cdc_dev_.interrupt_interface_number != 0xFF &&
|
||||
channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) {
|
||||
if (!this->claim_comm_interface_) {
|
||||
ESP_LOGD(TAG, "Skipping comm interface %d (claim_comm_interface: false)",
|
||||
channel->cdc_dev_.interrupt_interface_number);
|
||||
ESP_LOGD(TAG, "Comm interface %d left unclaimed", channel->cdc_dev_.interrupt_interface_number);
|
||||
} else {
|
||||
auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_,
|
||||
channel->cdc_dev_.interrupt_interface_number, 0);
|
||||
|
||||
@@ -39,6 +39,9 @@ struct CdcEps {
|
||||
// must remain valid even when the interface itself is not claimed.
|
||||
uint8_t interrupt_interface_number{0xFF};
|
||||
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 {
|
||||
@@ -186,6 +189,16 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented<USBUartCo
|
||||
: 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:
|
||||
// Not directly instantiable; construct a concrete channel type instead.
|
||||
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 {
|
||||
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;
|
||||
|
||||
protected:
|
||||
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;
|
||||
std::vector<CdcEps> parse_descriptors(usb_device_handle_t dev_hdl) override;
|
||||
|
||||
private:
|
||||
CH34xChipType chiptype_{CHIP_UNKNOWN};
|
||||
|
||||
@@ -57,7 +57,7 @@ namespace esphome::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.
|
||||
#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__)
|
||||
#pragma GCC diagnostic error "-Wstack-usage=2048"
|
||||
@@ -148,10 +148,9 @@ void AsyncWebServer::begin() {
|
||||
if (this->server_) {
|
||||
this->end();
|
||||
}
|
||||
// Default httpd stack is defined by ESP-IDF. Increase to accommodate SerializationBuffer's
|
||||
// 640-byte stack buffer used by web_server JSON request handlers.
|
||||
// Allow room for SerializationBuffer's 640-byte stack buffer and HTTP request handling.
|
||||
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
||||
config.stack_size = config.stack_size + 256;
|
||||
config.stack_size = 4512;
|
||||
config.server_port = this->port_;
|
||||
config.uri_match_fn = [](const char * /*unused*/, const char * /*unused*/, size_t /*unused*/) { return true; };
|
||||
// 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) {
|
||||
auto *response = static_cast<AsyncEventSourceResponse *>(arg);
|
||||
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
|
||||
// its next select/recv path delete the session and invoke destroy().
|
||||
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.
|
||||
// Treated as would-block; the stall timer ends a session that never becomes ours again.
|
||||
const int fd = this->fd_.load();
|
||||
if (httpd_sess_get_ctx(this->hd_, fd) != this) {
|
||||
if (!this->owns_session_(fd)) {
|
||||
return 0;
|
||||
}
|
||||
struct msghdr msg {};
|
||||
@@ -1081,6 +1109,7 @@ void AsyncEventSourceResponse::loop() {
|
||||
this->process_close_();
|
||||
return;
|
||||
}
|
||||
this->refresh_lru_();
|
||||
drain_tail_();
|
||||
process_deferred_queue_();
|
||||
if (this->close_requested_)
|
||||
@@ -1091,68 +1120,88 @@ void AsyncEventSourceResponse::loop() {
|
||||
|
||||
#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__)
|
||||
#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
|
||||
bool AsyncEventSourceResponse::send_json_(void *source, message_generator_t *generator) {
|
||||
// The arena lives only in this frame, so no call chain ever holds two of them
|
||||
AsyncEventSourceResponse::JsonPrepareResult AsyncEventSourceResponse::prepare_json_(void *source,
|
||||
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::JsonBuilder builder(&arena);
|
||||
generator(this->web_server_, source, builder);
|
||||
char buf[JSON_BUF_SIZE];
|
||||
const size_t len = builder.serialize_to(buf, sizeof(buf));
|
||||
if (len < sizeof(buf)) {
|
||||
return this->try_send_nodefer(buf, len, "state");
|
||||
*size_out = builder.serialize_to(buffer, JSON_BUF_SIZE);
|
||||
if (*size_out < JSON_BUF_SIZE) {
|
||||
return JsonPrepareResult::JSON_PREPARE_RESULT_BUFFER;
|
||||
}
|
||||
|
||||
// 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];
|
||||
const size_t prefix_len = build_chunk_prefix(prefix, sizeof(prefix), "state", 0, 0, true);
|
||||
char prefix[PREFIX_BUF_SIZE];
|
||||
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
|
||||
// cannot be held costs only this event, and the tail grown for it is released.
|
||||
size_t json_len = 0;
|
||||
size_t cap = std::max<size_t>(JSON_BUF_SIZE * 2, this->tail_cap_);
|
||||
for (;;) {
|
||||
if (!this->reserve_tail_(cap)) {
|
||||
this->tail_alloc_failed_(cap);
|
||||
return false; // stays deferred, retried on a later pass
|
||||
}
|
||||
const size_t room = cap - prefix_len - SSE_SUFFIX_LEN;
|
||||
json_len = builder.serialize_to(reinterpret_cast<char *>(this->tail_.get()) + prefix_len, room);
|
||||
if (json_len < room) {
|
||||
break;
|
||||
}
|
||||
if (cap >= TAIL_MAX_SIZE) {
|
||||
ESP_LOGW(TAG, "State event does not fit %zu bytes, dropped", room);
|
||||
this->tail_.reset();
|
||||
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);
|
||||
// Nothing reaches the wire until the complete document and its framing are owned by the caller.
|
||||
size_t json_len = 0;
|
||||
size_t cap = std::max<size_t>(JSON_BUF_SIZE * 2, this->tail_cap_);
|
||||
for (;;) {
|
||||
if (!this->reserve_tail_(cap)) {
|
||||
*size_out = cap;
|
||||
return JsonPrepareResult::JSON_PREPARE_RESULT_NO_MEMORY;
|
||||
}
|
||||
|
||||
const size_t total = prefix_len + json_len + SSE_SUFFIX_LEN;
|
||||
write_chunk_header(prefix, total - CHUNK_HDR_LEN - CHUNK_END_LEN);
|
||||
uint8_t *dst = this->tail_.get();
|
||||
std::memcpy(dst, prefix, prefix_len);
|
||||
std::memcpy(dst + prefix_len + json_len, SSE_SUFFIX, SSE_SUFFIX_LEN);
|
||||
this->tail_len_ = total;
|
||||
this->tail_sent_ = 0;
|
||||
const size_t room = cap - prefix_len - SSE_SUFFIX_LEN;
|
||||
json_len = builder.serialize_to(reinterpret_cast<char *>(this->tail_.get()) + prefix_len, room);
|
||||
if (json_len < room) {
|
||||
break;
|
||||
}
|
||||
if (cap >= TAIL_MAX_SIZE) {
|
||||
*size_out = room;
|
||||
return JsonPrepareResult::JSON_PREPARE_RESULT_TOO_LARGE;
|
||||
}
|
||||
cap = std::min<size_t>(cap * 2, TAIL_MAX_SIZE);
|
||||
}
|
||||
drain_tail_();
|
||||
return true;
|
||||
|
||||
const size_t total = prefix_len + json_len + SSE_SUFFIX_LEN;
|
||||
write_chunk_header(prefix, total - CHUNK_HDR_LEN - CHUNK_END_LEN);
|
||||
uint8_t *dst = this->tail_.get();
|
||||
std::memcpy(dst, prefix, prefix_len);
|
||||
std::memcpy(dst + prefix_len + json_len, SSE_SUFFIX, SSE_SUFFIX_LEN);
|
||||
this->tail_len_ = total;
|
||||
this->tail_sent_ = 0;
|
||||
return JsonPrepareResult::JSON_PREPARE_RESULT_TAIL;
|
||||
}
|
||||
#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__)
|
||||
#pragma GCC diagnostic pop
|
||||
#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) {
|
||||
// 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();
|
||||
|
||||
@@ -317,6 +317,17 @@ class AsyncEventSourceResponse {
|
||||
size_t sent);
|
||||
// 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);
|
||||
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
|
||||
void tail_alloc_failed_(size_t cap);
|
||||
void request_close_();
|
||||
@@ -329,10 +340,15 @@ class AsyncEventSourceResponse {
|
||||
};
|
||||
void process_close_();
|
||||
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 {
|
||||
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);
|
||||
@@ -347,11 +363,13 @@ class AsyncEventSourceResponse {
|
||||
RAMUniquePtr<uint8_t[]> tail_;
|
||||
uint32_t send_failure_started_ms_{0}; // Zero means no send stall in progress.
|
||||
uint32_t next_close_attempt_ms_{0};
|
||||
uint32_t last_lru_refresh_ms_{0};
|
||||
uint16_t tail_cap_{0};
|
||||
uint16_t tail_len_{0}; // Zero means nothing pending
|
||||
uint16_t tail_sent_{0};
|
||||
// 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> lru_work_queued_{false};
|
||||
// Main-loop only; the HTTPD task never reads or writes these flags.
|
||||
bool close_requested_{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 CLOSE_RETRY_INTERVAL_MS = 250;
|
||||
static constexpr uint32_t CLOSE_CONFIRM_INTERVAL_MS = 1000;
|
||||
static constexpr uint32_t LRU_REFRESH_INTERVAL_MS = 1000;
|
||||
};
|
||||
|
||||
using AsyncEventSourceClient = AsyncEventSourceResponse;
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
|
||||
from esphome.enum import StrEnum
|
||||
|
||||
__version__ = "2026.10.0b4"
|
||||
__version__ = "2026.10.1"
|
||||
|
||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||
|
||||
+18
-11
@@ -648,6 +648,8 @@ class EsphomeCore:
|
||||
self.main_statements: list[Statement] = []
|
||||
# A list of statements to insert in the global block (includes and global variables)
|
||||
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))
|
||||
self.platformio_libraries: dict[str, Library] = {}
|
||||
# A set of build flags to set in the platformio project
|
||||
@@ -716,6 +718,7 @@ class EsphomeCore:
|
||||
self.variables = {}
|
||||
self.main_statements = []
|
||||
self.global_statements = []
|
||||
self.global_statements_after_includes = []
|
||||
self.platformio_libraries = {}
|
||||
self.build_flags = set()
|
||||
self.cmake_args = {}
|
||||
@@ -1032,8 +1035,8 @@ class EsphomeCore:
|
||||
except RuntimeError as e:
|
||||
raise EsphomeError(str(e)) from e
|
||||
|
||||
def add(self, expression, prepend=False) -> "Statement":
|
||||
"""Add an expression or statement to the main setup() block."""
|
||||
@staticmethod
|
||||
def _as_statement(expression) -> "Statement":
|
||||
from esphome.cpp_generator import Expression, Statement, statement
|
||||
|
||||
if isinstance(expression, Expression):
|
||||
@@ -1042,7 +1045,11 @@ class EsphomeCore:
|
||||
raise ValueError(
|
||||
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:
|
||||
self.main_statements.insert(0, expression)
|
||||
else:
|
||||
@@ -1051,14 +1058,7 @@ class EsphomeCore:
|
||||
return expression
|
||||
|
||||
def add_global(self, expression, prepend=False) -> "Statement":
|
||||
from esphome.cpp_generator import Expression, Statement, statement
|
||||
|
||||
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)}"
|
||||
)
|
||||
expression = self._as_statement(expression)
|
||||
if prepend:
|
||||
self.global_statements.insert(0, expression)
|
||||
else:
|
||||
@@ -1066,6 +1066,13 @@ class EsphomeCore:
|
||||
_LOGGER.debug("Adding global: %s", 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):
|
||||
if not isinstance(library, Library):
|
||||
raise TypeError(
|
||||
@@ -1245,7 +1252,7 @@ class EsphomeCore:
|
||||
from esphome.cpp_generator import statement
|
||||
|
||||
global_code = []
|
||||
for exp in self.global_statements:
|
||||
for exp in (*self.global_statements, *self.global_statements_after_includes):
|
||||
text = str(statement(exp))
|
||||
text = text.rstrip()
|
||||
global_code.append(text)
|
||||
|
||||
@@ -169,7 +169,7 @@ struct Color {
|
||||
uint8_t r = rand >> 16;
|
||||
uint8_t g = rand >> 8;
|
||||
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)),
|
||||
uint8_t((uint16_t(b) * 255U / max_rgb)), w);
|
||||
}
|
||||
|
||||
@@ -577,7 +577,8 @@ async def add_arduino_global_workaround():
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
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:
|
||||
path = CORE.relative_config_path(include)
|
||||
if path.is_dir():
|
||||
|
||||
+20
-10
@@ -318,6 +318,8 @@ class FloatLiteral(Literal):
|
||||
def __str__(self):
|
||||
if math.isnan(self.f):
|
||||
return "NAN"
|
||||
if math.isinf(self.f):
|
||||
return "INFINITY" if self.f > 0 else "-INFINITY"
|
||||
return f"{self.f}f"
|
||||
|
||||
|
||||
@@ -506,8 +508,8 @@ def shared_progmem_array(
|
||||
used |= {str(i) for i in CORE.variables}
|
||||
used |= set(RESERVED_IDS) | CORE.loaded_integrations
|
||||
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.
|
||||
CORE.add_global(ProgmemAssignmentExpression(type_, id_, rhs))
|
||||
# After the user includes, so a lambda in the table can use what their headers declare.
|
||||
CORE.add_global_after_includes(ProgmemAssignmentExpression(type_, id_, rhs))
|
||||
array = MockObj(id_, ".")
|
||||
CORE.register_variable(id_, array)
|
||||
if share:
|
||||
@@ -731,15 +733,16 @@ def static_function(
|
||||
"""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.
|
||||
|
||||
Every id the body names must already be declared, which holds when the statements were
|
||||
rendered through ``get_variable`` or ``process_lambda``.
|
||||
The declaration goes with the other globals, so a template argument can name the function
|
||||
at once; the body goes after the user includes, where every id and everything those
|
||||
headers declare is in scope.
|
||||
"""
|
||||
params = ParameterListExpression(*parameters)
|
||||
add_global(
|
||||
RawStatement(
|
||||
f"static {safe_exp(return_type)} {name}({params}) {{\n"
|
||||
f"{indent(chr(10).join(body))}\n}}"
|
||||
)
|
||||
signature = (
|
||||
f"static {safe_exp(return_type)} {name}({ParameterListExpression(*parameters)})"
|
||||
)
|
||||
add_global(RawStatement(f"{signature};"))
|
||||
add_global_after_includes(
|
||||
RawStatement(f"{signature} {{\n{indent(chr(10).join(body))}\n}}")
|
||||
)
|
||||
return RawExpression(name)
|
||||
|
||||
@@ -758,6 +761,13 @@ def add_global(expression: SafeExpType | Statement, prepend: bool = False):
|
||||
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):
|
||||
"""Add a library to the codegen library storage.
|
||||
|
||||
|
||||
@@ -266,14 +266,24 @@ def _convert_pio_libs(
|
||||
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.
|
||||
"""
|
||||
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
|
||||
|
||||
libraries = _parse_lib_deps(platformio_ini, framework)
|
||||
# Same rule as noise._use_managed_components and the manifest
|
||||
managed = set() if arduino_bundles_libsodium() else _esphome_manifest_deps()
|
||||
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)}
|
||||
return deps
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ for each resolved library, running any PlatformIO ``extraScript``, and the
|
||||
ESP-IDF platform/framework compatibility defaults.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -288,18 +289,23 @@ def _emit_idf_component(component: IDFComponent) -> None:
|
||||
|
||||
|
||||
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]:
|
||||
"""Resolve and convert a batch of PlatformIO libraries to IDF components.
|
||||
|
||||
``managed`` names registry components already in the project manifest; they
|
||||
are not converted, since IDF rejects the same requirement from two 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(
|
||||
platform=ESP32_PLATFORM,
|
||||
framework=_idf_framework(),
|
||||
emit=_emit_idf_component,
|
||||
cache_key="idf",
|
||||
provides=provides,
|
||||
)
|
||||
return convert_libraries(libraries, backend, provided=managed)
|
||||
@@ -205,28 +205,42 @@ def run_command(
|
||||
return False, None, None
|
||||
|
||||
|
||||
def tool_version_runs(binary: str, warning: str) -> bool:
|
||||
"""Probe ``binary --version``; on failure warn with ``warning`` % binary.
|
||||
def tool_version_probe(binary: str) -> str | Exception:
|
||||
"""Run ``binary --version``: its stdout, or the error when it fails to run.
|
||||
|
||||
``shutil.which`` proves existence, not runnability (Windows .bat/.cmd
|
||||
shims, stale package-manager shims).
|
||||
"""
|
||||
try:
|
||||
subprocess.run(
|
||||
result = subprocess.run(
|
||||
[binary, "--version"],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
errors="replace",
|
||||
timeout=15,
|
||||
# Repo-wide convention (posix_spawn fast path)
|
||||
close_fds=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as err:
|
||||
# The cause (permission denied, missing DLL, timeout) is the one
|
||||
# detail the user needs to fix it
|
||||
_LOGGER.warning("%s (%s)", warning % binary, err)
|
||||
return False
|
||||
return True
|
||||
return err
|
||||
return result.stdout
|
||||
|
||||
|
||||
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
|
||||
# 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:
|
||||
|
||||
+251
-101
@@ -609,9 +609,34 @@ def parse_library_properties(library_properties_path: PathType):
|
||||
if not value.strip():
|
||||
continue
|
||||
data[key.strip()] = value.strip()
|
||||
if depends := data.pop("depends", None):
|
||||
data["dependencies"] = parse_properties_depends(depends)
|
||||
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:
|
||||
"""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
|
||||
|
||||
|
||||
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(
|
||||
dep: dict, platform: str | None, framework: str, requester: str
|
||||
) -> bool:
|
||||
@@ -762,6 +773,20 @@ def dependency_is_usable(
|
||||
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:
|
||||
"""Whether a normalized entry carries a usable name (non-empty string),
|
||||
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
|
||||
warning for entries that cannot be normalized.
|
||||
|
||||
Bare-name spellings carry no version; the dependency walk later drops
|
||||
version-less entries at DEBUG, since they are usually names of bundled
|
||||
framework libraries (Wire, SPI) that need no registry install.
|
||||
Bare-name spellings carry no version; the walk settles them once the graph is known.
|
||||
"""
|
||||
if dependencies is None:
|
||||
return []
|
||||
if isinstance(dependencies, str):
|
||||
# A plain string is one or more comma-separated names; iterating it
|
||||
# as a list would shred it into one-character "libraries"
|
||||
return [{"name": n.strip()} for n in dependencies.split(",") if n.strip()]
|
||||
return [_split_owner(entry) for entry in parse_properties_depends(dependencies)]
|
||||
if isinstance(dependencies, dict):
|
||||
normalized = []
|
||||
for raw_name, spec in dependencies.items():
|
||||
@@ -826,10 +847,10 @@ def normalize_dependencies(
|
||||
for entry in dependencies:
|
||||
if isinstance(entry, dict):
|
||||
if _valid_dependency_entry(entry, manifest_name):
|
||||
normalized.append(entry)
|
||||
normalized.append(_split_owner(entry))
|
||||
elif isinstance(entry, str) and entry:
|
||||
# PIO also accepts a bare list of names ("dependencies": ["Wire"])
|
||||
normalized.append({"name": entry})
|
||||
normalized.append(_split_owner({"name": entry}))
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"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(
|
||||
skipped_versionless: list[tuple[Any, Any, str]],
|
||||
def _short_name(node: _LibNode) -> 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],
|
||||
backend: LibraryBackend,
|
||||
) -> None:
|
||||
"""Warn for version-less deps nothing satisfied, and record the
|
||||
backend-provided ones in ``backend.provided_requests`` for its
|
||||
post-emit reconciliation; a silent drop surfaces as link errors far
|
||||
from the cause."""
|
||||
resolved_manifest_names = {c.data.get("name") for c in components.values()}
|
||||
# A treeless backend can never supply a bundled name; noise for it
|
||||
log = _LOGGER.warning if backend.provides is not None else _LOGGER.debug
|
||||
warned: set[str] = set()
|
||||
for dep_name, dep_owner, requester in skipped_versionless:
|
||||
if not isinstance(dep_name, str) or not dep_name or dep_name in warned:
|
||||
continue
|
||||
if dep_name in components:
|
||||
# A version-less dep's request key is the name itself
|
||||
continue
|
||||
if (
|
||||
not dep_owner
|
||||
and backend.provides is not None
|
||||
and backend.provides(dep_name)
|
||||
):
|
||||
# provides() only satisfies owner-less names (same guard as
|
||||
# the walk's skip); record for the post-emit reconciliation.
|
||||
# Checked before the manifest-name evidence so the overlap
|
||||
# case warns once, in the backend's own suppression loop
|
||||
backend.provided_requests.add(dep_name)
|
||||
continue
|
||||
if dep_name in resolved_manifest_names:
|
||||
# Name-only evidence: a coincidental collision must stay
|
||||
# visible where the user could pin it
|
||||
warned.add(dep_name)
|
||||
log(
|
||||
"Version-less dependency %s of %s assumed satisfied by a "
|
||||
"resolved library's manifest name only",
|
||||
dep_name,
|
||||
requester,
|
||||
)
|
||||
continue
|
||||
warned.add(dep_name)
|
||||
log(
|
||||
"Dependency %s of %s has no version to resolve and nothing "
|
||||
"provides it; skipping",
|
||||
dep_name,
|
||||
requester,
|
||||
)
|
||||
) -> tuple[str, bool] | None:
|
||||
"""The key of a resolved library a version-less dependency names, and whether
|
||||
only its manifest name matched: the registry package name (with the owner,
|
||||
when the dependency gives one) is checked first; never the requester itself."""
|
||||
wanted = name.lower()
|
||||
|
||||
def owner_of(key: str, component: ConvertedLibrary) -> str:
|
||||
# The owner as requested, else the one the registry (or a git path) gave
|
||||
head, sep, _ = component.name.partition("/")
|
||||
return nodes[key].owner or (head if sep else "")
|
||||
|
||||
candidates = [
|
||||
(key, component)
|
||||
for key, component in components.items()
|
||||
if key != requester
|
||||
and (not owner or owner_of(key, component).lower() == owner.lower())
|
||||
]
|
||||
for key, _ in candidates:
|
||||
if _short_name(nodes[key]).lower() == wanted:
|
||||
return key, False
|
||||
if owner:
|
||||
return None
|
||||
for key, component in candidates:
|
||||
manifest_name = component.data.get("name")
|
||||
if isinstance(manifest_name, str) and manifest_name.lower() == wanted:
|
||||
return key, True
|
||||
return None
|
||||
|
||||
|
||||
def _own_names(
|
||||
key: str, nodes: dict[str, _LibNode], components: dict[str, ConvertedLibrary]
|
||||
) -> set[str]:
|
||||
"""The lower-case key, requested and resolved package names and the manifest
|
||||
name a resolved library goes by."""
|
||||
names = {
|
||||
key.lower(),
|
||||
_short_name(nodes[key]).lower(),
|
||||
components[key].name.lower(),
|
||||
}
|
||||
manifest_name = components[key].data.get("name")
|
||||
if isinstance(manifest_name, str):
|
||||
names.add(manifest_name.lower())
|
||||
return names
|
||||
|
||||
|
||||
def _fetch_source(
|
||||
@@ -1147,7 +1175,12 @@ def convert_libraries(
|
||||
managed components from ``add_idf_component``); unlike ``backend.provides`` they
|
||||
carry an owner. They are excluded like ``lib_ignore`` so nothing is both
|
||||
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] = {}
|
||||
|
||||
# Folded into one set so every is_lib_ignored() call site honors both.
|
||||
@@ -1165,10 +1198,16 @@ def convert_libraries(
|
||||
else ""
|
||||
)
|
||||
|
||||
resolved_requirements: dict[str, frozenset[str]] = {}
|
||||
|
||||
def add_spec(name: str | None, version: str | None, repository: str | None) -> str:
|
||||
key, kind, locator = _node_key(name, version, repository)
|
||||
node = nodes.get(key) or _LibNode(key=key, is_git=kind == "git")
|
||||
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
|
||||
# different local paths) is a config mistake: one silently wins. Warn so
|
||||
# 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
|
||||
# graph is accounted for before conversion.
|
||||
components: dict[str, ConvertedLibrary] = {}
|
||||
resolved_requirements: dict[str, frozenset[str]] = {}
|
||||
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))
|
||||
while worklist:
|
||||
# Drain the frontier sequentially (spec resolution mutates shared
|
||||
@@ -1245,9 +1347,17 @@ def convert_libraries(
|
||||
elif node.is_local:
|
||||
component = ConvertedLibrary(key, "*", LocalSource(node.local_path))
|
||||
else:
|
||||
owner, name, version, url, size = _resolve_registry_version(
|
||||
node.owner, node.pkgname, node.requirements
|
||||
)
|
||||
try:
|
||||
owner, name, version, url, size = _resolve_registry_version(
|
||||
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(
|
||||
_owner_pkgname_to_name(owner, name), version, URLSource(url, size)
|
||||
)
|
||||
@@ -1255,9 +1365,9 @@ def convert_libraries(
|
||||
_prefetch_wave(wave, salt, backend.cache_key)
|
||||
for key, component in wave:
|
||||
node = nodes[key]
|
||||
if frozenset(node.requirements) != resolved_requirements[key]:
|
||||
# Requirements grew mid-wave: skip parsing a manifest the
|
||||
# next wave will re-resolve and replace
|
||||
if frozenset(node.requirements) != resolved_requirements.get(key):
|
||||
# Requirements grew (or the source changed) mid-wave: skip parsing
|
||||
# a manifest the next wave will re-resolve and replace
|
||||
worklist.append(key)
|
||||
continue
|
||||
component.download(salt=salt, namespace=backend.cache_key)
|
||||
@@ -1313,8 +1423,6 @@ def convert_libraries(
|
||||
raise EsphomeError(f"Library {key} has a malformed manifest")
|
||||
_LOGGER.warning("Skipping dependency %s: malformed manifest", key)
|
||||
continue
|
||||
warn_properties_depends(component.name, component.data)
|
||||
|
||||
try:
|
||||
check_library_data(component.data, backend.platform, backend.framework)
|
||||
except InvalidLibrary as e:
|
||||
@@ -1337,27 +1445,19 @@ def convert_libraries(
|
||||
# Requirements changed (we got past the short-circuit above), so
|
||||
# (re)walk this component's dependencies.
|
||||
node.edges = set()
|
||||
pending = pending_versionless[key] = []
|
||||
for dependency in normalize_dependencies(
|
||||
component.data.get("dependencies"), component.name
|
||||
):
|
||||
if "version" not in dependency:
|
||||
# Cannot resolve from the registry; the post-emit
|
||||
# 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 dependency.get("version"):
|
||||
# An empty version is as good as none; it settles the same way
|
||||
if not is_lib_ignored(
|
||||
dep_name, lib_ignore
|
||||
dependency.get("name"), lib_ignore
|
||||
) and dependency_is_usable(
|
||||
dependency, backend.platform, backend.framework, component.name
|
||||
):
|
||||
# Filtered or ignored deps are deliberately absent
|
||||
skipped_versionless.append(
|
||||
(dep_name, dependency.get("owner"), component.name)
|
||||
)
|
||||
pending.append(dependency)
|
||||
continue
|
||||
if not dependency_is_usable(
|
||||
dependency, backend.platform, backend.framework, component.name
|
||||
@@ -1375,11 +1475,7 @@ def convert_libraries(
|
||||
dep_url = _url_or_none(dep_version)
|
||||
if dep_url is not None:
|
||||
dep_version = None
|
||||
elif (
|
||||
backend.provides is not None
|
||||
and not dependency.get("owner")
|
||||
and backend.provides(dep_name)
|
||||
):
|
||||
elif backend_provides(dep_name, dependency.get("owner")):
|
||||
# The backend adds it from its own tree; resolving here
|
||||
# would fetch a same-named registry package
|
||||
if dep_version and dep_version != "*":
|
||||
@@ -1392,11 +1488,12 @@ def convert_libraries(
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug("Skip backend-provided dependency %s", dep_name)
|
||||
backend.provided_requests.add(dep_name)
|
||||
continue
|
||||
dep_key = add_spec(dep_name, dep_version, dep_url)
|
||||
node.edges.add(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
|
||||
# registry. That's intentional, but warn so the dropped registry spec isn't
|
||||
@@ -1420,6 +1517,61 @@ def convert_libraries(
|
||||
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
|
||||
# referenced both bare and as ``owner/name``) are not deduplicated and can
|
||||
# produce conflicting component definitions. Warn so it's not silent.
|
||||
@@ -1447,6 +1599,4 @@ def convert_libraries(
|
||||
for component in components.values():
|
||||
backend.emit(component)
|
||||
|
||||
_reconcile_versionless_skips(skipped_versionless, components, backend)
|
||||
|
||||
return [components[key] for key in top_level if key in components]
|
||||
@@ -190,8 +190,19 @@ class RedirectText:
|
||||
"""Whether output is handled line by line rather than passed through."""
|
||||
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:
|
||||
if self._is_filtered(line):
|
||||
self._drop_line(line)
|
||||
return
|
||||
line_without_end = ANSI_ESCAPE.sub("", line).rstrip()
|
||||
|
||||
|
||||
+2
-2
@@ -147,7 +147,7 @@ extends = common:arduino
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip
|
||||
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-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
|
||||
lib_deps =
|
||||
@@ -183,7 +183,7 @@ extra_scripts =
|
||||
extends = common:idf
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip
|
||||
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
|
||||
lib_deps =
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ pyserial==3.5
|
||||
platformio==6.2.0
|
||||
esptool==5.4.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
|
||||
zeroconf==0.151.5
|
||||
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
|
||||
# sequence array was generated instead of raising during code generation.
|
||||
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
|
||||
|
||||
# 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).
|
||||
model = MODELS["ESP32-8048S070"]
|
||||
model = MODELS["ST7701S"]
|
||||
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}})
|
||||
|
||||
# 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}})
|
||||
|
||||
|
||||
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(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
|
||||
@@ -100,6 +100,14 @@ button:
|
||||
id: climate_test_thermostat
|
||||
custom_fan_mode: !lambda return "quiet";
|
||||
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
|
||||
# passes float).
|
||||
|
||||
@@ -43,6 +43,21 @@ button:
|
||||
url: http://my.ha.net:8123/local/esphome/firmware.bin
|
||||
|
||||
- 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:
|
||||
- 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
|
||||
@@ -138,6 +138,10 @@ esphome:
|
||||
- light.turn_on:
|
||||
id: test_monochromatic_light
|
||||
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)
|
||||
- repeat:
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,3 +1,4 @@
|
||||
# The disconnects counter is compiled only with USE_SENSOR; declaring the
|
||||
# sensor domain makes the C++ unit test build define it.
|
||||
# The connection entities are compiled only with USE_BINARY_SENSOR and
|
||||
# USE_SENSOR; declaring the domains makes the C++ unit test build define them.
|
||||
binary_sensor:
|
||||
sensor:
|
||||
@@ -10,8 +10,16 @@ tcp_uart:
|
||||
allowed_ips:
|
||||
- 192.0.2.20
|
||||
- 192.0.2.0/24
|
||||
|
||||
binary_sensor:
|
||||
- platform: tcp_uart
|
||||
tcp_uart_id: tcp_uart_server
|
||||
connected:
|
||||
name: TCP UART Server Connected
|
||||
|
||||
sensor:
|
||||
- platform: tcp_uart
|
||||
tcp_uart_id: tcp_uart_server
|
||||
disconnects:
|
||||
name: TCP UART Server Disconnects
|
||||
|
||||
|
||||
@@ -7,7 +7,15 @@ tcp_uart:
|
||||
host: 192.0.2.10
|
||||
port: 502
|
||||
reconnect_interval: 10s
|
||||
|
||||
binary_sensor:
|
||||
- platform: tcp_uart
|
||||
tcp_uart_id: tcp_uart_1
|
||||
connected:
|
||||
name: TCP UART Connected
|
||||
|
||||
sensor:
|
||||
- platform: tcp_uart
|
||||
tcp_uart_id: tcp_uart_1
|
||||
disconnects:
|
||||
name: TCP UART Disconnects
|
||||
@@ -1,3 +1,4 @@
|
||||
# The disconnects counter is compiled only with USE_SENSOR; declaring the
|
||||
# sensor domain makes the C++ unit test build define it.
|
||||
# The connection entities are compiled only with USE_BINARY_SENSOR and
|
||||
# USE_SENSOR; declaring the domains makes the C++ unit test build define them.
|
||||
binary_sensor:
|
||||
sensor:
|
||||
@@ -10,7 +10,15 @@ uart_tcp:
|
||||
allowed_ips:
|
||||
- 192.168.1.10
|
||||
- 192.168.1.0/24
|
||||
|
||||
binary_sensor:
|
||||
- platform: uart_tcp
|
||||
uart_tcp_id: bridge
|
||||
connected:
|
||||
name: UART TCP Connected
|
||||
|
||||
sensor:
|
||||
- platform: uart_tcp
|
||||
uart_tcp_id: bridge
|
||||
disconnects:
|
||||
name: UART TCP Disconnects
|
||||
@@ -12,7 +12,15 @@ uart_tcp:
|
||||
host: 192.0.2.20
|
||||
port: 502
|
||||
reconnect_interval: 10s
|
||||
|
||||
binary_sensor:
|
||||
- platform: uart_tcp
|
||||
uart_tcp_id: bridge
|
||||
connected:
|
||||
name: UART TCP Connected
|
||||
|
||||
sensor:
|
||||
- platform: uart_tcp
|
||||
uart_tcp_id: bridge
|
||||
disconnects:
|
||||
name: UART TCP Disconnects
|
||||
@@ -8,7 +8,15 @@ uart_tcp:
|
||||
uart_id: uart_bus
|
||||
host: 127.0.0.1
|
||||
port: 44502
|
||||
|
||||
binary_sensor:
|
||||
- platform: uart_tcp
|
||||
uart_tcp_id: bridge
|
||||
connected:
|
||||
name: UART TCP Connected
|
||||
|
||||
sensor:
|
||||
- platform: uart_tcp
|
||||
uart_tcp_id: bridge
|
||||
disconnects:
|
||||
name: UART TCP Disconnects
|
||||
@@ -6,3 +6,6 @@ web_server:
|
||||
username: admin
|
||||
password: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
type: basic
|
||||
|
||||
ota:
|
||||
- platform: web_server
|
||||
@@ -236,6 +236,10 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s
|
||||
external_components_path = str(FIXTURES_DIR / "external_components")
|
||||
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:
|
||||
# _compile verifies the marked test compiles this content unmodified
|
||||
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
Reference in new issue
Block a user