mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 19:44:08 +00:00
Merge branch 'dev' into web-server-offline-hint
This commit is contained in:
@@ -219,5 +219,6 @@ jobs:
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "${{ github.workspace }}/docker/test_configs:/config" \
|
||||
-e ESPHOME_LDGEN_STRICT=1 \
|
||||
"ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \
|
||||
compile "${{ matrix.id }}.yaml"
|
||||
|
||||
@@ -299,7 +299,7 @@ jobs:
|
||||
# Pushes any fixes the hooks made back to the pull request. This step
|
||||
# must keep its default name: the GitHub App that performs the push
|
||||
# locates the workflow run by that name.
|
||||
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
|
||||
- uses: pre-commit-ci/lite-action@062bca0919bc9d6e66755cc05074b70c77e111fc # v1.2.0
|
||||
if: always()
|
||||
with:
|
||||
msg: apply automatic formatting fixes
|
||||
@@ -626,7 +626,7 @@ jobs:
|
||||
apt-get install -y libc6-dbg
|
||||
|
||||
- name: Run CodSpeed benchmarks
|
||||
uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea # v5.2.1
|
||||
uses: CodSpeedHQ/action@c4fd08a3a159bd0cc208da1e0edf32b8c47d75e5 # v5.4.0
|
||||
with:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
@@ -1225,7 +1225,14 @@ jobs:
|
||||
|
||||
if [ -n "$compile_csv" ]; then
|
||||
# Run compilation with grouping and isolation
|
||||
python3 script/test_build_components.py -e compile -c "$compile_csv" -f --isolate "$directly_changed_csv"
|
||||
# The bootloader has no ESPHome code and these builds never
|
||||
# flash; the check_idf_py batch keeps the full build so the
|
||||
# native sub-build and the equivalence check stay covered.
|
||||
skip_flag="--skip-bootloader"
|
||||
if [[ "${{ matrix.batch.check_idf_py }}" == "true" ]]; then
|
||||
skip_flag=""
|
||||
fi
|
||||
python3 script/test_build_components.py -e compile -c "$compile_csv" -f --isolate "$directly_changed_csv" $skip_flag
|
||||
if [[ "${{ matrix.batch.check_idf_py }}" == "true" ]]; then
|
||||
# The real idf.py must find nothing to configure or build in a
|
||||
# tree built above; catches drift on ESP-IDF bumps.
|
||||
|
||||
@@ -125,14 +125,20 @@ jobs:
|
||||
}
|
||||
|
||||
async function getEsphomeAndComponentChanges(github, owner, repo, prNumber) {
|
||||
const changedFiles = await github.rest.pulls.listFiles({
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
const changedFiles = await github.paginate(
|
||||
github.rest.pulls.listFiles,
|
||||
{
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
}
|
||||
);
|
||||
|
||||
const esphomeChanges = changedFiles.data
|
||||
.filter(file => file.filename !== "esphome/core/defines.h" && file.filename.startsWith('esphome/'))
|
||||
// Files used only for development and CI, which do not affect use as an external component
|
||||
const ignoredFiles = ["esphome/core/defines.h", "esphome/idf_component.yml"];
|
||||
const esphomeChanges = changedFiles
|
||||
.filter(file => !ignoredFiles.includes(file.filename) && file.filename.startsWith('esphome/'))
|
||||
.map(file => {
|
||||
const match = file.filename.match(/esphome\/([^/]+)/);
|
||||
return match ? match[1] : null;
|
||||
@@ -144,7 +150,7 @@ jobs:
|
||||
}
|
||||
|
||||
const uniqueEsphomeChanges = [...new Set(esphomeChanges)];
|
||||
const componentChanges = changedFiles.data
|
||||
const componentChanges = changedFiles
|
||||
.filter(file => file.filename.startsWith('esphome/components/'))
|
||||
.map(file => {
|
||||
const match = file.filename.match(/esphome\/components\/([^/]+)\//);
|
||||
|
||||
@@ -7,10 +7,6 @@ on:
|
||||
permissions:
|
||||
pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, needs-developer-docs, merge-after-release, chained-pr)
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check blocking labels
|
||||
|
||||
@@ -10,7 +10,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.16.9
|
||||
rev: v0.16.10
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
|
||||
@@ -187,10 +187,12 @@ esphome/components/esp32_rmt/* @jesserockz
|
||||
esphome/components/esp32_rmt_led_strip/* @jesserockz
|
||||
esphome/components/esp8266/* @esphome/core
|
||||
esphome/components/esp_ldo/* @clydebarrow
|
||||
esphome/components/espectre/* @francescopace
|
||||
esphome/components/espnow/* @jesserockz
|
||||
esphome/components/espnow/packet_transport/* @EasilyBoredEngineer
|
||||
esphome/components/ethernet_info/* @gtjadsonsantos
|
||||
esphome/components/event/* @nohat
|
||||
esphome/components/exponential_moving_average/* @clydebarrow
|
||||
esphome/components/exposure_notifications/* @OttoWinter
|
||||
esphome/components/ezo/* @ssieb
|
||||
esphome/components/ezo_pmp/* @carlos-sarmiento
|
||||
@@ -245,8 +247,11 @@ esphome/components/hmac_md5/* @dwmw2
|
||||
esphome/components/hmac_sha256/* @dwmw2
|
||||
esphome/components/hoermann_hcp/* @zweckj
|
||||
esphome/components/homeassistant/* @esphome/core @OttoWinter
|
||||
esphome/components/homeassistant/button/* @jesserockz
|
||||
esphome/components/homeassistant/number/* @landonr
|
||||
esphome/components/homeassistant/select/* @jesserockz
|
||||
esphome/components/homeassistant/switch/* @Links2004
|
||||
esphome/components/homeassistant/text/* @jesserockz
|
||||
esphome/components/honeywell_hih_i2c/* @Benichou34
|
||||
esphome/components/honeywellabp/* @RubyBailey
|
||||
esphome/components/honeywellabp2_i2c/* @jpfaff
|
||||
@@ -541,6 +546,7 @@ esphome/components/st7735/* @SenexCrenshaw
|
||||
esphome/components/st7789v/* @kbx81
|
||||
esphome/components/st7920/* @marsjan155
|
||||
esphome/components/statsd/* @Links2004
|
||||
esphome/components/stcc4/* @j9brown
|
||||
esphome/components/stts22h/* @B48D81EFCC
|
||||
esphome/components/substitutions/* @esphome/core
|
||||
esphome/components/sun/* @OttoWinter
|
||||
@@ -559,6 +565,7 @@ esphome/components/tc74/* @sethgirvan
|
||||
esphome/components/tca9548a/* @andreashergert1984
|
||||
esphome/components/tca9555/* @mobrembski
|
||||
esphome/components/tcl112/* @glmnet
|
||||
esphome/components/tcp_uart/* @Bascht74
|
||||
esphome/components/tee501/* @Stock-M
|
||||
esphome/components/teleinfo/* @0hax
|
||||
esphome/components/tem3200/* @bakerkj
|
||||
@@ -600,11 +607,13 @@ esphome/components/uart/button/* @ssieb
|
||||
esphome/components/uart/event/* @eoasmxd
|
||||
esphome/components/uart/packet_transport/* @clydebarrow
|
||||
esphome/components/uart_mux/* @kbx81
|
||||
esphome/components/uart_tcp/* @Bascht74
|
||||
esphome/components/udp/* @clydebarrow
|
||||
esphome/components/ufire_ec/* @pvizeli
|
||||
esphome/components/ufire_ise/* @pvizeli
|
||||
esphome/components/ufm01/* @ljungqvist
|
||||
esphome/components/ultrasonic/* @ssieb @swoboda1337
|
||||
esphome/components/unicode/* @esphome/core
|
||||
esphome/components/update/* @jesserockz
|
||||
esphome/components/uponor_smatrix/* @kroimon
|
||||
esphome/components/usb_cdc_acm/* @kbx81
|
||||
|
||||
+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.17.2
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.21.0
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
+50
-8
@@ -148,6 +148,7 @@ class ArgsProtocol(Protocol):
|
||||
file: str | None
|
||||
no_logs: bool
|
||||
only_generate: bool
|
||||
skip_bootloader: bool
|
||||
show_secrets: bool
|
||||
dashboard: bool
|
||||
configuration: str
|
||||
@@ -827,6 +828,14 @@ def write_cpp_file() -> int:
|
||||
|
||||
|
||||
def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
if CORE.skip_bootloader and not (CORE.is_esp32 and CORE.using_toolchain_esp_idf):
|
||||
# Debug only: an orchestrator cannot see YAML toolchain overrides,
|
||||
# so this is its expected no-op, and a full build is safe.
|
||||
_LOGGER.debug(
|
||||
"--skip-bootloader ignored: only supported on ESP32 with the "
|
||||
"esp-idf toolchain"
|
||||
)
|
||||
CORE.skip_bootloader = False
|
||||
# Keep this gate here, NOT in config validation: device-builder needs
|
||||
# `esphome config` to keep succeeding with placeholders so onboarding can run.
|
||||
if CONF_WIFI in config:
|
||||
@@ -857,7 +866,10 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
return rc
|
||||
|
||||
# Create factory.bin, ota.bin, and firmware.elf copy
|
||||
toolchain.create_factory_bin()
|
||||
if not toolchain.create_factory_bin():
|
||||
# A build whose factory image could not be produced must not
|
||||
# exit 0; downloads would serve an image from an older build.
|
||||
return 1
|
||||
toolchain.create_ota_bin()
|
||||
toolchain.create_elf_copy()
|
||||
from esphome.build_helpers.idedata import warn_if_idedata_missing
|
||||
@@ -975,8 +987,9 @@ def upload_using_esptool(
|
||||
# and partitions included where the target needs them)
|
||||
image = native.get_factory_firmware_path()
|
||||
if not image.is_file():
|
||||
hint = getattr(native, "missing_image_hint", lambda: None)()
|
||||
raise EsphomeError(
|
||||
f"{image} does not exist; compile the configuration first"
|
||||
hint or f"{image} does not exist; compile the configuration first"
|
||||
)
|
||||
flash_images = [FlashImage(path=image, offset="0x0")]
|
||||
else:
|
||||
@@ -1287,10 +1300,9 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
|
||||
The native API uses challenge-response auth with MD5/SHA256 hashing of a
|
||||
server-issued nonce, so the password is never sent over the wire; the
|
||||
``web_server`` path uses HTTP Basic auth which transmits credentials in
|
||||
cleartext over the LAN. (The native path also supports gzip compression
|
||||
on ESP8266, where flash space is tight; on ESP32/RP2040/LibreTiny the
|
||||
backend reports ``supports_compression() == false`` and the firmware is
|
||||
sent uncompressed regardless of which platform is used.) Falls back to
|
||||
cleartext over the LAN. (The native path also compresses the upload:
|
||||
gzip on ESP8266 and RP2040, which inflate it at reboot, and a deflate
|
||||
stream on ESP32/LibreTiny, which inflate it as it arrives.) Falls back to
|
||||
``web_server`` only when that is the only available platform.
|
||||
"""
|
||||
# Use a dict (insertion-ordered) instead of a list so error messages and
|
||||
@@ -1379,6 +1391,12 @@ def _upload_via_native_api(
|
||||
ota_type = espota2.OTA_TYPE_UPDATE_PARTITION_TABLE
|
||||
elif getattr(args, "bootloader", False):
|
||||
check_partition_access("--bootloader")
|
||||
if (
|
||||
getattr(args, "file", None) is None
|
||||
and (native := native_backend())
|
||||
and (hint := getattr(native, "missing_image_hint", lambda: None)())
|
||||
):
|
||||
raise EsphomeError(hint)
|
||||
binary = CORE.bootloader_bin
|
||||
ota_type = espota2.OTA_TYPE_UPDATE_BOOTLOADER
|
||||
if getattr(args, "file", None) is not None:
|
||||
@@ -1754,6 +1772,18 @@ def command_logs(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
|
||||
def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
if (
|
||||
CORE.skip_bootloader
|
||||
and CORE.is_esp32
|
||||
and CORE.using_toolchain_esp_idf
|
||||
and any(
|
||||
get_port_type(device) == PortType.SERIAL for device in (args.device or [])
|
||||
)
|
||||
):
|
||||
# Fail before the compile: the result could never flash over serial.
|
||||
# Elsewhere the flag is ignored, so serial stays fine there.
|
||||
_LOGGER.error("--skip-bootloader builds cannot be flashed over serial")
|
||||
return 1
|
||||
exit_code = write_cpp(config)
|
||||
if exit_code != 0:
|
||||
return exit_code
|
||||
@@ -2139,6 +2169,15 @@ SIMPLE_CONFIG_ACTIONS = [
|
||||
]
|
||||
|
||||
|
||||
def _add_skip_bootloader_arg(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"--skip-bootloader",
|
||||
help="Do not build the bootloader or the factory image; "
|
||||
"the result can only be flashed over OTA.",
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
|
||||
def _add_states_args(parser: argparse.ArgumentParser) -> None:
|
||||
"""Add mutually exclusive ``--states``/``--no-states`` flags to a parser.
|
||||
|
||||
@@ -2280,6 +2319,7 @@ def parse_args(argv):
|
||||
help="Only generate source code, do not compile.",
|
||||
action="store_true",
|
||||
)
|
||||
_add_skip_bootloader_arg(parser_compile)
|
||||
|
||||
parser_upload = subparsers.add_parser(
|
||||
"upload",
|
||||
@@ -2343,7 +2383,7 @@ def parse_args(argv):
|
||||
"-r",
|
||||
action="store_true",
|
||||
help="Reset the device before starting serial logs.",
|
||||
default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"),
|
||||
default=get_bool_env("ESPHOME_SERIAL_LOGGING_RESET"),
|
||||
)
|
||||
_add_states_args(parser_logs)
|
||||
|
||||
@@ -2376,6 +2416,7 @@ def parse_args(argv):
|
||||
parser_run.add_argument(
|
||||
"--no-logs", help="Disable starting logs.", action="store_true"
|
||||
)
|
||||
_add_skip_bootloader_arg(parser_run)
|
||||
|
||||
_add_states_args(parser_run)
|
||||
|
||||
@@ -2384,7 +2425,7 @@ def parse_args(argv):
|
||||
"-r",
|
||||
action="store_true",
|
||||
help="Reset the device before starting serial logs.",
|
||||
default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"),
|
||||
default=get_bool_env("ESPHOME_SERIAL_LOGGING_RESET"),
|
||||
)
|
||||
parser_run.add_argument(
|
||||
"--ota-platform",
|
||||
@@ -2635,6 +2676,7 @@ def run_esphome(argv):
|
||||
|
||||
CORE.config_path = conf_path
|
||||
CORE.dashboard = args.dashboard
|
||||
CORE.skip_bootloader = getattr(args, "skip_bootloader", False)
|
||||
if args.toolchain is not None:
|
||||
# CLI toolchain wins over esp32.toolchain in YAML.
|
||||
CORE.toolchain = args.toolchain
|
||||
|
||||
@@ -1065,10 +1065,10 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
|
||||
" rspfile_content = $in_newline",
|
||||
" description = LINK $out",
|
||||
"rule elf2bin",
|
||||
# --flash_size deliberately stays board-derived, as under
|
||||
# PlatformIO (which reads upload.maximum_size, not the ldscript).
|
||||
# --flash_size follows PlatformIO: ldscript filename first, then
|
||||
# upload.maximum_size, so the header always matches the layout.
|
||||
# -W: the framework's own elf2bin.py trips SyntaxWarning on 3.12+.
|
||||
f" command = $python -W ignore::SyntaxWarning {_q(framework / 'tools' / 'elf2bin.py')} --eboot {_q(framework / 'bootloaders' / 'eboot' / 'eboot.elf')} --app $in --flash_mode {flash_mode} --flash_freq {_FLASH_FREQ_MHZ} --flash_size {_flash_size_str(BOARDS[board][KEY_FLASH_SIZE])} --path {_q(toolchain_bin)} --out $out",
|
||||
f" command = $python -W ignore::SyntaxWarning {_q(framework / 'tools' / 'elf2bin.py')} --eboot {_q(framework / 'bootloaders' / 'eboot' / 'eboot.elf')} --app $in --flash_mode {flash_mode} --flash_freq {_FLASH_FREQ_MHZ} --flash_size {_elf2bin_flash_size(board, flash_ld_name)} --path {_q(toolchain_bin)} --out $out",
|
||||
" description = BIN $out",
|
||||
"rule copy",
|
||||
" command = $python $buildtool copy $in $out",
|
||||
@@ -1189,3 +1189,28 @@ def _flash_size_str(flash_size: int) -> str:
|
||||
"""Flash size argument for elf2bin (e.g. ``4M``, ``512K``)."""
|
||||
mb = 1024 * 1024
|
||||
return f"{flash_size // mb}M" if flash_size >= mb else f"{flash_size // 1024}K"
|
||||
|
||||
|
||||
# Same pattern PlatformIO's _get_flash_size applies to the ldscript path
|
||||
_LD_FLASH_SIZE_RE = re.compile(r"\.flash\.(\d+[mk]).*\.ld")
|
||||
# The framework elf2bin.py's --flash_size choices
|
||||
_ELF2BIN_FLASH_SIZES = frozenset({"256K", "512K", "1M", "2M", "4M", "8M", "16M"})
|
||||
|
||||
|
||||
def _elf2bin_flash_size(board: str, flash_ld_name: str) -> str:
|
||||
"""Image-header flash size as PlatformIO derives it: ldscript filename,
|
||||
else board_upload.maximum_size, else the board table. The SDK clamps the
|
||||
chip to the header size at boot, so a header smaller than the linked
|
||||
layout breaks OTA writes on the running device."""
|
||||
if match := _LD_FLASH_SIZE_RE.search(flash_ld_name):
|
||||
token = match.group(1)
|
||||
base = 1024 if token[-1] == "k" else 1024 * 1024
|
||||
return _flash_size_str(int(token[:-1]) * base)
|
||||
if max_size := _pio_option("board_upload.maximum_size", ""):
|
||||
if (
|
||||
not max_size.isdigit()
|
||||
or (size := _flash_size_str(int(max_size))) not in _ELF2BIN_FLASH_SIZES
|
||||
):
|
||||
raise EsphomeError(f"Invalid board_upload.maximum_size value {max_size!r}")
|
||||
return size
|
||||
return _flash_size_str(BOARDS[board][KEY_FLASH_SIZE])
|
||||
|
||||
+185
-1
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
import textwrap
|
||||
|
||||
from esphome.build_helpers import pch
|
||||
@@ -20,7 +21,7 @@ from esphome.framework_helpers import (
|
||||
get_project_cxx_compile_flags,
|
||||
get_project_link_flags,
|
||||
)
|
||||
from esphome.helpers import mkdir_p, write_file_if_changed
|
||||
from esphome.helpers import get_bool_env, mkdir_p, write_file_if_changed
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,6 +36,83 @@ list(FILTER esphome_cxx_compile_options EXCLUDE REGEX "^-std=")
|
||||
list(APPEND esphome_cxx_compile_options "-std={standard}")
|
||||
idf_build_set_property(CXX_COMPILE_OPTIONS "${{esphome_cxx_compile_options}}")"""
|
||||
|
||||
# Drops the app archive from ldgen's inputs so app-only edits skip the
|
||||
# sections.ld regeneration. Safe: no mapping fragment references it
|
||||
# (run_compile re-checks each build). Filters only the top-level call;
|
||||
# the prior definition stays reachable with an underscore prefix.
|
||||
_LDGEN_OVERRIDE = """\
|
||||
if(COMMAND __ldgen_get_lib_deps_of_target)
|
||||
set_property(GLOBAL PROPERTY ESPHOME_LDGEN_ARMED 1)
|
||||
function(__ldgen_get_lib_deps_of_target target out_list_var)
|
||||
if(NOT COMMAND ___ldgen_get_lib_deps_of_target)
|
||||
message(FATAL_ERROR "ESPHome ldgen override lost the original "
|
||||
"implementation; set ESPHOME_LDGEN_FULL_DEPS=1 and rebuild.")
|
||||
endif()
|
||||
___ldgen_get_lib_deps_of_target(${target} ${out_list_var})
|
||||
if(out_list_var STREQUAL "ldgen_libraries")
|
||||
set_property(GLOBAL PROPERTY ESPHOME_LDGEN_FILTERED 1)
|
||||
list(LENGTH ${out_list_var} esphome_ldgen_before)
|
||||
list(REMOVE_ITEM ${out_list_var} idf::src __idf_src)
|
||||
list(LENGTH ${out_list_var} esphome_ldgen_after)
|
||||
if(esphome_ldgen_before EQUAL esphome_ldgen_after)
|
||||
message(@SEVERITY@ "ESPHome ldgen app archive exclusion matched "
|
||||
"nothing; app edits will regenerate sections.ld.")
|
||||
endif()
|
||||
endif()
|
||||
set(${out_list_var} "${${out_list_var}}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
else()
|
||||
message(@MISSING@ "ESPHome ldgen override target not found; "
|
||||
"app edits will regenerate sections.ld.")
|
||||
endif()"""
|
||||
|
||||
# lwip sources that compile to empty objects with the option off (their own
|
||||
# #if guard). (option, regex valid for both Python and CMake); a source is
|
||||
# only dropped when its option is defined and off, so a renamed option
|
||||
# keeps it.
|
||||
LWIP_EMPTY_SOURCES: tuple[tuple[str, str], ...] = (
|
||||
("CONFIG_LWIP_PPP_SUPPORT", "/netif/ppp/"),
|
||||
("CONFIG_LWIP_IPV6", "/core/ipv6/"),
|
||||
("CONFIG_LWIP_AUTOIP", "/core/ipv4/autoip[.]c$"),
|
||||
("CONFIG_LWIP_STATS", "/core/stats[.]c$"),
|
||||
)
|
||||
# Drift guard only: keep every lwip source.
|
||||
LWIP_FULL_SOURCES_ENV = "ESPHOME_LWIP_FULL_SOURCES"
|
||||
|
||||
# Drops the empty objects after project(), once the lwip target exists.
|
||||
_LWIP_EMPTY_SOURCES_FILTER = f"""\
|
||||
idf_build_get_property(esphome_build_components BUILD_COMPONENTS)
|
||||
if(lwip IN_LIST esphome_build_components AND NOT DEFINED ENV{{{LWIP_FULL_SOURCES_ENV}}})
|
||||
idf_component_get_property(esphome_lwip_lib lwip COMPONENT_LIB)
|
||||
get_target_property(esphome_lwip_srcs ${{esphome_lwip_lib}} SOURCES)
|
||||
@FILTERS@
|
||||
set_property(TARGET ${{esphome_lwip_lib}} PROPERTY SOURCES ${{esphome_lwip_srcs}})
|
||||
endif()"""
|
||||
|
||||
|
||||
def lwip_empty_source_gate(option: str, regex: str) -> str:
|
||||
return (
|
||||
f" if(DEFINED {option} AND NOT {option})\n"
|
||||
f' list(FILTER esphome_lwip_srcs EXCLUDE REGEX "{regex}")\n'
|
||||
" endif()"
|
||||
)
|
||||
|
||||
|
||||
def _lwip_empty_sources_filter() -> str:
|
||||
gates = "\n".join(lwip_empty_source_gate(*entry) for entry in LWIP_EMPTY_SOURCES)
|
||||
return _LWIP_EMPTY_SOURCES_FILTER.replace("@FILTERS@", gates)
|
||||
|
||||
|
||||
# Runs after project() so the walk has happened; catches the remaining
|
||||
# silent path where the top-level out-var was renamed.
|
||||
_LDGEN_OVERRIDE_CHECK = """\
|
||||
get_property(esphome_ldgen_armed GLOBAL PROPERTY ESPHOME_LDGEN_ARMED)
|
||||
get_property(esphome_ldgen_filtered GLOBAL PROPERTY ESPHOME_LDGEN_FILTERED)
|
||||
if(esphome_ldgen_armed AND NOT esphome_ldgen_filtered)
|
||||
message(@SEVERITY@ "ESPHome ldgen override never filtered the app "
|
||||
"archive; app edits will regenerate sections.ld.")
|
||||
endif()"""
|
||||
|
||||
|
||||
def get_available_components() -> list[str] | None:
|
||||
"""List the built-in ESP-IDF components from ``project_description.json``.
|
||||
@@ -80,6 +158,85 @@ def _cmake_quote(value: str) -> str:
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
# CONFIG_APP_BUILD_BOOTLOADER is hidden and force-selected, so it can only be
|
||||
# cleared at the CMake level (the same state IDF's RAM-app build type uses).
|
||||
# The macro is IDF's __build_process_project_includes plus a few added lines;
|
||||
# the flag is ignored and the bootloader builds as usual if IDF changes it.
|
||||
IDF_BOOTLOADER_OVERRIDE = """\
|
||||
# ESPHome bootloader skip switch; see esphome/espidf/toolchain.py.
|
||||
if(ESPHOME_SKIP_BOOTLOADER)
|
||||
macro(__build_process_project_includes)
|
||||
idf_build_get_property(sdkconfig_cmake SDKCONFIG_CMAKE)
|
||||
include(${sdkconfig_cmake})
|
||||
set(CONFIG_APP_BUILD_BOOTLOADER "")
|
||||
# bt's CMakeLists reads the lowercase idf_target that the (now
|
||||
# skipped) bootloader project_include leaks; keep it defined, or
|
||||
# its empty TARGET_SRC_NAME sends file(GLOB_RECURSE) across /.
|
||||
idf_build_get_property(idf_target IDF_TARGET)
|
||||
# partition_table's V1 ECDSA signing reads this key, which the
|
||||
# skipped bootloader project_include also sets.
|
||||
get_filename_component(SECURE_BOOT_SIGNING_KEY "${CONFIG_SECURE_BOOT_SIGNING_KEY}" ABSOLUTE BASE_DIR "${project_dir}")
|
||||
idf_build_get_property(build_properties __BUILD_PROPERTIES)
|
||||
foreach(build_property ${build_properties})
|
||||
idf_build_get_property(val ${build_property})
|
||||
set(${build_property} "${val}")
|
||||
endforeach()
|
||||
idf_build_get_property(build_component_targets __BUILD_COMPONENT_TARGETS)
|
||||
foreach(component_target ${build_component_targets})
|
||||
__component_get_property(dir ${component_target} COMPONENT_DIR)
|
||||
__component_get_property(_name ${component_target} COMPONENT_NAME)
|
||||
set(COMPONENT_NAME ${_name})
|
||||
set(COMPONENT_DIR ${dir})
|
||||
set(COMPONENT_PATH ${dir})
|
||||
if(EXISTS ${COMPONENT_DIR}/project_include.cmake)
|
||||
include(${COMPONENT_DIR}/project_include.cmake)
|
||||
endif()
|
||||
endforeach()
|
||||
endmacro()
|
||||
endif()
|
||||
"""
|
||||
|
||||
# The lines the override adds to IDF's macro; idf_macro_matches() below
|
||||
# strips them before comparing with the live macro.
|
||||
BOOTLOADER_OVERRIDE_ADDED_LINES = (
|
||||
'set(CONFIG_APP_BUILD_BOOTLOADER "")',
|
||||
"idf_build_get_property(idf_target IDF_TARGET)",
|
||||
(
|
||||
"get_filename_component(SECURE_BOOT_SIGNING_KEY"
|
||||
' "${CONFIG_SECURE_BOOT_SIGNING_KEY}" ABSOLUTE BASE_DIR "${project_dir}")'
|
||||
),
|
||||
)
|
||||
|
||||
_MACRO = re.compile(
|
||||
r"macro\(__build_process_project_includes\)(.*?)endmacro\(\)", re.DOTALL
|
||||
)
|
||||
|
||||
|
||||
def _normalized_macro(text: str) -> list[str] | None:
|
||||
"""The macro body as comment-free, whitespace-collapsed lines."""
|
||||
if (match := _MACRO.search(text)) is None:
|
||||
return None
|
||||
return [
|
||||
re.sub(r"\s+", " ", line)
|
||||
for raw in match.group(1).splitlines()
|
||||
if (line := raw.split("#", 1)[0].strip())
|
||||
]
|
||||
|
||||
|
||||
_EXPECTED_MACRO = [
|
||||
line
|
||||
for line in _normalized_macro(IDF_BOOTLOADER_OVERRIDE)
|
||||
if line not in BOOTLOADER_OVERRIDE_ADDED_LINES
|
||||
]
|
||||
|
||||
|
||||
def idf_macro_matches(idf_path: Path) -> bool:
|
||||
"""Whether IDF's macro still matches the copy the override replays."""
|
||||
build_cmake = idf_path / "tools" / "cmake" / "build.cmake"
|
||||
live = _normalized_macro(build_cmake.read_text(encoding="utf-8"))
|
||||
return live == _EXPECTED_MACRO
|
||||
|
||||
|
||||
def get_project_cmakelists(
|
||||
minimal: bool = False, builtin_components: list[str] | None = None
|
||||
) -> str:
|
||||
@@ -125,6 +282,22 @@ def get_project_cmakelists(
|
||||
else ""
|
||||
)
|
||||
|
||||
# Stops the ~3s sections.ld regeneration on app-only edits; see
|
||||
# _LDGEN_OVERRIDE. ESPHOME_LDGEN_FULL_DEPS=1 restores stock behavior;
|
||||
# ESPHOME_LDGEN_STRICT=1 (CI) fails the configure when an IDF bump
|
||||
# breaks the override instead of degrading to stock deps.
|
||||
if get_bool_env("ESPHOME_LDGEN_FULL_DEPS"):
|
||||
ldgen_override = ""
|
||||
ldgen_override_check = ""
|
||||
else:
|
||||
strict = get_bool_env("ESPHOME_LDGEN_STRICT")
|
||||
severity = "FATAL_ERROR" if strict else "WARNING"
|
||||
missing = "FATAL_ERROR" if strict else "STATUS"
|
||||
ldgen_override = _LDGEN_OVERRIDE.replace("@SEVERITY@", severity).replace(
|
||||
"@MISSING@", missing
|
||||
)
|
||||
ldgen_override_check = _LDGEN_OVERRIDE_CHECK.replace("@SEVERITY@", severity)
|
||||
|
||||
# CMake variables registered via cg.add_cmake_arg(). Emitted before
|
||||
# include(project.cmake) so values like EXCLUDE_COMPONENTS are already
|
||||
# set when project.cmake seeds the component list, and on minimal
|
||||
@@ -202,6 +375,9 @@ set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src)
|
||||
|
||||
include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
|
||||
|
||||
{IDF_BOOTLOADER_OVERRIDE}
|
||||
{ldgen_override}
|
||||
|
||||
{cpp_standard_options}
|
||||
|
||||
{cxx_compile_options}
|
||||
@@ -214,14 +390,22 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
|
||||
|
||||
project({CORE.name})
|
||||
|
||||
{ldgen_override_check}
|
||||
|
||||
{_lwip_empty_sources_filter()}
|
||||
|
||||
# Emit per-memory-type JSON size data for ESPHome to read post-build.
|
||||
# json2 stays small; raw dumps every symbol (~2s on a large map) and
|
||||
# this command runs inside the link edge, blocking everything downstream.
|
||||
# The map is a BYPRODUCT so ninja knows the link writes it; IDF's size
|
||||
# target depends on the map and can then be built in the same run as all.
|
||||
# IDF's cmakev2 declares the map itself, so drop this line on that switch.
|
||||
add_custom_command(
|
||||
TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD
|
||||
COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=json2
|
||||
-o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json
|
||||
${{CMAKE_PROJECT_NAME}}.map
|
||||
BYPRODUCTS ${{CMAKE_BINARY_DIR}}/${{CMAKE_PROJECT_NAME}}.map
|
||||
WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}}
|
||||
VERBATIM
|
||||
)
|
||||
|
||||
@@ -40,6 +40,7 @@ from esphome.cpp_generator import ( # noqa: F401
|
||||
progmem_array,
|
||||
safe_exp,
|
||||
set_cpp_standard,
|
||||
shared_progmem_array,
|
||||
statement,
|
||||
static_const_array,
|
||||
static_function,
|
||||
|
||||
@@ -62,16 +62,10 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
sens = await sensor.new_sensor(config[CONF_TVOC])
|
||||
cg.add(var.set_tvoc(sens))
|
||||
|
||||
if version_config := config.get(CONF_VERSION):
|
||||
sens = await sensor.new_sensor(version_config)
|
||||
cg.add(var.set_version(sens))
|
||||
|
||||
if resistance_config := config.get(CONF_RESISTANCE):
|
||||
sens = await sensor.new_sensor(resistance_config)
|
||||
cg.add(var.set_resistance(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_TVOC, var.set_tvoc)
|
||||
await sensors(CONF_VERSION, var.set_version)
|
||||
await sensors(CONF_RESISTANCE, var.set_resistance)
|
||||
|
||||
|
||||
AGS10_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value(
|
||||
|
||||
@@ -57,10 +57,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
await i2c.register_i2c_device(var, config)
|
||||
cg.add(var.set_variant(config[CONF_VARIANT]))
|
||||
|
||||
if temperature := config.get(CONF_TEMPERATURE):
|
||||
sens = await sensor.new_sensor(temperature)
|
||||
cg.add(var.set_temperature_sensor(sens))
|
||||
|
||||
if humidity := config.get(CONF_HUMIDITY):
|
||||
sens = await sensor.new_sensor(humidity)
|
||||
cg.add(var.set_humidity_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
|
||||
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
|
||||
|
||||
@@ -46,10 +46,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
if temperature_config := config.get(CONF_TEMPERATURE):
|
||||
sens = await sensor.new_sensor(temperature_config)
|
||||
cg.add(var.set_temperature_sensor(sens))
|
||||
|
||||
if humidity_config := config.get(CONF_HUMIDITY):
|
||||
sens = await sensor.new_sensor(humidity_config)
|
||||
cg.add(var.set_humidity_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
|
||||
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
|
||||
|
||||
@@ -48,10 +48,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
if temperature_config := config.get(CONF_TEMPERATURE):
|
||||
sens = await sensor.new_sensor(temperature_config)
|
||||
cg.add(var.set_temperature_sensor(sens))
|
||||
|
||||
if humidity_config := config.get(CONF_HUMIDITY):
|
||||
sens = await sensor.new_sensor(humidity_config)
|
||||
cg.add(var.set_humidity_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
|
||||
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
|
||||
|
||||
@@ -48,10 +48,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await ble_client.register_ble_node(var, config)
|
||||
|
||||
if battery_level_config := config.get(CONF_BATTERY_LEVEL):
|
||||
sens = await sensor.new_sensor(battery_level_config)
|
||||
cg.add(var.set_battery(sens))
|
||||
|
||||
if illuminance_config := config.get(CONF_ILLUMINANCE):
|
||||
sens = await sensor.new_sensor(illuminance_config)
|
||||
cg.add(var.set_illuminance(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_BATTERY_LEVEL, var.set_battery)
|
||||
await sensors(CONF_ILLUMINANCE, var.set_illuminance)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from ipaddress import IPv4Address, IPv6Address
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
@@ -5,7 +6,7 @@ from typing import Any
|
||||
from esphome import automation
|
||||
from esphome.automation import Condition
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_DESCRIPTION
|
||||
from esphome.components.const import CONF_DESCRIPTION, CONF_HOST
|
||||
from esphome.components.logger import request_log_listener
|
||||
|
||||
# ENCRYPTION_SCHEMA and validate_encryption_key are re-exported for external
|
||||
@@ -13,6 +14,7 @@ from esphome.components.logger import request_log_listener
|
||||
from esphome.components.noise import ( # noqa: F401
|
||||
ENCRYPTION_SCHEMA,
|
||||
decode_encryption_key,
|
||||
enable_spare_ephemeral,
|
||||
encryption_schema,
|
||||
new_psk_progmem,
|
||||
validate_encryption_key,
|
||||
@@ -25,6 +27,8 @@ from esphome.const import (
|
||||
CONF_CAPTURE_RESPONSE,
|
||||
CONF_DATA,
|
||||
CONF_DATA_TEMPLATE,
|
||||
CONF_DELAY,
|
||||
CONF_ENABLE_IPV6,
|
||||
CONF_ENCRYPTION,
|
||||
CONF_EVENT,
|
||||
CONF_ID,
|
||||
@@ -48,7 +52,9 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority
|
||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
import esphome.final_validate as fv
|
||||
from esphome.helpers import fnv1_hash
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigFragmentType, ConfigType
|
||||
|
||||
# Compat alias: downstream consumers (e.g. device-builder) referenced the
|
||||
@@ -134,6 +140,7 @@ CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
|
||||
CONF_HOMEASSISTANT_STATES = "homeassistant_states"
|
||||
CONF_LISTEN_BACKLOG = "listen_backlog"
|
||||
CONF_MAX_SEND_QUEUE = "max_send_queue"
|
||||
CONF_OUTGOING_CONNECTION = "outgoing_connection"
|
||||
CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
|
||||
|
||||
# Schema defaults that also match the C++ initializers in api_server.h; codegen
|
||||
@@ -141,6 +148,7 @@ CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
|
||||
DEFAULT_PORT = 6053
|
||||
DEFAULT_REBOOT_TIMEOUT = "15min"
|
||||
DEFAULT_BATCH_DELAY = "100ms"
|
||||
DEFAULT_LISTEN_BACKLOG = 4
|
||||
|
||||
|
||||
def _register_provisioning_source(config: ConfigType) -> ConfigType:
|
||||
@@ -291,9 +299,72 @@ def _consume_api_sockets(config: ConfigType) -> ConfigType:
|
||||
# (not max_connections, which is the upper limit rarely reached)
|
||||
socket.consume_sockets(3, "api")(config)
|
||||
socket.consume_sockets(1, "api", socket.SocketType.TCP_LISTEN)(config)
|
||||
if CONF_OUTGOING_CONNECTION in config:
|
||||
socket.consume_sockets(1, "api_outgoing_connection")(config)
|
||||
return config
|
||||
|
||||
|
||||
def _validate_outgoing_connection(config: ConfigType) -> ConfigType:
|
||||
if (outgoing := config.get(CONF_OUTGOING_CONNECTION)) is None:
|
||||
return config
|
||||
if CONF_ENCRYPTION not in config:
|
||||
raise cv.Invalid(
|
||||
"outgoing_connection requires 'encryption' so the peer is verified by key",
|
||||
path=[CONF_OUTGOING_CONNECTION],
|
||||
)
|
||||
# A device with no client reboots once reboot_timeout passes, so a delay that
|
||||
# reaches it would reboot the device before it ever dials
|
||||
reboot_timeout = config[CONF_REBOOT_TIMEOUT]
|
||||
delay = outgoing[CONF_DELAY]
|
||||
if reboot_timeout.total_milliseconds and delay >= reboot_timeout:
|
||||
raise cv.Invalid(
|
||||
f"delay must be shorter than reboot_timeout ({reboot_timeout}), "
|
||||
"otherwise the device reboots before it dials",
|
||||
path=[CONF_OUTGOING_CONNECTION, CONF_DELAY],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def _validate_outgoing_host(value: str) -> IPv4Address | IPv6Address:
|
||||
"""Only accept an address the device itself can parse.
|
||||
|
||||
Python accepts a scope id, which neither `inet_pton` nor lwIP's `inet6_aton`
|
||||
takes, and a v4-mapped address is dialed as plain IPv4, needing no IPv6 build.
|
||||
"""
|
||||
address = cv.ipaddress(value)
|
||||
if isinstance(address, IPv6Address):
|
||||
if address.scope_id is not None:
|
||||
raise cv.Invalid(
|
||||
f"{value} carries a scope id, which the device cannot parse; "
|
||||
"give the address without the '%' part"
|
||||
)
|
||||
if (mapped := address.ipv4_mapped) is not None:
|
||||
return mapped
|
||||
return address
|
||||
|
||||
|
||||
_OUTGOING_CONNECTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_HOST): _validate_outgoing_host,
|
||||
cv.Optional(CONF_PORT, default=6054): cv.port,
|
||||
# Bounded against reboot_timeout in _validate_outgoing_connection
|
||||
cv.Optional(CONF_DELAY, default="60s"): cv.positive_time_period_milliseconds,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@schema_extractor("schema")
|
||||
def _outgoing_connection_schema(config: ConfigType | None) -> ConfigType:
|
||||
# A bare `outgoing_connection:` block is valid; without a host the device
|
||||
# dials the remembered last dial-back client
|
||||
if config is SCHEMA_EXTRACT:
|
||||
# Let the language-schema dumper walk host, port and delay
|
||||
return _OUTGOING_CONNECTION_SCHEMA
|
||||
if config is None:
|
||||
config = {}
|
||||
return _OUTGOING_CONNECTION_SCHEMA(config)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
@@ -318,6 +389,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
): ACTIONS_SCHEMA,
|
||||
cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA,
|
||||
cv.Optional(CONF_ENCRYPTION): encryption_schema,
|
||||
cv.Optional(CONF_OUTGOING_CONNECTION): _outgoing_connection_schema,
|
||||
cv.Optional(CONF_BATCH_DELAY, default=DEFAULT_BATCH_DELAY): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(max=cv.TimePeriod(milliseconds=65535)),
|
||||
@@ -373,6 +445,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
cv.rename_key(CONF_SERVICES, CONF_ACTIONS),
|
||||
_validate_outgoing_connection,
|
||||
_consume_api_sockets,
|
||||
_register_provisioning_source,
|
||||
)
|
||||
@@ -429,7 +502,28 @@ def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _validate_esp8266_action_strings
|
||||
def _validate_outgoing_host_ipv6(config: ConfigType) -> ConfigType:
|
||||
"""An IPv6 host can never be parsed, so never dialed, without IPv6."""
|
||||
if (
|
||||
(outgoing := config.get(CONF_OUTGOING_CONNECTION)) is None
|
||||
or (host := outgoing.get(CONF_HOST)) is None
|
||||
or host.version != 6
|
||||
):
|
||||
return config
|
||||
network_conf = fv.full_config.get().get("network") or {}
|
||||
if not network_conf.get(CONF_ENABLE_IPV6):
|
||||
raise cv.Invalid(
|
||||
"outgoing_connection host is an IPv6 address but IPv6 is not "
|
||||
"enabled; set 'network: enable_ipv6: true'",
|
||||
path=[CONF_OUTGOING_CONNECTION, CONF_HOST],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
_validate_esp8266_action_strings,
|
||||
_validate_outgoing_host_ipv6,
|
||||
)
|
||||
|
||||
|
||||
def _add_action_strings(
|
||||
@@ -476,8 +570,10 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(var.set_reboot_timeout(reboot_timeout))
|
||||
if (batch_delay := config[CONF_BATCH_DELAY]) != cv.time_period(DEFAULT_BATCH_DELAY):
|
||||
cg.add(var.set_batch_delay(batch_delay))
|
||||
if CONF_LISTEN_BACKLOG in config:
|
||||
cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG]))
|
||||
if (
|
||||
listen_backlog := config.get(CONF_LISTEN_BACKLOG)
|
||||
) is not None and listen_backlog != DEFAULT_LISTEN_BACKLOG:
|
||||
cg.add(var.set_listen_backlog(listen_backlog))
|
||||
cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS])
|
||||
cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE])
|
||||
|
||||
@@ -600,7 +696,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None:
|
||||
if key := encryption_config.get(CONF_KEY):
|
||||
cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key)))
|
||||
cg.add(var.set_noise_psk(new_psk_progmem(key)))
|
||||
cg.add_define("USE_API_NOISE_PSK_FROM_YAML")
|
||||
else:
|
||||
# No key provided, but encryption desired
|
||||
@@ -613,9 +709,17 @@ async def to_code(config: ConfigType) -> None:
|
||||
# and plaintext disabled. Only a factory reset can remove it.
|
||||
cg.add_define("USE_API_PLAINTEXT")
|
||||
cg.add_define("USE_API_NOISE")
|
||||
enable_spare_ephemeral()
|
||||
else:
|
||||
cg.add_define("USE_API_PLAINTEXT")
|
||||
|
||||
if (outgoing := config.get(CONF_OUTGOING_CONNECTION)) is not None:
|
||||
cg.add_define("USE_API_OUTGOING_CONNECTION")
|
||||
if (host := outgoing.get(CONF_HOST)) is not None:
|
||||
cg.add_define("API_OUTGOING_CONNECTION_HOST", str(host))
|
||||
cg.add_define("API_OUTGOING_CONNECTION_PORT", outgoing[CONF_PORT])
|
||||
cg.add_define("API_OUTGOING_CONNECTION_DELAY", outgoing[CONF_DELAY])
|
||||
|
||||
cg.add_define("USE_API")
|
||||
cg.add_global(api_ns.using)
|
||||
|
||||
@@ -1002,6 +1106,7 @@ _define_filter = filter_source_files_from_defines(
|
||||
"user_services.cpp": "USE_API_USER_DEFINED_ACTIONS",
|
||||
"api_frame_helper_noise.cpp": "USE_API_NOISE",
|
||||
"api_frame_helper_plaintext.cpp": "USE_API_PLAINTEXT",
|
||||
"api_outgoing_connection.cpp": "USE_API_OUTGOING_CONNECTION",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -113,6 +113,11 @@ message HelloRequest {
|
||||
string client_info = 1;
|
||||
uint32 api_version_major = 2;
|
||||
uint32 api_version_minor = 3;
|
||||
|
||||
// Set by clients that can accept connections the device opens to them
|
||||
// (see api: outgoing_connection:). The device remembers this client's
|
||||
// address as the target to dial when no such client is connected.
|
||||
bool outgoing_connection_target = 4 [(field_ifdef) = "USE_API_OUTGOING_CONNECTION"];
|
||||
}
|
||||
|
||||
// Confirmation of successful connection request.
|
||||
@@ -332,6 +337,10 @@ message DeviceInfoResponse {
|
||||
// all-zeros PSK, so the api encryption key can be provisioned without being
|
||||
// sent in plaintext (protects against passive sniffing, not active MITM)
|
||||
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
|
||||
|
||||
// Device is built with the api outgoing_connection option and can open
|
||||
// the TCP connection to a dial-back target itself
|
||||
bool api_outgoing_connection_supported = 27 [(field_ifdef) = "USE_API_OUTGOING_CONNECTION"];
|
||||
}
|
||||
|
||||
// ==================== DEVICE CAPABILITIES ====================
|
||||
@@ -1439,7 +1448,7 @@ message ListEntitiesSelectResponse {
|
||||
reserved 4; // Deprecated: was string unique_id
|
||||
|
||||
string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
|
||||
repeated string options = 6 [(container_pointer_no_template) = "FixedVector<const char *>"];
|
||||
repeated string options = 6 [(container_pointer_no_template) = "std::span<const char *const>"];
|
||||
bool disabled_by_default = 7;
|
||||
EntityCategory entity_category = 8;
|
||||
uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"];
|
||||
|
||||
@@ -990,7 +990,9 @@ uint16_t APIConnection::try_send_select_state(EntityBase *entity, APIConnection
|
||||
uint16_t APIConnection::try_send_select_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) {
|
||||
auto *select = static_cast<select::Select *>(entity);
|
||||
ListEntitiesSelectResponse msg;
|
||||
msg.options = &select->traits.get_options();
|
||||
const auto &opts = select->traits.get_options();
|
||||
const std::span<const char *const> options(opts.data(), opts.size());
|
||||
msg.options = &options;
|
||||
return fill_and_encode_entity_info(select, msg, conn, remaining_size);
|
||||
}
|
||||
void APIConnection::on_select_command_request(const SelectCommandRequest &msg) {
|
||||
@@ -1837,6 +1839,19 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
// Auto-authenticate - password auth was removed in ESPHome 2026.1.0
|
||||
this->complete_authentication_();
|
||||
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// With a PSK set only key-verified transports reach hello: plaintext and
|
||||
// zero-PSK are rejected, and pre-activation sessions are force-closed
|
||||
if (msg.outgoing_connection_target && !this->flags_.outgoing_connection_target) {
|
||||
if (this->parent_->get_noise_ctx().has_psk()) {
|
||||
this->flags_.outgoing_connection_target = true;
|
||||
this->parent_->on_outgoing_target_client(this);
|
||||
} else {
|
||||
this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Dial-back target refused; no key active"));
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
return this->send_message(resp);
|
||||
}
|
||||
|
||||
@@ -1959,6 +1974,9 @@ bool APIConnection::send_device_info_response_() {
|
||||
// one) so this advertisement survives the plaintext removal in 2027.2.0.
|
||||
resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk();
|
||||
#endif
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
resp.api_outgoing_connection_supported = true;
|
||||
#endif
|
||||
#endif
|
||||
#ifdef USE_DEVICES
|
||||
size_t device_index = 0;
|
||||
|
||||
@@ -317,9 +317,15 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg);
|
||||
#endif
|
||||
|
||||
// How long a new connection holds off the spare ephemeral refill
|
||||
static constexpr uint32_t CONNECT_GRACE_MS = 1000;
|
||||
bool is_authenticated() {
|
||||
return static_cast<ConnectionState>(this->flags_.connection_state) == ConnectionState::AUTHENTICATED;
|
||||
}
|
||||
// An older unauthenticated connection is a stale half open client and does not count
|
||||
bool is_still_connecting(uint32_t now) {
|
||||
return !this->is_authenticated() && now - this->last_traffic_ < CONNECT_GRACE_MS;
|
||||
}
|
||||
bool is_connection_setup() {
|
||||
return static_cast<ConnectionState>(this->flags_.connection_state) == ConnectionState::CONNECTED ||
|
||||
this->is_authenticated();
|
||||
@@ -372,6 +378,23 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
return this->helper_->get_peername_to(buf);
|
||||
}
|
||||
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
/// Get the peer address itself, for remembering a dial-back target
|
||||
int getpeername(struct sockaddr *addr, socklen_t *addrlen) const { return this->helper_->getpeername(addr, addrlen); }
|
||||
/// Outgoing connection: send our server hello immediately so the peer can
|
||||
/// pick the matching key. Outgoing connections are only dialed when a PSK
|
||||
/// is set, so the helper is always the noise helper. Call after start().
|
||||
void mark_outgoing() {
|
||||
if (this->flags_.remove) {
|
||||
return; // start() failed; the connection is already being torn down
|
||||
}
|
||||
APIError err = static_cast<APINoiseFrameHelper *>(this->helper_.get())->send_server_hello_first();
|
||||
if (err != APIError::OK) {
|
||||
this->fatal_error_with_log_(LOG_STR("Server hello failed"), err);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
protected:
|
||||
bool try_to_clear_buffer_slow_(bool log_out_of_space);
|
||||
|
||||
@@ -728,6 +751,9 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
uint8_t batch_first_message : 1; // For batch buffer allocation
|
||||
uint8_t should_try_send_immediately : 1; // True after initial states are sent
|
||||
uint8_t may_have_remaining_data : 1; // Read loop hit limit, retry without ready check
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
uint8_t outgoing_connection_target : 1; // Client declared itself a dial-back target in its hello
|
||||
#endif
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
uint8_t log_only_mode : 1;
|
||||
#endif
|
||||
|
||||
@@ -285,7 +285,8 @@ class APIFrameHelper {
|
||||
DATA = 5,
|
||||
CLOSED = 6,
|
||||
FAILED = 7,
|
||||
EXPLICIT_REJECT = 8, // Noise only
|
||||
EXPLICIT_REJECT = 8, // Noise only
|
||||
CLIENT_HELLO_OUTGOING = 9, // Noise only: like CLIENT_HELLO but the server hello already went out (outgoing conn)
|
||||
};
|
||||
|
||||
// Fast inline state check for read_packet/write_protobuf_messages hot path.
|
||||
|
||||
@@ -70,6 +70,13 @@ APIError APINoiseFrameHelper::init() {
|
||||
state_ = State::CLIENT_HELLO;
|
||||
return APIError::OK;
|
||||
}
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
APIError APINoiseFrameHelper::send_server_hello_first() {
|
||||
// The peer needs our name and MAC to pick the key before its first message
|
||||
this->state_ = State::CLIENT_HELLO_OUTGOING;
|
||||
return this->send_server_hello_frame_();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_PLAINTEXT
|
||||
APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) {
|
||||
APIError err = this->init();
|
||||
@@ -242,6 +249,9 @@ APIError APINoiseFrameHelper::state_action_() {
|
||||
HELPER_LOG("Bad state for method: %d", (int) this->state_);
|
||||
return APIError::BAD_STATE;
|
||||
case State::CLIENT_HELLO:
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
case State::CLIENT_HELLO_OUTGOING:
|
||||
#endif
|
||||
return this->state_action_client_hello_();
|
||||
case State::SERVER_HELLO:
|
||||
return this->state_action_server_hello_();
|
||||
@@ -274,11 +284,16 @@ APIError APINoiseFrameHelper::state_action_client_hello_() {
|
||||
std::memcpy(dst + 2, this->rx_buf_.data(), rx_size);
|
||||
}
|
||||
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
if (this->state_ == State::CLIENT_HELLO_OUTGOING) {
|
||||
// Server hello already went out at handoff
|
||||
return this->start_handshake_();
|
||||
}
|
||||
#endif
|
||||
state_ = State::SERVER_HELLO;
|
||||
return APIError::OK;
|
||||
}
|
||||
APIError APINoiseFrameHelper::state_action_server_hello_() {
|
||||
// send server hello
|
||||
APIError APINoiseFrameHelper::send_server_hello_frame_() {
|
||||
const auto &name = App.get_name();
|
||||
char mac[MAC_ADDRESS_BUFFER_SIZE];
|
||||
get_mac_address_into_buffer(mac);
|
||||
@@ -302,15 +317,18 @@ APIError APINoiseFrameHelper::state_action_server_hello_() {
|
||||
// node mac, terminated by null byte
|
||||
std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE);
|
||||
|
||||
APIError aerr = write_frame_(msg, total_size);
|
||||
return write_frame_(msg, total_size);
|
||||
}
|
||||
APIError APINoiseFrameHelper::state_action_server_hello_() {
|
||||
APIError aerr = this->send_server_hello_frame_();
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
|
||||
// start handshake
|
||||
aerr = init_handshake_();
|
||||
return this->start_handshake_();
|
||||
}
|
||||
APIError APINoiseFrameHelper::start_handshake_() {
|
||||
APIError aerr = init_handshake_();
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
|
||||
state_ = State::HANDSHAKE;
|
||||
return APIError::OK;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
||||
// Seeds the already-read header bytes and pumps the handshake state machine
|
||||
// until it would block.
|
||||
APIError init_from_handoff(const uint8_t *header, uint8_t header_len);
|
||||
#endif
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// Send the server hello immediately so the peer can pick the key before
|
||||
// its PSK-mixed message. Call after init(); the mode is tracked in state_
|
||||
// so the helper does not grow.
|
||||
APIError send_server_hello_first();
|
||||
#endif
|
||||
APIError loop() override;
|
||||
APIError read_packet(ReadPacketBuffer *buffer) override;
|
||||
@@ -39,6 +45,8 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
||||
APIError state_action_();
|
||||
APIError state_action_client_hello_();
|
||||
APIError state_action_server_hello_();
|
||||
APIError send_server_hello_frame_();
|
||||
APIError start_handshake_();
|
||||
APIError state_action_handshake_();
|
||||
APIError state_action_handshake_read_();
|
||||
APIError state_action_handshake_write_();
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
#include "api_outgoing_connection.h"
|
||||
#if defined(USE_API) && defined(USE_API_OUTGOING_CONNECTION)
|
||||
|
||||
#include "api_connection.h"
|
||||
#include "api_server.h"
|
||||
#include "esphome/components/network/util.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cerrno>
|
||||
#include <cinttypes>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
static const char *const TAG = "api.outgoing";
|
||||
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
static constexpr uint32_t OUTGOING_TARGET_PREF_HASH = 629847102UL;
|
||||
#endif
|
||||
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
// Read the connection's peer address into target; false when unavailable or
|
||||
// of a family this build cannot dial
|
||||
static bool peer_to_target(APIConnection *conn, SavedOutgoingTarget &target) {
|
||||
// Zeroed because the raw lwIP getpeername() leaves sin6_scope_id untouched
|
||||
struct sockaddr_storage peer = {};
|
||||
socklen_t peer_len = sizeof(peer);
|
||||
if (conn->getpeername((struct sockaddr *) &peer, &peer_len) != 0) {
|
||||
return false;
|
||||
}
|
||||
const sa_family_t family = ((struct sockaddr *) &peer)->sa_family;
|
||||
#if USE_NETWORK_IPV6
|
||||
if (family == AF_INET6) {
|
||||
const auto *addr6 = reinterpret_cast<const struct sockaddr_in6 *>(&peer);
|
||||
const auto *bytes = reinterpret_cast<const uint8_t *>(&addr6->sin6_addr);
|
||||
uint32_t prefix[3];
|
||||
memcpy(prefix, bytes, sizeof(prefix));
|
||||
// A dual-stack listener reports an IPv4 peer as ::ffff:a.b.c.d
|
||||
if (prefix[0] == 0 && prefix[1] == 0 && prefix[2] == htonl(0xFFFFUL)) {
|
||||
target.family = AF_INET;
|
||||
memcpy(target.addr, bytes + sizeof(prefix), sizeof(struct in_addr));
|
||||
return true;
|
||||
}
|
||||
// A link-local target is only reachable through the interface it came in
|
||||
// on. Device platforms number interfaces from one; a host build can hand
|
||||
// out an index too large to store, and a truncated one dials the wrong
|
||||
// interface, so that target is not remembered at all.
|
||||
if (addr6->sin6_scope_id > UINT8_MAX) {
|
||||
return false;
|
||||
}
|
||||
target.family = AF_INET6;
|
||||
memcpy(target.addr, bytes, sizeof(target.addr));
|
||||
target.scope_id = static_cast<uint8_t>(addr6->sin6_scope_id);
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
if (family != AF_INET) {
|
||||
return false;
|
||||
}
|
||||
const auto *addr4 = reinterpret_cast<const struct sockaddr_in *>(&peer);
|
||||
target.family = AF_INET;
|
||||
memcpy(target.addr, &addr4->sin_addr, sizeof(addr4->sin_addr));
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
socklen_t OutgoingConnectionManager::target_sockaddr_(struct sockaddr_storage *addr) const {
|
||||
#ifdef API_OUTGOING_CONNECTION_HOST
|
||||
// Validation only lets through a literal both inet_pton and inet6_aton
|
||||
// accept, so this cannot fail
|
||||
return socket::set_sockaddr((struct sockaddr *) addr, sizeof(*addr), API_OUTGOING_CONNECTION_HOST,
|
||||
API_OUTGOING_CONNECTION_PORT);
|
||||
#else
|
||||
#if USE_NETWORK_IPV6
|
||||
if (this->saved_.family == AF_INET6) {
|
||||
auto *addr6 = reinterpret_cast<struct sockaddr_in6 *>(addr);
|
||||
memset(addr6, 0, sizeof(*addr6));
|
||||
addr6->sin6_family = AF_INET6;
|
||||
addr6->sin6_port = htons(API_OUTGOING_CONNECTION_PORT);
|
||||
memcpy(&addr6->sin6_addr, this->saved_.addr, sizeof(this->saved_.addr));
|
||||
addr6->sin6_scope_id = this->saved_.scope_id;
|
||||
return sizeof(*addr6);
|
||||
}
|
||||
#endif
|
||||
if (this->saved_.family != AF_INET) {
|
||||
return 0;
|
||||
}
|
||||
auto *addr4 = reinterpret_cast<struct sockaddr_in *>(addr);
|
||||
memset(addr4, 0, sizeof(*addr4));
|
||||
addr4->sin_family = AF_INET;
|
||||
addr4->sin_port = htons(API_OUTGOING_CONNECTION_PORT);
|
||||
memcpy(&addr4->sin_addr, this->saved_.addr, sizeof(addr4->sin_addr));
|
||||
return sizeof(*addr4);
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
void OutgoingConnectionManager::format_target_(std::span<char, socket::SOCKADDR_STR_LEN> buf) const {
|
||||
struct sockaddr_storage addr;
|
||||
socklen_t addr_len = this->target_sockaddr_(&addr);
|
||||
if (addr_len == 0) {
|
||||
buf[0] = '\0';
|
||||
return;
|
||||
}
|
||||
// Clears buf itself if it cannot format the address
|
||||
socket::format_sockaddr_to((struct sockaddr *) &addr, addr_len, buf);
|
||||
}
|
||||
#endif
|
||||
|
||||
void OutgoingConnectionManager::setup() {
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
this->target_pref_ = global_preferences->make_preference<SavedOutgoingTarget>(OUTGOING_TARGET_PREF_HASH, true);
|
||||
struct sockaddr_storage addr;
|
||||
// dump_config() prints whichever target this leaves in place
|
||||
if (this->target_pref_.load(&this->saved_) && this->target_sockaddr_(&addr) != 0) {
|
||||
this->host_persisted_ = true;
|
||||
} else {
|
||||
// Never saved, failed its size or CRC check, or holds an unknown family
|
||||
this->saved_ = {};
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::loop(APIServer *server) {
|
||||
if (server->has_outgoing_target_client_()) {
|
||||
return; // on_target_client() already reset the dial state
|
||||
}
|
||||
if (this->dialed_conn_ != nullptr) {
|
||||
// A live dialed session (flagged or not, e.g. a host: peer) is the
|
||||
// target; a silent one dies on the handshake timeout
|
||||
return;
|
||||
}
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
switch (this->state_) {
|
||||
case DialState::DIAL_STATE_IDLE:
|
||||
// Target went away; give it the configured delay to reconnect first
|
||||
this->schedule_wait_(now, IDLE_WAIT_MS);
|
||||
break;
|
||||
case DialState::DIAL_STATE_WAITING:
|
||||
if (now - this->state_ts_ >= this->wait_) {
|
||||
this->try_dial_(server, now);
|
||||
}
|
||||
break;
|
||||
case DialState::DIAL_STATE_CONNECTING:
|
||||
this->poll_connect_(server, now);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
struct sockaddr_storage addr;
|
||||
socklen_t addr_len = this->target_sockaddr_(&addr);
|
||||
const bool at_limit = server->at_client_limit_();
|
||||
// No target is the steady state until a dial-back client has ever connected
|
||||
if (addr_len == 0 || at_limit || !server->noise_ctx_.has_psk()) {
|
||||
// Repeats for as long as the reason holds, so keep it out of debug logs
|
||||
ESP_LOGV(TAG, "Not dialing: %s",
|
||||
addr_len == 0 ? LOG_STR_LITERAL("no target")
|
||||
: (at_limit ? LOG_STR_LITERAL("max connections") : LOG_STR_LITERAL("no key")));
|
||||
// Not a dial failure; retry without escalating the backoff
|
||||
this->schedule_wait_(now, PRECONDITION_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
this->dial_socket_ = socket::socket_loop_monitored(((struct sockaddr *) &addr)->sa_family, SOCK_STREAM, IPPROTO_TCP);
|
||||
if (!this->dial_socket_ || this->dial_socket_->setblocking(false) != 0) {
|
||||
ESP_LOGW(TAG, "Socket %s failed: errno %d",
|
||||
this->dial_socket_ ? LOG_STR_LITERAL("setblocking") : LOG_STR_LITERAL("create"), errno);
|
||||
this->schedule_retry_(now);
|
||||
return;
|
||||
}
|
||||
#ifdef API_OUTGOING_CONNECTION_HOST
|
||||
ESP_LOGD(TAG, "Dialing " API_OUTGOING_CONNECTION_HOST ":%u", API_OUTGOING_CONNECTION_PORT);
|
||||
#else
|
||||
char host[socket::SOCKADDR_STR_LEN];
|
||||
socket::format_sockaddr_to((struct sockaddr *) &addr, addr_len, host);
|
||||
ESP_LOGD(TAG, "Dialing %s:%u", host, API_OUTGOING_CONNECTION_PORT);
|
||||
#endif
|
||||
int err = this->dial_socket_->connect((struct sockaddr *) &addr, addr_len);
|
||||
if (err == 0) {
|
||||
// Immediate success (possible for localhost)
|
||||
this->handoff_(server, now);
|
||||
return;
|
||||
}
|
||||
if (errno != EINPROGRESS) {
|
||||
ESP_LOGW(TAG, "Connect failed: %d", errno);
|
||||
this->schedule_retry_(now);
|
||||
return;
|
||||
}
|
||||
this->state_ = DialState::DIAL_STATE_CONNECTING;
|
||||
this->state_ts_ = now;
|
||||
this->last_poll_ = now;
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) {
|
||||
if (now - this->state_ts_ >= CONNECT_TIMEOUT_MS) {
|
||||
ESP_LOGW(TAG, "Connect timeout");
|
||||
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:
|
||||
break;
|
||||
case socket::ConnectPollResult::CONNECT_POLL_RESULT_CONNECTED:
|
||||
this->handoff_(server, now);
|
||||
break;
|
||||
case socket::ConnectPollResult::CONNECT_POLL_RESULT_ERROR:
|
||||
ESP_LOGW(TAG, "Connect failed: %d", err);
|
||||
this->schedule_retry_(now);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::handoff_(APIServer *server, uint32_t now) {
|
||||
this->dialed_conn_ = server->add_outgoing_client_(std::move(this->dial_socket_));
|
||||
if (this->dialed_conn_ == nullptr) {
|
||||
// Only preconditions (slot limit, key cleared) refuse the handoff; the
|
||||
// peer is reachable, so do not escalate the backoff
|
||||
this->schedule_wait_(now, PRECONDITION_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
// Connected; dialed_conn_ gates further dialing until the session settles
|
||||
this->state_ = DialState::DIAL_STATE_IDLE;
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::schedule_wait_(uint32_t now, uint32_t wait) {
|
||||
this->dial_socket_.reset(); // no-op when the socket was handed off
|
||||
this->state_ = DialState::DIAL_STATE_WAITING;
|
||||
this->state_ts_ = now;
|
||||
this->wait_ = wait;
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::schedule_retry_(uint32_t now) {
|
||||
// +/-20% jitter so a fleet of devices does not retry one server in lockstep
|
||||
const uint32_t jitter_span = this->backoff_ / 5;
|
||||
this->schedule_wait_(now, this->backoff_ - jitter_span + (random_uint32() % (2 * jitter_span + 1)));
|
||||
this->backoff_ = std::min(this->backoff_ * 2, BACKOFF_MAX_MS);
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::on_client_removed(APIConnection *conn, bool was_authenticated) {
|
||||
if (conn != this->dialed_conn_) {
|
||||
return;
|
||||
}
|
||||
this->dialed_conn_ = nullptr;
|
||||
if (was_authenticated) {
|
||||
// A working peer (e.g. a host: target that never sends the flag)
|
||||
// disconnected normally; state is IDLE, so loop() applies the delay
|
||||
this->backoff_ = BACKOFF_MIN_MS;
|
||||
} else {
|
||||
this->schedule_retry_(App.get_loop_component_start_time());
|
||||
}
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::on_target_client(APIConnection *conn) {
|
||||
// The target is connected; stop any dial in flight and reset the backoff.
|
||||
// A dialed connection stays tracked unless it is this one: an inbound
|
||||
// target must not orphan a still-open dial.
|
||||
this->dial_socket_.reset();
|
||||
if (conn == this->dialed_conn_) {
|
||||
this->dialed_conn_ = nullptr;
|
||||
}
|
||||
this->state_ = DialState::DIAL_STATE_IDLE;
|
||||
this->backoff_ = BACKOFF_MIN_MS;
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
SavedOutgoingTarget target{};
|
||||
if (!peer_to_target(conn, target)) {
|
||||
ESP_LOGW(TAG, "Not remembering this target; its address cannot be dialed");
|
||||
return;
|
||||
}
|
||||
if (this->host_persisted_ && memcmp(&target, &this->saved_, sizeof(target)) == 0) {
|
||||
return; // unchanged and already on flash; avoid flash wear
|
||||
}
|
||||
// Use the fresh address this boot even if the flash write fails; a failed
|
||||
// write is retried on the next flagged hello via host_persisted_
|
||||
this->saved_ = target;
|
||||
if (!this->persist_target_()) {
|
||||
ESP_LOGW(TAG, "Failed to save target");
|
||||
return;
|
||||
}
|
||||
char host[socket::SOCKADDR_STR_LEN];
|
||||
this->format_target_(host);
|
||||
ESP_LOGD(TAG, "Remembered %s as the dial target", host);
|
||||
#endif
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::dump_config() const {
|
||||
// The boot delay differs from delay: on deep sleep builds, so print the
|
||||
// value that actually applies
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Outgoing connection port: %u\n"
|
||||
" Outgoing connection boot delay: %" PRIu32 "ms",
|
||||
API_OUTGOING_CONNECTION_PORT, BOOT_WAIT_MS);
|
||||
// Both forms keep their text out of RAM on ESP8266: in the format string,
|
||||
// or through LOG_STR_LITERAL
|
||||
#ifdef API_OUTGOING_CONNECTION_HOST
|
||||
ESP_LOGCONFIG(TAG, " Outgoing connection host: " API_OUTGOING_CONNECTION_HOST);
|
||||
#else
|
||||
char buf[socket::SOCKADDR_STR_LEN];
|
||||
this->format_target_(buf);
|
||||
ESP_LOGCONFIG(TAG, " Outgoing connection host: %s", buf[0] == '\0' ? LOG_STR_LITERAL("none remembered yet") : buf);
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace esphome::api
|
||||
#endif // USE_API && USE_API_OUTGOING_CONNECTION
|
||||
@@ -0,0 +1,120 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#if defined(USE_API) && defined(USE_API_OUTGOING_CONNECTION)
|
||||
|
||||
#ifndef USE_API_NOISE
|
||||
#error "api outgoing_connection needs noise encryption so the peer is verified by key"
|
||||
#endif
|
||||
|
||||
#include "esphome/components/socket/socket.h"
|
||||
#include "esphome/core/preferences.h"
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
class APIServer;
|
||||
class APIConnection;
|
||||
|
||||
// Room for an IPv6 address in every build, so a remembered IPv4 target is
|
||||
// still dialed after enable_ipv6 is turned on. A size that followed the build
|
||||
// would also shift every preference registered after this one on ESP8266,
|
||||
// where slots are positional. An IPv6 target on a build without IPv6 is
|
||||
// dropped by target_sockaddr_() and relearned.
|
||||
// Bytes in an IPv6 address
|
||||
static constexpr size_t TARGET_ADDR_LEN = 16;
|
||||
|
||||
struct SavedOutgoingTarget {
|
||||
// 0 when none is remembered, else AF_INET or AF_INET6
|
||||
uint8_t family;
|
||||
// Network order, IPv4 in the first four bytes and the rest zero
|
||||
uint8_t addr[TARGET_ADDR_LEN];
|
||||
// Interface a link-local IPv6 target is reachable on, 0 when it needs none.
|
||||
// Free in flash: the record still rounds up to the same five words.
|
||||
uint8_t scope_id;
|
||||
} PACKED; // NOLINT
|
||||
|
||||
/// Dials out when no dial-back target client is connected. Only the TCP
|
||||
/// direction flips: the device stays the Noise responder, so both sides
|
||||
/// still verify by key. Targets the YAML host or the last remembered client.
|
||||
class OutgoingConnectionManager {
|
||||
public:
|
||||
void setup();
|
||||
void loop(APIServer *server);
|
||||
/// A key-verified client declared itself a dial-back target; last one wins
|
||||
void on_target_client(APIConnection *conn);
|
||||
/// Clears the dialed-connection gate; dying unauthenticated escalates the backoff
|
||||
void on_client_removed(APIConnection *conn, bool was_authenticated);
|
||||
void on_shutdown() { this->dial_socket_.reset(); }
|
||||
void dump_config() const;
|
||||
|
||||
protected:
|
||||
enum class DialState : uint8_t {
|
||||
DIAL_STATE_IDLE,
|
||||
DIAL_STATE_WAITING,
|
||||
DIAL_STATE_CONNECTING,
|
||||
};
|
||||
|
||||
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
|
||||
#ifdef USE_DEEP_SLEEP
|
||||
static constexpr uint32_t BOOT_WAIT_MS = 0;
|
||||
static constexpr uint32_t IDLE_WAIT_MS = BACKOFF_MIN_MS;
|
||||
#else
|
||||
static constexpr uint32_t BOOT_WAIT_MS = API_OUTGOING_CONNECTION_DELAY;
|
||||
static constexpr uint32_t IDLE_WAIT_MS = API_OUTGOING_CONNECTION_DELAY;
|
||||
#endif
|
||||
|
||||
void try_dial_(APIServer *server, uint32_t now);
|
||||
void poll_connect_(APIServer *server, uint32_t now);
|
||||
// Hand the connected socket to the server and gate on the new connection
|
||||
void handoff_(APIServer *server, uint32_t now);
|
||||
// Close any half-open dial and wait a jittered backoff before retrying
|
||||
void schedule_retry_(uint32_t now);
|
||||
// Wait without escalating the backoff (used for unmet preconditions)
|
||||
void schedule_wait_(uint32_t now, uint32_t wait);
|
||||
/// Fill addr with the target and return its length, or 0 when there is none
|
||||
socklen_t target_sockaddr_(struct sockaddr_storage *addr) const;
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
// Write saved_ to flash, tracking success in host_persisted_
|
||||
bool persist_target_() {
|
||||
this->host_persisted_ = this->target_pref_.save(&this->saved_) && global_preferences->sync();
|
||||
return this->host_persisted_;
|
||||
}
|
||||
/// Format the remembered target for a log line; empty when there is none
|
||||
void format_target_(std::span<char, socket::SOCKADDR_STR_LEN> buf) const;
|
||||
#endif
|
||||
|
||||
// Pointers first (4 bytes each on 32-bit)
|
||||
std::unique_ptr<socket::Socket> dial_socket_;
|
||||
// Compared only, never dereferenced
|
||||
APIConnection *dialed_conn_{nullptr};
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
ESPPreferenceObject target_pref_;
|
||||
#endif
|
||||
|
||||
// 4-byte types
|
||||
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
|
||||
SavedOutgoingTarget saved_{};
|
||||
// False while saved_ holds a value the flash write failed for; retried on
|
||||
// the next flagged hello
|
||||
bool host_persisted_{false};
|
||||
#endif
|
||||
DialState state_{DialState::DIAL_STATE_WAITING};
|
||||
};
|
||||
|
||||
} // namespace esphome::api
|
||||
#endif // USE_API && USE_API_OUTGOING_CONNECTION
|
||||
@@ -20,6 +20,11 @@ void HelloRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, p
|
||||
case proto_tag(3, WIRE_TYPE_VARINT):
|
||||
msg.api_version_minor = value.as_varint();
|
||||
break;
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
case proto_tag(4, WIRE_TYPE_VARINT):
|
||||
msg.outgoing_connection_target = value.as_bool();
|
||||
break;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
uint8_t *HelloResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) {
|
||||
@@ -176,6 +181,9 @@ uint8_t *DeviceInfoResponse::encode_msg(const void *self, ProtoWriteBuffer &buff
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.api_encryption_provisionable);
|
||||
#endif
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 27, msg.api_outgoing_connection_supported);
|
||||
#endif
|
||||
return pos;
|
||||
}
|
||||
@@ -242,6 +250,9 @@ uint32_t DeviceInfoResponse::calc_size_msg(const void *self) {
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
size += ProtoSize::calc_bool(2, msg.api_encryption_provisionable);
|
||||
#endif
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
size += ProtoSize::calc_bool(2, msg.api_outgoing_connection_supported);
|
||||
#endif
|
||||
return size;
|
||||
}
|
||||
|
||||
@@ -417,13 +417,16 @@ class CommandProtoMessage : public ProtoDecodableMessage {
|
||||
class HelloRequest final : public ProtoDecodableMessage {
|
||||
public:
|
||||
static constexpr uint16_t MESSAGE_TYPE = 1;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 17;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 19;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("hello_request"); }
|
||||
#endif
|
||||
StringRef client_info{};
|
||||
uint32_t api_version_major{0};
|
||||
uint32_t api_version_minor{0};
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
bool outgoing_connection_target{false};
|
||||
#endif
|
||||
void decode(const uint8_t *buffer, size_t length) {
|
||||
ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field);
|
||||
}
|
||||
@@ -579,7 +582,7 @@ class SerialProxyInfo final : public ProtoMessage {
|
||||
class DeviceInfoResponse final : public ProtoMessage {
|
||||
public:
|
||||
static constexpr uint16_t MESSAGE_TYPE = 10;
|
||||
static constexpr uint16_t ESTIMATED_SIZE = 312;
|
||||
static constexpr uint16_t ESTIMATED_SIZE = 315;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("device_info_response"); }
|
||||
#endif
|
||||
@@ -637,6 +640,9 @@ class DeviceInfoResponse final : public ProtoMessage {
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
bool api_encryption_provisionable{false};
|
||||
#endif
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
bool api_outgoing_connection_supported{false};
|
||||
#endif
|
||||
static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM);
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
@@ -1909,7 +1915,7 @@ class ListEntitiesSelectResponse final : public InfoResponseProtoMessage {
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("list_entities_select_response"); }
|
||||
#endif
|
||||
const FixedVector<const char *> *options{};
|
||||
const std::span<const char *const> *options{};
|
||||
static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM);
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG);
|
||||
|
||||
@@ -897,6 +897,9 @@ const char *HelloRequest::dump_to(DumpBuffer &out) const {
|
||||
dump_field(out, ESPHOME_PSTR("client_info"), this->client_info);
|
||||
dump_field(out, ESPHOME_PSTR("api_version_major"), this->api_version_major);
|
||||
dump_field(out, ESPHOME_PSTR("api_version_minor"), this->api_version_minor);
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
dump_field(out, ESPHOME_PSTR("outgoing_connection_target"), this->outgoing_connection_target);
|
||||
#endif
|
||||
return out.c_str();
|
||||
}
|
||||
const char *HelloResponse::dump_to(DumpBuffer &out) const {
|
||||
@@ -1020,6 +1023,9 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable);
|
||||
#endif
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
dump_field(out, ESPHOME_PSTR("api_outgoing_connection_supported"), this->api_outgoing_connection_supported);
|
||||
#endif
|
||||
return out.c_str();
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
// Standard library includes that might be needed
|
||||
#include <set>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
|
||||
|
||||
@@ -56,7 +56,12 @@ APIServer::APIServer() { global_api_server = this; }
|
||||
void APIServer::socket_failed_(const LogString *msg) {
|
||||
ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno);
|
||||
this->destroy_socket_();
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// Dial-out needs no listener; degrade instead of stopping the component
|
||||
this->status_set_error(LOG_STR("listen socket failed"));
|
||||
#else
|
||||
this->mark_failed();
|
||||
#endif
|
||||
}
|
||||
|
||||
void APIServer::setup() {
|
||||
@@ -72,43 +77,6 @@ void APIServer::setup() {
|
||||
#endif
|
||||
#endif
|
||||
|
||||
this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections
|
||||
if (this->socket_ == nullptr) {
|
||||
this->socket_failed_(LOG_STR("creation"));
|
||||
return;
|
||||
}
|
||||
int enable = 1;
|
||||
int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int));
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "Socket reuseaddr: errno %d", errno);
|
||||
// we can still continue
|
||||
}
|
||||
err = this->socket_->setblocking(false);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("nonblocking"));
|
||||
return;
|
||||
}
|
||||
|
||||
struct sockaddr_storage server;
|
||||
|
||||
socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_);
|
||||
if (sl == 0) {
|
||||
this->socket_failed_(LOG_STR("set sockaddr"));
|
||||
return;
|
||||
}
|
||||
|
||||
err = this->socket_->bind((struct sockaddr *) &server, sl);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("bind"));
|
||||
return;
|
||||
}
|
||||
|
||||
err = this->socket_->listen(this->listen_backlog_);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("listen"));
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef USE_LOGGER
|
||||
if (logger::global_logger != nullptr) {
|
||||
logger::global_logger->add_log_callback(
|
||||
@@ -154,6 +122,47 @@ void APIServer::setup() {
|
||||
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
this->status_set_warning(LOG_STR("waiting for client connection"));
|
||||
}
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
this->outgoing_conn_.setup();
|
||||
#endif
|
||||
|
||||
// Listener last: on failure socket_failed_() returns early, and an
|
||||
// outgoing_connection build keeps dialing out without one
|
||||
this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections
|
||||
if (this->socket_ == nullptr) {
|
||||
this->socket_failed_(LOG_STR("creation"));
|
||||
return;
|
||||
}
|
||||
int enable = 1;
|
||||
int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int));
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "Socket reuseaddr: errno %d", errno);
|
||||
// we can still continue
|
||||
}
|
||||
err = this->socket_->setblocking(false);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("nonblocking"));
|
||||
return;
|
||||
}
|
||||
|
||||
struct sockaddr_storage server;
|
||||
|
||||
socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_);
|
||||
if (sl == 0) {
|
||||
this->socket_failed_(LOG_STR("set sockaddr"));
|
||||
return;
|
||||
}
|
||||
|
||||
err = this->socket_->bind((struct sockaddr *) &server, sl);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("bind"));
|
||||
return;
|
||||
}
|
||||
|
||||
err = this->socket_->listen(this->listen_backlog_);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("listen"));
|
||||
}
|
||||
}
|
||||
|
||||
void APIServer::loop() {
|
||||
@@ -162,6 +171,19 @@ void APIServer::loop() {
|
||||
this->accept_new_connections_();
|
||||
}
|
||||
|
||||
const bool connected = network::is_connected();
|
||||
#ifdef USE_NOISE_SPARE_EPHEMERAL
|
||||
if (connected && !noise::has_spare_ephemeral()) {
|
||||
this->refill_spare_ephemeral_();
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
if (!this->shutting_down_) {
|
||||
this->outgoing_conn_.loop(this);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (this->api_connection_count_ == 0) {
|
||||
// Check reboot timeout - done in loop to avoid scheduler heap churn
|
||||
// (cancelled scheduler items sit in heap memory until their scheduled time).
|
||||
@@ -178,8 +200,7 @@ void APIServer::loop() {
|
||||
}
|
||||
|
||||
// Process clients and remove disconnected ones in a single pass
|
||||
// Check network connectivity once for all clients
|
||||
if (!network::is_connected()) {
|
||||
if (!connected) {
|
||||
// Network is down - disconnect all clients
|
||||
for (auto &client : this->active_clients()) {
|
||||
client->on_fatal_error();
|
||||
@@ -207,6 +228,19 @@ void APIServer::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_NOISE_SPARE_EPHEMERAL
|
||||
// An OTA handshake is not visible here and just pays the refill it triggered
|
||||
void APIServer::refill_spare_ephemeral_() {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
for (auto &client : this->active_clients()) {
|
||||
if (client->is_still_connecting(now)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
noise::prepare_spare_ephemeral();
|
||||
}
|
||||
#endif
|
||||
|
||||
void APIServer::remove_client_(uint8_t client_index) {
|
||||
auto &client = this->clients_[client_index];
|
||||
|
||||
@@ -222,6 +256,15 @@ void APIServer::remove_client_(uint8_t client_index) {
|
||||
std::string client_peername(client->get_peername_to(peername_buf));
|
||||
#endif
|
||||
|
||||
// Read before the swap-and-reset below destroys the connection
|
||||
const bool was_authenticated = client->is_authenticated();
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
if (client->flags_.outgoing_connection_target) {
|
||||
this->outgoing_target_count_--;
|
||||
}
|
||||
this->outgoing_conn_.on_client_removed(client.get(), was_authenticated);
|
||||
#endif
|
||||
|
||||
// Close socket now (was deferred from on_fatal_error to allow getpeername)
|
||||
client->helper_->close();
|
||||
|
||||
@@ -240,9 +283,15 @@ void APIServer::remove_client_(uint8_t client_index) {
|
||||
|
||||
// Last client disconnected - set warning and start tracking for reboot timeout
|
||||
// (suppressed while provisioning is pending - see loop()).
|
||||
// Refresh on every authenticated removal, not just the last one, so an
|
||||
// unauthenticated straggler removed later (e.g. a port scan, or a dial to
|
||||
// a host that accepts TCP but never speaks the API) cannot discard a
|
||||
// healthy session's timestamp and trigger a spurious reboot
|
||||
if (was_authenticated) {
|
||||
this->last_connected_ = App.get_loop_component_start_time();
|
||||
}
|
||||
if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
this->status_set_warning(LOG_STR("waiting for client connection"));
|
||||
this->last_connected_ = App.get_loop_component_start_time();
|
||||
}
|
||||
|
||||
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
|
||||
@@ -264,7 +313,7 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
|
||||
sock->getpeername_to(peername);
|
||||
|
||||
// Check if we're at the connection limit
|
||||
if (this->api_connection_count_ >= MAX_API_CONNECTIONS) {
|
||||
if (this->at_client_limit_()) {
|
||||
ESP_LOGW(TAG, "Max connections (%d), rejecting %s", MAX_API_CONNECTIONS, peername);
|
||||
// Immediately close - socket destructor will handle cleanup
|
||||
sock.reset();
|
||||
@@ -273,18 +322,47 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
|
||||
|
||||
ESP_LOGD(TAG, "Accept %s", peername);
|
||||
|
||||
auto *conn = new APIConnection(std::move(sock), this);
|
||||
this->clients_[this->api_connection_count_++].reset(conn);
|
||||
conn->start();
|
||||
|
||||
// First client connected - clear warning and update timestamp
|
||||
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
this->status_clear_warning();
|
||||
this->last_connected_ = App.get_loop_component_start_time();
|
||||
}
|
||||
this->add_client_(std::move(sock));
|
||||
}
|
||||
}
|
||||
|
||||
APIConnection *APIServer::add_client_(std::unique_ptr<socket::Socket> sock) {
|
||||
auto *conn = new APIConnection(std::move(sock), this); // NOLINT(cppcoreguidelines-owning-memory)
|
||||
this->clients_[this->api_connection_count_++].reset(conn);
|
||||
conn->start();
|
||||
|
||||
// First client connected - clear warning. The reboot watchdog timestamp is
|
||||
// refreshed when an authenticated client is removed (see remove_client_),
|
||||
// never on bare TCP connects.
|
||||
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
this->status_clear_warning();
|
||||
}
|
||||
return conn;
|
||||
}
|
||||
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
APIConnection *APIServer::add_outgoing_client_(std::unique_ptr<socket::Socket> sock) {
|
||||
// Re-check at the handoff: inbound clients may have taken the last slot and
|
||||
// the PSK may have been cleared since the dial started (mark_outgoing()
|
||||
// needs the noise helper)
|
||||
const bool at_limit = this->at_client_limit_();
|
||||
if (at_limit || !this->noise_ctx_.has_psk()) {
|
||||
ESP_LOGW(TAG, "Dropping outgoing connection (%s)",
|
||||
at_limit ? LOG_STR_LITERAL("max connections") : LOG_STR_LITERAL("no key"));
|
||||
return nullptr;
|
||||
}
|
||||
auto *conn = this->add_client_(std::move(sock));
|
||||
// After start(): sends our server hello first so the peer can pick the key
|
||||
conn->mark_outgoing();
|
||||
return conn;
|
||||
}
|
||||
|
||||
void APIServer::on_outgoing_target_client(APIConnection *conn) {
|
||||
this->outgoing_target_count_++;
|
||||
this->outgoing_conn_.on_target_client(conn);
|
||||
}
|
||||
#endif
|
||||
|
||||
void APIServer::dump_config() {
|
||||
char addr_buf[network::USE_ADDRESS_BUFFER_SIZE];
|
||||
ESP_LOGCONFIG(TAG,
|
||||
@@ -301,6 +379,9 @@ void APIServer::dump_config() {
|
||||
#else
|
||||
ESP_LOGCONFIG(TAG, " Noise encryption: NO");
|
||||
#endif
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
this->outgoing_conn_.dump_config();
|
||||
#endif
|
||||
}
|
||||
|
||||
void APIServer::handle_disconnect(APIConnection *conn) {}
|
||||
@@ -597,6 +678,8 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString
|
||||
if (!c->send_message(req)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Disconnect request");
|
||||
}
|
||||
// Force it: a session from before the key was active must not survive
|
||||
c->flags_.next_close = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -698,6 +781,9 @@ void APIServer::on_shutdown() {
|
||||
|
||||
// Close the listening socket to prevent new connections
|
||||
this->destroy_socket_();
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
this->outgoing_conn_.on_shutdown();
|
||||
#endif
|
||||
|
||||
// Change batch delay to 5ms for quick flushing during shutdown
|
||||
this->batch_delay_ = 5;
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
#include "api_buffer.h"
|
||||
// Must precede clients_ so APIConnection is complete for default_delete (libc++).
|
||||
#include "api_connection.h"
|
||||
#ifdef USE_API_NOISE
|
||||
#if defined(USE_API_NOISE) || defined(USE_NOISE_SPARE_EPHEMERAL)
|
||||
// Only present in the build when the noise component is loaded
|
||||
#include "esphome/components/noise/noise.h"
|
||||
#endif
|
||||
#include "api_pb2.h"
|
||||
#include "api_pb2_service.h"
|
||||
#include "api_outgoing_connection.h"
|
||||
#include "esphome/components/socket/socket.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
@@ -90,6 +91,10 @@ class APIServer final : public Component
|
||||
void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); }
|
||||
noise::NoiseContext &get_noise_ctx() { return this->noise_ctx_; }
|
||||
#endif // USE_API_NOISE
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// Called by APIConnection when a client declares itself a dial-back target in its hello
|
||||
void on_outgoing_target_client(APIConnection *conn);
|
||||
#endif
|
||||
|
||||
void handle_disconnect(APIConnection *conn);
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
@@ -267,6 +272,16 @@ class APIServer final : public Component
|
||||
protected:
|
||||
// Accept incoming socket connections. Only called when socket has pending connections.
|
||||
void __attribute__((noinline)) accept_new_connections_();
|
||||
/// Takes the socket into a new connection and starts it; callers must have
|
||||
/// checked at_client_limit_() first
|
||||
APIConnection *add_client_(std::unique_ptr<socket::Socket> sock);
|
||||
bool at_client_limit_() const { return this->api_connection_count_ >= MAX_API_CONNECTIONS; }
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// Returns the new connection, or nullptr (socket dropped) when at the limit
|
||||
APIConnection *add_outgoing_client_(std::unique_ptr<socket::Socket> sock);
|
||||
bool has_outgoing_target_client_() const { return this->outgoing_target_count_ != 0; }
|
||||
friend class OutgoingConnectionManager;
|
||||
#endif
|
||||
// Remove a disconnected client by index. Swaps with the last populated slot and resets it.
|
||||
void __attribute__((noinline)) remove_client_(uint8_t client_index);
|
||||
|
||||
@@ -307,6 +322,8 @@ class APIServer final : public Component
|
||||
delete this->socket_;
|
||||
this->socket_ = nullptr;
|
||||
}
|
||||
/// Log the failure, drop the listen socket, and mark the component failed
|
||||
/// unless this build can still dial out
|
||||
void socket_failed_(const LogString *msg);
|
||||
// Pointers and pointer-like types first (4 bytes each)
|
||||
socket::ListenSocket *socket_{nullptr};
|
||||
@@ -357,16 +374,21 @@ class APIServer final : public Component
|
||||
// Group smaller types together
|
||||
uint16_t port_{6053}; // Keep in sync with DEFAULT_PORT in __init__.py
|
||||
uint16_t batch_delay_{100}; // Keep in sync with DEFAULT_BATCH_DELAY in __init__.py
|
||||
// Connection limits - these defaults will be overridden by config values
|
||||
// from cv.SplitDefault in __init__.py which sets platform-specific defaults.
|
||||
uint8_t listen_backlog_{4};
|
||||
uint8_t listen_backlog_{4}; // Keep in sync with DEFAULT_LISTEN_BACKLOG in __init__.py
|
||||
bool shutting_down_ = false;
|
||||
uint8_t api_connection_count_{0};
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// Connected clients whose hello declared them a dial-back target
|
||||
uint8_t outgoing_target_count_{0};
|
||||
#endif
|
||||
#if defined(USE_PROVISIONING) && defined(USE_API_NOISE)
|
||||
// Index assigned by the provisioning manager for reporting this transport's state.
|
||||
uint8_t provisioning_source_{0};
|
||||
#endif
|
||||
|
||||
#ifdef USE_NOISE_SPARE_EPHEMERAL
|
||||
void refill_spare_ephemeral_();
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
noise::NoiseContext noise_ctx_;
|
||||
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
||||
@@ -374,6 +396,9 @@ class APIServer final : public Component
|
||||
#endif
|
||||
ESPPreferenceObject noise_pref_;
|
||||
#endif // USE_API_NOISE
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
OutgoingConnectionManager outgoing_conn_;
|
||||
#endif
|
||||
};
|
||||
|
||||
extern APIServer *global_api_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
@@ -56,22 +56,25 @@ inline constexpr int64_t decode_zigzag64(uint64_t value) {
|
||||
return (value & 1) ? static_cast<int64_t>(~(value >> 1)) : static_cast<int64_t>(value >> 1);
|
||||
}
|
||||
|
||||
/// Count number of varints in a packed buffer
|
||||
inline uint16_t count_packed_varints(const uint8_t *data, size_t len) {
|
||||
uint16_t count = 0;
|
||||
while (len > 0) {
|
||||
// Skip varint bytes until we find one without continuation bit
|
||||
while (len > 0 && (*data & 0x80)) {
|
||||
data++;
|
||||
len--;
|
||||
}
|
||||
if (len > 0) {
|
||||
data++;
|
||||
len--;
|
||||
count++;
|
||||
/// Count varints in a packed buffer: len minus bytes with the continuation bit, summed a word at a time.
|
||||
/// Word is a template parameter so tests can cover the 32-bit path on a 64-bit host.
|
||||
template<typename Word = size_t> inline uint16_t count_packed_varints(const uint8_t *data, size_t len) {
|
||||
constexpr size_t word_size = sizeof(Word);
|
||||
constexpr Word lane_ones = ~Word{0} / 0xFF; // 0x01..01
|
||||
const uint8_t *end = data + len;
|
||||
size_t continuations = 0;
|
||||
while (data != end) {
|
||||
// Unaligned word loads fault on Xtensa
|
||||
if ((reinterpret_cast<uintptr_t>(data) & (word_size - 1)) == 0 && static_cast<size_t>(end - data) >= word_size) {
|
||||
Word word;
|
||||
memcpy(&word, __builtin_assume_aligned(data, word_size), word_size);
|
||||
continuations += (((word >> 7) & lane_ones) * lane_ones) >> (word_size * 8 - 8);
|
||||
data += word_size;
|
||||
} else {
|
||||
continuations += *data++ >> 7;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
return static_cast<uint16_t>(len - continuations);
|
||||
}
|
||||
|
||||
/// Encode a varint directly into a pre-allocated buffer.
|
||||
|
||||
@@ -35,10 +35,6 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_AS3935_ID])
|
||||
|
||||
if distance_config := config.get(CONF_DISTANCE):
|
||||
sens = await sensor.new_sensor(distance_config)
|
||||
cg.add(hub.set_distance_sensor(sens))
|
||||
|
||||
if lightning_energy_config := config.get(CONF_LIGHTNING_ENERGY):
|
||||
sens = await sensor.new_sensor(lightning_energy_config)
|
||||
cg.add(hub.set_energy_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_DISTANCE, hub.set_distance_sensor)
|
||||
await sensors(CONF_LIGHTNING_ENERGY, hub.set_energy_sensor)
|
||||
|
||||
@@ -241,12 +241,9 @@ async def to_code(config: ConfigType) -> None:
|
||||
if peak_current_config := conf.get(CONF_PEAK_CURRENT):
|
||||
sens = await sensor.new_sensor(peak_current_config)
|
||||
cg.add(var.set_peak_current_sensor(i, sens))
|
||||
if frequency_config := config.get(CONF_FREQUENCY):
|
||||
sens = await sensor.new_sensor(frequency_config)
|
||||
cg.add(var.set_freq_sensor(sens))
|
||||
if chip_temperature_config := config.get(CONF_CHIP_TEMPERATURE):
|
||||
sens = await sensor.new_sensor(chip_temperature_config)
|
||||
cg.add(var.set_chip_temperature_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_FREQUENCY, var.set_freq_sensor)
|
||||
await sensors(CONF_CHIP_TEMPERATURE, var.set_chip_temperature_sensor)
|
||||
cg.add(var.set_line_freq(config[CONF_LINE_FREQUENCY]))
|
||||
cg.add(var.set_current_phases(config[CONF_CURRENT_PHASES]))
|
||||
cg.add(var.set_pga_gain(config[CONF_GAIN_PGA]))
|
||||
|
||||
@@ -36,14 +36,13 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
parent = await cg.get_variable(config[CONF_ID])
|
||||
hub = await cg.get_variable(config[CONF_ID])
|
||||
|
||||
if phase_cfg := config.get(CONF_PHASE_STATUS):
|
||||
for i, key in enumerate(PHASE_KEYS):
|
||||
if sub_phase_cfg := phase_cfg.get(key):
|
||||
sens = await text_sensor.new_text_sensor(sub_phase_cfg)
|
||||
cg.add(parent.set_phase_status_text_sensor(i, sens))
|
||||
cg.add(hub.set_phase_status_text_sensor(i, sens))
|
||||
|
||||
if freq_status_config := config.get(CONF_FREQUENCY_STATUS):
|
||||
sens = await text_sensor.new_text_sensor(freq_status_config)
|
||||
cg.add(parent.set_freq_status_text_sensor(sens))
|
||||
text_sensors = text_sensor.sub_text_sensors(config)
|
||||
await text_sensors(CONF_FREQUENCY_STATUS, hub.set_freq_status_text_sensor)
|
||||
|
||||
@@ -72,7 +72,7 @@ esp_err_t AudioReader::add_sink(const std::weak_ptr<ring_buffer::RingBuffer> &ou
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
esp_err_t AudioReader::start(AudioFile *audio_file, AudioFileType &file_type) {
|
||||
esp_err_t AudioReader::start(const AudioFile *audio_file, AudioFileType &file_type) {
|
||||
file_type = AudioFileType::NONE;
|
||||
|
||||
this->current_audio_file_ = audio_file;
|
||||
|
||||
@@ -47,7 +47,7 @@ class AudioReader {
|
||||
/// @param audio_file AudioFile struct containing the file.
|
||||
/// @param file_type AudioFileType variable passed-by-reference indicating the type of file being read.
|
||||
/// @return ESP_OK
|
||||
esp_err_t start(AudioFile *audio_file, AudioFileType &file_type);
|
||||
esp_err_t start(const AudioFile *audio_file, AudioFileType &file_type);
|
||||
|
||||
/// @brief Reads new file data from the source and sends to the ring buffer sink.
|
||||
/// @return AudioReaderState
|
||||
@@ -69,7 +69,7 @@ class AudioReader {
|
||||
|
||||
esp_http_client_handle_t client_{nullptr};
|
||||
|
||||
AudioFile *current_audio_file_{nullptr};
|
||||
const AudioFile *current_audio_file_{nullptr};
|
||||
AudioFileType audio_file_type_{AudioFileType::NONE};
|
||||
const uint8_t *file_current_{nullptr};
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ from esphome.const import (
|
||||
CONF_URL,
|
||||
)
|
||||
from esphome.core import CORE, ID, HexInt
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.cpp_generator import MockObj, ProgmemAssignmentExpression
|
||||
from esphome.external_files import download_web_files_in_config
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -151,11 +151,14 @@ TYPED_FILE_SCHEMA = cv.typed_schema(
|
||||
)
|
||||
|
||||
|
||||
CONF_FILE_DATA_ID = "file_data_id"
|
||||
|
||||
MEDIA_FILE_TYPE_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(audio.AudioFile),
|
||||
cv.Required(CONF_FILE): _file_schema,
|
||||
cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
|
||||
cv.GenerateID(CONF_FILE_DATA_ID): cv.declare_id(audio.AudioFile),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -217,9 +220,9 @@ def audio_files_schema() -> cv.All:
|
||||
|
||||
|
||||
def generate_audio_file_code(file_config: ConfigType) -> MockObj:
|
||||
"""Generate the progmem data, AudioFile struct, and Pvariable for one file.
|
||||
"""Generate the progmem data and a flash AudioFile for one file.
|
||||
|
||||
Returns the created Pvariable. Caller is responsible for any further
|
||||
Returns a const pointer to the AudioFile. Caller is responsible for any further
|
||||
registration (the audio_file component additionally registers each file in
|
||||
its named C++ registry; other consumers may skip that).
|
||||
"""
|
||||
@@ -230,17 +233,33 @@ def generate_audio_file_code(file_config: ConfigType) -> MockObj:
|
||||
else:
|
||||
data, media_file_type = read_audio_file_and_type(file_config)
|
||||
|
||||
rhs = [HexInt(x) for x in data]
|
||||
prog_arr = cg.progmem_array(file_config[CONF_RAW_DATA_ID], rhs)
|
||||
|
||||
media_files_struct = cg.StructInitializer(
|
||||
# Global constants so the AudioFile lives in flash; the id stays a plain pointer
|
||||
# because actions render id arguments as ``::<id>``.
|
||||
data_id = file_config[CONF_RAW_DATA_ID]
|
||||
cg.add_global(
|
||||
ProgmemAssignmentExpression(
|
||||
data_id.type, data_id, cg.safe_exp([HexInt(x) for x in data])
|
||||
)
|
||||
)
|
||||
media_file = cg.StructInitializer(
|
||||
audio.AudioFile,
|
||||
("data", prog_arr),
|
||||
("length", len(rhs)),
|
||||
("data", MockObj(data_id, ".")),
|
||||
("length", len(data)),
|
||||
("file_type", media_file_type),
|
||||
)
|
||||
|
||||
return cg.new_Pvariable(file_config[CONF_ID], media_files_struct)
|
||||
file_var_id = file_config[CONF_ID]
|
||||
storage = file_config[CONF_FILE_DATA_ID]
|
||||
cg.add_global(
|
||||
cg.RawStatement(f"static constexpr {storage.type} {storage} = {media_file};")
|
||||
)
|
||||
cg.add_global(
|
||||
cg.RawStatement(
|
||||
f"static const {storage.type} *const {file_var_id} = &{storage};"
|
||||
)
|
||||
)
|
||||
var = MockObj(file_var_id, "->")
|
||||
CORE.register_variable(file_var_id, var)
|
||||
return var
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
namespace esphome::audio_file {
|
||||
|
||||
struct NamedAudioFile {
|
||||
audio::AudioFile *file;
|
||||
const audio::AudioFile *file;
|
||||
const char *file_id;
|
||||
};
|
||||
|
||||
inline StaticVector<NamedAudioFile, AUDIO_FILE_MAX_FILES>
|
||||
named_audio_files; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
inline void add_named_audio_file(audio::AudioFile *file, const char *file_id) {
|
||||
inline void add_named_audio_file(const audio::AudioFile *file, const char *file_id) {
|
||||
named_audio_files.push_back({file, file_id});
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ class AudioFileMediaSource final : public Component,
|
||||
protected:
|
||||
std::unique_ptr<micro_decoder::DecoderSource> decoder_;
|
||||
audio::AudioStreamInfo stream_info_;
|
||||
audio::AudioFile *current_file_{nullptr};
|
||||
const audio::AudioFile *current_file_{nullptr};
|
||||
|
||||
// Written from the main loop in handle_command(), read from the decoder task in
|
||||
// on_audio_write(). Must be atomic to avoid a data race.
|
||||
|
||||
@@ -44,10 +44,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await register_bedjet_child(var, config)
|
||||
|
||||
if outlet_temperature_sensor := config.get(CONF_OUTLET_TEMPERATURE):
|
||||
sensor_var = await sensor.new_sensor(outlet_temperature_sensor)
|
||||
cg.add(var.set_outlet_temperature_sensor(sensor_var))
|
||||
|
||||
if ambient_temperature_sensor := config.get(CONF_AMBIENT_TEMPERATURE):
|
||||
sensor_var = await sensor.new_sensor(ambient_temperature_sensor)
|
||||
cg.add(var.set_ambient_temperature_sensor(sensor_var))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_OUTLET_TEMPERATURE, var.set_outlet_temperature_sensor)
|
||||
await sensors(CONF_AMBIENT_TEMPERATURE, var.set_ambient_temperature_sensor)
|
||||
|
||||
@@ -11,29 +11,34 @@
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <initializer_list>
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
/// True if `address` is in `table`, a list of MACs ended by 0.
|
||||
inline bool mac_in_table(const uint64_t *table, uint64_t address) {
|
||||
for (; *table != 0; table++) {
|
||||
if (*table == address)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// on_ble_advertise: fires on every BLE advertisement, optionally filtered to one or more MACs.
|
||||
class ESPBTAdvertiseTrigger final : public Trigger<const ESPBTDevice &>, public ESPBTDeviceListener {
|
||||
public:
|
||||
template<typename Hub> explicit ESPBTAdvertiseTrigger(Hub *parent) { parent->register_listener(this); }
|
||||
|
||||
void set_addresses(std::initializer_list<uint64_t> addresses) { this->addresses_ = addresses; }
|
||||
/// Table of MACs ended by 0; must outlive the trigger.
|
||||
void set_addresses(const uint64_t *addresses) { this->addresses_ = addresses; }
|
||||
|
||||
bool parse_device(const ESPBTDevice &device) override {
|
||||
if (!this->addresses_.empty() && std::find(this->addresses_.begin(), this->addresses_.end(),
|
||||
device.address_uint64()) == this->addresses_.end()) {
|
||||
if (this->addresses_ != nullptr && !mac_in_table(this->addresses_, device.address_uint64()))
|
||||
return false;
|
||||
}
|
||||
this->trigger(device);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected:
|
||||
FixedVector<uint64_t> addresses_;
|
||||
const uint64_t *addresses_{nullptr};
|
||||
};
|
||||
|
||||
// on_ble_service_data_advertise: fires when an advertisement contains service
|
||||
|
||||
@@ -69,13 +69,24 @@ def uuid_trigger_schema(
|
||||
)
|
||||
|
||||
|
||||
def _filter_mac(value: Any) -> Any:
|
||||
mac = cv.mac_address(value)
|
||||
if not any(mac.parts):
|
||||
# 0 ends the flash MAC table, and it is never a real device address.
|
||||
raise cv.Invalid("00:00:00:00:00:00 cannot be used as a MAC address filter")
|
||||
return mac
|
||||
|
||||
|
||||
MAC_FILTER_LIST = cv.ensure_list(_filter_mac)
|
||||
|
||||
|
||||
def advertise_trigger_schema(trigger_class: MockObjClass) -> Callable[[Any], Any]:
|
||||
"""on_ble_advertise schema: multi-mac list filter, unlike the single-mac
|
||||
uuid_trigger_schema() — pairs with advertise_trigger_to_code()."""
|
||||
return automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class),
|
||||
cv.Optional(CONF_MAC_ADDRESS): cv.ensure_list(cv.mac_address),
|
||||
cv.Optional(CONF_MAC_ADDRESS): MAC_FILTER_LIST,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -94,11 +105,18 @@ def scan_end_trigger_schema(trigger_class: MockObjClass) -> Callable[[Any], Any]
|
||||
_count_listener = cg.slot_counter(LISTENER_COUNT_DEFINE)
|
||||
|
||||
|
||||
def mac_filter_table(macs: list) -> cg.MockObj:
|
||||
"""Shared flash table of MACs ended by 0 (never a valid address), so triggers store a pointer."""
|
||||
return cg.shared_progmem_array(
|
||||
"ble_mac_filter", cg.uint64, [*(mac.as_hex for mac in macs), 0]
|
||||
)
|
||||
|
||||
|
||||
async def advertise_trigger_to_code(conf: ConfigType, var: cg.MockObj) -> None:
|
||||
"""Build an on_ble_advertise trigger (optional multi-mac filter)."""
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
if (macs := conf.get(CONF_MAC_ADDRESS)) is not None:
|
||||
cg.add(trigger.set_addresses([it.as_hex for it in macs]))
|
||||
if macs := conf.get(CONF_MAC_ADDRESS):
|
||||
cg.add(trigger.set_addresses(mac_filter_table(macs)))
|
||||
await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf)
|
||||
_count_listener()
|
||||
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <esp_idf_version.h>
|
||||
|
||||
// cancel_open exists from ESP-IDF 5.5.5 and 6.0.1.
|
||||
#if (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 5) && ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(6, 0, 0)) || \
|
||||
ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 1)
|
||||
#define BLUEDROID_HAS_CANCEL_OPEN
|
||||
#endif
|
||||
|
||||
namespace esphome::bluetooth_connection {
|
||||
|
||||
@@ -63,6 +70,12 @@ void BluedroidGattClient::loop() {
|
||||
// The one teardown safety net: a lost CLOSE_EVT, or a scheduled
|
||||
// teardown whose OPEN_EVT never arrives.
|
||||
if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
|
||||
if (this->conn_id_ == UNSET_CONN_ID) {
|
||||
this->cancel_pending_open_();
|
||||
} else if (st == ClientState::CONNECTING) {
|
||||
// CONNECT_EVT came but OPEN_EVT was lost, so our open still holds the ACL link.
|
||||
this->check_and_log_error_("esp_ble_gap_disconnect", esp_ble_gap_disconnect(this->remote_bda_));
|
||||
}
|
||||
ESP_LOGE(TAG, "[%d] Timeout waiting for teardown, forcing IDLE", this->connection_index_);
|
||||
// Release before idling: a lost completion must not leak the cache.
|
||||
this->release_services();
|
||||
@@ -140,6 +153,8 @@ void BluedroidGattClient::tracker_connect_() {
|
||||
this->services_released_ = false;
|
||||
this->seen_mtu_ = false;
|
||||
this->mtu_failed_ = false;
|
||||
this->cancel_open_sent_ = false;
|
||||
this->conn_id_ = UNSET_CONN_ID;
|
||||
this->enable_loop();
|
||||
this->set_state(ClientState::CONNECTING);
|
||||
if (this->connection_type_ == ConnectionType::V3_WITHOUT_CACHE) {
|
||||
@@ -183,12 +198,28 @@ int BluedroidGattClient::gatt_disconnect() {
|
||||
// Arm the safety window: a lost OPEN_EVT must not leak the teardown.
|
||||
this->disconnecting_started_ = millis();
|
||||
this->enable_loop();
|
||||
if (this->conn_id_ == UNSET_CONN_ID) {
|
||||
// CANCEL_OPEN_EVT or a racing OPEN_EVT settles the slot.
|
||||
this->cancel_pending_open_();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
this->unconditional_disconnect_();
|
||||
return 0;
|
||||
}
|
||||
|
||||
void BluedroidGattClient::cancel_pending_open_() {
|
||||
#ifdef BLUEDROID_HAS_CANCEL_OPEN
|
||||
esp_ble_gattc_cancel_open_params_t params{};
|
||||
params.gattc_if = this->gattc_if_;
|
||||
memcpy(params.remote_bda, this->remote_bda_, sizeof(esp_bd_addr_t));
|
||||
// A refused cancel schedules no CANCEL_OPEN_EVT, so the teardown stays cancellable.
|
||||
if (this->check_and_log_error_("esp_ble_gattc_cancel_open", esp_ble_gattc_cancel_open(¶ms)) == ESP_OK) {
|
||||
this->cancel_open_sent_ = true;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void BluedroidGattClient::unconditional_disconnect_() {
|
||||
ESP_LOGI(TAG, "[%d] Disconnecting (conn_id: %d)", this->connection_index_, this->conn_id_);
|
||||
if (this->conn_id_ == UNSET_CONN_ID) {
|
||||
@@ -214,6 +245,10 @@ bool BluedroidGattClient::cancel_gatt_disconnect() {
|
||||
if (this->state() != ClientState::CONNECTING || !this->disconnect_pending()) {
|
||||
return false;
|
||||
}
|
||||
if (this->cancel_open_sent_) {
|
||||
// The cancel already went out, so CANCEL_OPEN_EVT ends it.
|
||||
return false;
|
||||
}
|
||||
this->want_disconnect_ = false;
|
||||
return true;
|
||||
}
|
||||
@@ -574,6 +609,8 @@ void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) {
|
||||
this->listener_->on_connection_state(false, 0, param->open.status);
|
||||
return;
|
||||
}
|
||||
// ALREADY_OPEN on an existing link sends no CONNECT_EVT, so take the id here.
|
||||
this->conn_id_ = param->open.conn_id;
|
||||
if (this->disconnect_pending()) {
|
||||
// Open resolved with a teardown scheduled: close now (conn_id_ stays set
|
||||
// so CLOSE_EVT still matches).
|
||||
@@ -617,6 +654,11 @@ void BluedroidGattClient::handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param
|
||||
// Active close delivers CLOSE_EVT first; never walk back to DISCONNECTING.
|
||||
return;
|
||||
}
|
||||
if (this->conn_id_ == UNSET_CONN_ID) {
|
||||
// No CONNECT_EVT for this attempt yet, so the event belongs to an earlier
|
||||
// link, such as one the teardown timeout forced down.
|
||||
return;
|
||||
}
|
||||
// Passive disconnect: wait for CLOSE_EVT before going IDLE (reconnecting
|
||||
// earlier makes the controller reject with 133 or assert) and before
|
||||
// reporting - the wrapper frees the slot on the report, and a freed slot
|
||||
@@ -645,6 +687,10 @@ bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
|
||||
case ESP_GATTC_CONNECT_EVT: {
|
||||
if (!this->check_addr_(param->connect.remote_bda))
|
||||
return false;
|
||||
// Every client interface gets CONNECT_EVT for every new link; only an
|
||||
// attempt in flight owns it. An idle slot must not adopt another slot's link.
|
||||
if (this->state() != ClientState::CONNECTING)
|
||||
break;
|
||||
this->conn_id_ = param->connect.conn_id;
|
||||
// MTU request here rather than OPEN_EVT, matching the IDF examples.
|
||||
auto ret = esp_ble_gattc_send_mtu_req(this->gattc_if_, param->connect.conn_id);
|
||||
@@ -656,8 +702,15 @@ bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_OPEN_EVT: {
|
||||
if (!this->check_addr_(param->open.remote_bda))
|
||||
if (!this->check_addr_(param->open.remote_bda)) {
|
||||
// Our interface (filtered above), stale address, so nobody tracks this link.
|
||||
if (param->open.status == ESP_GATT_OK || param->open.status == ESP_GATT_ALREADY_OPEN) {
|
||||
ESP_LOGW(TAG, "[%d] Closing link left by an abandoned open", this->connection_index_);
|
||||
this->check_and_log_error_("esp_ble_gattc_close", esp_ble_gattc_close(this->gattc_if_, param->open.conn_id));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
this->handle_open_evt_(param);
|
||||
break;
|
||||
}
|
||||
@@ -748,6 +801,24 @@ bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
|
||||
this->listener_->on_notify_data(param->notify.handle, param->notify.value, param->notify.value_len);
|
||||
break;
|
||||
}
|
||||
#ifdef BLUEDROID_HAS_CANCEL_OPEN
|
||||
case ESP_GATTC_CANCEL_OPEN_EVT: {
|
||||
if (!this->check_addr_(param->cancel_open.remote_bda))
|
||||
return false;
|
||||
if (param->cancel_open.status != ESP_GATT_OK) {
|
||||
// Too late to cancel: OPEN_EVT follows and settles the slot. The latch
|
||||
// stays set because the event cannot be tied to this attempt's cancel.
|
||||
break;
|
||||
}
|
||||
if (this->state() == ClientState::CONNECTING && this->disconnect_pending()) {
|
||||
ESP_LOGD(TAG, "[%d] Pending open cancelled", this->connection_index_);
|
||||
this->release_services();
|
||||
this->set_idle_();
|
||||
this->listener_->on_connection_state(false, 0, ESP_GATT_CONN_CONN_CANCEL);
|
||||
}
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
|
||||
int handle_search_cmpl_(esp_gatt_status_t status);
|
||||
void deliver_pending_search_();
|
||||
void unconditional_disconnect_();
|
||||
void cancel_pending_open_();
|
||||
void set_idle_();
|
||||
void set_disconnecting_();
|
||||
esp_err_t update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout,
|
||||
@@ -133,6 +134,9 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
|
||||
bool seen_mtu_ : 1 {false};
|
||||
// The MTU request was refused at CONNECT_EVT; OPEN_EVT reports instead.
|
||||
bool mtu_failed_ : 1 {false};
|
||||
// esp_ble_gattc_cancel_open() was accepted; CANCEL_OPEN_EVT or OPEN_EVT ends
|
||||
// the attempt, so the scheduled teardown can no longer be cancelled.
|
||||
bool cancel_open_sent_ : 1 {false};
|
||||
// Search issued at OPEN_EVT overlaps the MTU exchange; discover_services()
|
||||
// completes from it. Reset by set_idle_().
|
||||
static_assert(static_cast<uint8_t>(SearchState::REPORT_PENDING) < (1 << 4), "search_state_ bitfield too narrow");
|
||||
|
||||
@@ -88,16 +88,11 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
for d in ["x", "y", "z"]:
|
||||
accel_key = f"acceleration_{d}"
|
||||
if accel_key in config:
|
||||
sens = await sensor.new_sensor(config[accel_key])
|
||||
cg.add(getattr(var, f"set_accel_{d}_sensor")(sens))
|
||||
accel_key = f"gyroscope_{d}"
|
||||
if accel_key in config:
|
||||
sens = await sensor.new_sensor(config[accel_key])
|
||||
cg.add(getattr(var, f"set_gyro_{d}_sensor")(sens))
|
||||
|
||||
if CONF_TEMPERATURE in config:
|
||||
sens = await sensor.new_sensor(config[CONF_TEMPERATURE])
|
||||
cg.add(var.set_temperature_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_ACCELERATION_X, var.set_accel_x_sensor)
|
||||
await sensors(CONF_GYROSCOPE_X, var.set_gyro_x_sensor)
|
||||
await sensors(CONF_ACCELERATION_Y, var.set_accel_y_sensor)
|
||||
await sensors(CONF_GYROSCOPE_Y, var.set_gyro_y_sensor)
|
||||
await sensors(CONF_ACCELERATION_Z, var.set_accel_z_sensor)
|
||||
await sensors(CONF_GYROSCOPE_Z, var.set_gyro_z_sensor)
|
||||
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
|
||||
|
||||
@@ -48,10 +48,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
if temperature_config := config.get(CONF_TEMPERATURE):
|
||||
sens = await sensor.new_sensor(temperature_config)
|
||||
cg.add(var.set_temperature(sens))
|
||||
|
||||
if pressure_config := config.get(CONF_PRESSURE):
|
||||
sens = await sensor.new_sensor(pressure_config)
|
||||
cg.add(var.set_pressure(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_TEMPERATURE, var.set_temperature)
|
||||
await sensors(CONF_PRESSURE, var.set_pressure)
|
||||
|
||||
@@ -75,14 +75,13 @@ async def to_code_base(config: ConfigType) -> MockObj:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
if temperature_config := config.get(CONF_TEMPERATURE):
|
||||
sens = await sensor.new_sensor(temperature_config)
|
||||
cg.add(var.set_temperature_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor):
|
||||
temperature_config = config[CONF_TEMPERATURE]
|
||||
cg.add(var.set_temperature_oversampling(temperature_config[CONF_OVERSAMPLING]))
|
||||
|
||||
if pressure_config := config.get(CONF_PRESSURE):
|
||||
sens = await sensor.new_sensor(pressure_config)
|
||||
cg.add(var.set_pressure_sensor(sens))
|
||||
if await sensors(CONF_PRESSURE, var.set_pressure_sensor):
|
||||
pressure_config = config[CONF_PRESSURE]
|
||||
cg.add(var.set_pressure_oversampling(pressure_config[CONF_OVERSAMPLING]))
|
||||
|
||||
cg.add(var.set_iir_filter(config[CONF_IIR_FILTER]))
|
||||
|
||||
@@ -80,18 +80,17 @@ async def to_code_base(config: ConfigType) -> MockObj:
|
||||
await cg.register_component(var, config)
|
||||
|
||||
cg.add(var.set_iir_filter_config(config[CONF_IIR_FILTER]))
|
||||
if temperature_config := config.get(CONF_TEMPERATURE):
|
||||
sens = await sensor.new_sensor(temperature_config)
|
||||
cg.add(var.set_temperature_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor):
|
||||
temperature_config = config[CONF_TEMPERATURE]
|
||||
cg.add(
|
||||
var.set_temperature_oversampling_config(
|
||||
temperature_config[CONF_OVERSAMPLING]
|
||||
)
|
||||
)
|
||||
|
||||
if pressure_config := config.get(CONF_PRESSURE):
|
||||
sens = await sensor.new_sensor(pressure_config)
|
||||
cg.add(var.set_pressure_sensor(sens))
|
||||
if await sensors(CONF_PRESSURE, var.set_pressure_sensor):
|
||||
pressure_config = config[CONF_PRESSURE]
|
||||
cg.add(var.set_pressure_oversampling_config(pressure_config[CONF_OVERSAMPLING]))
|
||||
|
||||
return var
|
||||
|
||||
@@ -137,9 +137,9 @@ CONFIG_SCHEMA_BASE = cv.Schema(
|
||||
async def to_code_base(config: ConfigType) -> MockObj:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
if temperature_config := config.get(CONF_TEMPERATURE):
|
||||
sens = await sensor.new_sensor(temperature_config)
|
||||
cg.add(var.set_temperature_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor):
|
||||
temperature_config = config[CONF_TEMPERATURE]
|
||||
cg.add(
|
||||
var.set_temperature_oversampling_config(
|
||||
temperature_config[CONF_OVERSAMPLING]
|
||||
@@ -149,9 +149,8 @@ async def to_code_base(config: ConfigType) -> MockObj:
|
||||
var.set_temperature_iir_filter_config(temperature_config[CONF_IIR_FILTER])
|
||||
)
|
||||
|
||||
if pressure_config := config.get(CONF_PRESSURE):
|
||||
sens = await sensor.new_sensor(pressure_config)
|
||||
cg.add(var.set_pressure_sensor(sens))
|
||||
if await sensors(CONF_PRESSURE, var.set_pressure_sensor):
|
||||
pressure_config = config[CONF_PRESSURE]
|
||||
cg.add(var.set_pressure_oversampling_config(pressure_config[CONF_OVERSAMPLING]))
|
||||
cg.add(var.set_pressure_iir_filter_config(pressure_config[CONF_IIR_FILTER]))
|
||||
|
||||
|
||||
@@ -422,6 +422,21 @@ bool BTHomeMiThermometer::handle_service_data_(const ble_device_base::ServiceDat
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 0x45: { // temperature (0.1C), e.g. Shelly BLU H&T
|
||||
if (this->temperature_ != nullptr) {
|
||||
const int16_t raw = encode_uint16(value[1], value[0]);
|
||||
this->temperature_->publish_state(raw * 0.1f);
|
||||
reported = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 0x2E: { // humidity (uint8, 1%), e.g. Shelly BLU H&T
|
||||
if (this->humidity_ != nullptr) {
|
||||
this->humidity_->publish_state(value[0]);
|
||||
reported = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -368,19 +368,11 @@ optional<ClimateDeviceRestoreState> Climate::restore_state_() {
|
||||
}
|
||||
|
||||
void Climate::save_state_(const ClimateTraits &traits) {
|
||||
#if (defined(USE_ESP32) || defined(USE_ESP8266)) && !defined(CLANG_TIDY)
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wclass-memaccess"
|
||||
#define TEMP_IGNORE_MEMACCESS
|
||||
#endif
|
||||
ClimateDeviceRestoreState state{};
|
||||
// initialize as zero (including padding) to prevent random data on stack triggering erase
|
||||
// initialize as zero (including padding) to prevent random data on stack triggering erase;
|
||||
// the void * cast tells GCC the bytewise clear of a non-trivial type is intentional
|
||||
// NOLINTNEXTLINE(bugprone-raw-memory-call-on-non-trivial-type) -- intentional bytewise zero for RTC save
|
||||
memset(&state, 0, sizeof(ClimateDeviceRestoreState));
|
||||
#ifdef TEMP_IGNORE_MEMACCESS
|
||||
#pragma GCC diagnostic pop
|
||||
#undef TEMP_IGNORE_MEMACCESS
|
||||
#endif
|
||||
memset(static_cast<void *>(&state), 0, sizeof(ClimateDeviceRestoreState));
|
||||
|
||||
state.mode = this->mode;
|
||||
if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
|
||||
|
||||
@@ -54,9 +54,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await uart.register_uart_device(var, config)
|
||||
if co2_config := config.get(CONF_CO2):
|
||||
sens = await sensor.new_sensor(co2_config)
|
||||
cg.add(var.set_co2_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_CO2, var.set_co2_sensor)
|
||||
|
||||
|
||||
CALIBRATION_ACTION_SCHEMA = maybe_simple_id(
|
||||
|
||||
@@ -203,6 +203,5 @@ async def to_code(config: ConfigType) -> None:
|
||||
else:
|
||||
cg.add(var.add_source(source))
|
||||
|
||||
if CONF_STD_DEV in config:
|
||||
sens = await sensor.new_sensor(config[CONF_STD_DEV])
|
||||
cg.add(var.set_std_dev_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_STD_DEV, var.set_std_dev_sensor)
|
||||
|
||||
@@ -7,6 +7,7 @@ BYTE_ORDER_BIG = "big_endian"
|
||||
|
||||
CONF_ACCELEROMETER_ODR = "accelerometer_odr"
|
||||
CONF_ACCELEROMETER_RANGE = "accelerometer_range"
|
||||
CONF_ALLOWED_IPS = "allowed_ips"
|
||||
CONF_B_CONSTANT = "b_constant"
|
||||
CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent"
|
||||
CONF_BYTE_ORDER = "byte_order"
|
||||
@@ -15,6 +16,7 @@ CONF_CLIMATE_ID = "climate_id"
|
||||
CONF_CO2_EQUIVALENT = "co2_equivalent"
|
||||
CONF_COLOR_DEPTH = "color_depth"
|
||||
CONF_COLUMNS = "columns"
|
||||
CONF_CONNECTED = "connected"
|
||||
CONF_CONVERSION_RATE = "conversion_rate"
|
||||
CONF_CRC_ENABLE = "crc_enable"
|
||||
CONF_DATA_BITS = "data_bits"
|
||||
@@ -41,7 +43,9 @@ CONF_ON_SCAN_END = "on_scan_end"
|
||||
CONF_ON_STATE_CHANGE = "on_state_change"
|
||||
CONF_PARITY = "parity"
|
||||
CONF_RECEIVER_FREQUENCY = "receiver_frequency"
|
||||
CONF_RECONNECT_INTERVAL = "reconnect_interval"
|
||||
CONF_REQUEST_HEADERS = "request_headers"
|
||||
CONF_ROLE = "role"
|
||||
CONF_ROWS = "rows"
|
||||
CONF_SCAN_PARAMETERS = "scan_parameters"
|
||||
CONF_SHA256 = "sha256"
|
||||
|
||||
@@ -117,15 +117,10 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(var.set_hpf_enable(config[CONF_CURRENT_HPF], config[CONF_VOLTAGE_HPF]))
|
||||
cg.add(var.set_pulse_energy_wh(config[CONF_PULSE_ENERGY]))
|
||||
|
||||
if voltage_config := config.get(CONF_VOLTAGE):
|
||||
sens = await sensor.new_sensor(voltage_config)
|
||||
cg.add(var.set_voltage_sensor(sens))
|
||||
if current_config := config.get(CONF_CURRENT):
|
||||
sens = await sensor.new_sensor(current_config)
|
||||
cg.add(var.set_current_sensor(sens))
|
||||
if power_config := config.get(CONF_POWER):
|
||||
sens = await sensor.new_sensor(power_config)
|
||||
cg.add(var.set_power_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_VOLTAGE, var.set_voltage_sensor)
|
||||
await sensors(CONF_CURRENT, var.set_current_sensor)
|
||||
await sensors(CONF_POWER, var.set_power_sensor)
|
||||
|
||||
|
||||
automation.register_apply_action(
|
||||
|
||||
@@ -61,11 +61,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
pin = await gpio_pin_expression(config[CONF_PIN])
|
||||
cg.add(var.set_pin(pin))
|
||||
|
||||
if CONF_TEMPERATURE in config:
|
||||
sens = await sensor.new_sensor(config[CONF_TEMPERATURE])
|
||||
cg.add(var.set_temperature_sensor(sens))
|
||||
if CONF_HUMIDITY in config:
|
||||
sens = await sensor.new_sensor(config[CONF_HUMIDITY])
|
||||
cg.add(var.set_humidity_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
|
||||
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
|
||||
|
||||
cg.add(var.set_dht_model(config[CONF_MODEL]))
|
||||
|
||||
@@ -46,10 +46,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
if CONF_TEMPERATURE in config:
|
||||
sens = await sensor.new_sensor(config[CONF_TEMPERATURE])
|
||||
cg.add(var.set_temperature_sensor(sens))
|
||||
|
||||
if CONF_HUMIDITY in config:
|
||||
sens = await sensor.new_sensor(config[CONF_HUMIDITY])
|
||||
cg.add(var.set_humidity_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
|
||||
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
|
||||
|
||||
@@ -287,13 +287,15 @@ template<typename T> class DisplayWriter {
|
||||
// Type alias for Display writer - uses optimized DisplayWriter instead of std::function
|
||||
using display_writer_t = DisplayWriter<Display>;
|
||||
|
||||
// Callers usually pass `this`; checking a copy keeps GCC's -Wnonnull-compare quiet.
|
||||
#define LOG_DISPLAY(prefix, type, obj) \
|
||||
if ((obj) != nullptr) { \
|
||||
if (auto *log_display_obj = (obj); log_display_obj != nullptr) { \
|
||||
ESP_LOGCONFIG(TAG, \
|
||||
prefix type "\n" \
|
||||
"%s Rotations: %d °\n" \
|
||||
"%s Dimensions: %dpx x %dpx", \
|
||||
prefix, (obj)->rotation_, prefix, (obj)->get_width(), (obj)->get_height()); \
|
||||
prefix, log_display_obj->rotation_, prefix, log_display_obj->get_width(), \
|
||||
log_display_obj->get_height()); \
|
||||
}
|
||||
|
||||
/// Turn the pixel OFF.
|
||||
|
||||
@@ -54,10 +54,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
if temperature := config.get(CONF_TEMPERATURE):
|
||||
sens = await sensor.new_sensor(temperature)
|
||||
cg.add(var.set_temperature_sensor(sens))
|
||||
|
||||
if pressure := config.get(CONF_PRESSURE):
|
||||
sens = await sensor.new_sensor(pressure)
|
||||
cg.add(var.set_pressure_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
|
||||
await sensors(CONF_PRESSURE, var.set_pressure_sensor)
|
||||
|
||||
@@ -63,9 +63,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
if CONF_LAMBDA in config:
|
||||
lambda_ = await cg.process_lambda(config[CONF_LAMBDA], [], return_type=cg.bool_)
|
||||
cg.add(var.set_lambda(lambda_))
|
||||
if CONF_LAST_TIME in config:
|
||||
sens = await sensor.new_sensor(config[CONF_LAST_TIME])
|
||||
cg.add(var.set_last_duty_time_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_LAST_TIME, var.set_last_duty_time_sensor)
|
||||
|
||||
|
||||
# AUTOMATIONS
|
||||
|
||||
@@ -57,14 +57,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
if temperature := config.get(CONF_TEMPERATURE):
|
||||
sens = await sensor.new_sensor(temperature)
|
||||
cg.add(var.set_temperature_sensor(sens))
|
||||
|
||||
if co2 := config.get(CONF_CO2):
|
||||
sens = await sensor.new_sensor(co2)
|
||||
cg.add(var.set_co2_sensor(sens))
|
||||
|
||||
if pressure := config.get(CONF_PRESSURE):
|
||||
sens = await sensor.new_sensor(pressure)
|
||||
cg.add(var.set_pressure_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
|
||||
await sensors(CONF_CO2, var.set_co2_sensor)
|
||||
await sensors(CONF_PRESSURE, var.set_pressure_sensor)
|
||||
|
||||
@@ -16,6 +16,7 @@ from esphome.const import (
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
DEVICE_CLASS_VOLTAGE,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
STATE_CLASS_TOTAL,
|
||||
STATE_CLASS_TOTAL_INCREASING,
|
||||
UNIT_AMPERE,
|
||||
UNIT_CELSIUS,
|
||||
@@ -70,7 +71,7 @@ SENSOR_CONFIGS = {
|
||||
"E": {
|
||||
CONF_UNIT_OF_MEASUREMENT: UNIT_WATT_HOURS,
|
||||
CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY,
|
||||
CONF_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING,
|
||||
CONF_STATE_CLASS: STATE_CLASS_TOTAL,
|
||||
CONF_ACCURACY_DECIMALS: 0,
|
||||
},
|
||||
"V": {
|
||||
|
||||
@@ -50,10 +50,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
if CONF_TEMPERATURE in config:
|
||||
sens = await sensor.new_sensor(config[CONF_TEMPERATURE])
|
||||
cg.add(var.set_temperature_sensor(sens))
|
||||
|
||||
if CONF_HUMIDITY in config:
|
||||
sens = await sensor.new_sensor(config[CONF_HUMIDITY])
|
||||
cg.add(var.set_humidity_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
|
||||
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
|
||||
|
||||
@@ -26,3 +26,15 @@ goodisplay_gdey042t81 = ssd1683.extend(
|
||||
width=400,
|
||||
height=300,
|
||||
)
|
||||
|
||||
# The SSD1681 shares the SSD1683 command set for everything this driver uses:
|
||||
# the same 0x01/0x11/0x18 initialisation, the same 0x44/0x45 window and
|
||||
# 0x4E/0x4F cursor registers with a byte-addressed X and a 16-bit Y, and the
|
||||
# same 0x22/0x20 update. It only differs in the panel sizes it drives.
|
||||
ssd1681 = SSD1683("ssd1681")
|
||||
|
||||
waveshare_1_54in_v2 = ssd1681.extend(
|
||||
"waveshare-1.54in-v2",
|
||||
width=200,
|
||||
height=200,
|
||||
)
|
||||
|
||||
@@ -92,6 +92,21 @@ uint8_t ES8311::calculate_resolution_value(ES8311Resolution resolution) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes the MCLK pre-multiplier for REG02 bits 3-4. The coefficient table stores the multiplier as the factor
|
||||
/// itself (1, 2, 4 or 8), while the register takes 0, 1, 2 or 3 (ES8311 datasheet, register 0x02 MULT_PRE).
|
||||
static uint8_t encode_pre_mult(uint8_t pre_mult) {
|
||||
switch (pre_mult) {
|
||||
case 2:
|
||||
return 1;
|
||||
case 4:
|
||||
return 2;
|
||||
case 8:
|
||||
return 3;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
const ES8311Coefficient *ES8311::get_coefficient(uint32_t mclk, uint32_t rate) {
|
||||
for (const auto &coefficient : ES8311_COEFFICIENTS) {
|
||||
if (coefficient.mclk == mclk && coefficient.rate == rate)
|
||||
@@ -127,7 +142,7 @@ bool ES8311::configure_clock_() {
|
||||
ES8311_ERROR_CHECK(this->read_byte(ES8311_REG02_CLK_MANAGER, ®02));
|
||||
reg02 &= 0x07;
|
||||
reg02 |= (coefficient->pre_div - 1) << 5;
|
||||
reg02 |= coefficient->pre_mult << 3;
|
||||
reg02 |= encode_pre_mult(coefficient->pre_mult) << 3;
|
||||
ES8311_ERROR_CHECK(this->write_byte(ES8311_REG02_CLK_MANAGER, reg02));
|
||||
|
||||
// Register 0x03
|
||||
|
||||
@@ -30,19 +30,13 @@ CONFIG_SCHEMA = cv.All(
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
parent = await cg.get_variable(config[CONF_ES8388_ID])
|
||||
if dac_output_config := config.get(CONF_DAC_OUTPUT):
|
||||
s = await select.new_select(
|
||||
dac_output_config,
|
||||
options=["LINE1", "LINE2", "BOTH"],
|
||||
)
|
||||
await cg.register_parented(s, parent)
|
||||
cg.add(parent.set_dac_output_select(s))
|
||||
|
||||
if adc_input_mic_config := config.get(CONF_ADC_INPUT_MIC):
|
||||
s = await select.new_select(
|
||||
adc_input_mic_config,
|
||||
options=["LINE1", "LINE2", "DIFFERENCE"],
|
||||
)
|
||||
await cg.register_parented(s, parent)
|
||||
cg.add(parent.set_adc_input_mic_select(s))
|
||||
hub = await cg.get_variable(config[CONF_ES8388_ID])
|
||||
selects = select.sub_selects(config, parent=hub)
|
||||
await selects(
|
||||
CONF_DAC_OUTPUT, hub.set_dac_output_select, options=["LINE1", "LINE2", "BOTH"]
|
||||
)
|
||||
await selects(
|
||||
CONF_ADC_INPUT_MIC,
|
||||
hub.set_adc_input_mic_select,
|
||||
options=["LINE1", "LINE2", "DIFFERENCE"],
|
||||
)
|
||||
|
||||
@@ -57,7 +57,7 @@ from esphome.const import (
|
||||
from esphome.core import CORE, EsphomeError, HexInt
|
||||
from esphome.core.config import BOARD_MAX_LENGTH
|
||||
from esphome.coroutine import CoroPriority, coroutine_with_priority
|
||||
from esphome.espidf.component import generate_idf_components
|
||||
from esphome.espidf.component import IDFComponent, generate_idf_components
|
||||
import esphome.final_validate as fv
|
||||
from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
@@ -322,6 +322,34 @@ ARDUINO_EXCLUDED_IDF_COMPONENTS = (
|
||||
"joltwallet__littlefs", # LittleFS - ESPHome doesn't use filesystem
|
||||
)
|
||||
|
||||
# Entries arduino-esp32 only declares below the given IDF version; stubbing one past
|
||||
# it clashes with ESPHome's own managed component of the same short name.
|
||||
ARDUINO_EXCLUDED_IDF_COMPONENTS_MAX_IDF: dict[str, cv.Version] = {
|
||||
"espressif__libsodium": cv.Version(6, 0, 0),
|
||||
}
|
||||
|
||||
|
||||
def arduino_bundles_libsodium() -> bool:
|
||||
"""arduino-esp32 ships its own libsodium below IDF 6.0."""
|
||||
return (
|
||||
CORE.using_arduino
|
||||
and idf_version()
|
||||
< ARDUINO_EXCLUDED_IDF_COMPONENTS_MAX_IDF["espressif__libsodium"]
|
||||
)
|
||||
|
||||
|
||||
def arduino_excluded_idf_components() -> set[str]:
|
||||
"""The arduino-bundled components to stub for this build's IDF version."""
|
||||
version = idf_version()
|
||||
return {
|
||||
component
|
||||
for component in ARDUINO_EXCLUDED_IDF_COMPONENTS
|
||||
if (max_version := ARDUINO_EXCLUDED_IDF_COMPONENTS_MAX_IDF.get(component))
|
||||
is None
|
||||
or version < max_version
|
||||
}
|
||||
|
||||
|
||||
# Mapping of Arduino libraries to IDF managed components they require
|
||||
# When an Arduino library is enabled via cg.add_library(), these components
|
||||
# are automatically un-stubbed from ARDUINO_EXCLUDED_IDF_COMPONENTS.
|
||||
@@ -642,20 +670,29 @@ def get_download_types(storage_json):
|
||||
# No recorded firmware path means nothing was built; no downloads.
|
||||
if storage_json.firmware_bin_path is None:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"title": "Factory format (Previously Modern)",
|
||||
"description": "For use with ESPHome Web and other tools.",
|
||||
"file": "firmware.factory.bin",
|
||||
"download": f"{storage_json.name}.factory.bin",
|
||||
},
|
||||
from esphome.espidf.toolchain import tree_skips_bootloader
|
||||
|
||||
types = []
|
||||
# A --skip-bootloader tree deliberately has no factory image; an
|
||||
# unreadable tree (PlatformIO, capability probes) reads as full.
|
||||
if not tree_skips_bootloader(Path(storage_json.firmware_bin_path).parent):
|
||||
types.append(
|
||||
{
|
||||
"title": "Factory format (Previously Modern)",
|
||||
"description": "For use with ESPHome Web and other tools.",
|
||||
"file": "firmware.factory.bin",
|
||||
"download": f"{storage_json.name}.factory.bin",
|
||||
}
|
||||
)
|
||||
types.append(
|
||||
{
|
||||
"title": "OTA format (Previously Legacy)",
|
||||
"description": "For OTA updating a device.",
|
||||
"file": "firmware.ota.bin",
|
||||
"download": f"{storage_json.name}.ota.bin",
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
return types
|
||||
|
||||
|
||||
def only_on_variant(*, supported=None, unsupported=None, msg_prefix="This feature"):
|
||||
@@ -705,9 +742,10 @@ def is_idf_sdkconfig_option_enabled(name: str) -> bool:
|
||||
def set_idf_sdkconfig_default(name: str, value: SdkconfigValueType) -> None:
|
||||
"""Set an sdkconfig option unless it is already set.
|
||||
|
||||
For the FINAL priority reconcile jobs: they run after every to_code,
|
||||
including the user's sdkconfig_options, and must not override an
|
||||
existing value.
|
||||
User sdkconfig_options take precedence regardless of to_code order:
|
||||
esp32.to_code applies them unconditionally, and this helper preserves
|
||||
values that are already set. FINAL priority reconcile jobs use the same
|
||||
guard because they run after every to_code, including the user's options.
|
||||
"""
|
||||
if name not in CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]:
|
||||
add_idf_sdkconfig_option(name, value)
|
||||
@@ -2836,6 +2874,8 @@ async def to_code(config):
|
||||
"CONFIG_ESP32P4_SELECTS_REV_LESS_V3",
|
||||
config.get(CONF_ENGINEERING_SAMPLE, False),
|
||||
)
|
||||
# Work around ESP-IDF bug: see https://github.com/espressif/esp-idf/issues/19020
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_MAIN_TASK_STACK_SIZE", 8192)
|
||||
|
||||
# ESP32-C2 defaults to the ROM's newlib "nano" printf, which does not
|
||||
# understand %zu or %lld and crashes on any %s that follows one.
|
||||
@@ -3500,6 +3540,30 @@ def _write_idf_component_yml():
|
||||
yml_path = CORE.relative_build_path("src/idf_component.yml")
|
||||
dependencies: dict[str, dict] = {}
|
||||
|
||||
converted: list[IDFComponent] = []
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
# Convert the PlatformIO libraries to ESP-IDF components as a batch so
|
||||
# PlatformIO resolves the whole dependency tree at once -- deduplicating
|
||||
# shared transitive deps (e.g. esphome/libsodium pulled by both noise-c
|
||||
# and esp_wireguard) to a single version instead of clashing
|
||||
# override_path entries.
|
||||
libraries = [
|
||||
library
|
||||
for name, library in CORE.platformio_libraries.items()
|
||||
# Don't process arduino libraries
|
||||
if name not in ARDUINO_DISABLED_LIBRARIES
|
||||
]
|
||||
# A library also declared as a managed component is not converted too, or
|
||||
# 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)
|
||||
# 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
|
||||
# on path order. Such a stub points at the converted library instead.
|
||||
converted_by_name = {component.path.name: component for component in converted}
|
||||
|
||||
# For Arduino builds, override unused managed components from the Arduino framework
|
||||
# by pointing them to empty stub directories using override_path
|
||||
# This prevents the IDF component manager from downloading the real components
|
||||
@@ -3513,9 +3577,7 @@ def _write_idf_component_yml():
|
||||
}
|
||||
|
||||
# Only stub components that are not required by any enabled Arduino library
|
||||
components_to_stub = (
|
||||
set(ARDUINO_EXCLUDED_IDF_COMPONENTS) - required_idf_components
|
||||
)
|
||||
components_to_stub = arduino_excluded_idf_components() - required_idf_components
|
||||
|
||||
stubs_dir = CORE.relative_build_path("component_stubs")
|
||||
stubs_dir.mkdir(exist_ok=True)
|
||||
@@ -3525,8 +3587,17 @@ def _write_idf_component_yml():
|
||||
# always writes, and ninja keeps triggering CMake re-runs on
|
||||
# otherwise-cached rebuilds.
|
||||
for component_name in sorted(components_to_stub):
|
||||
stub_name = _idf_component_stub_name(component_name)
|
||||
stub_path = stubs_dir / stub_name
|
||||
if (component := converted_by_name.get(stub_name)) is not None:
|
||||
if stub_path.exists():
|
||||
rmtree(stub_path)
|
||||
dependencies[_idf_component_dep_name(component_name)] = {
|
||||
"version": "*",
|
||||
"override_path": str(component.path),
|
||||
}
|
||||
continue
|
||||
# Create stub directory with minimal CMakeLists.txt
|
||||
stub_path = stubs_dir / _idf_component_stub_name(component_name)
|
||||
stub_path.mkdir(exist_ok=True)
|
||||
stub_cmake = stub_path / "CMakeLists.txt"
|
||||
if not stub_cmake.exists():
|
||||
@@ -3568,22 +3639,10 @@ def _write_idf_component_yml():
|
||||
ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]),
|
||||
)
|
||||
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
# Convert the PlatformIO libraries to ESP-IDF components as a batch so
|
||||
# PlatformIO resolves the whole dependency tree at once -- deduplicating
|
||||
# shared transitive deps (e.g. esphome/libsodium pulled by both noise-c
|
||||
# and esp_wireguard) to a single version instead of clashing
|
||||
# override_path entries.
|
||||
libraries = [
|
||||
library
|
||||
for name, library in CORE.platformio_libraries.items()
|
||||
# Don't process arduino libraries
|
||||
if name not in ARDUINO_DISABLED_LIBRARIES
|
||||
]
|
||||
for component in generate_idf_components(libraries):
|
||||
dependencies[component.get_sanitized_name()] = {
|
||||
"override_path": str(component.path)
|
||||
}
|
||||
for component in converted:
|
||||
dependencies[component.get_sanitized_name()] = {
|
||||
"override_path": str(component.path)
|
||||
}
|
||||
|
||||
if CORE.data[KEY_ESP32][KEY_COMPONENTS]:
|
||||
components: dict = CORE.data[KEY_ESP32][KEY_COMPONENTS]
|
||||
|
||||
@@ -17,8 +17,7 @@ extern "C" __attribute__((weak)) void initArduino() {}
|
||||
namespace esphome {
|
||||
|
||||
// HAL functions live in hal.cpp. This file keeps only the loop task setup.
|
||||
TaskHandle_t loop_task_handle = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static StackType_t
|
||||
loop_task_stack[ESPHOME_LOOP_TASK_STACK_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
@@ -40,11 +39,10 @@ extern "C" void app_main() {
|
||||
initArduino();
|
||||
esp32::setup_preferences();
|
||||
#if CONFIG_FREERTOS_UNICORE
|
||||
loop_task_handle = xTaskCreateStatic(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, loop_task_stack,
|
||||
&loop_task_tcb);
|
||||
xTaskCreateStatic(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, loop_task_stack, &loop_task_tcb);
|
||||
#else
|
||||
loop_task_handle = xTaskCreateStaticPinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1,
|
||||
loop_task_stack, &loop_task_tcb, 1);
|
||||
xTaskCreateStaticPinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, loop_task_stack,
|
||||
&loop_task_tcb, 1);
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -235,14 +235,14 @@ class ESP32BLE final : public Component {
|
||||
#ifdef USE_ESP32_BLE_ADVERTISING
|
||||
BLEAdvertising *advertising_{}; // 4 bytes (pointer)
|
||||
#endif
|
||||
const char *name_{nullptr}; // 4 bytes (pointer to string literal in flash)
|
||||
esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE}; // 4 bytes (enum)
|
||||
uint32_t advertising_cycle_time_{}; // 4 bytes
|
||||
const char *name_{nullptr}; // 4 bytes (pointer to string literal in flash)
|
||||
uint32_t advertising_cycle_time_{}; // 4 bytes
|
||||
|
||||
// 2-byte aligned members
|
||||
uint16_t appearance_{0}; // 2 bytes
|
||||
|
||||
// 1-byte aligned members (grouped together to minimize padding)
|
||||
esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE}; // 1 byte (uint8_t typedef)
|
||||
BLEComponentState state_{BLE_COMPONENT_STATE_OFF}; // 1 byte (uint8_t enum)
|
||||
bool enable_on_boot_{}; // 1 byte
|
||||
#ifdef USE_ESP32_BLE_ADVERTISING
|
||||
|
||||
@@ -8,6 +8,10 @@ from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import ble_device_base, esp32_ble, ota
|
||||
from esphome.components.ble_device_base import CONF_CONNECTION_SCAN_WINDOW
|
||||
from esphome.components.ble_device_base.automation import (
|
||||
MAC_FILTER_LIST,
|
||||
mac_filter_table,
|
||||
)
|
||||
from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW
|
||||
from esphome.components.esp32 import (
|
||||
add_idf_sdkconfig_option,
|
||||
@@ -277,7 +281,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
ESPBTAdvertiseTrigger
|
||||
),
|
||||
cv.Optional(CONF_MAC_ADDRESS): cv.ensure_list(cv.mac_address),
|
||||
cv.Optional(CONF_MAC_ADDRESS): MAC_FILTER_LIST,
|
||||
}
|
||||
),
|
||||
cv.Optional(
|
||||
@@ -381,9 +385,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
for conf in config.get(CONF_ON_BLE_ADVERTISE, []):
|
||||
_request_listener_slot()
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
if CONF_MAC_ADDRESS in conf:
|
||||
addr_list = [it.as_hex for it in conf[CONF_MAC_ADDRESS]]
|
||||
cg.add(trigger.set_addresses(addr_list))
|
||||
if macs := conf.get(CONF_MAC_ADDRESS):
|
||||
cg.add(trigger.set_addresses(mac_filter_table(macs)))
|
||||
await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf)
|
||||
for conf in config.get(CONF_ON_BLE_SERVICE_DATA_ADVERTISE, []):
|
||||
_request_listener_slot()
|
||||
@@ -436,6 +439,25 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add_define("USE_ESP32_BLE_SOFTWARE_COEXISTENCE")
|
||||
|
||||
|
||||
# First tagged release per series with espressif/esp-idf@82e71c1767 (see bluedroid_stubs.cpp).
|
||||
# A series without an entry keeps the guard until a fixed release is tagged; the guard is
|
||||
# harmless on fixed sources. The 5.4, 5.5 and 6.1 branches carry the fix but have no tag yet.
|
||||
DIRECT_CONN_FIX_VERSIONS = {
|
||||
(5, 2): cv.Version(5, 2, 8),
|
||||
(5, 3): cv.Version(5, 3, 6),
|
||||
(6, 0): cv.Version(6, 0, 3),
|
||||
}
|
||||
DIRECT_CONN_FIX_ALL_FROM = cv.Version(6, 2, 0)
|
||||
|
||||
|
||||
def _needs_direct_conn_guard() -> bool:
|
||||
ver = idf_version()
|
||||
if ver >= DIRECT_CONN_FIX_ALL_FROM:
|
||||
return False
|
||||
fixed = DIRECT_CONN_FIX_VERSIONS.get((ver.major, ver.minor))
|
||||
return fixed is None or ver < fixed
|
||||
|
||||
|
||||
# This needs to be run as a job with very low priority so that all components have
|
||||
# chance to call register_ble_tracker and register_client before the list is checked
|
||||
# and added to the global defines list.
|
||||
@@ -452,6 +474,11 @@ async def _add_ble_features() -> None:
|
||||
if BLEFeatures.ESP_BT_DEVICE in required_features:
|
||||
cg.add_define("USE_ESP32_BLE_DEVICE")
|
||||
cg.add_define("USE_ESP32_BLE_UUID")
|
||||
if cg.get_slot_count(CLIENT_COUNT_DEFINE) and _needs_direct_conn_guard():
|
||||
# --undefined keeps the wrapper, libsrc.a is scanned before the IDF libraries
|
||||
cg.add_define("USE_ESP32_BLE_TRACKER_DIRECT_CONN_GUARD")
|
||||
cg.add_build_flag("-Wl,--wrap=l2cble_init_direct_conn")
|
||||
cg.add_build_flag("-Wl,--undefined=__wrap_l2cble_init_direct_conn")
|
||||
|
||||
|
||||
ESP32_BLE_START_SCAN_ACTION_SCHEMA = cv.Schema(
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/components/ble_device_base/automation.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
namespace esphome::esp32_ble_tracker {
|
||||
@@ -12,22 +11,18 @@ namespace esphome::esp32_ble_tracker {
|
||||
class ESPBTAdvertiseTrigger final : public Trigger<const ESPBTDevice &>, public ESPBTDeviceListener {
|
||||
public:
|
||||
explicit ESPBTAdvertiseTrigger(ESP32BLETracker *parent) { parent->register_listener(this); }
|
||||
void set_addresses(std::initializer_list<uint64_t> addresses) { this->address_vec_ = addresses; }
|
||||
/// Table of MACs ended by 0; must outlive the trigger.
|
||||
void set_addresses(const uint64_t *addresses) { this->addresses_ = addresses; }
|
||||
|
||||
bool parse_device(const ESPBTDevice &device) override {
|
||||
uint64_t u64_addr = device.address_uint64();
|
||||
if (!address_vec_.empty()) {
|
||||
if (std::find(address_vec_.begin(), address_vec_.end(), u64_addr) == address_vec_.end()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (this->addresses_ != nullptr && !ble_device_base::mac_in_table(this->addresses_, device.address_uint64()))
|
||||
return false;
|
||||
this->trigger(device);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected:
|
||||
std::vector<uint64_t> address_vec_;
|
||||
const uint64_t *addresses_{nullptr};
|
||||
};
|
||||
|
||||
class BLEServiceDataAdvertiseTrigger final : public Trigger<const adv_data_t &>, public ESPBTDeviceListener {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Bluedroid queues outgoing BLE connections as raw link block pointers and does
|
||||
* not drop them when the block is released, so btm_send_pending_direct_conn()
|
||||
* can start a connect on a released block and l2c_link_timeout() later crashes
|
||||
* on its null timer parameter. Mirrors espressif/esp-idf@82e71c1767; codegen
|
||||
* only enables it for releases without that commit.
|
||||
*/
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESP32_BLE_TRACKER_DIRECT_CONN_GUARD
|
||||
|
||||
#include <esp_idf_version.h>
|
||||
#include <cstdint>
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::esp32_ble_tracker {
|
||||
static const char *const TAG = "esp32_ble_tracker";
|
||||
} // namespace esphome::esp32_ble_tracker
|
||||
|
||||
static_assert(ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(6, 2, 0),
|
||||
"ESP-IDF 6.2 and later have the fix, this guard should not be enabled (esphome/esphome#19373)");
|
||||
|
||||
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||
extern "C" {
|
||||
|
||||
bool __real_l2cble_init_direct_conn(void *p_lcb);
|
||||
void l2cu_release_lcb(void *p_lcb);
|
||||
|
||||
bool __wrap_l2cble_init_direct_conn(void *p_lcb) {
|
||||
// in_use is the first member of the private tL2C_LCB (checked ESP-IDF 5.0 to 6.1)
|
||||
const auto *in_use = static_cast<const uint8_t *>(p_lcb);
|
||||
if (p_lcb == nullptr || *in_use == 0) {
|
||||
ESP_LOGW(esphome::esp32_ble_tracker::TAG, "Dropped queued connect on a released link block");
|
||||
return false;
|
||||
}
|
||||
const bool started = __real_l2cble_init_direct_conn(p_lcb);
|
||||
// Every failure path releases the block except unknown device, also fixed upstream
|
||||
if (!started && *in_use != 0) {
|
||||
l2cu_release_lcb(p_lcb);
|
||||
}
|
||||
return started;
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||
|
||||
#endif // USE_ESP32_BLE_TRACKER_DIRECT_CONN_GUARD
|
||||
@@ -0,0 +1,185 @@
|
||||
from ipaddress import IPv4Address
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32, wifi
|
||||
from esphome.components.esp32.const import (
|
||||
VARIANT_ESP32,
|
||||
VARIANT_ESP32C3,
|
||||
VARIANT_ESP32C5,
|
||||
VARIANT_ESP32C6,
|
||||
VARIANT_ESP32S2,
|
||||
VARIANT_ESP32S3,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_WIFI
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DOMAIN = "espectre"
|
||||
CODEOWNERS = ["@francescopace"]
|
||||
DEPENDENCIES = ["esp32", "wifi"]
|
||||
|
||||
CONF_ESPECTRE_ID = "espectre_id"
|
||||
CONF_DETECTION_ALGORITHM = "detection_algorithm"
|
||||
CONF_CSI_CAPTURE_PROFILE = "csi_capture_profile"
|
||||
CONF_TRAFFIC_GENERATOR_MODE = "traffic_generator_mode"
|
||||
CONF_TRAFFIC_GENERATOR_TARGET_IP = "traffic_generator_target_ip"
|
||||
CONF_CSI_TRAFFIC_MULTICAST_GROUP = "csi_traffic_multicast_group"
|
||||
CONF_MOTION_ON_HITS = "motion_on_hits"
|
||||
CONF_MOTION_OFF_HITS = "motion_off_hits"
|
||||
|
||||
# Fully qualified: a bare espectre:: in main.cpp would clash with the SDK namespace.
|
||||
espectre_ns = cg.global_ns.namespace("esphome").namespace("espectre")
|
||||
ESPectreComponent = espectre_ns.class_("ESPectreComponent", cg.Component)
|
||||
sdk_ns = cg.global_ns.namespace("::espectre")
|
||||
DetectionAlgorithm = sdk_ns.enum("DetectionAlgorithm", is_class=True)
|
||||
CsiCapturePolicy = sdk_ns.enum("CsiCapturePolicy", is_class=True)
|
||||
TrafficGeneratorMode = sdk_ns.enum("TrafficGeneratorMode", is_class=True)
|
||||
WifiBandPolicy = sdk_ns.enum("WifiBandPolicy", is_class=True)
|
||||
|
||||
DETECTION_ALGORITHMS = {
|
||||
"lightweight": DetectionAlgorithm.LIGHTWEIGHT,
|
||||
"high_accuracy": DetectionAlgorithm.HIGH_ACCURACY,
|
||||
}
|
||||
CSI_CAPTURE_PROFILES = {
|
||||
"auto": CsiCapturePolicy.AUTO,
|
||||
"lltf": CsiCapturePolicy.LLTF,
|
||||
"ht_vht": CsiCapturePolicy.HT_VHT,
|
||||
}
|
||||
TRAFFIC_GENERATOR_MODES = {
|
||||
"ping": TrafficGeneratorMode.PING,
|
||||
"dns": TrafficGeneratorMode.DNS,
|
||||
"dns_tcp": TrafficGeneratorMode.DNS_TCP,
|
||||
"wifi_raw": TrafficGeneratorMode.WIFI_RAW,
|
||||
"external": TrafficGeneratorMode.EXTERNAL_HOST,
|
||||
}
|
||||
|
||||
|
||||
def validate_target_ip(value: str) -> str:
|
||||
value = str(cv.ipv4address(value))
|
||||
first_octet = int(IPv4Address(value)) >> 24
|
||||
if first_octet in (0, 127) or first_octet >= 224 or value == "255.255.255.255":
|
||||
raise cv.Invalid("ESPectre traffic target must be a unicast IPv4 address")
|
||||
return value
|
||||
|
||||
|
||||
def validate_multicast_group(value: str) -> str:
|
||||
"""An IPv4 multicast group, or an empty string to skip joining one."""
|
||||
if not (value := cv.string_strict(value).strip()):
|
||||
return value
|
||||
value = str(cv.ipv4address(value))
|
||||
if not IPv4Address(value).is_multicast:
|
||||
raise cv.Invalid("ESPectre multicast group must be an IPv4 multicast address")
|
||||
return value
|
||||
|
||||
|
||||
def supported_traffic_generator_modes(config: ConfigType) -> list[str]:
|
||||
"""Traffic generator modes available with this chip and CSI capture profile."""
|
||||
wifi_raw = (
|
||||
esp32.get_esp32_variant() != VARIANT_ESP32C6
|
||||
and config[CONF_CSI_CAPTURE_PROFILE] != "ht_vht"
|
||||
)
|
||||
return [mode for mode in TRAFFIC_GENERATOR_MODES if wifi_raw or mode != "wifi_raw"]
|
||||
|
||||
|
||||
def validate_config(config: ConfigType) -> ConfigType:
|
||||
mode = config[CONF_TRAFFIC_GENERATOR_MODE]
|
||||
if mode not in supported_traffic_generator_modes(config):
|
||||
raise cv.Invalid(
|
||||
"wifi_raw traffic is not supported on ESP32-C6 "
|
||||
"or with the ht_vht CSI capture profile"
|
||||
)
|
||||
if mode in ("wifi_raw", "external") and CONF_TRAFFIC_GENERATOR_TARGET_IP in config:
|
||||
raise cv.Invalid(f"{mode} traffic does not use a target IP address")
|
||||
if mode != "external" and CONF_CSI_TRAFFIC_MULTICAST_GROUP in config:
|
||||
raise cv.Invalid(
|
||||
f"{CONF_CSI_TRAFFIC_MULTICAST_GROUP} requires "
|
||||
f"{CONF_TRAFFIC_GENERATOR_MODE}: external"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(ESPectreComponent),
|
||||
cv.Optional(CONF_DETECTION_ALGORITHM, default="lightweight"): cv.enum(
|
||||
DETECTION_ALGORITHMS, lower=True
|
||||
),
|
||||
cv.Optional(CONF_CSI_CAPTURE_PROFILE, default="auto"): cv.enum(
|
||||
CSI_CAPTURE_PROFILES, lower=True
|
||||
),
|
||||
cv.Optional(CONF_TRAFFIC_GENERATOR_MODE, default="ping"): cv.enum(
|
||||
TRAFFIC_GENERATOR_MODES, lower=True
|
||||
),
|
||||
cv.Optional(CONF_TRAFFIC_GENERATOR_TARGET_IP): validate_target_ip,
|
||||
cv.Optional(CONF_CSI_TRAFFIC_MULTICAST_GROUP): validate_multicast_group,
|
||||
cv.Optional(CONF_MOTION_ON_HITS): cv.int_range(min=1, max=20),
|
||||
cv.Optional(CONF_MOTION_OFF_HITS): cv.int_range(min=1, max=20),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
esp32.only_on_variant(
|
||||
supported=[
|
||||
VARIANT_ESP32,
|
||||
VARIANT_ESP32S2,
|
||||
VARIANT_ESP32S3,
|
||||
VARIANT_ESP32C3,
|
||||
VARIANT_ESP32C5,
|
||||
VARIANT_ESP32C6,
|
||||
],
|
||||
msg_prefix="ESPectre",
|
||||
),
|
||||
# Arduino 3.3.7 is the first release built on ESP-IDF 5.5.3.
|
||||
cv.require_framework_version(
|
||||
esp_idf=cv.Version(5, 5, 3), esp32_arduino=cv.Version(3, 3, 7)
|
||||
),
|
||||
validate_config,
|
||||
)
|
||||
|
||||
|
||||
def final_validate(config: ConfigType) -> None:
|
||||
wifi.force_power_save_off(
|
||||
"ESPectre needs the radio awake to receive a steady flow of CSI packets"
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = final_validate
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
cg.add_define("USE_ESPECTRE")
|
||||
cg.add(var.set_detection_algorithm(config[CONF_DETECTION_ALGORITHM]))
|
||||
cg.add(var.set_csi_capture_profile(config[CONF_CSI_CAPTURE_PROFILE]))
|
||||
cg.add(var.set_traffic_generator_mode(config[CONF_TRAFFIC_GENERATOR_MODE]))
|
||||
if (target_ip := config.get(CONF_TRAFFIC_GENERATOR_TARGET_IP)) is not None:
|
||||
cg.add(var.set_traffic_generator_target_ip(target_ip))
|
||||
if (group := config.get(CONF_CSI_TRAFFIC_MULTICAST_GROUP)) is not None:
|
||||
cg.add(var.set_csi_traffic_multicast_group(group))
|
||||
if (on_hits := config.get(CONF_MOTION_ON_HITS)) is not None:
|
||||
cg.add(var.set_motion_on_hits(on_hits))
|
||||
if (off_hits := config.get(CONF_MOTION_OFF_HITS)) is not None:
|
||||
cg.add(var.set_motion_off_hits(off_hits))
|
||||
if esp32.get_esp32_variant() == VARIANT_ESP32C5:
|
||||
band = CORE.config[CONF_WIFI].get(wifi.CONF_BAND_MODE, "AUTO")
|
||||
cg.add(
|
||||
var.set_wifi_band_policy(
|
||||
{
|
||||
"2.4GHZ": WifiBandPolicy.BAND_2G,
|
||||
"5GHZ": WifiBandPolicy.BAND_5G,
|
||||
"AUTO": WifiBandPolicy.AUTO,
|
||||
}[band]
|
||||
)
|
||||
)
|
||||
|
||||
wifi.enable_runtime_roaming_suppression()
|
||||
esp32.add_idf_component(name="francescopace/espectre", ref="3.0.0")
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_CSI_ENABLED", True)
|
||||
# CSI is reported once per received transmission, so aggregation hides frames from sensing.
|
||||
# Disabling TX aggregation also lets the SDK fix the station TX rate (6.5 Mbps on ESP32).
|
||||
# Both may lower Wi-Fi throughput for the whole firmware.
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_AMPDU_TX_ENABLED", False)
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_AMPDU_RX_ENABLED", False)
|
||||
# Keep the radio awake while disconnected too, matching force_power_save_off().
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_STA_DISCONNECTED_PM_ENABLE", False)
|
||||
@@ -0,0 +1,32 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import binary_sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_MOTION, DEVICE_CLASS_MOTION, ENTITY_CATEGORY_DIAGNOSTIC
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_ESPECTRE_ID, ESPectreComponent
|
||||
|
||||
DEPENDENCIES = ["espectre"]
|
||||
CONF_CALIBRATING = "calibrating"
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent),
|
||||
cv.Optional(CONF_MOTION): binary_sensor.binary_sensor_schema(
|
||||
device_class=DEVICE_CLASS_MOTION
|
||||
),
|
||||
cv.Optional(CONF_CALIBRATING): binary_sensor.binary_sensor_schema(
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_MOTION, CONF_CALIBRATING),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_ESPECTRE_ID])
|
||||
binary_sensors = binary_sensor.sub_binary_sensors(config)
|
||||
await binary_sensors(CONF_MOTION, hub.set_motion_binary_sensor)
|
||||
await binary_sensors(CONF_CALIBRATING, hub.set_calibrating_binary_sensor)
|
||||
@@ -0,0 +1,20 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import button
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import ENTITY_CATEGORY_CONFIG
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_ESPECTRE_ID, ESPectreComponent, espectre_ns
|
||||
|
||||
DEPENDENCIES = ["espectre"]
|
||||
|
||||
RecalibrateButton = espectre_ns.class_("RecalibrateButton", button.Button)
|
||||
|
||||
CONFIG_SCHEMA = button.button_schema(
|
||||
RecalibrateButton, entity_category=ENTITY_CATEGORY_CONFIG
|
||||
).extend({cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent)})
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await button.new_button(config)
|
||||
await cg.register_parented(var, config[CONF_ESPECTRE_ID])
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESPECTRE
|
||||
|
||||
#include "esphome/components/button/button.h"
|
||||
#include "../espectre.h"
|
||||
|
||||
namespace esphome::espectre {
|
||||
|
||||
class RecalibrateButton final : public button::Button, public Parented<ESPectreComponent> {
|
||||
protected:
|
||||
void press_action() override { this->parent_->recalibrate(); }
|
||||
};
|
||||
|
||||
} // namespace esphome::espectre
|
||||
|
||||
#endif // USE_ESPECTRE
|
||||
@@ -0,0 +1,305 @@
|
||||
#include "espectre.h"
|
||||
|
||||
#ifdef USE_ESPECTRE
|
||||
|
||||
#include <cinttypes>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION)
|
||||
#include "esphome/components/wifi/wifi_component.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::espectre {
|
||||
|
||||
static const char *const TAG = "espectre";
|
||||
|
||||
struct TrafficModeOption {
|
||||
const char *name;
|
||||
::espectre::TrafficGeneratorMode mode;
|
||||
};
|
||||
// Option names match the traffic_generator_mode YAML values.
|
||||
static constexpr TrafficModeOption TRAFFIC_MODE_OPTIONS[] = {
|
||||
{"ping", ::espectre::TrafficGeneratorMode::PING},
|
||||
{"dns", ::espectre::TrafficGeneratorMode::DNS},
|
||||
{"dns_tcp", ::espectre::TrafficGeneratorMode::DNS_TCP},
|
||||
{"wifi_raw", ::espectre::TrafficGeneratorMode::WIFI_RAW},
|
||||
{"external", ::espectre::TrafficGeneratorMode::EXTERNAL_HOST},
|
||||
};
|
||||
|
||||
static const char *traffic_mode_name(::espectre::TrafficGeneratorMode mode) {
|
||||
for (const auto &option : TRAFFIC_MODE_OPTIONS) {
|
||||
if (option.mode == mode)
|
||||
return option.name;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static const char *csi_capture_profile_name(::espectre::CsiCapturePolicy profile) {
|
||||
switch (profile) {
|
||||
case ::espectre::CsiCapturePolicy::LLTF:
|
||||
return LOG_STR_LITERAL("lltf");
|
||||
case ::espectre::CsiCapturePolicy::HT_VHT:
|
||||
return LOG_STR_LITERAL("ht_vht");
|
||||
default:
|
||||
return LOG_STR_LITERAL("auto");
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_SELECT
|
||||
void ESPectreComponent::request_traffic_generator_mode(const char *name) {
|
||||
for (const auto &option : TRAFFIC_MODE_OPTIONS) {
|
||||
if (strcmp(option.name, name) == 0) {
|
||||
this->pending_traffic_mode_ = option.mode;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
static int log_level(::espectre::LogLevel level) {
|
||||
switch (level) {
|
||||
case ::espectre::LogLevel::ERROR:
|
||||
return ESPHOME_LOG_LEVEL_ERROR;
|
||||
case ::espectre::LogLevel::WARNING:
|
||||
return ESPHOME_LOG_LEVEL_WARN;
|
||||
case ::espectre::LogLevel::INFO:
|
||||
return ESPHOME_LOG_LEVEL_INFO;
|
||||
case ::espectre::LogLevel::DEBUG:
|
||||
return ESPHOME_LOG_LEVEL_DEBUG;
|
||||
default:
|
||||
return ESPHOME_LOG_LEVEL_VERBOSE;
|
||||
}
|
||||
}
|
||||
|
||||
void ESPectreComponent::setup() {
|
||||
// ESPHome owns scan results, including scans requested by the SDK's CSI recovery.
|
||||
this->runtime_.config().wifi_scan_results_managed_externally = true;
|
||||
#ifdef USE_SELECT
|
||||
this->restore_traffic_mode_();
|
||||
#endif
|
||||
this->start_runtime_();
|
||||
}
|
||||
|
||||
void ESPectreComponent::start_runtime_() {
|
||||
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION)
|
||||
// A sensor stays in one place, and each roaming scan takes the radio off-channel for
|
||||
// seconds, emptying the CSI window. Losing the access point still reconnects normally.
|
||||
if (wifi::global_wifi_component != nullptr) {
|
||||
wifi::global_wifi_component->request_roaming_suppression();
|
||||
this->roaming_suppressed_ = true;
|
||||
}
|
||||
#endif
|
||||
::espectre::set_log_sink({
|
||||
.context = nullptr,
|
||||
.enabled = [](void *, ::espectre::LogLevel level, const char *) { return log_level(level) <= ESPHOME_LOG_LEVEL; },
|
||||
.write = [](void *, ::espectre::LogLevel level, const char *tag, int line, const char *format,
|
||||
va_list args) { esp_log_vprintf_(log_level(level), tag, line, format, args); },
|
||||
});
|
||||
if (!this->runtime_.setup(this)) {
|
||||
ESP_LOGE(TAG, "Runtime setup failed");
|
||||
this->stop_();
|
||||
this->schedule_restart_();
|
||||
return;
|
||||
}
|
||||
this->running_ = true;
|
||||
this->status_clear_error();
|
||||
#ifdef USE_SELECT
|
||||
this->publish_traffic_mode_();
|
||||
#endif
|
||||
}
|
||||
|
||||
void ESPectreComponent::schedule_restart_() {
|
||||
// A fault can be transient, such as a Wi-Fi stall, so retry instead of failing for good.
|
||||
this->runtime_fault_ = false;
|
||||
this->status_set_error(LOG_STR("Runtime stopped"));
|
||||
ESP_LOGW(TAG, "Restarting the runtime in %" PRIu32 " s", RESTART_DELAY_MS / 1000);
|
||||
this->restart_pending_ = true;
|
||||
this->restart_requested_ms_ = App.get_loop_component_start_time();
|
||||
}
|
||||
|
||||
#ifdef USE_SELECT
|
||||
void ESPectreComponent::restore_traffic_mode_() {
|
||||
if (this->traffic_mode_select_ == nullptr)
|
||||
return;
|
||||
// Keyed by the YAML mode, so changing it in YAML discards a mode saved from the select.
|
||||
const auto yaml_mode = this->runtime_.config().traffic_generator_mode;
|
||||
this->traffic_mode_pref_ =
|
||||
this->traffic_mode_select_->make_entity_preference<uint8_t>(static_cast<uint32_t>(yaml_mode) + 1);
|
||||
uint8_t saved;
|
||||
if (!this->traffic_mode_pref_.load(&saved))
|
||||
return;
|
||||
const auto mode = static_cast<::espectre::TrafficGeneratorMode>(saved);
|
||||
const char *name = traffic_mode_name(mode);
|
||||
if (name != nullptr && this->traffic_mode_select_->has_option(name))
|
||||
this->runtime_.config().traffic_generator_mode = mode;
|
||||
}
|
||||
|
||||
void ESPectreComponent::apply_pending_traffic_mode_() {
|
||||
if (!this->pending_traffic_mode_.has_value())
|
||||
return;
|
||||
const auto mode = *this->pending_traffic_mode_;
|
||||
this->pending_traffic_mode_.reset();
|
||||
if (this->runtime_.set_traffic_generator_mode(mode)) {
|
||||
const auto saved = static_cast<uint8_t>(mode);
|
||||
this->traffic_mode_pref_.save(&saved);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Traffic generator mode %s was rejected", traffic_mode_name(mode));
|
||||
}
|
||||
this->publish_traffic_mode_();
|
||||
}
|
||||
|
||||
void ESPectreComponent::publish_traffic_mode_() {
|
||||
if (this->traffic_mode_select_ == nullptr)
|
||||
return;
|
||||
const char *name = traffic_mode_name(this->runtime_.config().traffic_generator_mode);
|
||||
if (name != nullptr)
|
||||
this->traffic_mode_select_->publish_state(name);
|
||||
}
|
||||
#endif
|
||||
|
||||
void ESPectreComponent::loop() {
|
||||
if (this->runtime_fault_) {
|
||||
this->stop_();
|
||||
this->schedule_restart_();
|
||||
return;
|
||||
}
|
||||
if (!this->running_) {
|
||||
// Without a backend this only reaps a traffic worker that outlived the last runtime.
|
||||
this->runtime_.loop();
|
||||
if (this->restart_pending_ &&
|
||||
App.get_loop_component_start_time() - this->restart_requested_ms_ >= RESTART_DELAY_MS) {
|
||||
this->restart_pending_ = false;
|
||||
this->start_runtime_();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this->recalibrate_pending_) {
|
||||
this->recalibrate_pending_ = false;
|
||||
if (!this->runtime_.trigger_recalibration()) {
|
||||
ESP_LOGW(TAG, "Recalibration is not available");
|
||||
}
|
||||
}
|
||||
#ifdef USE_SELECT
|
||||
this->apply_pending_traffic_mode_();
|
||||
#endif
|
||||
this->runtime_.loop();
|
||||
if (this->runtime_fault_)
|
||||
return;
|
||||
|
||||
// Read once after the SDK finishes dispatching callbacks, including readiness changes.
|
||||
const auto &snapshot = this->runtime_.snapshot();
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->calibrating_binary_sensor_ != nullptr &&
|
||||
(!this->calibrating_published_ || this->calibrating_state_ != snapshot.calibrating)) {
|
||||
this->calibrating_state_ = snapshot.calibrating;
|
||||
this->calibrating_published_ = true;
|
||||
this->calibrating_binary_sensor_->publish_state(snapshot.calibrating);
|
||||
}
|
||||
#endif
|
||||
if (!snapshot.ready_to_publish) {
|
||||
if (this->ready_)
|
||||
this->invalidate_sensing_();
|
||||
this->movement_pending_ = false;
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
const bool motion = snapshot.motion_state == ::espectre::MotionState::MOTION;
|
||||
if (this->motion_binary_sensor_ != nullptr && (!this->ready_ || this->motion_state_ != motion)) {
|
||||
this->motion_state_ = motion;
|
||||
this->motion_binary_sensor_->publish_state(motion);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
if (this->movement_sensor_ != nullptr && (this->movement_pending_ || !this->ready_))
|
||||
this->movement_sensor_->publish_state(snapshot.movement_metric);
|
||||
#endif
|
||||
this->movement_pending_ = false;
|
||||
this->ready_ = true;
|
||||
}
|
||||
|
||||
void ESPectreComponent::invalidate_sensing_() {
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->motion_binary_sensor_ != nullptr)
|
||||
this->motion_binary_sensor_->invalidate_state();
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
if (this->movement_sensor_ != nullptr)
|
||||
this->movement_sensor_->publish_state(NAN);
|
||||
#endif
|
||||
this->ready_ = false;
|
||||
}
|
||||
|
||||
void ESPectreComponent::on_calibration_finished(const ::espectre::RuntimeSnapshot &snapshot, bool success) {
|
||||
if (success) {
|
||||
this->calibrated_ = true;
|
||||
this->status_clear_warning();
|
||||
ESP_LOGI(TAG, "Calibration complete");
|
||||
return;
|
||||
}
|
||||
// After a successful calibration, a failed one keeps that calibrated threshold.
|
||||
if (!this->calibrated_)
|
||||
this->status_set_warning(LOG_STR("Calibration failed"));
|
||||
ESP_LOGW(TAG, "Calibration failed; retaining the previous threshold");
|
||||
}
|
||||
|
||||
void ESPectreComponent::on_runtime_fault(const char *message) {
|
||||
ESP_LOGE(TAG, "Runtime fault: %s", message);
|
||||
this->runtime_fault_ = true;
|
||||
}
|
||||
|
||||
void ESPectreComponent::stop_() {
|
||||
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION)
|
||||
if (this->roaming_suppressed_ && wifi::global_wifi_component != nullptr) {
|
||||
wifi::global_wifi_component->release_roaming_suppression();
|
||||
}
|
||||
#endif
|
||||
this->roaming_suppressed_ = false;
|
||||
this->running_ = false;
|
||||
// The next runtime starts from the default threshold and publishes its calibration again.
|
||||
this->calibrated_ = false;
|
||||
this->calibrating_published_ = false;
|
||||
this->runtime_.shutdown();
|
||||
::espectre::clear_log_sink();
|
||||
this->invalidate_sensing_();
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->calibrating_binary_sensor_ != nullptr)
|
||||
this->calibrating_binary_sensor_->invalidate_state();
|
||||
#endif
|
||||
}
|
||||
|
||||
void ESPectreComponent::on_shutdown() {
|
||||
this->restart_pending_ = false;
|
||||
this->stop_();
|
||||
}
|
||||
|
||||
void ESPectreComponent::dump_config() {
|
||||
const auto &config = this->runtime_.config();
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"ESPectre:\n"
|
||||
" Detection algorithm: %s\n"
|
||||
" CSI capture profile: %s\n"
|
||||
" Traffic generator mode: %s\n"
|
||||
" Motion on/off hits: %u/%u",
|
||||
config.detection_algorithm == ::espectre::DetectionAlgorithm::LIGHTWEIGHT
|
||||
? LOG_STR_LITERAL("lightweight")
|
||||
: LOG_STR_LITERAL("high_accuracy"),
|
||||
csi_capture_profile_name(config.csi_capture_policy), traffic_mode_name(config.traffic_generator_mode),
|
||||
config.motion_on_hits, config.motion_off_hits);
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
LOG_BINARY_SENSOR(" ", "Motion", this->motion_binary_sensor_);
|
||||
LOG_BINARY_SENSOR(" ", "Calibrating", this->calibrating_binary_sensor_);
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
LOG_SENSOR(" ", "Movement score", this->movement_sensor_);
|
||||
#endif
|
||||
#ifdef USE_SELECT
|
||||
LOG_SELECT(" ", "Traffic generator mode", this->traffic_mode_select_);
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace esphome::espectre
|
||||
|
||||
#endif // USE_ESPECTRE
|
||||
@@ -0,0 +1,110 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESPECTRE
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/optional.h"
|
||||
#include <espectre_sdk.h>
|
||||
#include <string>
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#endif
|
||||
#ifdef USE_SELECT
|
||||
#include "esphome/components/select/select.h"
|
||||
#include "esphome/core/preferences.h"
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::espectre {
|
||||
|
||||
class ESPectreComponent final : public Component, public ::espectre::IRuntimeListener {
|
||||
public:
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
void on_shutdown() override;
|
||||
// Register the SDK's Wi-Fi event handlers after network setup, before station startup.
|
||||
float get_setup_priority() const override { return setup_priority::WIFI + 1.0f; }
|
||||
|
||||
void set_detection_algorithm(::espectre::DetectionAlgorithm value) {
|
||||
this->runtime_.config().detection_algorithm = value;
|
||||
}
|
||||
void set_csi_capture_profile(::espectre::CsiCapturePolicy value) {
|
||||
this->runtime_.config().csi_capture_policy = value;
|
||||
}
|
||||
void set_traffic_generator_mode(::espectre::TrafficGeneratorMode value) {
|
||||
this->runtime_.config().traffic_generator_mode = value;
|
||||
}
|
||||
void set_traffic_generator_target_ip(const std::string &value) {
|
||||
this->runtime_.config().traffic_generator_target_ip = value;
|
||||
}
|
||||
void set_csi_traffic_multicast_group(const std::string &value) {
|
||||
this->runtime_.config().csi_traffic_multicast_group = value;
|
||||
}
|
||||
void set_motion_on_hits(uint8_t value) { this->runtime_.config().motion_on_hits = value; }
|
||||
void set_motion_off_hits(uint8_t value) { this->runtime_.config().motion_off_hits = value; }
|
||||
void set_wifi_band_policy(::espectre::WifiBandPolicy value) { this->runtime_.config().wifi_band_policy = value; }
|
||||
// Queue controls so entity automations cannot re-enter the SDK from a listener callback.
|
||||
void recalibrate() { this->recalibrate_pending_ = true; }
|
||||
#ifdef USE_SELECT
|
||||
void set_traffic_mode_select(select::Select *value) { this->traffic_mode_select_ = value; }
|
||||
void request_traffic_generator_mode(::espectre::TrafficGeneratorMode mode) { this->pending_traffic_mode_ = mode; }
|
||||
/// Request a mode by its traffic_generator_mode YAML name; unknown names are ignored.
|
||||
void request_traffic_generator_mode(const char *name);
|
||||
#endif
|
||||
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
SUB_BINARY_SENSOR(motion)
|
||||
SUB_BINARY_SENSOR(calibrating)
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
SUB_SENSOR(movement)
|
||||
#endif
|
||||
/// Latest one-second runtime diagnostics sample, or nullptr before the runtime starts.
|
||||
const ::espectre::RuntimeDiagnosticsSample *diagnostics_sample() const { return this->runtime_.diagnostics_sample(); }
|
||||
|
||||
protected:
|
||||
// Called once per SDK detector evaluation (every 250 ms), which bounds the movement publish rate.
|
||||
void on_live_telemetry(const ::espectre::RuntimeSnapshot &snapshot) override { this->movement_pending_ = true; }
|
||||
void on_calibration_finished(const ::espectre::RuntimeSnapshot &snapshot, bool success) override;
|
||||
void on_runtime_fault(const char *message) override;
|
||||
void start_runtime_();
|
||||
void schedule_restart_();
|
||||
void invalidate_sensing_();
|
||||
void stop_();
|
||||
#ifdef USE_SELECT
|
||||
void restore_traffic_mode_();
|
||||
void apply_pending_traffic_mode_();
|
||||
void publish_traffic_mode_();
|
||||
#endif
|
||||
|
||||
static constexpr uint32_t RESTART_DELAY_MS = 30000;
|
||||
|
||||
::espectre::RuntimeFrontendController runtime_;
|
||||
uint32_t restart_requested_ms_{0};
|
||||
#ifdef USE_SELECT
|
||||
select::Select *traffic_mode_select_{nullptr};
|
||||
ESPPreferenceObject traffic_mode_pref_;
|
||||
optional<::espectre::TrafficGeneratorMode> pending_traffic_mode_;
|
||||
#endif
|
||||
bool recalibrate_pending_{false};
|
||||
bool movement_pending_{false};
|
||||
bool ready_{false};
|
||||
bool motion_state_{false};
|
||||
bool calibrating_state_{false};
|
||||
bool calibrating_published_{false};
|
||||
bool runtime_fault_{false};
|
||||
bool running_{false};
|
||||
bool restart_pending_{false};
|
||||
bool calibrated_{false};
|
||||
bool roaming_suppressed_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::espectre
|
||||
|
||||
#endif // USE_ESPECTRE
|
||||
@@ -0,0 +1,32 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import select
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import ENTITY_CATEGORY_CONFIG
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
CONF_ESPECTRE_ID,
|
||||
DOMAIN,
|
||||
ESPectreComponent,
|
||||
espectre_ns,
|
||||
supported_traffic_generator_modes,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["espectre"]
|
||||
|
||||
TrafficModeSelect = espectre_ns.class_(
|
||||
"TrafficModeSelect", select.Select, cg.Parented.template(ESPectreComponent)
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = select.select_schema(
|
||||
TrafficModeSelect, entity_category=ENTITY_CATEGORY_CONFIG
|
||||
).extend({cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent)})
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
options = supported_traffic_generator_modes(CORE.config[DOMAIN])
|
||||
var = await select.new_select(config, options=options)
|
||||
await cg.register_parented(var, config[CONF_ESPECTRE_ID])
|
||||
parent = await cg.get_variable(config[CONF_ESPECTRE_ID])
|
||||
cg.add(parent.set_traffic_mode_select(var))
|
||||
@@ -0,0 +1,11 @@
|
||||
#include "espectre_select.h"
|
||||
|
||||
#ifdef USE_ESPECTRE
|
||||
|
||||
namespace esphome::espectre {
|
||||
|
||||
void TrafficModeSelect::control(size_t index) { this->parent_->request_traffic_generator_mode(this->option_at(index)); }
|
||||
|
||||
} // namespace esphome::espectre
|
||||
|
||||
#endif // USE_ESPECTRE
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESPECTRE
|
||||
|
||||
#include "esphome/components/select/select.h"
|
||||
#include "../espectre.h"
|
||||
|
||||
namespace esphome::espectre {
|
||||
|
||||
class TrafficModeSelect final : public select::Select, public Parented<ESPectreComponent> {
|
||||
protected:
|
||||
void control(size_t index) override;
|
||||
};
|
||||
|
||||
} // namespace esphome::espectre
|
||||
|
||||
#endif // USE_ESPECTRE
|
||||
@@ -0,0 +1,78 @@
|
||||
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_MEASUREMENT,
|
||||
UNIT_PERCENT,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_ESPECTRE_ID, ESPectreComponent, espectre_ns
|
||||
|
||||
DEPENDENCIES = ["espectre"]
|
||||
|
||||
CONF_MOVEMENT = "movement"
|
||||
CONF_DIAGNOSTICS = "diagnostics"
|
||||
CONF_GENERATOR_RATE = "generator_rate"
|
||||
CONF_TRAFFIC_TX_RATE = "traffic_tx_rate"
|
||||
CONF_TRAFFIC_RX_RATE = "traffic_rx_rate"
|
||||
CONF_CSI_ACCEPTED_RATE = "csi_accepted_rate"
|
||||
CONF_CSI_OCCUPANCY = "csi_occupancy"
|
||||
UNIT_PACKETS_PER_SECOND = "pps"
|
||||
|
||||
DiagnosticsUpdater = espectre_ns.class_("DiagnosticsUpdater", cg.PollingComponent)
|
||||
|
||||
|
||||
def _diagnostic_schema(unit: str, accuracy_decimals: int) -> cv.Schema:
|
||||
return sensor.sensor_schema(
|
||||
unit_of_measurement=unit,
|
||||
accuracy_decimals=accuracy_decimals,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
)
|
||||
|
||||
|
||||
DIAGNOSTIC_SENSORS = {
|
||||
CONF_GENERATOR_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1),
|
||||
CONF_TRAFFIC_TX_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1),
|
||||
CONF_TRAFFIC_RX_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1),
|
||||
CONF_CSI_ACCEPTED_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1),
|
||||
CONF_CSI_OCCUPANCY: _diagnostic_schema(UNIT_PERCENT, 0),
|
||||
}
|
||||
|
||||
# Diagnostics are rarely watched, so they publish only on request unless an interval is set.
|
||||
DIAGNOSTICS_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(DiagnosticsUpdater),
|
||||
**{cv.Optional(key): schema for key, schema in DIAGNOSTIC_SENSORS.items()},
|
||||
}
|
||||
).extend(cv.polling_component_schema("never")),
|
||||
cv.has_at_least_one_key(*DIAGNOSTIC_SENSORS),
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent),
|
||||
cv.Optional(CONF_MOVEMENT): sensor.sensor_schema(
|
||||
accuracy_decimals=3, state_class=STATE_CLASS_MEASUREMENT
|
||||
),
|
||||
cv.Optional(CONF_DIAGNOSTICS): DIAGNOSTICS_SCHEMA,
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_MOVEMENT, CONF_DIAGNOSTICS),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_ESPECTRE_ID])
|
||||
await sensor.sub_sensors(config)(CONF_MOVEMENT, hub.set_movement_sensor)
|
||||
if (diagnostics_config := config.get(CONF_DIAGNOSTICS)) is not None:
|
||||
updater = cg.new_Pvariable(diagnostics_config[CONF_ID], hub)
|
||||
await cg.register_component(updater, diagnostics_config)
|
||||
diagnostic_sensors = sensor.sub_sensors(diagnostics_config)
|
||||
for key in DIAGNOSTIC_SENSORS:
|
||||
await diagnostic_sensors(key, getattr(updater, f"set_{key}_sensor"))
|
||||
@@ -0,0 +1,40 @@
|
||||
#include "espectre_diagnostics.h"
|
||||
|
||||
#ifdef USE_ESPECTRE
|
||||
|
||||
#include <cmath>
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::espectre {
|
||||
|
||||
static const char *const TAG = "espectre.sensor";
|
||||
|
||||
static void publish_diagnostic(sensor::Sensor *sensor, const ::espectre::RuntimeDiagnosticsSample *sample,
|
||||
float ::espectre::RuntimeDiagnosticsSample::*field, float scale = 1.0f) {
|
||||
if (sensor != nullptr)
|
||||
sensor->publish_state(sample != nullptr ? sample->*field * scale : NAN);
|
||||
}
|
||||
|
||||
void DiagnosticsUpdater::update() {
|
||||
using Sample = ::espectre::RuntimeDiagnosticsSample;
|
||||
const auto *sample = this->parent_->diagnostics_sample();
|
||||
publish_diagnostic(this->generator_rate_sensor_, sample, &Sample::generator_pps);
|
||||
publish_diagnostic(this->traffic_tx_rate_sensor_, sample, &Sample::traffic_tx_pps);
|
||||
publish_diagnostic(this->traffic_rx_rate_sensor_, sample, &Sample::traffic_rx_pps);
|
||||
publish_diagnostic(this->csi_accepted_rate_sensor_, sample, &Sample::csi_accepted_pps);
|
||||
publish_diagnostic(this->csi_occupancy_sensor_, sample, &Sample::csi_occupancy_ratio, 100.0f);
|
||||
}
|
||||
|
||||
void DiagnosticsUpdater::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "ESPectre diagnostics:");
|
||||
LOG_UPDATE_INTERVAL(this);
|
||||
LOG_SENSOR(" ", "Generator rate", this->generator_rate_sensor_);
|
||||
LOG_SENSOR(" ", "Traffic TX rate", this->traffic_tx_rate_sensor_);
|
||||
LOG_SENSOR(" ", "Traffic RX rate", this->traffic_rx_rate_sensor_);
|
||||
LOG_SENSOR(" ", "CSI accepted rate", this->csi_accepted_rate_sensor_);
|
||||
LOG_SENSOR(" ", "CSI occupancy", this->csi_occupancy_sensor_);
|
||||
}
|
||||
|
||||
} // namespace esphome::espectre
|
||||
|
||||
#endif // USE_ESPECTRE
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESPECTRE
|
||||
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "../espectre.h"
|
||||
|
||||
namespace esphome::espectre {
|
||||
|
||||
/// Publishes all diagnostic sensors together from the same runtime sample.
|
||||
class DiagnosticsUpdater final : public PollingComponent {
|
||||
public:
|
||||
explicit DiagnosticsUpdater(ESPectreComponent *parent) : parent_(parent) {}
|
||||
void update() override;
|
||||
void dump_config() override;
|
||||
|
||||
SUB_SENSOR(generator_rate)
|
||||
SUB_SENSOR(traffic_tx_rate)
|
||||
SUB_SENSOR(traffic_rx_rate)
|
||||
SUB_SENSOR(csi_accepted_rate)
|
||||
SUB_SENSOR(csi_occupancy)
|
||||
|
||||
protected:
|
||||
ESPectreComponent *parent_;
|
||||
};
|
||||
|
||||
} // namespace esphome::espectre
|
||||
|
||||
#endif // USE_ESPECTRE
|
||||
@@ -133,20 +133,12 @@ def ota_esphome_final_validate(config: ConfigType) -> None:
|
||||
_resolve_encryption_key(encryption_conf, api_conf)
|
||||
elif CONF_PASSWORD in ota_conf and static_encryption_key(api_conf) is not None:
|
||||
_LOGGER.warning(
|
||||
"'%s' %s wastes significant flash and RAM (about 3.5 KB and 60 "
|
||||
"bytes plus the password on the heap): the device already offers "
|
||||
"encryption with the '%s' %s %s, which authenticates any uploader "
|
||||
"that takes it, and a password only matters for uploaders without "
|
||||
"encryption support; remove '%s' and add '%s' under '%s' so "
|
||||
"uploads use the key and encryption is required",
|
||||
"'%s' %s wastes significant flash and RAM; "
|
||||
"using '%s' instead is recommended - "
|
||||
"see https://esphome.io/components/ota/esphome/#configuration-variables",
|
||||
CONF_OTA,
|
||||
CONF_PASSWORD,
|
||||
CONF_API,
|
||||
CONF_ENCRYPTION,
|
||||
CONF_KEY,
|
||||
CONF_PASSWORD,
|
||||
CONF_ENCRYPTION,
|
||||
CONF_OTA,
|
||||
)
|
||||
elif (
|
||||
CONF_PASSWORD in ota_conf
|
||||
@@ -315,10 +307,19 @@ FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate
|
||||
|
||||
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_defines(
|
||||
{"ota_esphome_noise.cpp": "USE_OTA_ENCRYPTION"}
|
||||
{
|
||||
"ota_esphome_noise.cpp": "USE_OTA_ENCRYPTION",
|
||||
"ota_esphome_inflate_session.cpp": "USE_OTA_DEFLATE",
|
||||
"ota_esphome_inflate.c": "USE_OTA_DEFLATE",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def enable_deflate() -> None:
|
||||
"""Compile the on-the-fly inflater for compressed uploads."""
|
||||
cg.add_define("USE_OTA_DEFLATE")
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.OTA_UPDATES)
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
@@ -340,6 +341,10 @@ async def to_code(config: ConfigType) -> None:
|
||||
if config.get(CONF_ALLOW_PARTITION_ACCESS):
|
||||
cg.add_define("USE_OTA_PARTITIONS")
|
||||
|
||||
# ESP8266 and RP2040 inflate gzip at reboot; the rest inflate on the fly
|
||||
if not (CORE.is_esp8266 or CORE.is_rp2):
|
||||
enable_deflate()
|
||||
|
||||
# One key per device: an api encryption block supplies it (static or
|
||||
# runtime) and offers; the ota block only adds the requirement
|
||||
api_conf = CORE.config.get(CONF_API) or {}
|
||||
@@ -347,7 +352,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
# Build time key: the ota keeps its own pointer so safe mode, which
|
||||
# has no api server, still has it
|
||||
cg.add_define("USE_OTA_ENCRYPTION")
|
||||
cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key)))
|
||||
cg.add(var.set_noise_psk(new_psk_progmem(key)))
|
||||
elif CONF_ENCRYPTION in api_conf:
|
||||
# Runtime key: found in the api server, or in preferences in safe mode
|
||||
cg.add_define("USE_OTA_ENCRYPTION")
|
||||
|
||||
@@ -22,8 +22,10 @@
|
||||
#include "esphome/core/lwip_fast_select.h"
|
||||
#endif
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <sys/time.h>
|
||||
|
||||
namespace esphome {
|
||||
@@ -47,6 +49,8 @@ static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds
|
||||
// practice for a lost chunk ack (1.5 + 3 + 6 + 12 + 24 + 48 s); the CLI waits
|
||||
// longer (espota2.DATA_PHASE_TIMEOUT) so the device is free before it retries
|
||||
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 105000;
|
||||
static constexpr uint32_t OTA_PROGRESS_INTERVAL_MS = 1000;
|
||||
static constexpr size_t OTA_SIZE_FIELD_BYTES = 4; // sizes on the wire are 4 bytes MSB first
|
||||
|
||||
// Single-instance pointer — multi-port configs are rejected in final_validate.
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
@@ -194,12 +198,23 @@ static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01;
|
||||
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02;
|
||||
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04;
|
||||
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_NOISE = 0x08;
|
||||
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_DEFLATE = 0x10;
|
||||
// Noise needs the extended protocol: the prologue binds the 2-byte feature ack
|
||||
static constexpr uint8_t CLIENT_NOISE_FEATURES =
|
||||
CLIENT_FEATURE_SUPPORTS_NOISE | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL;
|
||||
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01;
|
||||
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02;
|
||||
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_NOISE = 0x04;
|
||||
// Raw deflate, window <= OTA_INFLATE_WINDOW_SIZE. Binding once offered: the
|
||||
// client must then send the image size frame and a deflate stream.
|
||||
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_DEFLATE = 0x08;
|
||||
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
inline bool ESPHomeOTAComponent::noise_offered_() const {
|
||||
return (this->handshake_buf_[1] & SERVER_FEATURE_SUPPORTS_NOISE) != 0 &&
|
||||
(this->ota_features_ & CLIENT_NOISE_FEATURES) == CLIENT_NOISE_FEATURES;
|
||||
}
|
||||
#endif
|
||||
|
||||
inline bool ESPHomeOTAComponent::extended_proto_() const {
|
||||
#ifdef USE_OTA_ENCRYPTION_REQUIRED
|
||||
@@ -305,7 +320,7 @@ void ESPHomeOTAComponent::handle_handshake_() {
|
||||
this->transition_ota_state_(OTAState::FEATURE_ACK);
|
||||
|
||||
const bool supports_compression =
|
||||
(this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression();
|
||||
(this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && ota::OTABackend::supports_compression();
|
||||
|
||||
// Compose the feature-ack response. When the client negotiates the extended protocol we emit
|
||||
// a 2-byte response (marker + server feature flags); otherwise we emit the single-byte
|
||||
@@ -325,6 +340,28 @@ void ESPHomeOTAComponent::handle_handshake_() {
|
||||
#elif defined(USE_OTA_ENCRYPTION)
|
||||
// A yaml key always exists: validation rejects the all-zeros key
|
||||
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_NOISE;
|
||||
#endif
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
// Reserve the noise session before the optional inflate buffer, so the
|
||||
// required allocation is not starved by the compression window
|
||||
if (this->noise_offered_()) {
|
||||
this->noise_reserve_session_();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_OTA_DEFLATE
|
||||
// Offered only once the session memory is in hand; else uncompressed
|
||||
if ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_DEFLATE) != 0) {
|
||||
// Value initialized: a corrupt stream that back references the
|
||||
// window before it is filled then copies zeros, never stale memory.
|
||||
// Default placement, PSRAM first where present: the session lives for one
|
||||
// upload and keeps 4.9 KB of internal heap free while it runs
|
||||
this->inflate_ = RAMAllocator<InflateSession>().make_unique();
|
||||
if (this->inflate_ != nullptr) {
|
||||
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_DEFLATE;
|
||||
} else {
|
||||
ESP_LOGW(TAG, "No memory to inflate");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
this->handshake_buf_[0] =
|
||||
@@ -343,8 +380,7 @@ void ESPHomeOTAComponent::handle_handshake_() {
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
// Latch the offer actually sent: a key activating between the two
|
||||
// states must not start a session the client never expects
|
||||
if ((this->handshake_buf_[1] & SERVER_FEATURE_SUPPORTS_NOISE) != 0 &&
|
||||
(this->ota_features_ & CLIENT_NOISE_FEATURES) == CLIENT_NOISE_FEATURES) {
|
||||
if (this->noise_offered_()) {
|
||||
// handshake_buf_ still holds the feature ack composed above; a
|
||||
// would-block re-entry lands here without rebuilding it
|
||||
if (!this->noise_start_session_(this->handshake_buf_[1])) {
|
||||
@@ -442,16 +478,11 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
// Backend calls overwrite this with OK; reset to UNKNOWN before any
|
||||
// goto error that follows a successful begin()/write()
|
||||
ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
size_t total = 0;
|
||||
uint32_t last_progress = 0;
|
||||
uint32_t last_data_ms = 0;
|
||||
DataTransfer xfer;
|
||||
uint8_t buf[OTA_BUFFER_SIZE];
|
||||
char *sbuf = reinterpret_cast<char *>(buf);
|
||||
size_t ota_size;
|
||||
size_t image_size;
|
||||
ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP;
|
||||
#if USE_OTA_VERSION == 2
|
||||
size_t size_acknowledged = 0;
|
||||
#endif
|
||||
|
||||
// Set socket timeouts and blocking mode (see strategy table above)
|
||||
struct timeval tv;
|
||||
@@ -480,16 +511,20 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
ESP_LOGV(TAG, "OTA type is 0x%02x", ota_type);
|
||||
|
||||
// Read size, 4 bytes MSB first
|
||||
if (!this->data_readall_(buf, 4)) {
|
||||
if (!this->read_size_(buf, xfer.ota_size, LOG_STR("size"))) {
|
||||
// The first request byte is the type on the extended protocol; a close after it was a cut-off request
|
||||
if (!this->extended_proto_() && this->client_left_before_start_())
|
||||
return;
|
||||
this->log_read_error_(LOG_STR("size"));
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
ota_size = (static_cast<size_t>(buf[0]) << 24) | (static_cast<size_t>(buf[1]) << 16) |
|
||||
(static_cast<size_t>(buf[2]) << 8) | buf[3];
|
||||
ESP_LOGV(TAG, "Size is %zu bytes", ota_size);
|
||||
image_size = xfer.ota_size;
|
||||
#ifdef USE_OTA_DEFLATE
|
||||
if (this->inflate_ != nullptr && !this->read_size_(buf, image_size, LOG_STR("image size"))) {
|
||||
this->log_read_error_(LOG_STR("image size"));
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifndef USE_OTA_PARTITIONS
|
||||
if (ota_type != ota::OTA_TYPE_UPDATE_APP) {
|
||||
@@ -509,7 +544,7 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
#endif
|
||||
|
||||
// begin() returns quickly; flash sectors are erased incrementally during write().
|
||||
error_code = this->backend_->begin(ota_size, ota_type);
|
||||
error_code = this->backend_->begin(image_size, ota_type);
|
||||
if (error_code != ota::OTA_RESPONSE_OK)
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
|
||||
@@ -529,77 +564,25 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
// Acknowledge MD5 OK - 1 byte
|
||||
this->data_write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK);
|
||||
|
||||
// Track when we last received data so a silently-vanished peer (no FIN/RST
|
||||
// delivered, e.g. uploader killed mid-transfer or NAT/router dropped state)
|
||||
// can't wedge the device indefinitely. Without this, the loop only exits
|
||||
// on actual data, EOF, or a non-EWOULDBLOCK error from read(), and lwIP
|
||||
// TCP keepalive isn't enabled here.
|
||||
last_data_ms = millis();
|
||||
while (total < ota_size) {
|
||||
if (millis() - last_data_ms > OTA_SOCKET_TIMEOUT_DATA) {
|
||||
ESP_LOGW(TAG, "No data received for %u ms", (unsigned) OTA_SOCKET_TIMEOUT_DATA);
|
||||
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
xfer.last_data_ms = millis();
|
||||
#ifdef USE_OTA_DEFLATE
|
||||
if (this->inflate_ != nullptr) {
|
||||
error_code = this->inflate_data_(buf, image_size, xfer);
|
||||
if (error_code != ota::OTA_RESPONSE_OK)
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
size_t remaining = ota_size - total;
|
||||
size_t requested = remaining < OTA_BUFFER_SIZE ? remaining : OTA_BUFFER_SIZE;
|
||||
ssize_t read;
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
if (this->noise_ != nullptr) {
|
||||
// One frame per call; noise_read_data_ waits internally (readall_), so
|
||||
// there is no would-block retry here and failures are already logged.
|
||||
read = this->noise_read_data_(buf, requested);
|
||||
if (read <= 0) {
|
||||
if (this->remote_closed_)
|
||||
this->log_remote_closed_(LOG_STR("data"));
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
while (xfer.total < xfer.ota_size) {
|
||||
ssize_t read = this->receive_data_(buf, xfer);
|
||||
if (read < 0) {
|
||||
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
read = this->client_->read(buf, requested);
|
||||
if (read == -1) {
|
||||
const int err = errno;
|
||||
if (this->would_block_(err)) {
|
||||
// read() already waited up to SO_RCVTIMEO for data, just feed WDT
|
||||
App.feed_wdt();
|
||||
continue;
|
||||
}
|
||||
ESP_LOGW(TAG, "Read err %d", err);
|
||||
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
error_code = this->write_flash_(buf, read);
|
||||
if (error_code != ota::OTA_RESPONSE_OK)
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
} else if (read == 0) {
|
||||
ESP_LOGW(TAG, "Remote closed");
|
||||
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
}
|
||||
|
||||
last_data_ms = millis();
|
||||
error_code = this->backend_->write(buf, read);
|
||||
if (error_code != ota::OTA_RESPONSE_OK) {
|
||||
ESP_LOGW(TAG, "Flash write err %d", error_code);
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
total += read;
|
||||
#if USE_OTA_VERSION == 2
|
||||
while (size_acknowledged + OTA_BLOCK_SIZE <= total || (total == ota_size && size_acknowledged < ota_size)) {
|
||||
this->data_write_byte_(ota::OTA_RESPONSE_CHUNK_OK);
|
||||
size_acknowledged += OTA_BLOCK_SIZE;
|
||||
}
|
||||
#endif
|
||||
|
||||
uint32_t now = millis();
|
||||
if (now - last_progress > 1000) {
|
||||
last_progress = now;
|
||||
float percentage = (total * 100.0f) / ota_size;
|
||||
ESP_LOGD(TAG, "Progress: %0.1f%%", percentage);
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
this->notify_state_(ota::OTA_IN_PROGRESS, percentage, 0);
|
||||
#endif
|
||||
// feed watchdog and give other tasks a chance to run
|
||||
this->yield_and_feed_watchdog_();
|
||||
this->ack_written_(xfer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -815,6 +798,90 @@ bool ESPHomeOTAComponent::try_write_(size_t to_write, const LogString *desc) {
|
||||
return this->handshake_buf_pos_ >= to_write;
|
||||
}
|
||||
|
||||
bool ESPHomeOTAComponent::read_size_(uint8_t *buf, size_t &size, const LogString *desc) {
|
||||
if (!this->data_readall_(buf, OTA_SIZE_FIELD_BYTES))
|
||||
return false;
|
||||
size = encode_uint32(buf[0], buf[1], buf[2], buf[3]);
|
||||
ESP_LOGV(TAG, "%s is %zu bytes", LOG_STR_ARG(desc), size);
|
||||
return true;
|
||||
}
|
||||
|
||||
ota::OTAResponseTypes ESPHomeOTAComponent::write_flash_(uint8_t *data, size_t len) {
|
||||
ota::OTAResponseTypes result = this->backend_->write(data, len);
|
||||
if (result != ota::OTA_RESPONSE_OK) {
|
||||
ESP_LOGW(TAG, "Flash write err %d", result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
ssize_t ESPHomeOTAComponent::receive_data_(uint8_t *buf, DataTransfer &xfer) {
|
||||
const size_t remaining = xfer.ota_size - xfer.total;
|
||||
const size_t requested = std::min(remaining, OTA_BUFFER_SIZE);
|
||||
ssize_t read;
|
||||
for (;;) {
|
||||
// A silently-vanished peer (no FIN/RST delivered, e.g. uploader killed
|
||||
// mid-transfer or NAT/router dropped state) must not wedge the device:
|
||||
// read() only fails on EOF or a real error, and lwIP TCP keepalive isn't
|
||||
// enabled here.
|
||||
if (millis() - xfer.last_data_ms > OTA_SOCKET_TIMEOUT_DATA) {
|
||||
ESP_LOGW(TAG, "No data received for %u ms", (unsigned) OTA_SOCKET_TIMEOUT_DATA);
|
||||
return -1;
|
||||
}
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
if (this->noise_ != nullptr) {
|
||||
// One frame per call; noise_read_data_ waits internally (readall_), so
|
||||
// there is no would-block retry here and failures are already logged.
|
||||
read = this->noise_read_data_(buf, requested);
|
||||
if (read <= 0) {
|
||||
if (this->remote_closed_)
|
||||
this->log_remote_closed_(LOG_STR("data"));
|
||||
return -1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
read = this->client_->read(buf, requested);
|
||||
if (read > 0)
|
||||
break;
|
||||
if (read == 0) {
|
||||
this->log_remote_closed_(LOG_STR("data"));
|
||||
return -1;
|
||||
}
|
||||
if (!this->would_block_(errno)) {
|
||||
this->log_socket_error_(LOG_STR("data"));
|
||||
return -1;
|
||||
}
|
||||
// read() already waited up to SO_RCVTIMEO for data, just feed WDT
|
||||
App.feed_wdt();
|
||||
}
|
||||
|
||||
const uint32_t now = millis();
|
||||
xfer.last_data_ms = now;
|
||||
xfer.total += read;
|
||||
this->ack_received_(xfer);
|
||||
if (now - xfer.last_progress > OTA_PROGRESS_INTERVAL_MS) {
|
||||
xfer.last_progress = now;
|
||||
float percentage = (xfer.total * 100.0f) / xfer.ota_size;
|
||||
ESP_LOGD(TAG, "Progress: %0.1f%%", percentage);
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
this->notify_state_(ota::OTA_IN_PROGRESS, percentage, 0);
|
||||
#endif
|
||||
// feed watchdog and give other tasks a chance to run
|
||||
this->yield_and_feed_watchdog_();
|
||||
}
|
||||
return read;
|
||||
}
|
||||
|
||||
void ESPHomeOTAComponent::send_chunk_acks_(DataTransfer &xfer) {
|
||||
#if USE_OTA_VERSION == 2
|
||||
while (xfer.acknowledged + OTA_BLOCK_SIZE <= xfer.total ||
|
||||
(xfer.total == xfer.ota_size && xfer.acknowledged < xfer.ota_size)) {
|
||||
this->data_write_byte_(ota::OTA_RESPONSE_CHUNK_OK);
|
||||
xfer.acknowledged += OTA_BLOCK_SIZE;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void ESPHomeOTAComponent::cleanup_connection_() {
|
||||
this->client_->close();
|
||||
this->client_ = nullptr;
|
||||
@@ -829,6 +896,9 @@ void ESPHomeOTAComponent::cleanup_connection_() {
|
||||
#endif
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
this->noise_ = nullptr;
|
||||
#endif
|
||||
#ifdef USE_OTA_DEFLATE
|
||||
this->inflate_ = nullptr;
|
||||
#endif
|
||||
// Intentionally no disable_loop() — letting loop() run one more iteration catches
|
||||
// any connection that queued on the listener mid-session (otherwise the wake flag,
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
#include "esphome/components/noise/noise_handshake.h"
|
||||
#endif
|
||||
#ifdef USE_OTA_DEFLATE
|
||||
#include "ota_esphome_inflate.h"
|
||||
#endif
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/preferences.h"
|
||||
@@ -89,6 +92,9 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
// The api server's live context when it exists, otherwise our own (a build
|
||||
// time key, or the saved key loaded in safe mode)
|
||||
const noise::NoiseContext &noise_context_() const;
|
||||
// True once the feature ack offers noise and the client asked for it
|
||||
bool noise_offered_() const;
|
||||
void noise_reserve_session_();
|
||||
bool noise_start_session_(uint8_t server_feature_flags);
|
||||
bool handle_noise_handshake_();
|
||||
bool noise_try_read_frame_();
|
||||
@@ -120,6 +126,38 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
return this->readall_(buf, len);
|
||||
}
|
||||
|
||||
// Upload accounting shared by the data loop and the inflate read callback
|
||||
struct DataTransfer {
|
||||
size_t ota_size{0}; // bytes the client sends
|
||||
size_t total{0}; // bytes received so far
|
||||
#if USE_OTA_VERSION == 2
|
||||
size_t acknowledged{0};
|
||||
#endif
|
||||
uint32_t last_data_ms{0};
|
||||
uint32_t last_progress{0};
|
||||
};
|
||||
// Up to OTA_BUFFER_SIZE bytes into buf; returns bytes read, -1 on failure (logged)
|
||||
ssize_t receive_data_(uint8_t *buf, DataTransfer &xfer);
|
||||
// Raw lwIP cannot service the radio during a sector write, so the ack waits
|
||||
// for the write there; a socket task lets the next block arrive meanwhile
|
||||
#ifdef USE_SOCKET_IMPL_LWIP_TCP
|
||||
static constexpr bool ACK_AFTER_WRITE = true;
|
||||
#else
|
||||
static constexpr bool ACK_AFTER_WRITE = false;
|
||||
#endif
|
||||
void send_chunk_acks_(DataTransfer &xfer);
|
||||
inline void ack_received_(DataTransfer &xfer) {
|
||||
if (!ACK_AFTER_WRITE)
|
||||
this->send_chunk_acks_(xfer);
|
||||
}
|
||||
inline void ack_written_(DataTransfer &xfer) {
|
||||
if (ACK_AFTER_WRITE)
|
||||
this->send_chunk_acks_(xfer);
|
||||
}
|
||||
inline bool read_size_(uint8_t *buf, size_t &size, const LogString *desc);
|
||||
// Writes to the backend and logs a failure
|
||||
ota::OTAResponseTypes write_flash_(uint8_t *data, size_t len);
|
||||
|
||||
bool try_read_(size_t to_read, const LogString *desc);
|
||||
bool try_write_(size_t to_write, const LogString *desc);
|
||||
|
||||
@@ -175,6 +213,34 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
static_assert(OTA_BUFFER_SIZE >= NOISE_CLIENT_MAX_PLAINTEXT + noise::MAC_SIZE,
|
||||
"OTA_BUFFER_SIZE must fit a full encrypted data frame");
|
||||
#endif
|
||||
#ifdef USE_OTA_DEFLATE
|
||||
// At least 1 << espota2.DEFLATE_WINDOW_BITS; also the inflate output buffer
|
||||
static constexpr size_t OTA_INFLATE_WINDOW_SIZE = 4096;
|
||||
// Heap-allocated only while a deflate upload is negotiated; the decoder
|
||||
// state is the base so the read callback can recover the session
|
||||
struct InflateSession : OtaInflateState {
|
||||
// The session outlives the upload it serves, but these three are borrowed
|
||||
// from inflate_data_'s caller and dangle once that call returns; only that
|
||||
// call, and the flush and read callback it drives, may read them
|
||||
ESPHomeOTAComponent *self;
|
||||
DataTransfer *xfer;
|
||||
uint8_t *in; // caller's buffer for the compressed input
|
||||
size_t image_size;
|
||||
size_t written; // inflated bytes in flash
|
||||
size_t flushed; // bytes of the current window already in flash
|
||||
ota::OTAResponseTypes error; // first failure inside the read callback
|
||||
uint8_t window[OTA_INFLATE_WINDOW_SIZE];
|
||||
};
|
||||
#ifndef CLANG_TIDY // static analysis sets every define at once
|
||||
static_assert(!ota::OTABackend::supports_compression(),
|
||||
"USE_OTA_DEFLATE is for backends that cannot store a gzip image");
|
||||
#endif
|
||||
// Writes the decoded bytes not yet in flash without moving dest
|
||||
ota::OTAResponseTypes inflate_flush_(InflateSession &session);
|
||||
ota::OTAResponseTypes inflate_data_(uint8_t *in, size_t image_size, DataTransfer &xfer);
|
||||
RAMUniquePtr<InflateSession> inflate_;
|
||||
#endif
|
||||
|
||||
static constexpr uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45};
|
||||
// Derived from the feature byte; storing it would pad the trailing bytes
|
||||
bool extended_proto_() const;
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
/*
|
||||
* uzlib - tiny deflate/inflate library (deflate, gzip, zlib)
|
||||
*
|
||||
* Copyright (c) 2003 by Joergen Ibsen / Jibz
|
||||
* All Rights Reserved
|
||||
* http://www.ibsensoftware.com/
|
||||
*
|
||||
* Copyright (c) 2014-2018 by Paul Sokolovsky
|
||||
*
|
||||
* This software is provided 'as-is', without any express
|
||||
* or implied warranty. In no event will the authors be
|
||||
* held liable for any damages arising from the use of
|
||||
* this software.
|
||||
*
|
||||
* Permission is granted to anyone to use this software
|
||||
* for any purpose, including commercial applications,
|
||||
* and to alter it and redistribute it freely, subject to
|
||||
* the following restrictions:
|
||||
*
|
||||
* 1. The origin of this software must not be
|
||||
* misrepresented; you must not claim that you
|
||||
* wrote the original software. If you use this
|
||||
* software in a product, an acknowledgment in
|
||||
* the product documentation would be appreciated
|
||||
* but is not required.
|
||||
*
|
||||
* 2. Altered source versions must be plainly marked
|
||||
* as such, and must not be misrepresented as
|
||||
* being the original software.
|
||||
*
|
||||
* 3. This notice may not be removed or altered from
|
||||
* any source distribution.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Altered for ESPHome: this is the raw deflate decoder from uzlib's
|
||||
* tinflate.c (v2.9.5) with the gzip/zlib header parsers, checksums,
|
||||
* runtime table builder and in-memory (non ring window) output path
|
||||
* removed, and the public names prefixed with ota_inflate.
|
||||
*/
|
||||
|
||||
#include "ota_esphome_inflate.h"
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#define TINF_OK OTA_INFLATE_OK
|
||||
#define TINF_DONE OTA_INFLATE_DONE
|
||||
#define TINF_DATA_ERROR OTA_INFLATE_DATA_ERROR
|
||||
#define TINF_DICT_ERROR OTA_INFLATE_DICT_ERROR
|
||||
#define TINF_DATA struct OtaInflateState
|
||||
#define TINF_TREE struct OtaInflateTree
|
||||
#define TINF_ARRAY_SIZE(arr) (sizeof(arr) / sizeof(*(arr)))
|
||||
|
||||
/* every output byte also goes into the ring window */
|
||||
#define TINF_PUT(d, c) \
|
||||
{ \
|
||||
*d->dest++ = c; \
|
||||
d->dict_ring[d->dict_idx++] = c; \
|
||||
if (d->dict_idx == d->dict_size) \
|
||||
d->dict_idx = 0; \
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- *
|
||||
* -- constant tables (upstream builds them at runtime) -- *
|
||||
* --------------------------------------------------- */
|
||||
|
||||
static const unsigned char LENGTH_BITS[30] = {0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2,
|
||||
2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5};
|
||||
static const unsigned short LENGTH_BASE[30] = {3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27,
|
||||
31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258};
|
||||
|
||||
static const unsigned char DIST_BITS[30] = {0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6,
|
||||
6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13};
|
||||
static const unsigned short DIST_BASE[30] = {1, 2, 3, 4, 5, 7, 9, 13, 17, 25,
|
||||
33, 49, 65, 97, 129, 193, 257, 385, 513, 769,
|
||||
1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577};
|
||||
|
||||
/* special ordering of code length codes */
|
||||
static const unsigned char CLCIDX[] = {16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15};
|
||||
|
||||
/* ----------------------- *
|
||||
* -- utility functions -- *
|
||||
* ----------------------- */
|
||||
|
||||
/* given an array of code lengths, build a tree */
|
||||
static void tinf_build_tree(TINF_TREE *t, const unsigned char *lengths, unsigned int num) {
|
||||
unsigned short offs[16];
|
||||
unsigned int i, sum;
|
||||
|
||||
/* clear code length count table */
|
||||
for (i = 0; i < 16; ++i)
|
||||
t->table[i] = 0;
|
||||
|
||||
/* scan symbol lengths, and sum code length counts */
|
||||
for (i = 0; i < num; ++i)
|
||||
t->table[lengths[i]]++;
|
||||
|
||||
/* In the lengths array, 0 means unused code. So, t->table[0] now contains
|
||||
number of unused codes. But table's purpose is to contain # of codes of
|
||||
particular length, and there're 0 codes of length 0. */
|
||||
t->table[0] = 0;
|
||||
|
||||
/* compute offset table for distribution sort */
|
||||
for (sum = 0, i = 0; i < 16; ++i) {
|
||||
offs[i] = sum;
|
||||
sum += t->table[i];
|
||||
}
|
||||
|
||||
/* create code->symbol translation table (symbols sorted by code) */
|
||||
for (i = 0; i < num; ++i) {
|
||||
if (lengths[i])
|
||||
t->trans[offs[lengths[i]]++] = i;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------- *
|
||||
* -- decode functions -- *
|
||||
* ---------------------- */
|
||||
|
||||
static unsigned char uzlib_get_byte(TINF_DATA *d) {
|
||||
/* If end of source buffer is not reached, return next byte from source
|
||||
buffer. */
|
||||
if (d->source < d->source_limit) {
|
||||
return *d->source++;
|
||||
}
|
||||
|
||||
/* Otherwise if there's callback and we haven't seen EOF yet, try to
|
||||
read next byte using it. (Note: the callback can also update ->source
|
||||
and ->source_limit). */
|
||||
if (!d->eof) {
|
||||
int val = d->source_read_cb(d);
|
||||
if (val >= 0) {
|
||||
return (unsigned char) val;
|
||||
}
|
||||
}
|
||||
|
||||
/* Otherwise, we hit EOF (either from ->source_read_cb() or from exhaustion
|
||||
of the buffer), and it will be "sticky", i.e. further calls to this
|
||||
function will end up here too. */
|
||||
d->eof = true;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* get one bit from source stream */
|
||||
static int tinf_getbit(TINF_DATA *d) {
|
||||
unsigned int bit;
|
||||
|
||||
/* check if tag is empty */
|
||||
if (!d->bitcount--) {
|
||||
/* load next tag */
|
||||
d->tag = uzlib_get_byte(d);
|
||||
d->bitcount = 7;
|
||||
}
|
||||
|
||||
/* shift bit out of tag */
|
||||
bit = d->tag & 0x01;
|
||||
d->tag >>= 1;
|
||||
|
||||
return bit;
|
||||
}
|
||||
|
||||
/* read a num bit value from a stream and add base */
|
||||
static unsigned int tinf_read_bits(TINF_DATA *d, int num, int base) {
|
||||
unsigned int val = 0;
|
||||
|
||||
/* read num bits */
|
||||
if (num) {
|
||||
unsigned int limit = 1 << (num);
|
||||
unsigned int mask;
|
||||
|
||||
for (mask = 1; mask < limit; mask *= 2)
|
||||
if (tinf_getbit(d))
|
||||
val += mask;
|
||||
}
|
||||
|
||||
return val + base;
|
||||
}
|
||||
|
||||
/* given a data stream and a tree, decode a symbol */
|
||||
static int tinf_decode_symbol(TINF_DATA *d, TINF_TREE *t) {
|
||||
int sum = 0, cur = 0, len = 0;
|
||||
|
||||
/* get more bits while code value is above sum */
|
||||
do {
|
||||
cur = 2 * cur + tinf_getbit(d);
|
||||
|
||||
if (++len == TINF_ARRAY_SIZE(t->table)) {
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
sum += t->table[len];
|
||||
cur -= t->table[len];
|
||||
|
||||
} while (cur >= 0);
|
||||
|
||||
sum += cur;
|
||||
if (sum < 0 || sum >= t->size) {
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
return t->trans[sum];
|
||||
}
|
||||
|
||||
/* given a data stream, decode dynamic trees from it */
|
||||
static int tinf_decode_trees(TINF_DATA *d, TINF_TREE *lt, TINF_TREE *dt) {
|
||||
/* code lengths for 288 literal/len symbols and 32 dist symbols */
|
||||
unsigned char lengths[288 + 32];
|
||||
unsigned int hlit, hdist, hclen, hlimit;
|
||||
unsigned int i, num, length;
|
||||
|
||||
/* get 5 bits HLIT (257-286) */
|
||||
hlit = tinf_read_bits(d, 5, 257);
|
||||
|
||||
/* get 5 bits HDIST (1-32) */
|
||||
hdist = tinf_read_bits(d, 5, 1);
|
||||
|
||||
/* get 4 bits HCLEN (4-19) */
|
||||
hclen = tinf_read_bits(d, 4, 4);
|
||||
|
||||
for (i = 0; i < 19; ++i)
|
||||
lengths[i] = 0;
|
||||
|
||||
/* read code lengths for code length alphabet */
|
||||
for (i = 0; i < hclen; ++i) {
|
||||
/* get 3 bits code length (0-7) */
|
||||
unsigned int clen = tinf_read_bits(d, 3, 0);
|
||||
|
||||
lengths[CLCIDX[i]] = clen;
|
||||
}
|
||||
|
||||
/* build code length tree, temporarily use length tree */
|
||||
tinf_build_tree(lt, lengths, 19);
|
||||
|
||||
/* decode code lengths for the dynamic trees */
|
||||
hlimit = hlit + hdist;
|
||||
for (num = 0; num < hlimit;) {
|
||||
int sym = tinf_decode_symbol(d, lt);
|
||||
unsigned char fill_value = 0;
|
||||
int lbits, lbase = 3;
|
||||
|
||||
/* error decoding */
|
||||
if (sym < 0)
|
||||
return sym;
|
||||
|
||||
switch (sym) {
|
||||
case 16:
|
||||
/* copy previous code length 3-6 times (read 2 bits) */
|
||||
if (num == 0)
|
||||
return TINF_DATA_ERROR;
|
||||
fill_value = lengths[num - 1];
|
||||
lbits = 2;
|
||||
break;
|
||||
case 17:
|
||||
/* repeat code length 0 for 3-10 times (read 3 bits) */
|
||||
lbits = 3;
|
||||
break;
|
||||
case 18:
|
||||
/* repeat code length 0 for 11-138 times (read 7 bits) */
|
||||
lbits = 7;
|
||||
lbase = 11;
|
||||
break;
|
||||
default:
|
||||
/* values 0-15 represent the actual code lengths */
|
||||
lengths[num++] = sym;
|
||||
/* continue the for loop */
|
||||
continue;
|
||||
}
|
||||
|
||||
/* special code length 16-18 are handled here */
|
||||
length = tinf_read_bits(d, lbits, lbase);
|
||||
if (num + length > hlimit)
|
||||
return TINF_DATA_ERROR;
|
||||
for (; length; --length) {
|
||||
lengths[num++] = fill_value;
|
||||
}
|
||||
}
|
||||
|
||||
/* Check that there's "end of block" symbol */
|
||||
if (lengths[256] == 0) {
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
/* build dynamic trees */
|
||||
tinf_build_tree(lt, lengths, hlit);
|
||||
tinf_build_tree(dt, lengths + hlit, hdist);
|
||||
|
||||
return TINF_OK;
|
||||
}
|
||||
|
||||
/* build the fixed huffman trees (RFC 1951 3.2.6) through the generic tree
|
||||
builder; altered from upstream, which unrolls them by hand */
|
||||
static void tinf_build_fixed_trees(TINF_TREE *lt, TINF_TREE *dt) {
|
||||
unsigned char lengths[288];
|
||||
unsigned int i;
|
||||
|
||||
for (i = 0; i < 144; ++i)
|
||||
lengths[i] = 8;
|
||||
for (; i < 256; ++i)
|
||||
lengths[i] = 9;
|
||||
for (; i < 280; ++i)
|
||||
lengths[i] = 7;
|
||||
for (; i < 288; ++i)
|
||||
lengths[i] = 8;
|
||||
tinf_build_tree(lt, lengths, 288);
|
||||
|
||||
for (i = 0; i < 32; ++i)
|
||||
lengths[i] = 5;
|
||||
tinf_build_tree(dt, lengths, 32);
|
||||
}
|
||||
|
||||
/* ----------------------------- *
|
||||
* -- block inflate functions -- *
|
||||
* ----------------------------- */
|
||||
|
||||
/* given a stream and two trees, inflate next chunk of output (a byte or more) */
|
||||
static int tinf_inflate_block_data(TINF_DATA *d, TINF_TREE *lt, TINF_TREE *dt) {
|
||||
if (d->curlen == 0) {
|
||||
unsigned int offs;
|
||||
int dist;
|
||||
int sym = tinf_decode_symbol(d, lt);
|
||||
|
||||
if (d->eof) {
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
if (sym < 0) {
|
||||
return sym;
|
||||
}
|
||||
|
||||
/* literal byte */
|
||||
if (sym < 256) {
|
||||
TINF_PUT(d, sym);
|
||||
return TINF_OK;
|
||||
}
|
||||
|
||||
/* end of block */
|
||||
if (sym == 256) {
|
||||
return TINF_DONE;
|
||||
}
|
||||
|
||||
/* substring from sliding dictionary */
|
||||
sym -= 257;
|
||||
if (sym >= 29) {
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
/* possibly get more bits from length code */
|
||||
d->curlen = tinf_read_bits(d, LENGTH_BITS[sym], LENGTH_BASE[sym]);
|
||||
|
||||
dist = tinf_decode_symbol(d, dt);
|
||||
if (dist < 0 || dist >= 30) {
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
/* possibly get more bits from distance code */
|
||||
offs = tinf_read_bits(d, DIST_BITS[dist], DIST_BASE[dist]);
|
||||
|
||||
/* calculate and validate actual LZ offset to use */
|
||||
if (offs > d->dict_size) {
|
||||
return TINF_DICT_ERROR;
|
||||
}
|
||||
/* Note: we don't try to catch offset which points to not yet filled
|
||||
part of the dictionary here. Doing so would require keeping another
|
||||
variable to track "filled in" size of the dictionary. Appearance of
|
||||
such an offset cannot lead to accessing memory outside of the
|
||||
dictionary buffer, and clients which don't want to leak unrelated
|
||||
information, should explicitly initialize dictionary buffer passed
|
||||
to uzlib. */
|
||||
|
||||
d->lz_off = d->dict_idx - offs;
|
||||
if (d->lz_off < 0) {
|
||||
d->lz_off += d->dict_size;
|
||||
}
|
||||
}
|
||||
|
||||
/* copy next byte from dict substring */
|
||||
TINF_PUT(d, d->dict_ring[d->lz_off]);
|
||||
if ((unsigned) ++d->lz_off == d->dict_size) {
|
||||
d->lz_off = 0;
|
||||
}
|
||||
d->curlen--;
|
||||
return TINF_OK;
|
||||
}
|
||||
|
||||
/* inflate next byte from uncompressed block of data */
|
||||
static int tinf_inflate_uncompressed_block(TINF_DATA *d) {
|
||||
if (d->curlen == 0) {
|
||||
unsigned int length, invlength;
|
||||
|
||||
/* get length */
|
||||
length = uzlib_get_byte(d);
|
||||
length += 256 * uzlib_get_byte(d);
|
||||
/* get one's complement of length */
|
||||
invlength = uzlib_get_byte(d);
|
||||
invlength += 256 * uzlib_get_byte(d);
|
||||
/* check length */
|
||||
if (length != (~invlength & 0x0000ffff))
|
||||
return TINF_DATA_ERROR;
|
||||
|
||||
/* increment length to properly return TINF_DONE below, without
|
||||
producing data at the same time */
|
||||
d->curlen = length + 1;
|
||||
|
||||
/* make sure we start next block on a byte boundary */
|
||||
d->bitcount = 0;
|
||||
}
|
||||
|
||||
if (--d->curlen == 0) {
|
||||
return TINF_DONE;
|
||||
}
|
||||
|
||||
unsigned char c = uzlib_get_byte(d);
|
||||
TINF_PUT(d, c);
|
||||
return TINF_OK;
|
||||
}
|
||||
|
||||
/* ---------------------- *
|
||||
* -- public functions -- *
|
||||
* ---------------------- */
|
||||
|
||||
/* initialize decompression structure */
|
||||
void ota_inflate_init(TINF_DATA *d, unsigned char *dict, unsigned int dict_len) {
|
||||
d->source = NULL;
|
||||
d->source_limit = NULL;
|
||||
d->tag = 0;
|
||||
d->eof = 0;
|
||||
d->bitcount = 0;
|
||||
d->lz_off = 0;
|
||||
d->bfinal = 0;
|
||||
d->btype = -1;
|
||||
d->dict_size = dict_len;
|
||||
d->dict_ring = dict;
|
||||
d->dict_idx = 0;
|
||||
d->curlen = 0;
|
||||
d->ltree.trans = d->ltrans;
|
||||
d->ltree.size = TINF_ARRAY_SIZE(d->ltrans);
|
||||
d->dtree.trans = d->dtrans;
|
||||
d->dtree.size = TINF_ARRAY_SIZE(d->dtrans);
|
||||
}
|
||||
|
||||
/* inflate next output bytes from compressed stream */
|
||||
int ota_inflate(TINF_DATA *d) {
|
||||
do {
|
||||
int res;
|
||||
|
||||
/* start a new block */
|
||||
if (d->btype == -1) {
|
||||
int old_btype;
|
||||
next_blk:
|
||||
old_btype = d->btype;
|
||||
/* read final block flag */
|
||||
d->bfinal = tinf_getbit(d);
|
||||
/* read block type (2 bits) */
|
||||
d->btype = tinf_read_bits(d, 2, 0);
|
||||
|
||||
if (d->btype == 1 && old_btype != 1) {
|
||||
/* build fixed huffman trees */
|
||||
tinf_build_fixed_trees(&d->ltree, &d->dtree);
|
||||
} else if (d->btype == 2) {
|
||||
/* decode trees from stream */
|
||||
res = tinf_decode_trees(d, &d->ltree, &d->dtree);
|
||||
if (res != TINF_OK) {
|
||||
return res;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* process current block */
|
||||
switch (d->btype) {
|
||||
case 0:
|
||||
/* decompress uncompressed block */
|
||||
res = tinf_inflate_uncompressed_block(d);
|
||||
break;
|
||||
case 1:
|
||||
case 2:
|
||||
/* decompress block with fixed/dynamic huffman trees */
|
||||
/* trees were decoded previously, so it's the same routine for both */
|
||||
res = tinf_inflate_block_data(d, &d->ltree, &d->dtree);
|
||||
break;
|
||||
default:
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
if (res == TINF_DONE && !d->bfinal) {
|
||||
/* the block has ended (without producing more data), but we
|
||||
can't return without data, so start procesing next block */
|
||||
goto next_blk;
|
||||
}
|
||||
|
||||
if (res != TINF_OK) {
|
||||
return res;
|
||||
}
|
||||
|
||||
} while (d->dest < d->dest_limit);
|
||||
|
||||
return TINF_OK;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
#pragma once
|
||||
// Raw deflate decoder cut down from uzlib (https://github.com/pfalcon/uzlib,
|
||||
// zlib licence, see the .c file); output goes through a ring window.
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
enum OtaInflateResult {
|
||||
OTA_INFLATE_OK = 0, /* more data produced, call again */
|
||||
OTA_INFLATE_DONE = 1, /* end of compressed stream reached */
|
||||
OTA_INFLATE_DATA_ERROR = -3,
|
||||
OTA_INFLATE_DICT_ERROR = -5,
|
||||
};
|
||||
|
||||
struct OtaInflateTree {
|
||||
uint16_t table[16]; /* table of code length counts */
|
||||
uint16_t *trans; /* code -> symbol translation table, size entries */
|
||||
uint16_t size;
|
||||
};
|
||||
|
||||
struct OtaInflateState {
|
||||
/* Next byte in the input buffer and one past its end */
|
||||
const unsigned char *source;
|
||||
const unsigned char *source_limit;
|
||||
/* Called when source is exhausted; returns the next byte or -1 at EOF.
|
||||
It may refill source/source_limit for buffered operation. */
|
||||
int (*source_read_cb)(struct OtaInflateState *d);
|
||||
/* Output cursor and one past the end of the output buffer */
|
||||
unsigned char *dest;
|
||||
unsigned char *dest_limit;
|
||||
/* Ring window holding the last dict_size output bytes for back references */
|
||||
unsigned char *dict_ring;
|
||||
|
||||
unsigned int tag;
|
||||
unsigned int curlen;
|
||||
int lz_off;
|
||||
unsigned int dict_size;
|
||||
unsigned int dict_idx;
|
||||
|
||||
/* One word: btype is -1 between blocks, bitcount never exceeds 7 */
|
||||
int8_t btype;
|
||||
uint8_t bfinal;
|
||||
uint8_t bitcount;
|
||||
bool eof;
|
||||
|
||||
struct OtaInflateTree ltree; /* dynamic length/symbol tree */
|
||||
struct OtaInflateTree dtree; /* dynamic distance tree */
|
||||
uint16_t ltrans[288];
|
||||
uint16_t dtrans[32]; /* the distance alphabet has 30 symbols, so the tree is kept small */
|
||||
};
|
||||
|
||||
/* dict must cover the encoder's window (its largest back reference) */
|
||||
void ota_inflate_init(struct OtaInflateState *d, unsigned char *dict, unsigned int dict_len);
|
||||
/* Fills dest up to dest_limit (OK) or to the end of the stream (DONE). dest may
|
||||
alias dict only if dest_limit - dest == dict_len and dest is reset to dict
|
||||
exactly when a call returns OK, so the ring index and dest stay in lockstep */
|
||||
int ota_inflate(struct OtaInflateState *d);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,94 @@
|
||||
#include "ota_esphome.h"
|
||||
#ifdef USE_OTA
|
||||
#ifdef USE_OTA_DEFLATE
|
||||
#include "esphome/components/ota/ota_backend.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome {
|
||||
|
||||
static const char *const TAG = "esphome.ota";
|
||||
|
||||
// The window doubles as the output buffer; flushed bytes stay as back
|
||||
// reference history for the next windowful.
|
||||
ota::OTAResponseTypes ESPHomeOTAComponent::inflate_flush_(InflateSession &session) {
|
||||
const size_t produced = session.dest - session.window;
|
||||
const size_t pending = produced - session.flushed;
|
||||
if (pending != 0) {
|
||||
if (pending > session.image_size - session.written) {
|
||||
ESP_LOGW(TAG, "Inflate overrun");
|
||||
return ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
}
|
||||
ota::OTAResponseTypes result = this->write_flash_(session.window + session.flushed, pending);
|
||||
if (result != ota::OTA_RESPONSE_OK)
|
||||
return result;
|
||||
session.flushed = produced;
|
||||
session.written += pending;
|
||||
// A compressible region yields many windows per socket read
|
||||
App.feed_wdt();
|
||||
}
|
||||
// Even with nothing new written: a block boundary can fall inside a header
|
||||
this->ack_written_(*session.xfer);
|
||||
return ota::OTA_RESPONSE_OK;
|
||||
}
|
||||
|
||||
ota::OTAResponseTypes ESPHomeOTAComponent::inflate_data_(uint8_t *in, size_t image_size, DataTransfer &xfer) {
|
||||
InflateSession &session = *this->inflate_;
|
||||
session.self = this;
|
||||
session.xfer = &xfer;
|
||||
session.in = in;
|
||||
session.image_size = image_size;
|
||||
session.written = 0;
|
||||
session.error = ota::OTA_RESPONSE_OK;
|
||||
ota_inflate_init(&session, session.window, OTA_INFLATE_WINDOW_SIZE);
|
||||
// Where the ack must follow the write, flush and ack before waiting for
|
||||
// input, or the client waits for an ack while the decoder waits for data
|
||||
session.source_read_cb = [](OtaInflateState *d) -> int {
|
||||
auto *s = static_cast<InflateSession *>(d);
|
||||
if (ACK_AFTER_WRITE) {
|
||||
s->error = s->self->inflate_flush_(*s);
|
||||
if (s->error != ota::OTA_RESPONSE_OK)
|
||||
return -1;
|
||||
}
|
||||
// More input than announced; reported by the size check below
|
||||
if (s->xfer->total >= s->xfer->ota_size)
|
||||
return -1;
|
||||
ssize_t read = s->self->receive_data_(s->in, *s->xfer);
|
||||
if (read <= 0) {
|
||||
// Already logged by receive_data_
|
||||
s->error = ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
return -1;
|
||||
}
|
||||
d->source = s->in + 1;
|
||||
d->source_limit = s->in + read;
|
||||
return s->in[0];
|
||||
};
|
||||
|
||||
int res;
|
||||
do {
|
||||
// The ring index wrapped to 0 exactly when the window filled
|
||||
session.dest = session.window;
|
||||
session.dest_limit = session.window + OTA_INFLATE_WINDOW_SIZE;
|
||||
session.flushed = 0;
|
||||
res = ota_inflate(&session);
|
||||
// A stored block keeps emitting zeros after a failed read, hence eof
|
||||
if (res < 0 || session.eof)
|
||||
break;
|
||||
session.error = this->inflate_flush_(session);
|
||||
} while (res != OTA_INFLATE_DONE && session.error == ota::OTA_RESPONSE_OK);
|
||||
|
||||
// Transport and flash failures are logged where they happen
|
||||
if (session.error != ota::OTA_RESPONSE_OK)
|
||||
return session.error;
|
||||
if (res != OTA_INFLATE_DONE || session.written != image_size || xfer.total != xfer.ota_size) {
|
||||
ESP_LOGW(TAG, "Inflate err %d, %zu of %zu B from %zu of %zu", res, session.written, image_size, xfer.total,
|
||||
xfer.ota_size);
|
||||
return ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
}
|
||||
ESP_LOGD(TAG, "Inflated %zu bytes from %zu", session.written, xfer.total);
|
||||
return ota::OTA_RESPONSE_OK;
|
||||
}
|
||||
|
||||
} // namespace esphome
|
||||
#endif // USE_OTA_DEFLATE
|
||||
#endif // USE_OTA
|
||||
@@ -32,7 +32,12 @@ ESPHomeOTAComponent::NoiseSession::~NoiseSession() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Allocate the session and start the responder handshake.
|
||||
void ESPHomeOTAComponent::noise_reserve_session_() {
|
||||
// Default placement, PSRAM first where present: the session lives for one upload
|
||||
this->noise_ = RAMAllocator<NoiseSession>().make_unique();
|
||||
}
|
||||
|
||||
/** Start the responder handshake, on the session reserved at offer time.
|
||||
*
|
||||
* The prologue binds the whole plaintext preamble, so any tampering with the
|
||||
* negotiation (a stripped feature flag, a changed version) breaks the first
|
||||
@@ -41,9 +46,7 @@ ESPHomeOTAComponent::NoiseSession::~NoiseSession() {
|
||||
*/
|
||||
bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) {
|
||||
// A provisioned key cleared between the offer and here is not guarded: the
|
||||
// session runs on the zero key load_psk fills in and fails the client's MAC.
|
||||
// Default placement, PSRAM first where present: the session only lives for one upload
|
||||
this->noise_ = RAMAllocator<NoiseSession>().make_unique();
|
||||
// session runs on the zero key load_psk fills in and fails the client's MAC
|
||||
static constexpr size_t PROLOGUE_ACK_LEN = 2; // OTA_RESPONSE_OK + version
|
||||
static constexpr size_t PROLOGUE_CLIENT_FEATURES_LEN = 1;
|
||||
static constexpr size_t PROLOGUE_FEATURE_ACK_LEN = 2; // OTA_RESPONSE_FEATURE_FLAGS + server flags
|
||||
|
||||
@@ -636,6 +636,19 @@ def phy_register(address: int, value: int, page: int) -> cg.StructInitializer:
|
||||
)
|
||||
|
||||
|
||||
def _add_phy_registers(var: cg.MockObj, config: ConfigType) -> None:
|
||||
if not (registers := config.get(CONF_PHY_REGISTERS)):
|
||||
return
|
||||
cg.add_define("ESPHOME_ETHERNET_PHY_REGISTER_COUNT", len(registers))
|
||||
for register_value in registers:
|
||||
reg = phy_register(
|
||||
register_value.get(CONF_ADDRESS),
|
||||
register_value.get(CONF_VALUE),
|
||||
register_value.get(CONF_PAGE_ID),
|
||||
)
|
||||
cg.add(var.add_phy_register(reg))
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.COMMUNICATION)
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
@@ -741,13 +754,7 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None:
|
||||
cg.add(var.set_mdio_pin(config[CONF_MDIO_PIN]))
|
||||
if CONF_POWER_PIN in config:
|
||||
cg.add(var.set_power_pin(config[CONF_POWER_PIN]))
|
||||
for register_value in config.get(CONF_PHY_REGISTERS, []):
|
||||
reg = phy_register(
|
||||
register_value.get(CONF_ADDRESS),
|
||||
register_value.get(CONF_VALUE),
|
||||
register_value.get(CONF_PAGE_ID),
|
||||
)
|
||||
cg.add(var.add_phy_register(reg))
|
||||
_add_phy_registers(var, config)
|
||||
else:
|
||||
cg.add(var.set_phy_addr(config[CONF_PHY_ADDR]))
|
||||
cg.add(var.set_mdc_pin(config[CONF_MDC_PIN]))
|
||||
@@ -756,13 +763,7 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None:
|
||||
cg.add(var.set_clk_pin(config[CONF_CLK][CONF_PIN]))
|
||||
if CONF_POWER_PIN in config:
|
||||
cg.add(var.set_power_pin(config[CONF_POWER_PIN]))
|
||||
for register_value in config.get(CONF_PHY_REGISTERS, []):
|
||||
reg = phy_register(
|
||||
register_value.get(CONF_ADDRESS),
|
||||
register_value.get(CONF_VALUE),
|
||||
register_value.get(CONF_PAGE_ID),
|
||||
)
|
||||
cg.add(var.add_phy_register(reg))
|
||||
_add_phy_registers(var, config)
|
||||
|
||||
# Register Ethernet with the esp32 sdkconfig reconciler. It disables the
|
||||
# WiFi stack and WiFi/BT coexistence only when Ethernet runs without WiFi,
|
||||
|
||||
@@ -193,7 +193,9 @@ class EthernetComponent final : public Component {
|
||||
void set_mdio_pin(uint8_t mdio_pin) { this->mdio_pin_ = mdio_pin; }
|
||||
void set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; }
|
||||
void set_clk_mode(emac_rmii_clock_mode_t clk_mode) { this->clk_mode_ = clk_mode; }
|
||||
void add_phy_register(PHYRegister register_value);
|
||||
#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT
|
||||
void add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); }
|
||||
#endif
|
||||
#endif // USE_ETHERNET_SPI
|
||||
#endif // USE_ESP32
|
||||
|
||||
@@ -220,6 +222,9 @@ class EthernetComponent final : public Component {
|
||||
protected:
|
||||
void start_connect_();
|
||||
void finish_connect_();
|
||||
#if LWIP_IPV6
|
||||
esp_err_t ensure_ip6_linklocal_();
|
||||
#endif
|
||||
void dump_connect_params_();
|
||||
|
||||
#ifdef USE_ESP32
|
||||
@@ -252,8 +257,10 @@ class EthernetComponent final : public Component {
|
||||
/// reset) and set the RGMII Tx/Rx clock delays needed for reliable data sampling.
|
||||
void yt8531_phy_init_();
|
||||
#endif
|
||||
#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT
|
||||
/// @brief Set arbitratry PHY registers from config.
|
||||
void write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data);
|
||||
#endif
|
||||
|
||||
#ifdef USE_ETHERNET_SPI
|
||||
uint8_t clk_pin_;
|
||||
@@ -277,7 +284,9 @@ class EthernetComponent final : public Component {
|
||||
// Group all 32-bit members first
|
||||
int power_pin_{-1};
|
||||
emac_rmii_clock_mode_t clk_mode_{EMAC_CLK_EXT_IN};
|
||||
std::vector<PHYRegister> phy_registers_{};
|
||||
#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT
|
||||
StaticVector<PHYRegister, ESPHOME_ETHERNET_PHY_REGISTER_COUNT> phy_registers_{};
|
||||
#endif
|
||||
|
||||
// Group all 8-bit members together
|
||||
uint8_t clk_pin_{0};
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
#include <lwip/dns.h>
|
||||
#include <cinttypes>
|
||||
#include "esp_event.h"
|
||||
#if USE_NETWORK_IPV6
|
||||
#include <esp_netif_net_stack.h>
|
||||
#include <lwip/netif.h>
|
||||
#endif
|
||||
#ifdef USE_PSRAM
|
||||
#include <esp_psram.h>
|
||||
#endif
|
||||
@@ -458,9 +462,11 @@ void EthernetComponent::ethernet_lazy_init_() {
|
||||
}
|
||||
#endif // USE_ETHERNET_KSZ8081
|
||||
|
||||
#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT
|
||||
for (const auto &phy_register : this->phy_registers_) {
|
||||
this->write_phy_register_(mac, phy_register);
|
||||
}
|
||||
#endif
|
||||
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
|
||||
#ifdef USE_ETHERNET_GENERIC
|
||||
@@ -509,6 +515,18 @@ void EthernetComponent::ethernet_lazy_init_() {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if USE_NETWORK_IPV6 && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
|
||||
// Since IDF 5.5 the internal EMAC drops multicast groups that were never added (before,
|
||||
// it passed all multicast), and lwIP never adds all-nodes, so router advertisements
|
||||
// were lost and SLAAC never ran.
|
||||
{
|
||||
uint8_t all_nodes[6] = {0x33, 0x33, 0x00, 0x00, 0x00, 0x01};
|
||||
if (esp_err_t filter_err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_ADD_MAC_FILTER, all_nodes);
|
||||
filter_err != ESP_OK) {
|
||||
ESP_LOGD(TAG, "IPv6 all-nodes multicast filter not added: %s", esp_err_to_name(filter_err));
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Register user defined event handers
|
||||
err = esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, &EthernetComponent::eth_event_handler, nullptr);
|
||||
@@ -755,6 +773,13 @@ void EthernetComponent::eth_event_handler(void *arg, esp_event_base_t event_base
|
||||
global_eth_component->notify_ip_state_listeners_();
|
||||
}
|
||||
#endif
|
||||
#if USE_NETWORK_IPV6
|
||||
// Start SLAAC on link-up, not after the DHCPv4 lease. This also restores the
|
||||
// link-local after a link flap, which clears the IPv6 addresses.
|
||||
if (esp_err_t ll_err = esp_netif_create_ip6_linklocal(global_eth_component->eth_netif_); ll_err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "esp_netif_create_ip6_linklocal failed on link-up: %s", esp_err_to_name(ll_err));
|
||||
}
|
||||
#endif /* USE_NETWORK_IPV6 */
|
||||
break;
|
||||
case ETHERNET_EVENT_DISCONNECTED:
|
||||
event_name = "ETH disconnected";
|
||||
@@ -791,7 +816,10 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_
|
||||
void *event_data) {
|
||||
ip_event_got_ip6_t *event = (ip_event_got_ip6_t *) event_data;
|
||||
ESP_LOGV(TAG, "[Ethernet event] ETH Got IPv6: " IPV6STR, IPV62STR(event->ip6_info.ip));
|
||||
global_eth_component->ipv6_count_ += 1;
|
||||
// Count the addresses on the interface, not the events: recreating the link-local
|
||||
// after a link flap fires another event for the same address.
|
||||
struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES];
|
||||
global_eth_component->ipv6_count_ = esp_netif_get_all_ip6(global_eth_component->eth_netif_, if_ip6s);
|
||||
#if (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0)
|
||||
global_eth_component->connected_ =
|
||||
global_eth_component->got_ipv4_address_ && (global_eth_component->ipv6_count_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT);
|
||||
@@ -806,6 +834,29 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_
|
||||
}
|
||||
#endif /* USE_NETWORK_IPV6 */
|
||||
|
||||
#if USE_NETWORK_IPV6
|
||||
// Create the link-local address unless the interface already has one, including one still in
|
||||
// duplicate address detection: recreating it would restart DAD. esp_netif_get_ip6_linklocal()
|
||||
// only reports a preferred address, so ask lwIP for the slot state instead.
|
||||
esp_err_t EthernetComponent::ensure_ip6_linklocal_() {
|
||||
if (auto *netif = static_cast<struct netif *>(esp_netif_get_netif_impl(this->eth_netif_)); netif != nullptr) {
|
||||
u8_t state;
|
||||
{
|
||||
LwIPLock lock;
|
||||
state = netif_ip6_addr_state(netif, 0);
|
||||
}
|
||||
if (ip6_addr_istentative(state) || ip6_addr_isvalid(state)) {
|
||||
return ESP_OK;
|
||||
}
|
||||
}
|
||||
esp_err_t err = esp_netif_create_ip6_linklocal(this->eth_netif_);
|
||||
if (err == ESP_OK) {
|
||||
ESP_LOGD(TAG, "IPv6 link-local address created");
|
||||
}
|
||||
return err;
|
||||
}
|
||||
#endif /* USE_NETWORK_IPV6 */
|
||||
|
||||
void EthernetComponent::finish_connect_() {
|
||||
#if USE_NETWORK_IPV6
|
||||
// Retry IPv6 link-local setup if it failed during initial connect
|
||||
@@ -816,10 +867,7 @@ void EthernetComponent::finish_connect_() {
|
||||
// - Cable unplugged/network interruption (#10705)
|
||||
// We can now retry since we're in CONNECTED state and the interface is definitely up.
|
||||
if (!this->ipv6_setup_done_) {
|
||||
esp_err_t err = esp_netif_create_ip6_linklocal(this->eth_netif_);
|
||||
if (err == ESP_OK) {
|
||||
ESP_LOGD(TAG, "IPv6 link-local address created (retry succeeded)");
|
||||
}
|
||||
this->ensure_ip6_linklocal_();
|
||||
// Always set the flag to prevent continuous retries
|
||||
// If IPv6 setup fails here with the interface up and stable, it's
|
||||
// likely a persistent issue (IPv6 disabled at router, hardware
|
||||
@@ -833,7 +881,9 @@ void EthernetComponent::finish_connect_() {
|
||||
void EthernetComponent::start_connect_() {
|
||||
global_eth_component->got_ipv4_address_ = false;
|
||||
#if USE_NETWORK_IPV6
|
||||
global_eth_component->ipv6_count_ = 0;
|
||||
// Recount rather than zero: addresses that survive a reconnect are not announced again.
|
||||
struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES];
|
||||
global_eth_component->ipv6_count_ = esp_netif_get_all_ip6(this->eth_netif_, if_ip6s);
|
||||
this->ipv6_setup_done_ = false;
|
||||
#endif /* USE_NETWORK_IPV6 */
|
||||
this->connect_begin_ = millis();
|
||||
@@ -912,7 +962,7 @@ void EthernetComponent::start_connect_() {
|
||||
// - At bootup when link isn't ready (#10281)
|
||||
// - After disconnection/cable unplugged (#10705)
|
||||
// We'll retry in finish_connect_() if it fails here.
|
||||
err = esp_netif_create_ip6_linklocal(this->eth_netif_);
|
||||
err = this->ensure_ip6_linklocal_();
|
||||
if (err != ESP_OK) {
|
||||
if (err == ESP_ERR_ESP_NETIF_INVALID_PARAMS) {
|
||||
// This is a programming error, not a transient failure
|
||||
@@ -995,10 +1045,6 @@ void EthernetComponent::dump_connect_params_() {
|
||||
#endif /* USE_NETWORK_IPV6 */
|
||||
}
|
||||
|
||||
#ifndef USE_ETHERNET_SPI
|
||||
void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); }
|
||||
#endif
|
||||
|
||||
void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) {
|
||||
if (!this->ethernet_initialized_) {
|
||||
// External callers (mdns, ethernet_info, etc.) may ask for the MAC before/regardless
|
||||
@@ -1096,6 +1142,7 @@ void EthernetComponent::ksz8081_set_clock_reference_(esp_eth_mac_t *mac) {
|
||||
}
|
||||
#endif // USE_ETHERNET_KSZ8081
|
||||
|
||||
#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT
|
||||
void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data) {
|
||||
esp_err_t err;
|
||||
|
||||
@@ -1120,6 +1167,7 @@ void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister regi
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif // ESPHOME_ETHERNET_PHY_REGISTER_COUNT
|
||||
|
||||
#ifdef USE_ETHERNET_YT8531
|
||||
void EthernetComponent::yt8531_phy_init_() {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
CODEOWNERS = ["@clydebarrow"]
|
||||
@@ -0,0 +1,116 @@
|
||||
#include "exponential_moving_average_sensor.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace esphome::exponential_moving_average {
|
||||
|
||||
static const char *const TAG = "exponential_moving_average";
|
||||
|
||||
const LogString *time_weighting_to_string(TimeWeighting weighting) {
|
||||
switch (weighting) {
|
||||
case TIME_WEIGHTING_PREVIOUS:
|
||||
return LOG_STR("previous");
|
||||
case TIME_WEIGHTING_LINEAR:
|
||||
return LOG_STR("linear");
|
||||
default:
|
||||
return LOG_STR("new");
|
||||
}
|
||||
}
|
||||
|
||||
ScaledDuration scale_duration(uint32_t ms) {
|
||||
if (ms < 1000)
|
||||
return {static_cast<float>(ms), LOG_STR("ms"), 0};
|
||||
if (ms < 60 * 1000)
|
||||
return {ms / 1000.0f, LOG_STR("s"), 1};
|
||||
if (ms < 60 * 60 * 1000)
|
||||
return {ms / (60 * 1000.0f), LOG_STR("min"), 1};
|
||||
return {ms / (60 * 60 * 1000.0f), LOG_STR("h"), 1};
|
||||
}
|
||||
|
||||
void ExponentialMovingAverageSensor::setup() {
|
||||
if (this->restore_) {
|
||||
this->pref_ = this->make_entity_preference<float>();
|
||||
float restored;
|
||||
if (this->pref_.load(&restored) && std::isfinite(restored)) {
|
||||
this->accumulator_ = restored;
|
||||
this->publish_state(restored);
|
||||
}
|
||||
}
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
this->last_update_ = now;
|
||||
this->source_->add_on_state_callback(
|
||||
[this](float value) { this->process_(value, App.get_loop_component_start_time()); });
|
||||
// The source may have published during its own setup(), before the callback was added.
|
||||
if (this->source_->has_state())
|
||||
this->process_(this->source_->state, now);
|
||||
}
|
||||
|
||||
void ExponentialMovingAverageSensor::dump_config() {
|
||||
LOG_SENSOR("", "Exponential Moving Average Sensor", this);
|
||||
if (this->time_constant_ms_ != 0) {
|
||||
const ScaledDuration time_constant = scale_duration(this->time_constant_ms_);
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Time Constant: %.*f %s\n"
|
||||
" Time Weighting: %s",
|
||||
time_constant.decimals, time_constant.value, LOG_STR_ARG(time_constant.unit),
|
||||
LOG_STR_ARG(time_weighting_to_string(this->time_weighting_)));
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Alpha: %.3f", this->alpha_);
|
||||
}
|
||||
ESP_LOGCONFIG(TAG, " Restore: %s", YESNO(this->restore_));
|
||||
}
|
||||
|
||||
void ExponentialMovingAverageSensor::reset() { this->publish_and_save_(NAN); }
|
||||
|
||||
void ExponentialMovingAverageSensor::process_(float value, uint32_t now) {
|
||||
if (std::isnan(value))
|
||||
return;
|
||||
// After a reboot the downtime is unknown, so the first interval is measured from setup().
|
||||
const uint32_t dt = now - this->last_update_;
|
||||
this->last_update_ = now;
|
||||
const float previous = this->previous_value_;
|
||||
this->previous_value_ = value;
|
||||
if (std::isnan(this->accumulator_)) {
|
||||
this->publish_and_save_(value);
|
||||
return;
|
||||
}
|
||||
if (this->time_constant_ms_ == 0) {
|
||||
this->publish_and_save_(this->alpha_ * value + (1.0f - this->alpha_) * this->accumulator_);
|
||||
return;
|
||||
}
|
||||
// Computed in double with expm1(): when the interval is short compared to the time constant, the weights are
|
||||
// tiny and float rounding of exp() would swamp them.
|
||||
const double x = static_cast<double>(dt) / this->time_constant_ms_;
|
||||
// The share of the old average replaced during this interval.
|
||||
const double gain = -std::expm1(-x);
|
||||
const double average = this->accumulator_;
|
||||
// After a reboot there is no previous reading, so only the new value can be used.
|
||||
const TimeWeighting weighting = std::isnan(previous) ? TIME_WEIGHTING_NEW : this->time_weighting_;
|
||||
double result;
|
||||
switch (weighting) {
|
||||
case TIME_WEIGHTING_PREVIOUS:
|
||||
result = average + gain * (previous - average);
|
||||
break;
|
||||
case TIME_WEIGHTING_LINEAR: {
|
||||
// Exact result for a value moving in a straight line from the previous reading to the new one.
|
||||
const double weight_new = x > 0.0 ? (x + std::expm1(-x)) / x : 0.0;
|
||||
result = average + (gain - weight_new) * (previous - average) + weight_new * (value - average);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
result = average + gain * (value - average);
|
||||
break;
|
||||
}
|
||||
this->publish_and_save_(static_cast<float>(result));
|
||||
}
|
||||
|
||||
void ExponentialMovingAverageSensor::publish_and_save_(float value) {
|
||||
this->accumulator_ = value;
|
||||
this->publish_state(value);
|
||||
if (this->restore_)
|
||||
this->pref_.save(&value);
|
||||
}
|
||||
|
||||
} // namespace esphome::exponential_moving_average
|
||||
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/preferences.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
|
||||
namespace esphome::exponential_moving_average {
|
||||
|
||||
/// Which value is assumed to apply during the time between two readings, when a time constant is used.
|
||||
enum TimeWeighting : uint8_t {
|
||||
TIME_WEIGHTING_NEW = 0,
|
||||
TIME_WEIGHTING_PREVIOUS,
|
||||
TIME_WEIGHTING_LINEAR,
|
||||
};
|
||||
|
||||
const LogString *time_weighting_to_string(TimeWeighting weighting);
|
||||
|
||||
/// A duration in the largest of ms, s, min or h that keeps the value at 1 or more.
|
||||
struct ScaledDuration {
|
||||
float value;
|
||||
const LogString *unit;
|
||||
uint8_t decimals;
|
||||
};
|
||||
|
||||
ScaledDuration scale_duration(uint32_t ms);
|
||||
|
||||
class ExponentialMovingAverageSensor : public sensor::Sensor, public Component {
|
||||
public:
|
||||
explicit ExponentialMovingAverageSensor(sensor::Sensor *source) : source_(source) {}
|
||||
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_alpha(float alpha) { this->alpha_ = alpha; }
|
||||
/// When non-zero, each sample is weighted by the time since the previous one instead of by a fixed alpha.
|
||||
void set_time_constant(uint32_t time_constant_ms) { this->time_constant_ms_ = time_constant_ms; }
|
||||
void set_time_weighting(TimeWeighting weighting) { this->time_weighting_ = weighting; }
|
||||
void set_restore(bool restore) { this->restore_ = restore; }
|
||||
/// Clear the average; the next sample starts it again.
|
||||
void reset();
|
||||
|
||||
protected:
|
||||
void process_(float value, uint32_t now);
|
||||
void publish_and_save_(float value);
|
||||
|
||||
sensor::Sensor *source_;
|
||||
ESPPreferenceObject pref_;
|
||||
float alpha_{0.1f};
|
||||
float accumulator_{NAN};
|
||||
float previous_value_{NAN};
|
||||
uint32_t time_constant_ms_{0};
|
||||
uint32_t last_update_{0};
|
||||
TimeWeighting time_weighting_{TIME_WEIGHTING_NEW};
|
||||
bool restore_{true};
|
||||
};
|
||||
|
||||
} // namespace esphome::exponential_moving_average
|
||||
@@ -0,0 +1,103 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ACCURACY_DECIMALS,
|
||||
CONF_ALPHA,
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_ICON,
|
||||
CONF_ID,
|
||||
CONF_RESTORE,
|
||||
CONF_SENSOR,
|
||||
CONF_STATE_CLASS,
|
||||
CONF_TIME_CONSTANT,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
)
|
||||
from esphome.core.entity_helpers import inherit_property_from
|
||||
from esphome.types import ConfigType
|
||||
|
||||
exponential_moving_average_ns = cg.esphome_ns.namespace("exponential_moving_average")
|
||||
ExponentialMovingAverageSensor = exponential_moving_average_ns.class_(
|
||||
"ExponentialMovingAverageSensor", sensor.Sensor, cg.Component
|
||||
)
|
||||
|
||||
TimeWeighting = exponential_moving_average_ns.enum("TimeWeighting")
|
||||
TIME_WEIGHTINGS: dict[str, cg.MockObj] = {
|
||||
"new": TimeWeighting.TIME_WEIGHTING_NEW,
|
||||
"previous": TimeWeighting.TIME_WEIGHTING_PREVIOUS,
|
||||
"linear": TimeWeighting.TIME_WEIGHTING_LINEAR,
|
||||
}
|
||||
|
||||
CONF_TIME_WEIGHTING: str = "time_weighting"
|
||||
|
||||
DEFAULT_ALPHA: float = 0.1
|
||||
|
||||
|
||||
def inherit_accuracy_decimals(decimals: int, config: ConfigType) -> int:
|
||||
# An average carries more precision than the individual readings.
|
||||
return decimals + 1
|
||||
|
||||
|
||||
def validate_time_weighting(config: ConfigType) -> ConfigType:
|
||||
if CONF_TIME_WEIGHTING in config and CONF_TIME_CONSTANT not in config:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_TIME_WEIGHTING}' can only be used with '{CONF_TIME_CONSTANT}'",
|
||||
path=[CONF_TIME_WEIGHTING],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
sensor.sensor_schema(ExponentialMovingAverageSensor)
|
||||
.extend(
|
||||
{
|
||||
cv.Required(CONF_SENSOR): cv.use_id(sensor.Sensor),
|
||||
cv.Optional(CONF_ALPHA): cv.All(
|
||||
cv.float_, cv.Range(min=0, min_included=False, max=1)
|
||||
),
|
||||
cv.Optional(CONF_TIME_CONSTANT): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(CONF_TIME_WEIGHTING): cv.enum(TIME_WEIGHTINGS, lower=True),
|
||||
cv.Optional(CONF_RESTORE, default=True): cv.boolean,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
cv.has_at_most_one_key(CONF_ALPHA, CONF_TIME_CONSTANT),
|
||||
validate_time_weighting,
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
inherit_property_from(CONF_ICON, CONF_SENSOR),
|
||||
inherit_property_from(CONF_UNIT_OF_MEASUREMENT, CONF_SENSOR),
|
||||
inherit_property_from(
|
||||
CONF_ACCURACY_DECIMALS, CONF_SENSOR, transform=inherit_accuracy_decimals
|
||||
),
|
||||
inherit_property_from(CONF_DEVICE_CLASS, CONF_SENSOR),
|
||||
inherit_property_from(CONF_STATE_CLASS, CONF_SENSOR),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
source = await cg.get_variable(config[CONF_SENSOR])
|
||||
var = cg.new_Pvariable(config[CONF_ID], source)
|
||||
await cg.register_component(var, config)
|
||||
await sensor.register_sensor(var, config)
|
||||
|
||||
if (time_constant := config.get(CONF_TIME_CONSTANT)) is not None:
|
||||
cg.add(var.set_time_constant(time_constant))
|
||||
if (weighting := config.get(CONF_TIME_WEIGHTING)) is not None:
|
||||
cg.add(var.set_time_weighting(weighting))
|
||||
else:
|
||||
cg.add(var.set_alpha(config.get(CONF_ALPHA, DEFAULT_ALPHA)))
|
||||
cg.add(var.set_restore(config[CONF_RESTORE]))
|
||||
|
||||
|
||||
automation.register_apply_action(
|
||||
"sensor.exponential_moving_average.reset",
|
||||
automation.maybe_simple_id(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(ExponentialMovingAverageSensor),
|
||||
}
|
||||
),
|
||||
automation.ApplyCall("reset()"),
|
||||
)
|
||||
@@ -41,6 +41,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "font"
|
||||
MULTI_CONF = True
|
||||
AUTO_LOAD = ["unicode"]
|
||||
|
||||
CODEOWNERS = ["@esphome/core", "@clydebarrow"]
|
||||
|
||||
@@ -726,7 +727,8 @@ async def to_code(config):
|
||||
)
|
||||
]
|
||||
|
||||
glyphs = cg.static_const_array(config[CONF_RAW_GLYPH_ID], glyph_initializer)
|
||||
# constexpr Glyph table in flash; on ESP8266 static_const_array would place it in RAM.
|
||||
glyphs = cg.progmem_array(config[CONF_RAW_GLYPH_ID], glyph_initializer)
|
||||
|
||||
font_height = pt_to_px(base_font.size.height)
|
||||
ascender = pt_to_px(base_font.size.ascender)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user