Compare commits

..
Author SHA1 Message Date
J. Nick Koston 98ac756534 Tell the post-upload probe results apart and blame no connection for a device refusal 2026-10-26 06:19:24 -10:00
J. Nick Koston 25b3633620 A quoted include path keeps its hash in the shared-configuration scan 2026-10-26 06:16:31 -10:00
J. Nick Koston 2352b0a594 A comment needs whitespace before its hash in a secret name, old_key takes its secret from the ota block it lands on 2026-10-26 06:16:31 -10:00
J. Nick Koston 1d154e5ce4 The snapshot now carries the mode 2026-10-26 06:16:31 -10:00
J. Nick Koston f68b57ca07 Show the report an interrupted restore carries 2026-10-26 06:16:31 -10:00
J. Nick Koston b5f82e4a81 Keep the edit from the point the device accepted the session 2026-10-26 06:16:31 -10:00
J. Nick Koston cedf4777f1 Advise by what the probe found: unreachable, another key, no encryption, a device error 2026-10-26 06:16:31 -10:00
J. Nick Koston 857241f894 Refuse a key that is not on a line of its own, name the sharers of an include that gets old_key 2026-10-26 06:16:31 -10:00
J. Nick Koston fb71f9d61c [cli] Add rotate-key to change the OTA encryption key over the air
Locates the current key on its yaml or secrets.yaml line, writes the new
one with the editor from #20602, keeps the replaced key as old_key, checks
the device with the probe from #20601, builds, uploads with the current key
and confirms with the new one; anything short of a reached device puts the
files back.
2026-10-26 06:16:31 -10:00
161 changed files with 2949 additions and 4473 deletions

No files matched your search

+2 -2
View File
@@ -17,7 +17,7 @@ runs:
steps:
- name: Set up Python ${{ inputs.python-version }}
id: python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ inputs.python-version }}
- name: Restore Python virtual environment
@@ -32,7 +32,7 @@ runs:
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
# downstream jobs rely on is preserved.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
+2 -2
View File
@@ -23,13 +23,13 @@ jobs:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Set up uv
# ``--system`` (below) installs into the setup-python interpreter;
# no venv is created or restored by this workflow.
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pull-request-only workflow: a save could never be shared and
+4 -4
View File
@@ -62,7 +62,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Set up Docker Buildx
@@ -97,7 +97,7 @@ jobs:
- name: Log in to the GitHub container registry
if: steps.tag.outputs.push == 'true'
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -154,14 +154,14 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Log in to the GitHub container registry
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+7 -34
View File
@@ -34,7 +34,7 @@ jobs:
run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
@@ -49,7 +49,7 @@ jobs:
# detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
# that ``. venv/bin/activate`` see an identical layout.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
@@ -422,7 +422,7 @@ jobs:
version: 1.2
- name: Set up Python 3.13
id: python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
- name: Restore integration library cache
@@ -441,7 +441,7 @@ jobs:
- name: Set up uv
# Only needed on cache miss to populate the venv.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
@@ -462,7 +462,6 @@ jobs:
- name: Register matcher
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
- name: Run integration tests
id: integration
env:
# JSON array of test paths; parsed into a bash array below to avoid
# shell word-splitting / glob hazards.
@@ -478,32 +477,6 @@ jobs:
# A hung test fails after 30 min: the 15 min shared build lock wait plus a cold compile
pytest -vv --no-cov --tb=native --durations=30 -n auto --dist worksteal --timeout=1800 \
--junitxml=junit-integration.xml "${test_files[@]}"
- name: Re-run the failed tests with debug symbols
# The first run builds without -g. A crash in the re-run prints a gdb
# backtrace of its core; exit 1 keeps the job red when the re-run passes.
if: failure() && steps.integration.outcome == 'failure'
timeout-minutes: 30
env:
ESPHOME_INTEGRATION_DEBUG_SYMBOLS: "1"
ESPHOME_INTEGRATION_CORE_DIR: /tmp/esphome-cores
run: |
. venv/bin/activate
# The image's package lists usually suffice; refresh them only if not
if ! command -v gdb >/dev/null &&
! sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 apt-get install -y -qq gdb; then
sudo timeout -k 15 300 apt-get update -qq &&
sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 apt-get install -y -qq gdb ||
echo "::warning::gdb install failed; no backtraces"
fi
{
mkdir -p "$ESPHOME_INTEGRATION_CORE_DIR" &&
echo "$ESPHOME_INTEGRATION_CORE_DIR/core.%p" | sudo tee /proc/sys/kernel/core_pattern &&
ulimit -c unlimited
} || echo "::warning::core dumps not enabled; no backtraces"
# Under the step limit, so a hung test still reports before Actions kills the step
pytest -vv --no-cov --tb=native -n auto --dist worksteal --timeout=600 \
--lf --lfnf=none tests/integration || true
exit 1
- name: Upload test artifacts
# Tests that compare rendered output write the image they actually got here, so a
# failure can be looked at without reproducing the whole build locally.
@@ -657,7 +630,7 @@ jobs:
apt-get install -y libc6-dbg
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@78102a468f96d8e0ccb92d0ec49425753fc42062 # v5.6.0
uses: CodSpeedHQ/action@821a40cd6c80e3cfe387caf55668287addd808da # v5.5.0
with:
run: |
. venv/bin/activate
@@ -1484,7 +1457,7 @@ jobs:
ref: main
path: device-builder
- name: Set up Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
- name: Set up uv
@@ -1493,7 +1466,7 @@ jobs:
# install step (order-of-magnitude faster on cold boots,
# with its own wheel cache). actions/setup-python still
# provides the interpreter.
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
category: "/language:${{matrix.language}}"
+6 -6
View File
@@ -86,7 +86,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.x"
- name: Build
@@ -118,7 +118,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
@@ -126,12 +126,12 @@ jobs:
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Log in to docker hub
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -206,13 +206,13 @@ jobs:
- name: Log in to docker hub
if: matrix.registry == 'dockerhub'
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry
if: matrix.registry == 'ghcr'
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -71,7 +71,7 @@ jobs:
token: ${{ steps.generate-token.outputs.token }}
- name: Set up Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
+2 -2
View File
@@ -37,7 +37,7 @@ jobs:
path: lib/home-assistant
- name: Setup Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
@@ -47,7 +47,7 @@ jobs:
# setup-python interpreter so subsequent ``prek`` /
# ``script/run-in-env.py`` steps find the deps without a
# ``uv run`` prefix.
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
@@ -31,7 +31,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
+1 -1
View File
@@ -10,7 +10,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.17.2
rev: v0.17.1
hooks:
# Run the linter.
- id: ruff
+48 -17
View File
@@ -1305,7 +1305,10 @@ def upload_program(
f"{option_string} is only supported with the esphome OTA platform; "
"the web_server OTA path can only update the firmware image."
)
return _upload_via_web_server(config, network_devices, _app_ota_image(args))
binary = CORE.firmware_bin
if getattr(args, "file", None) is not None:
binary = Path(args.file)
return _upload_via_web_server(config, network_devices, binary)
return _upload_via_native_api(config, network_devices, args)
@@ -1361,16 +1364,6 @@ def _esphome_ota_conf(config: ConfigType) -> ConfigType | None:
return next(iter(esphome_ota_items(config)), None)
def _app_ota_image(args: ArgsProtocol) -> Path:
"""The file an app update sends: ``--file`` or the build output, as the
target platform's ota_image hook picks it."""
binary = CORE.firmware_bin
if (file := getattr(args, "file", None)) is not None:
binary = Path(file)
ota_image = platform_hooks.get_platform_hook(CORE.target_platform, "ota_image")
return ota_image(binary) if ota_image is not None else binary
def _upload_via_native_api(
config: ConfigType, network_devices: list[str], args: ArgsProtocol
) -> tuple[int, str | None]:
@@ -1420,27 +1413,27 @@ def _upload_via_native_api(
f"flash a build with the option enabled, and then retry {option_string}."
)
binary = CORE.firmware_bin
ota_type = espota2.OTA_TYPE_UPDATE_APP
file = getattr(args, "file", None)
if getattr(args, "partition_table", False):
# Fail fast if the resolved ESPHome OTA config does not enable allow_partition_access.
# The device-side handshake also rejects this with "Device only supports app updates",
# but checking here surfaces the misconfiguration before opening a network connection.
check_partition_access("--partition-table")
binary = Path(file) if file is not None else CORE.partition_table_bin
binary = CORE.partition_table_bin
ota_type = espota2.OTA_TYPE_UPDATE_PARTITION_TABLE
elif getattr(args, "bootloader", False):
check_partition_access("--bootloader")
if (
file is None
getattr(args, "file", None) is None
and (native := native_backend())
and (hint := getattr(native, "missing_image_hint", lambda: None)())
):
raise EsphomeError(hint)
binary = Path(file) if file is not None else CORE.bootloader_bin
binary = CORE.bootloader_bin
ota_type = espota2.OTA_TYPE_UPDATE_BOOTLOADER
else:
binary = _app_ota_image(args)
if getattr(args, "file", None) is not None:
binary = Path(args.file)
if ota_type == espota2.OTA_TYPE_UPDATE_PARTITION_TABLE:
_validate_partition_table_binary(binary)
@@ -2240,6 +2233,12 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
return 0
def command_rotate_key(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome.cli.rotate_key import command_rotate_key as run
return run(args, config)
def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome.cli.rename import command_rename as run
@@ -2266,6 +2265,7 @@ POST_CONFIG_ACTIONS = {
"clean-mqtt": command_clean_mqtt,
"idedata": command_idedata,
"rename": command_rename,
"rotate-key": command_rotate_key,
"discover": command_discover,
"analyze-memory": command_analyze_memory,
"bundle": command_bundle,
@@ -2663,6 +2663,37 @@ def parse_args(argv):
)
parser_rename.add_argument("name", help="The new name for the device.", type=str)
parser_rotate_key = subparsers.add_parser(
"rotate-key",
help=(
"Change the OTA encryption key over the air: write a new key to the "
"yaml, build with it, upload with the current key, confirm."
),
parents=[mqtt_options],
)
parser_rotate_key.add_argument(
"configuration", help="Your YAML configuration file.", nargs=1
)
parser_rotate_key.add_argument(
"--device",
action="append",
help=ARGUMENT_HELP_DEVICE,
)
parser_rotate_key.add_argument(
"--prompt-new-key",
action="store_true",
help=(
"Ask for the new key instead of generating one; read without echo, "
"or as one line from stdin when that is not a terminal."
),
)
parser_rotate_key.add_argument(
"-y",
"--yes",
action="store_true",
help="Do not ask for confirmation when the api encryption key changes too.",
)
parser_analyze_memory = subparsers.add_parser(
"analyze-memory",
help="Analyze memory usage by component.",
+6 -54
View File
@@ -25,7 +25,6 @@ from esphome.framework_helpers import (
get_project_link_flags,
)
from esphome.helpers import get_bool_env, mkdir_p, write_file_if_changed
from esphome.platformio.library import lex_build_flags
_LOGGER = logging.getLogger(__name__)
@@ -155,47 +154,6 @@ def has_discovered_components() -> bool:
return bool(get_available_components())
_KEY_WARNED_BUILD_FLAGS = "warned_build_flags"
def _lexed_build_flags() -> list[str]:
"""Shell-lex the build flag entries as the other generators do, so an
entry holding several flags is classified flag by flag. Warns once per
entry about a flag that native ESP-IDF builds do not pass on."""
warned: set[tuple[str, str]] = CORE.data.setdefault(KEY_ESP32, {}).setdefault(
_KEY_WARNED_BUILD_FLAGS, set()
)
flags: list[str] = []
for entry in sorted(CORE.build_flags):
tokens = lex_build_flags(entry, "esphome build_flags")
for token in tokens:
if (
(token, entry) not in warned
and not get_project_compile_flags([token])
and not get_project_link_flags([token])
):
warned.add((token, entry))
_LOGGER.warning(
"Build flag '%s'%s is ignored: native ESP-IDF builds only "
"pass -D, -W and -Wl, flags",
token,
"" if token == entry else f" in '{entry}'",
)
flags += tokens
return flags
def _cmake_args(flags: list[str]) -> list[str]:
"""Quote each flag as a CMake argument so quotes, backslashes and $
survive."""
return [
'"{}"'.format(
flag.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$")
)
for flag in flags
]
def _cmake_quote(value: str) -> str:
"""Quote a cmake arg value for a set() line. add_cmake_arg rejects
whitespace, quotes, and '$', so only backslashes need escaping."""
@@ -336,24 +294,18 @@ def get_project_cmakelists(
# esphome__micro-mp3) rather than just src/. Required so suppressions
# like ``-Wno-error=maybe-uninitialized`` actually silence warnings in
# third-party components we don't author.
# CMake keeps only the first copy of a repeated option; drop all but the
# last copy instead so the last definition wins, as on the command line.
project_compile_opts = list(
reversed(
dict.fromkeys(reversed(get_project_compile_flags(_lexed_build_flags())))
)
)
project_compile_opts = get_project_compile_flags()
extra_compile_options = "\n".join(
f"idf_build_set_property(COMPILE_OPTIONS {flag} APPEND)"
for flag in _cmake_args(project_compile_opts)
f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)'
for flag in project_compile_opts
)
# Flags registered via cg.add_cxx_build_flag() go on CXX_COMPILE_OPTIONS
# (not COMPILE_OPTIONS) because GCC warns when a C++-only flag such as
# -Wno-volatile is passed on a C compile.
cxx_compile_options = "\n".join(
f"idf_build_set_property(CXX_COMPILE_OPTIONS {flag} APPEND)"
for flag in _cmake_args(get_project_cxx_compile_flags())
f'idf_build_set_property(CXX_COMPILE_OPTIONS "{flag}" APPEND)'
for flag in get_project_cxx_compile_flags()
)
cpp_standard_options = (
@@ -509,7 +461,7 @@ def get_component_cmakelists() -> str:
# Extract linker options (-Wl, flags). Compile flags (-D, -W) are
# emitted project-wide via idf_build_set_property in
# get_project_cmakelists so they reach every component, not just src/.
link_opts = _cmake_args(get_project_link_flags(_lexed_build_flags()))
link_opts = get_project_link_flags()
link_opts_str = "\n ".join(link_opts) if link_opts else ""
src_cmake = "\n".join(CORE.data[KEY_ESP32].get(KEY_SRC_CMAKE, []))
+289
View File
@@ -0,0 +1,289 @@
"""``esphome rotate-key``."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
from esphome import espota2
from esphome.components.noise import (
generate_encryption_key,
static_encryption_key,
validate_encryption_key,
)
from esphome.config_validation import Invalid
from esphome.const import CONF_API, CONF_PASSWORD, CONF_PORT
from esphome.core import CORE, EsphomeError
from esphome.espota2 import ProbeResult
from esphome.log import AnsiFore, color
from esphome.ota_key_edit import locate_key_edits, old_key_edit, with_sharers
from esphome.types import ConfigType
from esphome.upload_targets import PortType, get_port_type
from esphome.util import (
ESPHOME_COMMAND,
read_secret_line,
run_external_process,
safe_input,
safe_print,
)
from esphome.yaml_edit import RestoreError, Snapshot, apply_line_edits, restore_files
PRECHECK_TIMEOUT = 15.0
# What to do when the device does not accept the key in the yaml before anything is written
PRECHECK_ADVICE = {
ProbeResult.UNREACHABLE: (
"The device could not be reached; check that it is online, then try again."
),
ProbeResult.REJECTED: (
"The device runs a different key than the yaml. Install the current "
"configuration first, presenting its key with --prompt-ota-key, then rotate."
),
ProbeResult.NOT_OFFERED: (
"The device does not offer encryption: it runs a firmware older than "
"ESPHome 2026.9.0 or without a key. Install the current configuration "
"first, then rotate."
),
ProbeResult.DEVICE_ERROR: (
"The device answered with an error (see the warning above); fix that first."
),
}
# Why the device did not confirm the new key after the upload
CONFIRM_FAILURE = {
ProbeResult.REJECTED: "The device still answers with the previous key.",
ProbeResult.NOT_OFFERED: "The device came back without encryption.",
ProbeResult.DEVICE_ERROR: "The device answered with an error (see the warning above).",
ProbeResult.UNREACHABLE: "The device did not come back to confirm the new key.",
}
def command_rotate_key(args: argparse.Namespace, config: ConfigType) -> int | None:
"""Write a new key, build with it, upload with the current one, confirm."""
from esphome.__main__ import (
Purpose,
_esphome_ota_conf,
_resolve_network_devices,
choose_upload_log_host,
)
def fail(message: str) -> int:
safe_print(color(AnsiFore.BOLD_RED, message))
return 1
ota_conf = _esphome_ota_conf(config)
if ota_conf is None:
return fail("rotate-key needs the esphome OTA platform in the configuration")
old_key = static_encryption_key(ota_conf)
if old_key is None:
return fail(
"rotate-key needs 'encryption:' under the esphome OTA platform with a "
"key in the yaml; a key provisioned at runtime is rotated from Home "
"Assistant"
)
if CORE.is_host:
return fail("rotate-key is for devices updated over the air")
old_key = str(old_key)
remote_port = int(ota_conf[CONF_PORT])
devices = choose_upload_log_host(
default=args.device, check_default=None, purpose=Purpose.UPLOADING
)
if get_port_type(devices[0]) in (PortType.SERIAL, PortType.BOOTSEL):
return fail(
"rotate-key works over the air; on a serial connection change the "
"key in the yaml and run 'esphome run' instead"
)
network_devices = _resolve_network_devices(devices, config, args)
if args.prompt_new_key:
new_key = read_secret_line("New OTA encryption key: ")
else:
new_key = generate_encryption_key()
try:
validate_encryption_key(new_key)
except Invalid as err:
return fail(f"Invalid new key: {err}")
if new_key == old_key:
return fail("The new key is the same as the current one")
try:
edits = locate_key_edits(old_key, new_key)
# The previous key stays in the config as old_key so an install still
# reaches the device if it ends up running either key
edits += old_key_edit(old_key)
unchecked = with_sharers(edits)
except EsphomeError as err:
return fail(str(err))
warnings = []
# With the recommended shape the ota inherits the api key, so rotating
# it changes what Home Assistant connects with
if str(static_encryption_key(config.get(CONF_API) or {})) == old_key:
warnings.append(
"The api encryption key changes too; Home Assistant will ask for "
"the new key after the install."
)
shared: dict[str, set[str]] = {}
for edit in edits:
what = f"secret '{edit.secret}'" if edit.secret else edit.path.name
for path in edit.shared_with:
shared.setdefault(
str(path.relative_to(CORE.config_dir.resolve())), set()
).add(what)
if unchecked:
warnings.append(
"Could not check every configuration for shared use: "
+ "; ".join(unchecked)
)
if shared:
warnings.append(
"Other configurations share what is rewritten: "
+ "; ".join(
f"{p} ({', '.join(sorted(w))})" for p, w in sorted(shared.items())
)
+ ". Their devices keep the previous key until their next install; "
"make sure those configurations carry it as old_key."
)
if warnings:
for warning in warnings:
safe_print(color(AnsiFore.BOLD_YELLOW, warning))
if not args.yes:
if not sys.stdin.isatty():
return fail("Confirm with --yes when there is no terminal")
if safe_input("Continue? [y/N] ").strip().lower() not in ("y", "yes"):
return 1
safe_print("Checking that the device accepts the current key...")
found = espota2.probe_ota_key(
network_devices,
remote_port,
old_key,
timeout=PRECHECK_TIMEOUT,
retry_rejected=False,
)
if found is not ProbeResult.ACCEPTED:
return fail(PRECHECK_ADVICE[found])
# Both keys are shown only when a file could not be put back
keys = f"Previous key: {old_key}\nNew key: {new_key}"
try:
originals = apply_line_edits(edits)
except RestoreError as err:
safe_print(keys)
return fail(str(err))
except EsphomeError as err:
return fail(str(err))
except KeyboardInterrupt as err:
if notes := getattr(err, "__notes__", None): # a rollback that failed too
safe_print(keys)
return fail("Interrupted; " + "; ".join(notes))
return fail("Interrupted before the upload")
# From here every exit restores or reports, so nothing sits outside the try
uploaded = False
try:
safe_print(
"Wrote the new key to "
+ ", ".join(color(AnsiFore.CYAN, str(p)) for p in originals)
)
# Global options go before the subcommand; the compile parser is strict
cli_args = ["--dashboard"] if CORE.dashboard else []
if args.toolchain:
cli_args += ["--toolchain", str(args.toolchain)]
for key, value in args.substitution or []:
cli_args += ["-s", key, value]
cli_args += ["compile", str(CORE.config_path)]
if run_external_process(*ESPHOME_COMMAND, *cli_args) != 0:
return fail("Compiling with the new key failed")
def image_sent() -> None:
# The device accepted the session and may commit from here on;
# the edited config reaches it on either key
nonlocal uploaded
uploaded = True
rc, _ = espota2.run_ota(
network_devices,
remote_port,
ota_conf.get(CONF_PASSWORD),
CORE.firmware_bin,
noise_psk=old_key,
plaintext_fallback=False,
on_upload_start=image_sent,
)
if rc != 0:
return fail(
"Uploading with the current key failed. The configuration keeps "
"the new key with the previous one as old_key, so the next "
"install reaches the device whichever key it runs."
if uploaded
else "The upload failed before the image was sent (see the error "
"above); nothing changed on the device."
)
safe_print("Waiting for the device to come back with the new key...")
found = espota2.probe_ota_key(network_devices, remote_port, new_key)
if found is not ProbeResult.ACCEPTED:
return fail(
CONFIRM_FAILURE[found]
+ " The configuration keeps the new key, with the previous one as "
"old_key, so the next install reaches the device either way."
)
except KeyboardInterrupt:
return fail(
"Interrupted; the device's key was not confirmed"
if uploaded
else "Interrupted before the upload"
)
finally:
# Before the upload nothing changed on the device; after it the device
# most likely runs the new key, and old_key covers the other case
if uploaded:
safe_print(f"New OTA encryption key: {color(AnsiFore.CYAN, new_key)}")
else:
_restore(originals, keys)
safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
return 0
def _restore(originals: dict[Path, Snapshot], keys: str) -> None:
"""Put the previous key back, and drop the images a build made with the
new key; both keys are shown when a step fails."""
files = ", ".join(str(p) for p in originals)
try:
restore_files(originals)
except (EsphomeError, KeyboardInterrupt) as err:
# An interrupted restore carries its report as a note
why = str(err) or "; ".join(getattr(err, "__notes__", ()))
safe_print(color(AnsiFore.BOLD_RED, why or f"Interrupted restoring {files}"))
safe_print(keys)
else:
safe_print(color(AnsiFore.BOLD_YELLOW, f"Restored the previous key in {files}"))
build_dir = CORE.firmware_bin.parent
failed: list[str] = []
images: list[Path] = []
try:
images = [p for s in (".bin", ".uf2") for p in build_dir.glob(f"*{s}")]
for image in images: # every one is tried, whatever the others did
try:
image.unlink()
except OSError as err:
failed.append(f"{image}: {err}")
except (OSError, KeyboardInterrupt) as err:
failed.append(f"{build_dir}: {str(err) or 'interrupted'}")
if failed:
safe_print(
color(
AnsiFore.BOLD_RED,
"Could not remove images that may hold the new key: "
+ "; ".join(failed),
)
)
safe_print(keys)
elif images:
safe_print(
color(
AnsiFore.BOLD_YELLOW,
f"Removed the images in {build_dir}, which may hold the new key; "
"compile again before the next upload.",
)
)
+3 -1
View File
@@ -297,7 +297,9 @@ void ADE7880::init_device_() {
void ADE7880::reset_device_() {
if (this->reset_pin_ != nullptr) {
ESP_LOGD(TAG, "Reset device using RESET pin");
pulse_reset(this->reset_pin_, {.low_ms = 1});
this->reset_pin_->digital_write(false);
delay(1);
this->reset_pin_->digital_write(true);
} else {
ESP_LOGD(TAG, "Reset device using SWRST command");
this->write_u16_register16_(CONFIG, CONFIG_SWRST);
+3 -4
View File
@@ -253,9 +253,8 @@ template<typename... Ts> class APIRespondAction final : public Action<Ts...> {
#endif
void play(const Ts &...x) override {
// Extract call_id from first argument - it's always first for optional/only/status modes.
// By reference: array args are non-copyable FixedVector views.
auto args = std::forward_as_tuple(x...);
// Extract call_id from first argument - it's always first for optional/only/status modes
auto args = std::make_tuple(x...);
uint32_t call_id = std::get<0>(args);
bool success = this->success_.value(x...);
@@ -309,7 +308,7 @@ template<typename... Ts> class APIUnregisterServiceCallAction final : public Act
void play(const Ts &...x) override {
// Extract call_id from first argument - same convention as APIRespondAction
auto args = std::forward_as_tuple(x...);
auto args = std::make_tuple(x...);
uint32_t call_id = std::get<0>(args);
if (call_id != 0) {
this->parent_->unregister_active_action_call(call_id);
@@ -16,7 +16,13 @@ constexpr static const uint8_t AXS_READ_TOUCHPAD[11] = {0xb5, 0xab, 0xa5, 0x5a,
}
void AXS15231Touchscreen::setup() {
pulse_reset(this->reset_pin_, {.low_ms = 5, .high_after_ms = 10});
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(false);
delay(5);
this->reset_pin_->digital_write(true);
delay(10);
}
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->pin_mode(gpio::FLAG_INPUT);
this->interrupt_pin_->setup();
-4
View File
@@ -78,7 +78,3 @@ async def pin_to_code(config: ConfigType) -> MockObj:
# Called by writer.py; delegates to the shared libretiny implementation.
def copy_files() -> None:
libretiny.copy_files()
# Called by the OTA upload; the shared libretiny implementation.
ota_image = libretiny.device_ota_image
+3 -2
View File
@@ -45,8 +45,9 @@ void CopyCover::control(const cover::CoverCall &call) {
auto position = call.get_position();
if (position.has_value())
call2.set_position(*position);
if (call.get_toggle().has_value())
call2.set_command_toggle();
auto tilt2 = call.get_tilt();
if (tilt2.has_value())
call2.set_tilt(*tilt2);
call2.perform();
}
@@ -21,6 +21,4 @@ void CopyLock::control(const lock::LockCall &call) {
call2.perform();
}
void CopyLock::open_latch() { this->source_->open(); }
} // namespace esphome::copy
-1
View File
@@ -13,7 +13,6 @@ class CopyLock final : public lock::Lock, public Component {
protected:
void control(const lock::LockCall &call) override;
void open_latch() override;
lock::Lock *source_;
};
@@ -6,7 +6,12 @@ ESPHOME_LOG_TAG(TAG, "cst226.touchscreen");
void CST226Touchscreen::setup() {
if (this->reset_pin_ != nullptr) {
pulse_reset(this->reset_pin_, {.high_before_ms = 5, .low_ms = 5});
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(5);
this->reset_pin_->digital_write(false);
delay(5);
this->reset_pin_->digital_write(true);
this->set_timeout(30, [this] { this->continue_setup_(); });
} else {
this->continue_setup_();
@@ -43,7 +43,9 @@ void CST328Touchscreen::setup() {
}
void CST328Touchscreen::reset_device_() {
pulse_reset(this->reset_pin_, {.low_ms = 5});
this->reset_pin_->digital_write(false);
delay(5);
this->reset_pin_->digital_write(true);
this->set_timeout(CST328_TRANSITION_TIMEOUT, [this] { this->continue_setup_(); });
}
@@ -52,7 +52,12 @@ void CST816Touchscreen::continue_setup_() {
void CST816Touchscreen::setup() {
if (this->reset_pin_ != nullptr) {
pulse_reset(this->reset_pin_, {.high_before_ms = 5, .low_ms = 5});
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(5);
this->reset_pin_->digital_write(false);
delay(5);
this->reset_pin_->digital_write(true);
this->set_timeout(30, [this] { this->continue_setup_(); });
} else {
this->continue_setup_();
@@ -10,7 +10,14 @@ static constexpr uint32_t REREAD_TIMEOUT_ID = 1;
static constexpr uint32_t REREAD_DELAY_MS = 20;
void CST9220Touchscreen::setup() {
pulse_reset(this->reset_pin_, {.high_before_ms = 5, .low_ms = 10});
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(5);
this->reset_pin_->digital_write(false);
delay(10);
this->reset_pin_->digital_write(true);
}
// Wait for the controller to leave its bootloader before talking to it.
this->set_timeout(30, [this] { this->continue_setup_(); });
}
-1
View File
@@ -57,7 +57,6 @@ class DemoCover final : public cover::Cover, public Component {
}
if (call.get_stop()) {
this->cancel_timeout(MOVE_TIMEOUT_ID);
this->current_operation = cover::COVER_OPERATION_IDLE;
}
this->publish_state();
@@ -42,7 +42,6 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_LINE_READER")
await uart.new_uart_component(config)
@@ -11,10 +11,10 @@
namespace esphome::dfrobot_sen0395 {
ESPHOME_LOG_TAG(TAG, "dfrobot_sen0395");
static constexpr char ASCII_CR = 0x0D;
static constexpr char ASCII_LF = 0x0A;
static constexpr float DET_RANGE_STEP_M = 0.15f;
static constexpr float LATENCY_STEP_S = 0.025f;
// Sent after a reset with no line end
static constexpr char PROMPT[] PROGMEM = "leapMMW:/>";
// What each CommandType cannot do while the sensor runs; empty for commands that do not need it stopped
PROGMEM_STRING_TABLE(BusyActions, "", "configure range config", "configure output latency",
@@ -121,9 +121,9 @@ bool DfrobotSen0395Component::run_command_(const Command &cmd, uint32_t now) {
return false; // Command not done yet
}
if (const char *message = this->read_message_()) {
if (ESPHOME_strstr_P(message, ESPHOME_PSTR("is not recognized as a CLI command")) == nullptr) {
if (!this->handle_reply_(cmd, message))
if (this->read_message_()) {
if (ESPHOME_strstr_P(this->read_buffer_, ESPHOME_PSTR("is not recognized as a CLI command")) == nullptr) {
if (!this->handle_reply_(cmd))
return false; // Command not done yet
this->read_message_(); // Skip the prompt
return true;
@@ -146,13 +146,12 @@ bool DfrobotSen0395Component::run_command_(const Command &cmd, uint32_t now) {
}
void DfrobotSen0395Component::read_state_() {
const char *message = this->read_message_();
if (message == nullptr)
if (!this->read_message_())
return;
if (ESPHOME_strstr_P(message, ESPHOME_PSTR("$JYBSS,0, , , *")) != nullptr) {
if (ESPHOME_strstr_P(this->read_buffer_, ESPHOME_PSTR("$JYBSS,0, , , *")) != nullptr) {
this->set_detected_(false);
this->set_active(true);
} else if (ESPHOME_strstr_P(message, ESPHOME_PSTR("$JYBSS,1, , , *")) != nullptr) {
} else if (ESPHOME_strstr_P(this->read_buffer_, ESPHOME_PSTR("$JYBSS,1, , , *")) != nullptr) {
this->set_detected_(true);
this->set_active(true);
}
@@ -207,7 +206,8 @@ void DfrobotSen0395Component::format_command(const Command &cmd, char *buf, size
}
}
bool DfrobotSen0395Component::handle_reply_(const Command &cmd, const char *message) {
bool DfrobotSen0395Component::handle_reply_(const Command &cmd) {
const char *message = this->read_buffer_;
auto is = [message](ESPHOME_PGM_P text) { return ESPHOME_strcmp_P(message, text) == 0; };
// Commands with their own replies
@@ -230,7 +230,7 @@ bool DfrobotSen0395Component::handle_reply_(const Command &cmd, const char *mess
}
return true;
case CommandType::COMMAND_TYPE_RESET_SYSTEM:
if (!is(PROMPT))
if (!is(ESPHOME_PSTR("leapMMW:/>")))
return false;
ESP_LOGI(TAG, "Restarted sensor.");
return true;
@@ -300,24 +300,34 @@ bool DfrobotSen0395Component::handle_reply_(const Command &cmd, const char *mess
return true;
}
const char *DfrobotSen0395Component::read_message_() {
bool DfrobotSen0395Component::read_message_() {
while (this->available()) {
uint8_t byte;
this->read_byte(&byte);
if (this->read_pos_ == MMWAVE_READ_BUFFER_LENGTH)
this->read_pos_ = 0;
ESP_LOGVV(TAG, "Buffer pos: %u %d", this->read_pos_, byte);
if (byte == ASCII_CR)
continue;
if (byte >= 0x7F)
byte = '?'; // needs to be valid utf8 string for log functions.
const char *line = this->line_reader_.feed(byte);
if (line == nullptr && byte == '>' && this->line_reader_.length() == sizeof(PROMPT) - 1) {
line = this->line_reader_.partial();
this->line_reader_.reset();
this->read_buffer_[this->read_pos_] = byte;
if (this->read_pos_ == 9 && byte == '>')
this->read_buffer_[++this->read_pos_] = ASCII_LF;
if (this->read_buffer_[this->read_pos_] == ASCII_LF) {
this->read_buffer_[this->read_pos_] = 0;
this->read_pos_ = 0;
ESP_LOGV(TAG, "Message: %s", this->read_buffer_);
return true; // Full message in buffer
}
if (line != nullptr) {
ESP_LOGV(TAG, "Message: %s", line);
return line;
}
this->line_reader_.log_if_overflowed(TAG);
this->read_pos_++;
}
return nullptr;
return false; // No full message yet
}
void DfrobotSen0395Component::set_detected_(bool detected) {
@@ -4,7 +4,6 @@
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/line_reader.h"
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
@@ -15,7 +14,7 @@
namespace esphome::dfrobot_sen0395 {
static constexpr uint8_t MMWAVE_READ_BUFFER_LENGTH = 253; // largest LineReader size with a one byte position
static constexpr uint8_t MMWAVE_READ_BUFFER_LENGTH = 255;
static constexpr uint8_t COMMAND_QUEUE_SIZE = 20;
// Keep in sync with the BusyActions table in dfrobot_sen0395.cpp
@@ -122,7 +121,8 @@ class DfrobotSen0395Component final : public uart::UARTDevice, public Component
bool led_active_{false};
bool uart_presence_active_{false};
bool start_after_boot_{false};
LineReader<MMWAVE_READ_BUFFER_LENGTH> line_reader_;
char read_buffer_[MMWAVE_READ_BUFFER_LENGTH];
size_t read_pos_{0};
bool cmd_sent_{false};
uint8_t retries_left_{2};
StaticRingBuffer<Command, COMMAND_QUEUE_SIZE> cmd_queue_;
@@ -131,13 +131,12 @@ class DfrobotSen0395Component final : public uart::UARTDevice, public Component
int16_t det_range_steps_[8]{};
uint16_t latency_steps_[2]{};
/// The next complete line, valid until the next call, or nullptr
const char *read_message_();
bool read_message_();
void read_state_();
/// Run the command at the front of the queue; true when it is done
bool run_command_(const Command &cmd, uint32_t now);
/// Handle a reply to a sent command; true when the command is done
bool handle_reply_(const Command &cmd, const char *message);
bool handle_reply_(const Command &cmd);
void set_detected_(bool detected);
};
+4 -15
View File
@@ -1,5 +1,4 @@
from dataclasses import dataclass
import logging
from typing import Any, Protocol
from esphome import automation, core, pins
@@ -35,8 +34,6 @@ from esphome.types import ConfigType
DOMAIN = "display"
IS_PLATFORM_COMPONENT = True
_LOGGER = logging.getLogger(__name__)
display_ns = cg.esphome_ns.namespace("display")
Display = display_ns.class_("Display", cg.PollingComponent)
DisplayBuffer = display_ns.class_("DisplayBuffer", Display)
@@ -105,21 +102,13 @@ BASIC_DISPLAY_SCHEMA = cv.Schema(
).extend(cv.polling_component_schema("1s"))
def _validate_test_card(config: ConfigType) -> ConfigType:
def _validate_test_card(config):
if (
config.get(CONF_SHOW_TEST_CARD, False)
and (update_interval := config.get(CONF_UPDATE_INTERVAL)) is not None
and update_interval.total_milliseconds == SCHEDULER_DONT_RUN
and config.get(CONF_UPDATE_INTERVAL, False) == SCHEDULER_DONT_RUN
):
# Only a warning: something else can still update the display, for example
# component.update, and the test card is drawn then.
display_id = config.get(CONF_ID)
_LOGGER.warning(
"%s`%s: True` is used with `%s: never`, so the test card is only drawn "
"when something updates the display (for example component.update)",
f"{display_id.id}: " if display_id is not None and display_id.id else "",
CONF_SHOW_TEST_CARD,
CONF_UPDATE_INTERVAL,
raise cv.Invalid(
f"`{CONF_SHOW_TEST_CARD}: True` cannot be used with `{CONF_UPDATE_INTERVAL}: never` because this combination will not show a test_card."
)
return config
+1 -1
View File
@@ -22,7 +22,7 @@ CONF_CUSTOM_PATTERNS = "custom_patterns"
CONF_SKIP_CRC = "skip_crc"
CONF_DEFAULT_OBIS = "default_obis"
DLMS_PARSER_VERSION = "2.2.2"
DLMS_PARSER_VERSION = "2.2.0"
dlms_meter_component_ns = cg.esphome_ns.namespace("dlms_meter")
DlmsMeterComponent = dlms_meter_component_ns.class_(
@@ -20,7 +20,9 @@ void EKTF2232Touchscreen::setup() {
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
pulse_reset(this->reset_pin_, {.low_ms = 15, .high_after_ms = 15});
this->reset_pin_->setup();
this->hard_reset_();
if (!this->soft_reset_()) {
ESP_LOGE(TAG, "Failed to soft reset EKT2232!");
this->interrupt_pin_->detach_interrupt();
@@ -94,6 +96,13 @@ bool EKTF2232Touchscreen::get_power_state() {
return (received[1] >> 3) & 1;
}
void EKTF2232Touchscreen::hard_reset_() {
this->reset_pin_->digital_write(false);
delay(15);
this->reset_pin_->digital_write(true);
delay(15);
}
bool EKTF2232Touchscreen::soft_reset_() {
auto err = this->write(SOFT_RESET_CMD, 4);
if (err != i2c::ERROR_OK)
@@ -22,6 +22,7 @@ class EKTF2232Touchscreen final : public Touchscreen, public i2c::I2CDevice {
bool get_power_state();
protected:
void hard_reset_();
bool soft_reset_();
void update_touches() override;
@@ -72,7 +72,9 @@ bool EPaperJD79660::reset() {
// As commented on SLEEP_MS_RESET1: Reset pulse must happen within time window.
// So do not use FSM loop, and avoid other calls/logs during pulse below.
pulse_reset(this->reset_pin_, {.low_ms = SLEEP_MS_RESET1});
this->reset_pin_->digital_write(false);
delay(SLEEP_MS_RESET1);
this->reset_pin_->digital_write(true);
this->reset_duration_ = SLEEP_MS_RESET2;
this->step_ = FSMState::RESET_STEP2_IDLECHECK;
+1 -3
View File
@@ -2389,9 +2389,7 @@ FRAMEWORK_SCHEMA = cv.Schema(
cv.Schema(
{
cv.Required(CONF_NAME): cv.string_strict,
cv.Optional(CONF_SOURCE): cv.All(
cv.string_strict, cv.Length(min=1)
),
cv.Optional(CONF_SOURCE): cv.git_ref,
cv.Optional(CONF_REF): cv.string,
cv.Optional(CONF_PATH): cv.string,
}
+11 -19
View File
@@ -17,13 +17,8 @@
#include "hal/rtc_io_hal.h"
#endif
// some variants don't map rtcio to gpio 1:1
#if defined(USE_ESP32_VARIANT_ESP32) || defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32H21)
#define RTCIO_NEED_MAPPING 1 // NOLINT
#endif
#if defined(USE_ESP32_VARIANT_ESP32) || (SOC_RTCIO_PIN_COUNT > 0 && defined(USE_GPIO_HOLD))
#define ISR_PIN_USE_RTC 1 // NOLINT
#ifndef SOC_GPIO_SUPPORT_RTC_INDEPENDENT
#define SOC_GPIO_SUPPORT_RTC_INDEPENDENT 0 // NOLINT
#endif
namespace esphome {
@@ -58,12 +53,10 @@ struct ISRPinArg {
#ifdef USE_GPIO_HOLD
bool hold;
#endif
#ifdef ISR_PIN_USE_RTC
#if defined(USE_ESP32_VARIANT_ESP32)
bool use_rtc;
#ifdef RTCIO_NEED_MAPPING
int rtc_pin;
#endif
#endif
};
#ifdef USE_GPIO_HOLD
@@ -76,15 +69,17 @@ static inline void refresh_hold(const ESP32InternalGPIOPin &pin) {
static inline void IRAM_ATTR isr_refresh_hold(const ISRPinArg *arg) {
if (!arg->hold)
return;
#if defined(ISR_PIN_USE_RTC) && defined(SOC_RTCIO_HOLD_SUPPORTED)
#if defined(USE_ESP32_VARIANT_ESP32)
if (arg->use_rtc) {
#ifdef RTCIO_NEED_MAPPING
rtcio_hal_hold_disable(arg->rtc_pin);
rtcio_hal_hold_enable(arg->rtc_pin);
#else
return;
}
#elif defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3)
// GPIO0-21 are held from the RTC domain; the digital hold mask is zero for them
if (arg->pin < SOC_RTCIO_PIN_COUNT) {
rtcio_hal_hold_disable(arg->pin);
rtcio_hal_hold_enable(arg->pin);
#endif
return;
}
#endif
@@ -101,12 +96,10 @@ ISRInternalGPIOPin ESP32InternalGPIOPin::to_isr() const {
#ifdef USE_GPIO_HOLD
arg->hold = this->get_hold();
#endif
#ifdef ISR_PIN_USE_RTC
#if defined(USE_ESP32_VARIANT_ESP32)
arg->use_rtc = rtc_gpio_is_valid_gpio(this->get_pin_num());
#ifdef RTCIO_NEED_MAPPING
if (arg->use_rtc)
arg->rtc_pin = rtc_io_number_get(this->get_pin_num());
#endif
#endif
return ISRInternalGPIOPin((void *) arg);
}
@@ -258,8 +251,7 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) {
if (diff & gpio::FLAG_INPUT) {
if (flags & gpio::FLAG_INPUT) {
gpio_hal_input_enable(&GPIO_HAL, arg->pin);
#ifdef USE_ESP32_VARIANT_ESP32
// Only ESP32 uses the RTC registers for pullup/pulldown
#if defined(USE_ESP32_VARIANT_ESP32)
if (arg->use_rtc) {
if (flags & gpio::FLAG_PULLUP) {
rtcio_hal_pullup_enable(arg->rtc_pin);
+12 -1
View File
@@ -34,7 +34,10 @@ void FT63X6Touchscreen::setup() {
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_ANY_EDGE);
}
pulse_reset(this->reset_pin_, {.low_ms = 10});
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->hard_reset_();
}
// Get touch resolution
if (this->x_raw_max_ == this->x_raw_min_) {
@@ -54,6 +57,14 @@ void FT63X6Touchscreen::setup() {
this->write_byte(FT6X36_ADDR_TOUCHRATE_ACTIVE, 0x0E);
}
void FT63X6Touchscreen::hard_reset_() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->digital_write(false);
delay(10);
this->reset_pin_->digital_write(true);
}
}
void FT63X6Touchscreen::dump_config() {
ESP_LOGCONFIG(TAG, "FT63X6 Touchscreen:");
LOG_I2C_DEVICE(this);
+1
View File
@@ -27,6 +27,7 @@ class FT63X6Touchscreen final : public Touchscreen, public i2c::I2CDevice {
void set_threshold(uint8_t threshold) { this->threshold_ = threshold; }
protected:
void hard_reset_();
void update_touches() override;
InternalGPIOPin *interrupt_pin_{nullptr};
@@ -83,7 +83,13 @@ void GSL3670Touchscreen::update_touches() {
void GSL3670Touchscreen::clear_reg_() {
ESP_LOGD(TAG, "clear_reg");
pulse_reset(this->reset_pin_, {.low_ms = 1, .high_after_ms = 5});
// GPIO reset pulse
if (this->reset_pin_ != nullptr) {
this->reset_pin_->digital_write(false);
delay(1);
this->reset_pin_->digital_write(true);
delay(5);
}
this->write_reg8_(0x88, 0x01);
// delay(5);
@@ -100,7 +106,12 @@ void GSL3670Touchscreen::clear_reg_() {
void GSL3670Touchscreen::reset_() {
ESP_LOGD(TAG, "reset");
pulse_reset(this->reset_pin_, {.low_ms = 1, .high_after_ms = 5});
if (this->reset_pin_ != nullptr) {
this->reset_pin_->digital_write(false);
delay(1);
this->reset_pin_->digital_write(true);
delay(5);
}
this->write_reg8_(0xe4, 0x04);
+6 -1
View File
@@ -19,7 +19,12 @@ void ICNT86Touchscreen::setup() {
}
// Perform reset if necessary
pulse_reset(this->reset_pin_, {.low_ms = 10});
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(false);
delay(10);
this->reset_pin_->digital_write(true);
}
if (this->x_raw_max_ == this->x_raw_min_) {
this->x_raw_max_ = this->display_->get_native_width();
+3 -4
View File
@@ -1,7 +1,8 @@
"""Shared base for the infrared and radio_frequency entity components."""
import esphome.codegen as cg
from esphome.const import CONF_API
from esphome.components import remote_base
from esphome.components.api import DOMAIN as API_DOMAIN
from esphome.core import CORE
from esphome.core.entity_helpers import queue_entity_register
from esphome.types import ConfigType
@@ -23,10 +24,8 @@ async def attach_transmitter(var: cg.MockObj, config: ConfigType, key: str) -> N
does. Without this call anywhere in the build a request is answered as
soon as the frame is handed over.
"""
from esphome.components import remote_base
await remote_base.register_transmittable(var, config, key)
if CONF_API in CORE.config:
if API_DOMAIN in CORE.config:
cg.add_define("USE_IR_RF_TRANSMIT_COMPLETE")
+6 -26
View File
@@ -138,11 +138,6 @@ static uint8_t calc_checksum(const void *data, size_t size) {
return xor_bytes(static_cast<const uint8_t *>(data), size);
}
// Simple mode needs simple reporting from the module; Normal and Calibrate read energy frames
static uint16_t system_mode_for(uint8_t mode) {
return mode == OP_SIMPLE_MODE ? CMD_SYSTEM_MODE_SIMPLE : CMD_SYSTEM_MODE_ENERGY;
}
static int32_t get_firmware_int(const char *version_string) {
// Convert "v1.5.4" -> 154 by skipping 'v' and '.', accumulating digits
const char *p = (*version_string == 'v') ? version_string + 1 : version_string;
@@ -212,10 +207,13 @@ void LD2420Component::setup() {
memcpy(&this->new_config, &this->current_config, sizeof(this->current_config));
if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) {
this->set_operating_mode(OP_SIMPLE_MODE);
this->set_mode_(CMD_SYSTEM_MODE_SIMPLE);
ESP_LOGW(TAG, "Firmware version %s and older supports Simple Mode only", this->firmware_ver_);
} else {
this->set_operating_mode(OP_NORMAL_MODE);
this->set_mode_(CMD_SYSTEM_MODE_ENERGY);
}
this->set_operating_mode(OP_NORMAL_MODE); // older firmware is held in Simple
this->set_mode_(system_mode_for(this->current_operating_mode));
#ifdef USE_NUMBER
this->init_gate_config_numbers();
#endif
@@ -246,9 +244,7 @@ void LD2420Component::apply_config_action() {
#endif
this->set_system_mode(this->system_mode_);
this->set_config_mode(false); // Disable config mode to save new values in LD2420 nvm
// Applying the calibrated thresholds ends calibration; Normal and Simple stay as they are
if (this->current_operating_mode == OP_CALIBRATE_MODE)
this->set_operating_mode(OP_NORMAL_MODE);
this->set_operating_mode(OP_NORMAL_MODE);
}
void LD2420Component::factory_reset_action() {
@@ -360,22 +356,6 @@ void LD2420Component::set_operating_mode(const char *state) {
}
}
void LD2420Component::change_operating_mode(OpMode mode) {
this->set_operating_mode(mode);
const uint16_t system_mode = system_mode_for(this->current_operating_mode);
if (system_mode == this->get_mode_())
return;
if (this->set_config_mode(true) != LD2420_ERROR_TIMEOUT) {
this->set_system_mode(system_mode);
this->set_config_mode(false);
}
if (this->get_mode_() != system_mode) {
// The module kept its reporting mode; show the mode that matches it
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
this->set_operating_mode(this->get_mode_() == CMD_SYSTEM_MODE_SIMPLE ? OP_SIMPLE_MODE : OP_NORMAL_MODE);
}
}
void LD2420Component::set_operating_mode(OpMode mode) {
// Older firmware only supports simple mode
if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) {
-1
View File
@@ -119,7 +119,6 @@ class LD2420Component final : public Component, public uart::UARTDevice {
void handle_cmd_error(uint16_t error);
void set_operating_mode(const char *state);
void set_operating_mode(OpMode mode);
void change_operating_mode(OpMode mode);
void auto_calibrate_sensitivity();
void update_radar_data(uint16_t const *gate_energy, uint8_t sample_number);
uint8_t set_config_mode(bool enable);
+2 -1
View File
@@ -30,5 +30,6 @@ async def to_code(config: ConfigType) -> None:
CONF_OPERATING_MODE,
hub.set_operating_mode_select,
options=CONF_SELECTS,
apply="change_operating_mode(static_cast<ld2420::OpMode>(index + 1))",
apply="set_operating_mode(static_cast<ld2420::OpMode>(index + 1))",
publish="before",
)
+1 -21
View File
@@ -26,7 +26,7 @@ from esphome.const import (
)
from esphome.core import CORE
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.helpers import copy_file_if_changed, write_file
from esphome.helpers import copy_file_if_changed
from esphome.platformio.toolchain import copy_ccache_script
from esphome.storage_json import StorageJSON
@@ -656,26 +656,6 @@ async def component_to_code(config):
await cg.register_component(var, config)
# Called by the OTA upload through the platforms' ota_image hook
def device_ota_image(firmware: Path) -> Path:
"""The file to send for an app update: the UF2 without the images the
device skips, written next to the build output, or ``firmware`` itself
when there is nothing to drop."""
from .ota_uf2 import device_ota_uf2
data = firmware.read_bytes()
if (trimmed := device_ota_uf2(data)) is None:
return firmware
image = CORE.relative_pioenvs_path(CORE.name, "firmware.ota.uf2")
write_file(image, trimmed)
_LOGGER.info(
"Sending the images this device writes: %s of %s bytes",
len(trimmed),
len(data),
)
return image
# Called by writer.py
def copy_files() -> None:
script_dir = Path(__file__).parent
@@ -91,10 +91,6 @@ async def pin_to_code(config: ConfigType) -> MockObj:
# Called by writer.py; delegates to the shared libretiny implementation.
def copy_files() -> None:
libretiny.copy_files()
# Called by the OTA upload; the shared libretiny implementation.
ota_image = libretiny.device_ota_image
'''
BASE_CODE_BOARDS = '''
-98
View File
@@ -1,98 +0,0 @@
"""Trim a LibreTiny UF2 package to the images a device writes over OTA.
A LibreTiny UF2 holds one image per OTA scheme: the device schemes (single,
dual slot 1, dual slot 2) and the flasher schemes used over UART. The device
skips every block of an image whose scheme it does not run (uf2ota), so an
image no device scheme writes is only sent to be thrown away. On single OTA
chips (BK72xx, LN882H) that is the whole flasher image, more than half the
package. The legacy OTA format names the same device partitions, so devices
that read it write the same images.
"""
import struct
UF2_BLOCK_SIZE = 512
UF2_MAGIC_HEAD = struct.pack("<2I", 0x0A324655, 0x9E5D5157)
UF2_MAGIC_TAIL = struct.pack("<I", 0x0AB16F30)
UF2_FLAG_HAS_TAGS = 0x8000
# Header words after the magic: flags, target address, payload length, block
# number, block count
_HEADER_WORDS = struct.Struct("<5I")
_SEQ_OFFSET = 20
_DATA_START = 32
_DATA_END = UF2_BLOCK_SIZE - len(UF2_MAGIC_TAIL)
# One partition index nibble per scheme, high nibble first: device single,
# dual 1 and dual 2 are the first three, the flasher schemes the next three
UF2_TAG_OTA_PART_INFO = 0xC0EE0C
# Tags uf2ota acts on beyond the image they sit in; one in an image that
# would be dropped leaves the package untrimmed
_KEEP_TAGS = frozenset(
{
0x5D57D0, # OTA_FORMAT_1
0x6C8492, # OTA_FORMAT_2
0x6EC68A, # OTA_PART_LIST
0xB948DE, # BINPATCH
0x8288ED, # FAL_PTABLE
}
)
def _tags(block: bytes, length: int) -> dict[int, bytes] | None:
"""The extension tags after a block's payload, or None if malformed."""
tags: dict[int, bytes] = {}
pos = _DATA_START + length
while pos + 4 <= _DATA_END and (size := block[pos]):
if size < 4 or pos + size > _DATA_END:
return None
tags[int.from_bytes(block[pos + 1 : pos + 4], "little")] = block[
pos + 4 : pos + size
]
pos += (size + 3) & ~3
return tags
def device_ota_uf2(data: bytes) -> bytes | None:
"""``data`` without the images no device scheme writes.
None when there is nothing to drop, or when ``data`` is not a well
formed UF2, so the caller sends the file as it is.
"""
if len(data) % UF2_BLOCK_SIZE:
return None
count = len(data) // UF2_BLOCK_SIZE
kept: list[bytes] = []
keep = True
for seq in range(count):
block = data[seq * UF2_BLOCK_SIZE : (seq + 1) * UF2_BLOCK_SIZE]
flags, _, length, block_seq, block_count = _HEADER_WORDS.unpack_from(block, 8)
# A missing or repeated block must stay visible to the device, which
# takes the update size from the block count
if (
block[:8] != UF2_MAGIC_HEAD
or block[_DATA_END:] != UF2_MAGIC_TAIL
or _DATA_START + length > _DATA_END
or (block_seq, block_count) != (seq, count)
):
return None
tags = _tags(block, length) if flags & UF2_FLAG_HAS_TAGS else {}
if tags is None:
return None
if (part_info := tags.get(UF2_TAG_OTA_PART_INFO)) is not None:
# uf2ota rejects a selector without the whole scheme map
if len(part_info) < 3:
return None
# Any of the three device scheme nibbles names a partition
keep = int.from_bytes(part_info[:2], "big") >> 4 != 0
if keep:
kept.append(block)
elif not _KEEP_TAGS.isdisjoint(tags):
# Without its image's OTA_PART_INFO the device would write this
# block to the partition of the image before it
return None
if len(kept) == count:
return None
out = bytearray(b"".join(kept))
for seq in range(len(kept)):
struct.pack_into("<2I", out, seq * UF2_BLOCK_SIZE + _SEQ_OFFSET, seq, len(kept))
return bytes(out)
-4
View File
@@ -77,7 +77,3 @@ async def pin_to_code(config: ConfigType) -> MockObj:
# Called by writer.py; delegates to the shared libretiny implementation.
def copy_files() -> None:
libretiny.copy_files()
# Called by the OTA upload; the shared libretiny implementation.
ota_image = libretiny.device_ota_image
+3 -4
View File
@@ -607,10 +607,9 @@ async def _late_logger_init(config: ConfigType) -> None:
# esp_rom_printf instead of pulling ~1.2KB esp_rom_vprintf into IRAM.
# S2/C2 have a ROM vprintf, so IDF forces CONSTRAINED_ENV_SAFE=1 and
# cache-off DRAM logs would reach the flash-resident wrapper; keep V1.
if (
CORE.using_toolchain_esp_idf
and idf_version() >= cv.Version(6, 1, 0)
and get_esp32_variant() not in (VARIANT_ESP32C2, VARIANT_ESP32S2)
if idf_version() >= cv.Version(6, 1, 0) and get_esp32_variant() not in (
VARIANT_ESP32C2,
VARIANT_ESP32S2,
):
add_idf_sdkconfig_option("CONFIG_LOG_VERSION_1", False)
add_idf_sdkconfig_option("CONFIG_LOG_VERSION_2", True)
+3 -4
View File
@@ -339,8 +339,8 @@ def final_validation(config_list):
def lv_conf_path_flag(lv_conf_h_file: Path) -> str:
"""The ``-DLV_CONF_PATH`` build flag, decoded identically by the shell
lexers (PlatformIO, host, ESP8266, and ESP-IDF before it quotes each
token for CMake) and the raw quoted CMake argument (Zephyr)."""
lexers (PlatformIO, host, ESP8266) and the quoted CMake arguments
(ESP-IDF, Zephyr) the build flags pass through."""
# forward slashes for Windows, then escape what shlex or a quoted CMake
# argument would not pass through literally
path = re.sub(r"[^\w/.:+=,-]", r"\\\g<0>", Path(lv_conf_h_file).as_posix())
@@ -414,8 +414,7 @@ async def to_code(configs):
"LV_COLOR_CHROMA_KEY",
await lvalid.lv_color.process(config_0[df.CONF_TRANSPARENCY_KEY]),
)
if not CORE.using_toolchain_esp_idf:
cg.add_build_flag("-Isrc")
cg.add_build_flag("-Isrc")
cg.add_global(lvgl_ns.using)
for font in df.get_esphome_fonts_used():
+1 -4
View File
@@ -2,6 +2,7 @@ from collections.abc import Callable
from esphome import automation
import esphome.codegen as cg
from esphome.components import audio
from esphome.components.const import CONF_BITRATE
import esphome.config_validation as cv
from esphome.const import (
@@ -115,8 +116,6 @@ def validate_preferred_format(
"""
def validator(config: ConfigType) -> ConfigType:
from esphome.components import audio
# Inherit settings from audio device if not manually set
inherit_property_from(CONF_NUM_CHANNELS, audio_device_key)(config)
inherit_property_from(CONF_SAMPLE_RATE, audio_device_key)(config)
@@ -156,8 +155,6 @@ def request_codecs_for_format_configs(
Public API for external components. Do not remove.
"""
from esphome.components import audio
needed_formats: set[str] = set()
need_all = False
+14 -19
View File
@@ -4,7 +4,6 @@
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <span>
#include <type_traits>
#include <utility>
@@ -54,25 +53,16 @@ using DataCallbackManagerBase = CallbackManager<void(const AudioCapture &)>;
class DataCallbackManager : public DataCallbackManagerBase {
public:
DataCallbackManager(const audio::AudioStreamInfo *stream_info, const bool *mute_state)
: stream_info_(stream_info), mute_state_(mute_state) {}
explicit DataCallbackManager(const audio::AudioStreamInfo *stream_info) : stream_info_(stream_info) {}
using DataCallbackManagerBase::call;
// Remove before 2027.5.0, along with this class (use a plain CallbackManager)
ESPDEPRECATED("Use Microphone::publish_audio_() instead. Removed in 2027.5.0", "2026.11.0")
void call(const std::vector<uint8_t> &data) {
if (*this->mute_state_) {
const std::vector<uint8_t> zeros(data.size(), 0);
this->call(AudioCapture{zeros, *this->stream_info_, 0});
} else {
this->call(AudioCapture{data, *this->stream_info_, 0});
}
}
void call(const std::vector<uint8_t> &data) { this->call(AudioCapture{data, *this->stream_info_, 0}); }
protected:
const audio::AudioStreamInfo *stream_info_;
const bool *mute_state_;
};
class Microphone {
@@ -82,7 +72,15 @@ class Microphone {
/// Callbacks take (const AudioCapture &) and run on the microphone's task.
template<typename F> void add_data_callback(F &&data_callback) {
this->data_callbacks_.add(as_capture_callback(std::forward<F>(data_callback)));
this->data_callbacks_.add(
[this, callback = as_capture_callback(std::forward<F>(data_callback))](const AudioCapture &capture) {
if (this->mute_state_) {
const std::vector<uint8_t> zeros(capture.data.size(), 0);
callback(AudioCapture{zeros, capture.stream_info, capture.capture_time_us});
} else {
callback(capture);
}
});
}
bool is_running() const { return this->state_ == STATE_RUNNING; }
@@ -94,11 +92,8 @@ class Microphone {
audio::AudioStreamInfo get_audio_stream_info() { return this->audio_stream_info_; }
protected:
/// While muted, data is zeroed first, so it must be a buffer the platform owns.
void publish_audio_(std::span<uint8_t> data, const audio::AudioStreamInfo &stream_info, int64_t capture_time_us = 0) {
if (this->mute_state_) {
std::memset(data.data(), 0, data.size());
}
void publish_audio_(std::span<const uint8_t> data, const audio::AudioStreamInfo &stream_info,
int64_t capture_time_us = 0) {
this->data_callbacks_.call(AudioCapture{data, stream_info, capture_time_us});
}
@@ -110,7 +105,7 @@ class Microphone {
audio::AudioStreamInfo audio_stream_info_;
// Make private when the deprecated call(data) is removed in 2027.5.0
DataCallbackManager data_callbacks_{&this->audio_stream_info_, &this->mute_state_};
DataCallbackManager data_callbacks_{&this->audio_stream_info_};
};
} // namespace esphome::microphone
+6 -1
View File
@@ -107,7 +107,12 @@ void MipiDsi::setup() {
}
#endif
if (this->reset_pin_ != nullptr) {
pulse_reset(this->reset_pin_, {.high_before_ms = 5, .low_ms = 5});
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(5);
this->reset_pin_->digital_write(false);
delay(5);
this->reset_pin_->digital_write(true);
} else {
esp_lcd_panel_io_tx_param(this->io_handle_, SW_RESET_CMD, nullptr, 0);
}
+8 -1
View File
@@ -33,7 +33,14 @@ void MipiRgb::setup_enables_() {
}
delay(10);
}
pulse_reset(this->reset_pin_, {.high_before_ms = 5, .low_ms = 5});
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(5);
this->reset_pin_->digital_write(false);
delay(5);
this->reset_pin_->digital_write(true);
}
}
#ifdef USE_SPI
+9 -2
View File
@@ -151,8 +151,15 @@ class MipiSpi : public display::Display,
pin->setup();
pin->digital_write(true);
}
// required delay after reset is already in the init sequence, don't duplicate
pulse_reset(this->reset_pin_, {.high_before_ms = 5, .low_ms = 5});
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(5);
this->reset_pin_->digital_write(false);
delay(5);
this->reset_pin_->digital_write(true);
// required delay after reset is already in the init sequence, don't duplicate
}
// need to know when the display is ready for SLPOUT command - will be 120ms after reset
auto when = millis() + 120;
+9 -27
View File
@@ -452,16 +452,6 @@ bool Nextion::remove_from_q_(bool report_empty) {
}
NextionComponentBase *component = nb->component;
#ifdef USE_NEXTION_COMMAND_SPACING
if (!nb->pending_command.empty()) {
// Not sent yet, so this response cannot belong to it. Dropping the
// response leaves the command queued instead of losing it.
ESP_LOGW(TAG, "Response for unsent cmd '%s' (queue %zu), ignoring", component->get_variable_name().c_str(),
this->nextion_queue_.size());
return false;
}
#endif // USE_NEXTION_COMMAND_SPACING
ESP_LOGN(TAG, "Removed: %s", component->get_variable_name().c_str());
if (is_sleep_wake_command(component)) {
@@ -1183,8 +1173,8 @@ void Nextion::add_no_result_to_queue_(const std::string &variable_name) {
* (e.g. add_no_result_to_queue_with_ignore_sleep_printf_()) are explicitly
* sleep-safe and must bypass it.
*
* If USE_NEXTION_COMMAND_SPACING is enabled and the command cannot go out right
* away, it is saved in the queue entry for retry rather than dropped.
* If USE_NEXTION_COMMAND_SPACING is enabled and the pacer is not ready,
* the command is saved in the queue entry for retry rather than dropped.
*
* @param variable_name Name of the variable or component associated with the command.
* @param command The raw command string to send.
@@ -1195,15 +1185,6 @@ void Nextion::add_no_result_to_queue_with_command_(const std::string &variable_n
ESP_LOGV(TAG, "Queue NORESULT '%s': %s", variable_name.c_str(), command.c_str());
#ifdef USE_NEXTION_COMMAND_SPACING
// Sending now would overtake commands that are still waiting, so only send when
// nothing is waiting. process_pending_in_queue_() transmits the rest in order.
if (this->has_unsent_commands_()) {
this->add_no_result_to_queue_with_pending_command_(variable_name, command);
return;
}
#endif // USE_NEXTION_COMMAND_SPACING
if (this->send_command_(command)) {
this->add_no_result_to_queue_(variable_name);
#ifdef USE_NEXTION_COMMAND_SPACING
@@ -1365,13 +1346,14 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) {
std::string command = "get " + component->get_variable_name_to_send();
#ifdef USE_NEXTION_COMMAND_SPACING
// Only send right away when nothing is waiting, otherwise this command would
// overtake the ones still queued. Anything not sent here keeps its command for
// process_pending_in_queue_() to transmit in order.
if (this->has_unsent_commands_() || !this->send_command_(command)) {
nextion_queue->pending_command = std::move(command);
}
// Always enqueue first so the response handler is present when the command
// is eventually sent. Store the command for retry if spacing blocked it;
// process_pending_in_queue_() will transmit it when the pacer allows.
nextion_queue->pending_command = command;
this->nextion_queue_.push_back(nextion_queue);
if (this->send_command_(command)) {
nextion_queue->pending_command.clear();
}
#else // USE_NEXTION_COMMAND_SPACING
if (this->send_command_(command)) {
this->nextion_queue_.push_back(nextion_queue);
-10
View File
@@ -1470,16 +1470,6 @@ class Nextion final : public NextionBase, public PollingComponent, public uart::
* Called from loop() to retry sending commands that were delayed by spacing.
*/
void process_pending_in_queue_();
/**
* @brief Whether the queue still holds a command that has not reached the wire.
*
* The queue is kept in transmit order, so anything still waiting sits at the back.
*/
bool has_unsent_commands_() const {
return !this->nextion_queue_.empty() && this->nextion_queue_.back() != nullptr &&
!this->nextion_queue_.back()->pending_command.empty();
}
#endif // USE_NEXTION_COMMAND_SPACING
std::list<NextionQueue *> nextion_queue_;
@@ -226,12 +226,6 @@ def set_sdkconfig_options(config: ConfigType) -> None:
add_idf_sdkconfig_option("CONFIG_OPENTHREAD_DNS64_CLIENT", not border_router)
add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT", not border_router)
if not border_router:
# The DNS64 hook breaks IPv6 literal lookups; see openthread_esp.cpp.
# Fixed upstream on ESP-IDF master, remove this once a release includes
# "fix(openthread): bypass DNS64 synthesis for IPv6 literals",
# https://github.com/espressif/esp-idf/commit/87b133ee1a7e91c1ee95176b4733b7f72236e5da
# (merged in https://github.com/espressif/esp-idf/commit/83239509832bf5d427935cd365f4b62a320e8765)
cg.add_build_flag("-Wl,--wrap=lwip_hook_dns_external_resolve")
add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT_MAX_SERVICES", 5)
if border_router:
@@ -35,10 +35,6 @@
#include "esp_vfs_eventfd.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#ifndef USE_OPENTHREAD_BORDER_ROUTER
#include "lwip/dns.h"
#include "lwip/ip_addr.h"
#endif
ESPHOME_LOG_TAG(TAG, "openthread");
@@ -493,26 +489,4 @@ InstanceLock::~InstanceLock() {
}
} // namespace esphome::openthread
#ifndef USE_OPENTHREAD_BORDER_ROUTER
// ESP-IDF's DNS64 hook runs before lwIP's literal address check and retries every lookup as IPv4,
// so an IPv6 literal never resolves. Let lwIP handle those; linked with --wrap from __init__.py.
// Fixed upstream on ESP-IDF master, remove this once a release includes
// "fix(openthread): bypass DNS64 synthesis for IPv6 literals",
// https://github.com/espressif/esp-idf/commit/87b133ee1a7e91c1ee95176b4733b7f72236e5da
// (merged in https://github.com/espressif/esp-idf/commit/83239509832bf5d427935cd365f4b62a320e8765)
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
extern "C" {
int __real_lwip_hook_dns_external_resolve(const char *name, ip_addr_t *addr, dns_found_callback found,
void *callback_arg, u8_t addrtype, err_t *err);
int __wrap_lwip_hook_dns_external_resolve(const char *name, ip_addr_t *addr, dns_found_callback found,
void *callback_arg, u8_t addrtype, err_t *err) {
ip_addr_t literal;
if (ipaddr_aton(name, &literal) && IP_IS_V6_VAL(literal))
return 0;
return __real_lwip_hook_dns_external_resolve(name, addr, found, callback_arg, addrtype, err);
}
} // extern "C"
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
#endif // USE_OPENTHREAD_BORDER_ROUTER
#endif
+14 -1
View File
@@ -9,10 +9,23 @@ ESPHOME_LOG_TAG(TAG, "pcd_8544");
void PCD8544::setup_pins_() {
this->spi_setup();
pulse_reset(this->reset_pin_, {.high_before_ms = 1, .low_ms = 10});
this->init_reset_();
this->dc_pin_->setup();
}
void PCD8544::init_reset_() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(1);
// Trigger Reset
this->reset_pin_->digital_write(false);
delay(10);
// Wake up
this->reset_pin_->digital_write(true);
}
}
void PCD8544::initialize() {
this->init_internal_(this->get_buffer_length_());
+2
View File
@@ -57,6 +57,8 @@ class PCD8544 final : public display::DisplayBuffer,
void setup_pins_();
void init_reset_();
size_t get_buffer_length_();
void start_command_();
@@ -90,7 +90,6 @@ bool ResamplerMicrophone::init_resampler_(const audio::AudioStreamInfo &input_st
if (input_stream_info.get_sample_rate() == this->target_sample_rate_) {
// The source already delivers the target sample rate, so its audio is passed through unchanged
this->output_buffer_.reserve(input_stream_info.ms_to_bytes(BUFFER_DURATION_MS));
this->input_stream_info_ = input_stream_info;
this->resampler_ready_ = true;
return true;
@@ -138,23 +137,7 @@ void ResamplerMicrophone::process_audio_(const microphone::AudioCapture &capture
}
if (this->resampler_ == nullptr) {
if (this->mute_state_) {
// The source's buffer is shared with its other consumers, so publish zeros from our own buffer, in pieces that
// fit its reserved size so muting never allocates
const uint32_t max_frames = input_stream_info.ms_to_frames(BUFFER_DURATION_MS);
const uint32_t total_frames = input_stream_info.bytes_to_frames(capture.data.size());
for (uint32_t done = 0; done < total_frames;) {
const uint32_t frames = std::min(total_frames - done, max_frames);
this->output_buffer_.assign(input_stream_info.frames_to_bytes(frames), 0);
const int64_t time_us =
capture.capture_time_us == 0 ? 0 : capture.capture_time_us + input_stream_info.frames_to_microseconds(done);
this->publish_audio_(this->output_buffer_, input_stream_info, time_us);
done += frames;
}
} else {
// Needs a protected forwarding method once data_callbacks_ is made private in 2027.5.0
this->data_callbacks_.call(microphone::AudioCapture{capture.data, input_stream_info, capture.capture_time_us});
}
this->publish_audio_(capture.data, input_stream_info, capture.capture_time_us);
return;
}
-4
View File
@@ -77,7 +77,3 @@ async def pin_to_code(config: ConfigType) -> MockObj:
# Called by writer.py; delegates to the shared libretiny implementation.
def copy_files() -> None:
libretiny.copy_files()
# Called by the OTA upload; the shared libretiny implementation.
ota_image = libretiny.device_ota_image
+12 -1
View File
@@ -122,7 +122,18 @@ void SH1122::fill(Color color) {
const uint8_t fill = (color4 & SH1122_COLORMASK) | ((color4 & SH1122_COLORMASK) << SH1122_COLORSHIFT);
memset(this->buffer_, fill, this->get_buffer_length_());
}
void SH1122::init_reset_() { pulse_reset(this->reset_pin_, {.high_before_ms = 1, .low_ms = 10}); }
void SH1122::init_reset_() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(1);
// Trigger Reset
this->reset_pin_->digital_write(false);
delay(10);
// Wake up
this->reset_pin_->digital_write(true);
}
}
const LogString *SH1122::model_str_() {
switch (this->model_) {
case SH1122_MODEL_256_64:
@@ -355,7 +355,18 @@ void SSD1306::fill(Color color) {
for (uint32_t i = 0; i < this->get_buffer_length_(); i++)
this->buffer_[i] = fill;
}
void SSD1306::init_reset_() { pulse_reset(this->reset_pin_, {.high_before_ms = 1, .low_ms = 10}); }
void SSD1306::init_reset_() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(1);
// Trigger Reset
this->reset_pin_->digital_write(false);
delay(10);
// Wake up
this->reset_pin_->digital_write(true);
}
}
const LogString *SSD1306::model_str_() {
return ModelStrings::get_log_str(static_cast<uint8_t>(this->model_), ModelStrings::LAST_INDEX);
}
@@ -184,7 +184,18 @@ void SSD1322::fill(Color color) {
for (uint32_t i = 0; i < this->get_buffer_length_(); i++)
this->buffer_[i] = fill;
}
void SSD1322::init_reset_() { pulse_reset(this->reset_pin_, {.high_before_ms = 1, .low_ms = 10}); }
void SSD1322::init_reset_() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(1);
// Trigger Reset
this->reset_pin_->digital_write(false);
delay(10);
// Wake up
this->reset_pin_->digital_write(true);
}
}
const char *SSD1322::model_str_() {
switch (this->model_) {
case SSD1322_MODEL_256_64:
@@ -211,7 +211,18 @@ void SSD1325::fill(Color color) {
for (uint32_t i = 0; i < this->get_buffer_length_(); i++)
this->buffer_[i] = fill;
}
void SSD1325::init_reset_() { pulse_reset(this->reset_pin_, {.high_before_ms = 1, .low_ms = 10}); }
void SSD1325::init_reset_() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(1);
// Trigger Reset
this->reset_pin_->digital_write(false);
delay(10);
// Wake up
this->reset_pin_->digital_write(true);
}
}
const char *SSD1325::model_str_() {
switch (this->model_) {
case SSD1325_MODEL_128_32:
@@ -160,7 +160,18 @@ void SSD1327::fill(Color color) {
for (uint32_t i = 0; i < this->get_buffer_length_(); i++)
this->buffer_[i] = fill;
}
void SSD1327::init_reset_() { pulse_reset(this->reset_pin_, {.high_before_ms = 1, .low_ms = 10}); }
void SSD1327::init_reset_() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(1);
// Trigger Reset
this->reset_pin_->digital_write(false);
delay(10);
// Wake up
this->reset_pin_->digital_write(true);
}
}
const char *SSD1327::model_str_() {
switch (this->model_) {
case SSD1327_MODEL_128_128:
@@ -142,6 +142,17 @@ void SSD1331::fill(Color color) {
}
}
}
void SSD1331::init_reset_() { pulse_reset(this->reset_pin_, {.high_before_ms = 1, .low_ms = 10}); }
void SSD1331::init_reset_() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(1);
// Trigger Reset
this->reset_pin_->digital_write(false);
delay(10);
// Wake up
this->reset_pin_->digital_write(true);
}
}
} // namespace esphome::ssd1331_base
@@ -174,7 +174,18 @@ void SSD1351::fill(Color color) {
}
}
}
void SSD1351::init_reset_() { pulse_reset(this->reset_pin_, {.high_before_ms = 1, .low_ms = 10}); }
void SSD1351::init_reset_() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(1);
// Trigger Reset
this->reset_pin_->digital_write(false);
delay(10);
// Wake up
this->reset_pin_->digital_write(true);
}
}
const char *SSD1351::model_str_() {
switch (this->model_) {
case SSD1351_MODEL_128_96:
@@ -9,8 +9,12 @@ ESPHOME_LOG_TAG(TAG, "st7123.touchscreen");
void ST7123Touchscreen::setup() {
if (this->reset_pin_ != nullptr) {
// TP_RESX is active low, assert for at least tRSTW (2ms)
pulse_reset(this->reset_pin_, {.high_before_ms = 5, .low_ms = 5});
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(5);
this->reset_pin_->digital_write(false); // TP_RESX is active low, assert for at least tRSTW (2ms)
delay(5);
this->reset_pin_->digital_write(true);
// The controller needs up to 20ms to initialize after reset before it can be accessed.
this->setup_time_ = millis() + 30;
}
+12 -1
View File
@@ -137,7 +137,18 @@ void ST7567::fill(Color color) {
memset(buffer_, fill, this->get_buffer_length_());
}
void ST7567::init_reset_() { pulse_reset(this->reset_pin_, {.high_before_ms = 1, .low_ms = 10}); }
void ST7567::init_reset_() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(1);
// Trigger Reset
this->reset_pin_->digital_write(false);
delay(10);
// Wake up
this->reset_pin_->digital_write(true);
}
}
const char *ST7567::model_str_() { return "ST7567 128x64"; }
-23
View File
@@ -6,7 +6,6 @@ from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components import i2c
from esphome.components.audio_dac import AudioDac
from esphome.components.const import CONF_MODULATION
import esphome.config_validation as cv
from esphome.const import CONF_ENABLE_PIN, CONF_ID, CONF_MODEL
from esphome.cpp_generator import MockObj
@@ -20,7 +19,6 @@ DEPENDENCIES = ["i2c"]
CONF_ANALOG_GAIN = "analog_gain"
CONF_DAC_MODE = "dac_mode"
CONF_FAULT_OUTPUT = "fault_output"
CONF_IGNORE_ENABLE_PIN_WARNING = "ignore_enable_pin_warning"
CONF_MIXER_MODE = "mixer_mode"
CONF_VOLUME_MIN_DB = "volume_min_db"
@@ -41,11 +39,6 @@ MIXER_MODES = MixerMode.options(
"stereo", "stereo_inverse", "mono", "left", "right", prefix="MIXER_MODE_"
)
Modulation = tas58xx_ns.enum("Modulation")
MODULATIONS = Modulation.options("bd_mode", "1spw_mode", prefix="MODULATION_")
FAULT_OUTPUT_NONE = "none"
@dataclass(frozen=True)
class Model:
@@ -56,9 +49,6 @@ class Model:
volume_min_db: float
volume_max_db: float
default_address: int
# Pins that can report FAULTZ: name -> pin number n, selected at 0x61 + n (ModelInfo::fault_output_pins)
fault_outputs: dict[str, int]
default_fault_output: str
MODELS: dict[str, Model] = {
@@ -68,8 +58,6 @@ MODELS: dict[str, Model] = {
volume_min_db=-103.0,
volume_max_db=24.0,
default_address=0x2D,
fault_outputs={"adr": 0},
default_fault_output="adr",
),
DAC_TAS5825M: Model(
model_info=tas58xx_ns.TAS5825M_MODEL,
@@ -77,8 +65,6 @@ MODELS: dict[str, Model] = {
volume_min_db=-103.0,
volume_max_db=24.0,
default_address=0x4C,
fault_outputs={"gpio0": 0, "gpio1": 1, "gpio2": 2},
default_fault_output="gpio1",
),
}
@@ -114,15 +100,9 @@ def _model_schema(model: Model) -> cv.Schema:
cv.Optional(CONF_DAC_MODE, default="btl"): cv.enum(
DAC_MODES, lower=True
),
cv.Optional(
CONF_FAULT_OUTPUT, default=model.default_fault_output
): cv.one_of(FAULT_OUTPUT_NONE, *model.fault_outputs, lower=True),
cv.Optional(CONF_MIXER_MODE, default="stereo"): cv.enum(
MIXER_MODES, lower=True
),
cv.Optional(CONF_MODULATION, default="bd_mode"): cv.enum(
MODULATIONS, lower=True
),
cv.Optional(
CONF_VOLUME_MIN_DB, default=model.volume_min_db
): volume_validator,
@@ -192,10 +172,7 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_analog_gain(config[CONF_ANALOG_GAIN]))
cg.add(var.set_dac_mode(config[CONF_DAC_MODE]))
if (fault_output := config[CONF_FAULT_OUTPUT]) != FAULT_OUTPUT_NONE:
cg.add(var.set_fault_output(model.fault_outputs[fault_output]))
cg.add(var.set_mixer_mode(config[CONF_MIXER_MODE]))
cg.add(var.set_modulation(config[CONF_MODULATION]))
cg.add(var.set_volume_min_db(config[CONF_VOLUME_MIN_DB]))
cg.add(var.set_volume_max_db(config[CONF_VOLUME_MAX_DB]))
await cg.add_pin(var.set_enable_pin, config.get(CONF_ENABLE_PIN))
@@ -8,8 +8,11 @@ namespace esphome::tas58xx {
// Registers 0x46, 0x7D, 0x7E and page 1 register 0x51 are not documented in the datasheet.
static constexpr i2c::I2CRegValue STARTUP_SEQUENCE[] PROGMEM = {
{0x03, 0x00}, // DEVICE_CTRL_2: deep sleep
{0x46, 0x01}, // PPC 96 kHz process flow value (its 48 kHz FIR flows write 0x11)
{0x46, 0x01},
{0x03, 0x02}, // DEVICE_CTRL_2: Hi-Z
// The I2C address is latched at power up, after which the ADR pin can report faults
{0x61, 0x0B}, // ADR_PIN_CONFIG: FAULTZ
{0x60, 0x01}, // ADR_PIN_CTRL: output
{0x7D, 0x11},
{0x7E, 0xFF},
{0x00, 0x01}, // Page 1
@@ -22,8 +25,6 @@ static constexpr i2c::I2CRegValue STARTUP_SEQUENCE[] PROGMEM = {
static const LogString *model_name() { return LOG_STR("TAS5805M"); }
static const LogString *fault_output_name(uint8_t) { return LOG_STR("ADR"); }
// An if chain rather than a switch: a switch table would land in rodata, which is RAM on ESP8266.
static const LogString *fault_name(uint8_t index) {
if (index == 0)
@@ -87,8 +88,6 @@ const ModelInfo TAS5805M_MODEL = {
NO_BIT, // FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT_WARNING
NO_BIT, // FAULT_SENSOR_OVER_TEMP_146C_WARNING
},
.fault_output_pins = 1, // ADR
.fault_output_name = fault_output_name,
};
} // namespace esphome::tas58xx
+5 -12
View File
@@ -6,7 +6,7 @@ namespace esphome::tas58xx {
// Remainder of the startup sequence, run after the reset in TAS58xx::init_(). Register 0x00 selects the page.
// Based on the TI PurePath Console (PPC) export for "TAS5825M 2-Band DRC&AGL 2.0 96k".
// Registers 0x7D, 0x7E, page 1 register 0x51 and page 2 registers 0x1D, 0x19 are not documented in datasheet.
// Registers 0x46, 0x7D, 0x7E, page 1 register 0x51 and page 2 registers 0x1D, 0x19 are not documented in datasheet.
static constexpr i2c::I2CRegValue STARTUP_SEQUENCE[] PROGMEM = {
{0x03, 0x00}, // DEVICE_CTRL_2: deep sleep, the state the PPC sequence starts from
{0x7D, 0x11},
@@ -17,25 +17,20 @@ static constexpr i2c::I2CRegValue STARTUP_SEQUENCE[] PROGMEM = {
{0x1D, 0x00},
{0x19, 0x80},
{0x00, 0x00}, // Page 0
{0x46, 0x11}, // DSP_CTRL: 96 kHz processing (bits 4-3 = 10), use default coefficients (bit 0 = 1)
{0x46, 0x11},
// PPC also writes 0x02 (DEVICE_CTRL_1) and 0x54 (AGAIN) here; init_() writes both from YAML
{0x53, 0x01}, // ANA_CTRL: in phase, 100kHz bandwidth for high audio performance with Fsw=384kHz
{0x03, 0x02}, // DEVICE_CTRL_2: Hi-Z
// Additional register configuration
// GPIO1 is a dedicated pin on TAS5825M (separate from ADR), used as FAULTZ
{0x62, 0x0B}, // GPIO1_SEL: GPIO1 as FAULTZ
{0x60, 0x02}, // GPIO_CTRL: GPIO1 is output
{0x77, 0x07}, // CBC_CONTROL: enable cycle-by-cycle current limit for warnings and faults
};
static const LogString *model_name() { return LOG_STR("TAS5825M"); }
static const LogString *fault_output_name(uint8_t pin) {
if (pin == 0)
return LOG_STR("GPIO0");
if (pin == 1)
return LOG_STR("GPIO1");
return LOG_STR("GPIO2");
}
// An if chain rather than a switch: a switch table would land in rodata, which is RAM on ESP8266.
static const LogString *fault_name(uint8_t index) {
if (index == 0)
@@ -118,8 +113,6 @@ const ModelInfo TAS5825M_MODEL = {
// 25, // FAULT_SENSOR_OVER_TEMP_122C_WARNING // not currently included
// 24, // FAULT_SENSOR_OVER_TEMP_112C_WARNING // not currently included
},
.fault_output_pins = 3, // GPIO0, GPIO1, GPIO2 (ADR is an analog input only on TAS5825M)
.fault_output_name = fault_output_name,
};
} // namespace esphome::tas58xx
+6 -35
View File
@@ -20,7 +20,6 @@ static constexpr uint8_t TAS58XX_RESET_CTRL = 0x01;
static constexpr uint8_t TAS58XX_RESET_CTRL_ALL = 0x11; // Reset DSP/control port and registers
static constexpr uint8_t TAS58XX_DEVICE_CTRL_1 = 0x02;
static constexpr uint8_t TAS58XX_DEVICE_CTRL_1_PBTL = (1 << 2);
static constexpr uint8_t TAS58XX_DEVICE_CTRL_1_1SPW = 0x01; // DAMP_MOD = 01
static constexpr uint8_t TAS58XX_DEVICE_CTRL_2 = 0x03;
static constexpr uint8_t TAS58XX_DEVICE_CTRL_2_MUTE = (1 << 3);
static constexpr uint8_t TAS58XX_CTRL_STATE_MASK = 0x03; // DEVICE_CTRL_2 and POWER_STATE
@@ -34,9 +33,6 @@ static constexpr uint8_t TAS58XX_DIG_VOL_0DB = 0x30;
static constexpr uint8_t TAS58XX_DIG_VOL_MINUS_103DB = 0xFE;
static constexpr uint8_t TAS58XX_AGAIN = 0x54; // 0x00 = 0 dB to 0x1F = -15.5 dB in 0.5 dB steps
static constexpr uint8_t TAS58XX_AGAIN_MINUS_15_5DB = 0x1F;
static constexpr uint8_t TAS58XX_GPIO_CTRL = 0x60; // ADR_PIN_CTRL on TAS5805M: output enable, bit n for pin n
static constexpr uint8_t TAS58XX_GPIO0_SEL = 0x61; // ADR_PIN_CONFIG on TAS5805M: pin n is selected at 0x61 + n
static constexpr uint8_t TAS58XX_GPIO_SEL_FAULTZ = 0x0B; // pin function: FAULTZ output
static constexpr uint8_t TAS58XX_POWER_STATE = 0x68;
static constexpr uint8_t TAS58XX_CHAN_FAULT = 0x70; // GLOBAL_FAULT1, GLOBAL_FAULT2 and OT_WARNING follow
static constexpr uint8_t TAS58XX_FAULT_REGISTER_COUNT = 4;
@@ -102,26 +98,12 @@ bool TAS58xx::init_() {
return false;
}
// The I2C address is latched at power up, so ADR can now be an output. The pin function is written before the
// output enable, so the pin never drives another function.
if (this->fault_output_pin_ != FAULT_OUTPUT_NONE) {
// The setter is public, so only write this model's pins; YAML only allows the pins of the model
if (this->fault_output_pin_ >= this->model_->fault_output_pins) {
ESP_LOGW(TAG, "Invalid fault output index %u", this->fault_output_pin_);
} else if (!this->write_byte(TAS58XX_GPIO0_SEL + this->fault_output_pin_, TAS58XX_GPIO_SEL_FAULTZ) ||
!this->write_byte(TAS58XX_GPIO_CTRL, static_cast<uint8_t>(1 << this->fault_output_pin_))) {
ESP_LOGE(TAG, "I2C write failed during init");
return false;
}
}
// Setters are public, so keep the values inside the register ranges
// Setters are public, so keep the value inside the register range
const uint8_t again =
static_cast<uint8_t>(lroundf(clamp(-this->analog_gain_db_ * 2.0f, 0.0f, float{TAS58XX_AGAIN_MINUS_15_5DB})));
const uint8_t device_ctrl_1 = (this->dac_mode_ == DAC_MODE_PBTL ? TAS58XX_DEVICE_CTRL_1_PBTL : 0) |
(this->modulation_ == MODULATION_1SPW_MODE ? TAS58XX_DEVICE_CTRL_1_1SPW : 0);
if (!this->write_byte(TAS58XX_DEVICE_CTRL_1, device_ctrl_1) || !this->write_byte(TAS58XX_AGAIN, again) ||
!this->write_volume_() || !this->write_ctrl_state_(TAS58XX_CTRL_STATE_PLAY, this->is_muted_) ||
if (!this->write_byte(TAS58XX_DEVICE_CTRL_1, this->dac_mode_ == DAC_MODE_PBTL ? TAS58XX_DEVICE_CTRL_1_PBTL : 0) ||
!this->write_byte(TAS58XX_AGAIN, again) || !this->write_volume_() ||
!this->write_ctrl_state_(TAS58XX_CTRL_STATE_PLAY, this->is_muted_) ||
!this->write_byte(TAS58XX_FAULT_CLEAR, TAS58XX_FAULT_CLEAR_ANALOG)) {
ESP_LOGE(TAG, "I2C write failed during init");
return false;
@@ -281,14 +263,6 @@ void TAS58xx::dump_config() {
} else {
mixer_mode = LOG_STR("Stereo");
}
const LogString *fault_output;
if (this->fault_output_pin_ == FAULT_OUTPUT_NONE) {
fault_output = LOG_STR("None");
} else if (this->fault_output_pin_ >= this->model_->fault_output_pins) {
fault_output = LOG_STR("Invalid");
} else {
fault_output = this->model_->fault_output_name(this->fault_output_pin_);
}
ESP_LOGCONFIG(TAG,
"Audio Amplifier:\n"
" Model: %s",
@@ -299,14 +273,11 @@ void TAS58xx::dump_config() {
ESP_LOGCONFIG(TAG,
" Analog Gain: %.1f dB\n"
" DAC Mode: %s\n"
" Modulation: %s\n"
" Mixer Mode: %s\n"
" Volume Range: %.1f dB to %.1f dB\n"
" Fault Output: %s",
" Volume Range: %.1f dB to %.1f dB",
this->analog_gain_db_,
this->dac_mode_ == DAC_MODE_PBTL ? LOG_STR_LITERAL("PBTL") : LOG_STR_LITERAL("BTL"),
this->modulation_ == MODULATION_1SPW_MODE ? LOG_STR_LITERAL("1SPW") : LOG_STR_LITERAL("BD"),
LOG_STR_ARG(mixer_mode), this->volume_min_db_, this->volume_max_db_, LOG_STR_ARG(fault_output));
LOG_STR_ARG(mixer_mode), this->volume_min_db_, this->volume_max_db_);
#ifdef USE_BINARY_SENSOR
LOG_BINARY_SENSOR(" ", "Any Fault", this->have_fault_binary_sensor_);
for (auto *sensor : this->fault_binary_sensors_) {
-17
View File
@@ -22,12 +22,6 @@ enum DacMode : uint8_t {
DAC_MODE_PBTL = 1, // Parallel bridge tied load, one speaker
};
/// DEVICE_CTRL_1 DAMP_MOD values, the same on all models
enum Modulation : uint8_t {
MODULATION_BD_MODE = 0,
MODULATION_1SPW_MODE = 1,
};
enum MixerMode : uint8_t {
MIXER_MODE_STEREO = 0,
MIXER_MODE_STEREO_INVERSE,
@@ -70,8 +64,6 @@ static_assert(
/// ModelInfo::fault_sensor_bits value for a FaultSensor that the model does not have.
static constexpr uint8_t NO_BIT = 0xFF;
/// fault_output_pin_ value for no fault output
static constexpr uint8_t FAULT_OUTPUT_NONE = 0xFF;
/// Everything that differs between models of the family. One constant instance exists per model, see
/// model_*.cpp, and each TAS58xx instance points to the one for its model.
@@ -99,10 +91,6 @@ struct ModelInfo {
const LogString *(*fault_name)(uint8_t index);
/// Bit index for each FaultSensor, NO_BIT used where the model does not have that fault bit
uint8_t fault_sensor_bits[FAULT_SENSOR_COUNT];
/// Number of pins that can report FAULTZ: pin n is selected at 0x61 + n and enabled by bit n of 0x60
uint8_t fault_output_pins;
/// Name of FAULTZ pin n, for dump_config
const LogString *(*fault_output_name)(uint8_t pin);
};
#ifdef USE_TAS58XX_TAS5805M
@@ -139,10 +127,7 @@ class TAS58xx : public audio_dac::AudioDac, public PollingComponent, public i2c:
void set_enable_pin(GPIOPin *enable_pin) { this->enable_pin_ = enable_pin; }
void set_analog_gain(float analog_gain_db) { this->analog_gain_db_ = analog_gain_db; }
void set_dac_mode(DacMode dac_mode) { this->dac_mode_ = dac_mode; }
/// pin: 0 to 2 for GPIOn on TAS5825M, 0 for ADR on TAS5805M; leave unset for no fault output
void set_fault_output(uint8_t pin) { this->fault_output_pin_ = pin; }
void set_mixer_mode(MixerMode mixer_mode) { this->mixer_mode_ = mixer_mode; }
void set_modulation(Modulation modulation) { this->modulation_ = modulation; }
void set_volume_min_db(float volume_min_db) { this->volume_min_db_ = volume_min_db; }
void set_volume_max_db(float volume_max_db) { this->volume_max_db_ = volume_max_db; }
@@ -186,9 +171,7 @@ class TAS58xx : public audio_dac::AudioDac, public PollingComponent, public i2c:
float volume_max_db_{24.0f};
uint32_t logged_faults_{0}; // Fault bits as last logged, packed like the fault masks
DacMode dac_mode_{DAC_MODE_BTL};
uint8_t fault_output_pin_{FAULT_OUTPUT_NONE}; // No fault output: the pin keeps its power on state
MixerMode mixer_mode_{MIXER_MODE_STEREO};
Modulation modulation_{MODULATION_BD_MODE};
uint8_t ctrl_state_{0};
uint8_t power_state_{0xFF}; // Last POWER_STATE seen by update(), 0xFF until the first read
bool mixer_written_{false}; // Mixer written since the last reset
@@ -124,7 +124,6 @@ CoverTraits TemplateCover::get_traits() {
Trigger<float> *TemplateCover::get_position_trigger() { return &this->position_trigger_; }
Trigger<float> *TemplateCover::get_tilt_trigger() { return &this->tilt_trigger_; }
void TemplateCover::stop_prev_trigger_() {
this->position_trigger_.stop_action();
if (this->prev_command_trigger_ != nullptr) {
this->prev_command_trigger_->stop_action();
this->prev_command_trigger_ = nullptr;
@@ -54,7 +54,10 @@ void TT21100Touchscreen::setup() {
}
// Perform reset if necessary
pulse_reset(this->reset_pin_, {.low_ms = 10, .high_after_ms = 10});
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_();
}
// Update display dimensions if they were updated during display setup
if (this->display_ != nullptr) {
@@ -119,6 +122,15 @@ void TT21100Touchscreen::update_touches() {
}
}
void TT21100Touchscreen::reset_() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->digital_write(false);
delay(10);
this->reset_pin_->digital_write(true);
delay(10);
}
}
void TT21100Touchscreen::dump_config() {
ESP_LOGCONFIG(TAG, "TT21100 Touchscreen:");
LOG_I2C_DEVICE(this);
@@ -28,6 +28,8 @@ class TT21100Touchscreen final : public Touchscreen, public i2c::I2CDevice {
void register_button_listener(TT21100ButtonListener *listener) { this->button_listeners_.push_back(listener); }
protected:
void reset_();
void update_touches() override;
InternalGPIOPin *interrupt_pin_;
+5 -103
View File
@@ -5,37 +5,25 @@ import esphome.codegen as cg
from esphome.components import display, network, socket
from esphome.components.const import (
BYTE_ORDER_LITTLE,
CONF_BUTTONS,
CONF_HOST,
CONF_RECONNECT_INTERVAL,
)
from esphome.components.touchscreen import CONF_TOUCHSCREEN_ID, Touchscreen
import esphome.config_validation as cv
from esphome.const import (
CONF_ACTION,
CONF_AUTO_CLEAR_ENABLED,
CONF_DISPLAY,
CONF_DISPLAY_ID,
CONF_ID,
CONF_KEY,
CONF_ON_CONNECT,
CONF_ON_DISCONNECT,
CONF_PLATFORM,
CONF_PORT,
CONF_TEXT,
CONF_X,
CONF_Y,
)
from esphome.core import ID
from esphome.cpp_generator import MockObj
import esphome.final_validate as fv
from esphome.types import ConfigType, TemplateArgsType
from .keysyms import button_mask, keysym
from esphome.types import ConfigType
CODEOWNERS = ["@MindFreeze"]
DEPENDENCIES = ["display", "network"]
AUTO_LOAD = ["socket", "unicode"]
AUTO_LOAD = ["socket"]
DOMAIN = "vnc"
MULTI_CONF = True
@@ -46,14 +34,11 @@ CONF_PIXEL_FORMAT = "pixel_format"
vnc_ns = cg.esphome_ns.namespace("vnc")
VncClient = vnc_ns.class_("VncClient", cg.Component)
VncPixelFormat = vnc_ns.enum("VncPixelFormat", is_class=True)
VncKeyAction = vnc_ns.enum("VncKeyAction", is_class=True)
PIXEL_FORMATS = VncPixelFormat.options(
"auto", "rgb565", "rgb332", prefix="VNC_PIXEL_FORMAT_"
)
KEY_ACTIONS = VncKeyAction.options("press", "down", "up", prefix="VNC_KEY_ACTION_")
# Drivers that cannot take another component's pixels yet.
UNSUPPORTED_DISPLAYS = dict.fromkeys(
("mipi_dsi", "mipi_rgb", "mipi_spi"), "it is not supported by vnc yet"
@@ -87,7 +72,6 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_PIXEL_FORMAT, default="auto"): cv.enum(
PIXEL_FORMATS, lower=True
),
cv.Optional(CONF_TOUCHSCREEN_ID): cv.use_id(Touchscreen),
cv.Optional(CONF_ON_CONNECT): automation.validate_automation({}),
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation({}),
}
@@ -131,14 +115,6 @@ def _final_validate(config: ConfigType) -> None:
raise cv.Invalid(
f"Display '{disp_id}' is also used by LVGL", path=[CONF_DISPLAY_ID]
)
if (ts_id := config.get(CONF_TOUCHSCREEN_ID)) is not None:
ts_display = fv.get_declaring_config(ts_id).get(CONF_DISPLAY)
if ts_display is not None and ts_display.id != disp_id.id:
raise cv.Invalid(
f"Touchscreen '{ts_id}' belongs to display '{ts_display}', "
f"not '{disp_id}'",
path=[CONF_TOUCHSCREEN_ID],
)
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -162,89 +138,15 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_update_delay(config[CONF_UPDATE_DELAY]))
cg.add(var.set_max_update_delay(config[CONF_MAX_UPDATE_DELAY]))
cg.add(var.set_pixel_format(config[CONF_PIXEL_FORMAT]))
if (
await cg.add_variable(var.register_touchscreen, config.get(CONF_TOUCHSCREEN_ID))
is not None
):
cg.add_define("USE_VNC_INPUT")
cg.add_define("USE_VNC_TOUCHSCREEN")
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
_CLIENT_ID = {cv.GenerateID(): cv.use_id(VncClient)}
VNC_ID_SCHEMA = automation.maybe_simple_id(_CLIENT_ID)
VNC_ID_SCHEMA = automation.maybe_simple_id(
{cv.GenerateID(): cv.use_id(VncClient)},
)
automation.register_apply_action(
"vnc.refresh", VNC_ID_SCHEMA, automation.ApplyCall("refresh()")
)
automation.register_apply_condition("vnc.is_connected", VNC_ID_SCHEMA, "is_connected()")
def _register_input_action(
name: str, schema: cv.Schema, *fields: automation.ApplyField | automation.ApplyCall
) -> None:
builder = automation.apply_action_builder(name, schema, *fields)
@automation.register_action(name, automation.ApplyAction, schema, synchronous=True)
async def input_action_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
cg.add_define("USE_VNC_INPUT")
return await builder(config, action_id, template_arg, args)
_register_input_action(
"vnc.send_pointer",
cv.Schema(
{
**_CLIENT_ID,
cv.Required(CONF_X): cv.templatable(cv.uint16_t),
cv.Required(CONF_Y): cv.templatable(cv.uint16_t),
cv.Optional(CONF_BUTTONS, default=0): cv.templatable(button_mask),
}
),
automation.ApplyCall(
"send_pointer({}, {}, {})",
((CONF_X, cg.uint16), (CONF_Y, cg.uint16), (CONF_BUTTONS, cg.uint8)),
),
)
_register_input_action(
"vnc.send_key",
automation.maybe_conf(
CONF_KEY,
cv.Schema(
{
**_CLIENT_ID,
cv.Required(CONF_KEY): cv.templatable(keysym),
cv.Optional(CONF_ACTION, default="press"): cv.enum(
KEY_ACTIONS, lower=True
),
}
),
),
automation.ApplyCall(
"send_key({}, {})", ((CONF_KEY, cg.uint32), (CONF_ACTION, VncKeyAction))
),
)
_register_input_action(
"vnc.type_text",
automation.maybe_conf(
CONF_TEXT,
cv.Schema(
{
**_CLIENT_ID,
cv.Required(CONF_TEXT): cv.templatable(cv.string),
}
),
),
automation.ApplyField(
CONF_TEXT, "type_text", cg.std_string, const_fn=automation.literal_with_length
),
)
-136
View File
@@ -1,136 +0,0 @@
"""X11 keysyms and pointer button masks for VNC input, resolved at code generation."""
import esphome.config_validation as cv
from esphome.core import HexInt
_KEYSYM_MAX = 0x1FFFFFFF
# X11 encodes a Unicode character outside Latin-1 as this prefix plus its code point
_UNICODE_KEYSYM = 0x01000000
KEYSYMS: dict[str, int] = {
"BackSpace": 0xFF08,
"Tab": 0xFF09,
"Return": 0xFF0D,
"Pause": 0xFF13,
"Scroll_Lock": 0xFF14,
"Escape": 0xFF1B,
"Home": 0xFF50,
"Left": 0xFF51,
"Up": 0xFF52,
"Right": 0xFF53,
"Down": 0xFF54,
"Page_Up": 0xFF55,
"Page_Down": 0xFF56,
"End": 0xFF57,
"Print": 0xFF61,
"Insert": 0xFF63,
"Menu": 0xFF67,
"Num_Lock": 0xFF7F,
"KP_Enter": 0xFF8D,
**{f"F{n}": 0xFFBE + n - 1 for n in range(1, 13)},
"Shift_L": 0xFFE1,
"Shift_R": 0xFFE2,
"Control_L": 0xFFE3,
"Control_R": 0xFFE4,
"Caps_Lock": 0xFFE5,
"Meta_L": 0xFFE7,
"Meta_R": 0xFFE8,
"Alt_L": 0xFFE9,
"Alt_R": 0xFFEA,
"Super_L": 0xFFEB,
"Super_R": 0xFFEC,
"Delete": 0xFFFF,
"space": 0x20,
"ISO_Left_Tab": 0xFE20,
}
ALIASES: dict[str, str] = {
"alt": "Alt_L",
"ctrl": "Control_L",
"del": "Delete",
"enter": "Return",
"esc": "Escape",
"ins": "Insert",
"next": "Page_Down",
"pgdn": "Page_Down",
"pgup": "Page_Up",
"prior": "Page_Up",
"shift": "Shift_L",
"super": "Super_L",
}
_BY_LOWER_NAME: dict[str, int] = {
**{name.lower(): code for name, code in KEYSYMS.items()},
**{alias: KEYSYMS[name] for alias, name in ALIASES.items()},
}
BUTTONS: dict[str, int] = {
"left": 1,
"middle": 2,
"right": 4,
"wheel_up": 8,
"wheel_down": 16,
}
_BUTTON_NAMES = cv.ensure_list(cv.one_of(*BUTTONS, lower=True))
_keysym_number = cv.hex_int_range(min=0, max=_KEYSYM_MAX)
# Keep in step with keysym_for_codepoint() in vnc_client.cpp.
def _character_keysym(char: str) -> HexInt:
code = ord(char)
if 0x20 <= code <= 0x7E or 0xA0 <= code <= 0xFF:
return HexInt(code)
if code <= 0x9F:
raise cv.Invalid(
f"Control character U+{code:04X} is not a key. Use a key name such as Return, Tab or BackSpace"
)
return HexInt(_UNICODE_KEYSYM | code)
def keysym(value: object) -> HexInt:
"""An X11 keysym from a key name, a single character or a number.
An unquoted digit 0 to 9 is read as that digit, as in tinyusb_keyboard. Names are matched
without regard to case, but a single character is always that character, so "a" and "A" differ.
"""
if isinstance(value, bool):
raise cv.Invalid(
'YAML read this key as a boolean. Put it in quotes, for example key: "y"'
)
if isinstance(value, int):
if 0 <= value <= 9:
return HexInt(ord("0") + value)
return _keysym_number(value)
if not isinstance(value, str):
raise cv.Invalid(
f"Expected a key name, a single character or a keysym number, got {value!r}"
)
if len(value) == 1:
return _character_keysym(value)
lower = value.lower()
if lower.startswith("0x"):
return _keysym_number(value)
if (code := _BY_LOWER_NAME.get(lower)) is not None:
return HexInt(code)
raise cv.Invalid(
f"Unknown key '{value}'. Use a single character, a keysym number such as 0xFF0D, "
f""
"or a key name such as Return, Escape, Tab, BackSpace, Delete, Left, Page_Up, F1 or Control_L"
)
def button_mask(value: object) -> int:
"""A pointer button mask from a number 0 to 255, or one or more button names."""
if isinstance(value, bool):
raise cv.Invalid(
f"Expected a button mask or button names ({', '.join(BUTTONS)}), got a boolean"
)
if isinstance(value, int):
return cv.uint8_t(value)
mask = 0
for name in _BUTTON_NAMES(value):
mask |= BUTTONS[name]
return mask
+2 -224
View File
@@ -1,7 +1,6 @@
#include "vnc_client.h"
#include "esphome/components/network/util.h"
#include "esphome/components/unicode/unicode.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
@@ -9,7 +8,6 @@
#include <algorithm>
#include <cinttypes>
#include <cstdlib>
#include <cstring>
#include <iterator>
@@ -55,17 +53,6 @@ static constexpr uint8_t MSG_SET_COLOUR_MAP = 1;
static constexpr uint8_t MSG_BELL = 2;
static constexpr uint8_t MSG_SERVER_CUT_TEXT = 3;
#ifdef USE_VNC_INPUT
static constexpr uint8_t MSG_KEY_EVENT = 4;
static constexpr uint8_t MSG_POINTER_EVENT = 5;
static constexpr uint32_t KEYSYM_BACKSPACE = 0xFF08;
static constexpr uint32_t KEYSYM_TAB = 0xFF09;
static constexpr uint32_t KEYSYM_RETURN = 0xFF0D;
// X11 encodes a character outside Latin-1 as this prefix plus its code point.
static constexpr uint32_t KEYSYM_UNICODE = 0x01000000;
#endif
static inline uint16_t read_be16(const uint8_t *p) { return encode_uint16(p[0], p[1]); }
static inline uint32_t read_be32(const uint8_t *p) { return encode_uint32(p[0], p[1], p[2], p[3]); }
static inline void write_be16(uint8_t *p, uint16_t v) {
@@ -90,59 +77,8 @@ static int parse_three_digits(const uint8_t *p) {
return value;
}
#ifdef USE_VNC_INPUT
// Keep in step with _character_keysym() in keysyms.py.
static uint32_t keysym_for_codepoint(uint32_t cp) {
switch (cp) {
case '\n':
return KEYSYM_RETURN;
case '\t':
return KEYSYM_TAB;
case '\b':
return KEYSYM_BACKSPACE;
default:
break;
}
if (cp < 0x20 || (cp >= 0x7F && cp < 0xA0)) {
return 0;
}
return cp <= 0xFF ? cp : (KEYSYM_UNICODE | cp);
}
#endif
#ifdef USE_VNC_TOUCHSCREEN
static constexpr uint16_t TOUCH_SLOP_PX = 8;
static bool is_down(const touchscreen::TouchPoint &tp) {
// A lifted finger lingers in the list as RELEASED or RELEASING until every finger is up.
return (tp.state & (touchscreen::STATE_PRESSED | touchscreen::STATE_UPDATED)) != 0 &&
(tp.state & touchscreen::STATE_RELEASING) == 0;
}
// A first touch at (0, 0) matches a new point's default previous position, so only touch() reports it.
void VncClient::touch(touchscreen::TouchPoint tp) {
if (is_down(tp)) {
this->handle_touch_(tp.x, tp.y);
}
}
void VncClient::update(const touchscreen::TouchPoints_t &tpoints) {
for (const auto &tp : tpoints) {
if (is_down(tp)) {
this->handle_touch_(tp.x, tp.y);
return;
}
}
}
void VncClient::release() { this->handle_release_(); }
#endif
void VncClient::setup() {
this->setup_ms_ = App.get_loop_component_start_time();
#ifdef USE_VNC_INPUT
this->last_input_ms_ = this->setup_ms_ - INPUT_BOOST_MS; // no input yet
#endif
this->link_.begin(TAG);
// Sized for the display now; a rotation changed later swaps the sides but keeps the cell count.
const uint32_t wanted = static_cast<uint32_t>((this->display_->get_width() + 15) / 16) *
@@ -185,9 +121,6 @@ void VncClient::dump_config() {
this->max_update_delay_ms_,
LOG_STR_ARG(PixelFormatStrings::get_log_str(static_cast<uint8_t>(this->pixel_format_), 0)),
this->hash_capacity_);
#ifdef USE_VNC_TOUCHSCREEN
ESP_LOGCONFIG(TAG, " Touchscreen: %s", this->has_touchscreen_ ? LOG_STR_LITERAL("yes") : LOG_STR_LITERAL("no"));
#endif
}
void VncClient::on_shutdown() { this->link_.close(); }
@@ -262,15 +195,10 @@ void VncClient::loop() {
// A display still starting up (it8951) can go from its start-up straight into a refresh without parking.
const bool display_starting =
!this->display_parks_ && this->display_->has_overridden_loop() && now - this->setup_ms_ < DISPLAY_START_MS;
uint32_t update_delay = this->update_delay_ms_;
#ifdef USE_VNC_INPUT
if (now - this->last_input_ms_ < INPUT_BOOST_MS) {
update_delay = 0;
}
#endif
// Wait until the screen stops changing, so an animation becomes one refresh showing where it ended.
if (this->refresh_pending_ && !this->in_update_ && !this->display_busy_ && !display_starting &&
(now - this->last_change_ms_ >= update_delay || now - this->first_change_ms_ >= this->max_update_delay_ms_)) {
(now - this->last_change_ms_ >= this->update_delay_ms_ ||
now - this->first_change_ms_ >= this->max_update_delay_ms_)) {
// Synchronous displays flush inside update(), so this pass does nothing else.
this->refresh_pending_ = false;
this->display_->update();
@@ -279,9 +207,6 @@ void VncClient::loop() {
this->service_rx_(now);
}
if (this->state_ == VncState::VNC_STATE_RUNNING) {
#ifdef USE_VNC_INPUT
this->send_pending_();
#endif
// A new session lays the screen out again for the new rotation and redraws all of it.
if (static_cast<uint8_t>(this->display_->get_rotation() / 90) != this->hash_rotation_) {
this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Display rotation changed"));
@@ -338,13 +263,6 @@ void VncClient::reset_session_() {
this->refresh_pending_ = false;
this->need_full_ = false;
this->frame_drawn_ = false;
#ifdef USE_VNC_INPUT
this->pending_count_ = 0;
this->ptr_buttons_ = 0;
#endif
#ifdef USE_VNC_TOUCHSCREEN
this->touching_ = false;
#endif
this->step_ = VncStep::VNC_STEP_VERSION;
this->reason_failure_ = VncFailure::VNC_FAILURE_TRANSIENT;
this->high_freq_.stop();
@@ -1066,144 +984,4 @@ bool VncClient::send_update_request_(bool incremental, uint32_t now) {
return true;
}
#ifdef USE_VNC_INPUT
bool VncClient::send_key_event_(uint32_t keysym, VncKeyAction action) {
// A press is the down and the up event, sent whole or not at all.
uint8_t msg[2 * KEY_EVENT_SIZE] = {MSG_KEY_EVENT, static_cast<uint8_t>(action != VncKeyAction::VNC_KEY_ACTION_UP)};
msg[KEY_EVENT_SIZE] = MSG_KEY_EVENT;
write_be32(msg + 4, keysym);
write_be32(msg + KEY_EVENT_SIZE + 4, keysym);
return this->send_input_(msg, action == VncKeyAction::VNC_KEY_ACTION_PRESS ? sizeof(msg) : KEY_EVENT_SIZE,
action == VncKeyAction::VNC_KEY_ACTION_UP);
}
// Input goes out after the releases that are still waiting. A release that does not fit waits for room,
// because a key or button left down would change every later key and click; other input is dropped.
bool VncClient::send_input_(const uint8_t *msg, size_t len, bool release) {
if (this->send_pending_() && this->send_(msg, len)) {
this->note_input_();
return true;
}
if (!release) {
return false;
}
if (this->pending_count_ == MAX_PENDING_RELEASES) {
// Losing a release would leave its key or button held on the server
this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Too many releases waiting for the send buffer"));
return false;
}
std::memcpy(this->pending_[this->pending_count_++], msg, len);
return true;
}
bool VncClient::send_pending_() {
while (this->pending_count_ != 0) {
const uint8_t *msg = this->pending_[0];
if (!this->send_(msg, msg[0] == MSG_POINTER_EVENT ? POINTER_EVENT_SIZE : KEY_EVENT_SIZE)) {
return false;
}
this->note_input_();
this->pending_count_--;
std::memmove(this->pending_[0], this->pending_[1], this->pending_count_ * sizeof(this->pending_[0]));
}
return true;
}
bool VncClient::send_pointer(uint16_t x, uint16_t y, uint8_t buttons) {
if (!this->is_connected()) {
ESP_LOGV(TAG, "Not connected, pointer event dropped");
return false;
}
x = std::min<uint16_t>(x, this->fb_w_ - 1);
y = std::min<uint16_t>(y, this->fb_h_ - 1);
uint8_t msg[POINTER_EVENT_SIZE] = {MSG_POINTER_EVENT, buttons};
write_be16(msg + 2, x);
write_be16(msg + 4, y);
const bool release = buttons != this->ptr_buttons_ && (buttons & ~this->ptr_buttons_) == 0;
if (!this->send_input_(msg, sizeof(msg), release)) {
ESP_LOGW(TAG, "Send buffer full, pointer event dropped");
return false;
}
this->ptr_x_ = x;
this->ptr_y_ = y;
this->ptr_buttons_ = buttons;
return true;
}
void VncClient::note_input_() { this->last_input_ms_ = App.get_loop_component_start_time(); }
void VncClient::send_key(uint32_t keysym, VncKeyAction action) {
if (!this->is_connected()) {
ESP_LOGV(TAG, "Not connected, key dropped");
return;
}
if (!this->send_key_event_(keysym, action)) {
ESP_LOGW(TAG, "Send buffer full, key dropped");
}
}
void VncClient::type_text(const char *text, size_t len) {
if (!this->is_connected()) {
ESP_LOGV(TAG, "Not connected, text dropped");
return;
}
size_t typed = 0;
for (size_t i = 0; i < len;) {
// The decoder reads up to a NUL, so give it only the bytes that belong to this text.
char sequence[5] = {};
std::memcpy(sequence, text + i, std::min<size_t>(4, len - i));
size_t used;
const uint32_t cp = unicode::extract_unicode_codepoint(sequence, &used);
// A broken sequence or a NUL types nothing; carry on at the next byte.
i += used == 0 ? 1 : used;
const uint32_t keysym = keysym_for_codepoint(cp);
if (keysym == 0) {
continue;
}
if (!this->send_key_event_(keysym, VncKeyAction::VNC_KEY_ACTION_PRESS)) {
ESP_LOGW(TAG, "Send buffer full, text cut after %u characters", static_cast<unsigned>(typed));
return;
}
typed++;
}
}
#endif
#ifdef USE_VNC_TOUCHSCREEN
void VncClient::handle_touch_(uint16_t x, uint16_t y) {
if (!this->is_connected()) {
return;
}
if (!this->touching_) {
if (x >= this->vis_w_ || y >= this->vis_h_) {
return;
}
this->press_x_ = x;
this->press_y_ = y;
this->dragging_ = false;
this->touching_ = this->send_pointer(x, y, 1);
return;
}
x = std::min<uint16_t>(x, this->vis_w_ - 1);
y = std::min<uint16_t>(y, this->vis_h_ - 1);
// Small jitter during a tap would start a text selection in a browser.
if (!this->dragging_) {
if (std::abs(x - this->press_x_) <= TOUCH_SLOP_PX && std::abs(y - this->press_y_) <= TOUCH_SLOP_PX) {
return;
}
this->dragging_ = true;
}
if (x != this->ptr_x_ || y != this->ptr_y_) {
this->send_pointer(x, y, 1);
}
}
void VncClient::handle_release_() {
if (this->touching_) {
this->touching_ = false;
this->send_pointer(this->ptr_x_, this->ptr_y_, 0);
}
}
#endif
} // namespace esphome::vnc
+1 -74
View File
@@ -4,13 +4,9 @@
#include "esphome/components/socket/tcp_client_link.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#ifdef USE_VNC_TOUCHSCREEN
#include "esphome/components/touchscreen/touchscreen.h"
#endif
#include <cstddef>
#include <cstdint>
#include <string>
namespace esphome::vnc {
@@ -20,12 +16,6 @@ enum class VncPixelFormat : uint8_t {
VNC_PIXEL_FORMAT_RGB332,
};
enum class VncKeyAction : uint8_t {
VNC_KEY_ACTION_PRESS,
VNC_KEY_ACTION_DOWN,
VNC_KEY_ACTION_UP,
};
enum class VncState : uint8_t {
VNC_STATE_IDLE,
VNC_STATE_HANDSHAKE,
@@ -65,12 +55,7 @@ enum class VncFailure : uint8_t {
};
/// RFB (VNC) client that draws the remote framebuffer into a display.
class VncClient : public Component
#ifdef USE_VNC_TOUCHSCREEN
,
public touchscreen::TouchListener
#endif
{
class VncClient : public Component {
public:
explicit VncClient(display::Display *display, bool big_endian = false) : display_(display), big_endian_(big_endian) {}
@@ -80,15 +65,6 @@ class VncClient : public Component
void set_update_delay(uint32_t ms) { this->update_delay_ms_ = ms; }
void set_max_update_delay(uint32_t ms) { this->max_update_delay_ms_ = ms; }
void set_pixel_format(VncPixelFormat format) { this->pixel_format_ = format; }
#ifdef USE_VNC_TOUCHSCREEN
void register_touchscreen(touchscreen::Touchscreen *touchscreen) {
touchscreen->register_listener(this);
this->has_touchscreen_ = true;
}
void touch(touchscreen::TouchPoint tp) override;
void update(const touchscreen::TouchPoints_t &tpoints) override;
void release() override;
#endif
void setup() override;
void loop() override;
@@ -100,13 +76,6 @@ class VncClient : public Component
/// Ask the server for the whole screen again.
void refresh();
#ifdef USE_VNC_INPUT
bool send_pointer(uint16_t x, uint16_t y, uint8_t buttons);
void send_key(uint32_t keysym, VncKeyAction action);
void type_text(const char *text, size_t len);
void type_text(const std::string &text) { this->type_text(text.data(), text.size()); }
#endif
template<typename F> void add_on_connect_callback(F &&callback) {
this->connect_callback_.add(std::forward<F>(callback));
}
@@ -117,13 +86,6 @@ class VncClient : public Component
protected:
static constexpr size_t RX_BUFFER_SIZE = 512;
static constexpr size_t TILE_BUFFER_SIZE = 16 * 16 * 2;
#ifdef USE_VNC_INPUT
// For this long after input, frames are painted as soon as they arrive: the reaction matters more than a calm panel.
static constexpr uint32_t INPUT_BOOST_MS = 2000;
static constexpr size_t KEY_EVENT_SIZE = 8;
static constexpr size_t POINTER_EVENT_SIZE = 6;
static constexpr size_t MAX_PENDING_RELEASES = 4;
#endif
void connect_step_(uint32_t now);
void start_handshake_(uint32_t now);
@@ -145,17 +107,6 @@ class VncClient : public Component
bool budget_exhausted_() const;
bool send_(const uint8_t *data, size_t len);
bool send_update_request_(bool incremental, uint32_t now);
#ifdef USE_VNC_INPUT
bool send_key_event_(uint32_t keysym, VncKeyAction action);
bool send_input_(const uint8_t *msg, size_t len, bool release);
bool send_pending_();
void note_input_();
#endif
#ifdef USE_VNC_TOUCHSCREEN
/// Touch input in display coordinates.
void handle_touch_(uint16_t x, uint16_t y);
void handle_release_();
#endif
VncParse fail_(VncFailure failure, const LogString *reason);
VncParse fail_send_() { return this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Send failed")); }
VncFailure lost_failure_() const;
@@ -184,9 +135,6 @@ class VncClient : public Component
// The first and the latest frame drawn since the display last refreshed.
uint32_t first_change_ms_{0};
uint32_t last_change_ms_{0};
#ifdef USE_VNC_INPUT
uint32_t last_input_ms_{0};
#endif
uint32_t rx_total_{0};
uint32_t update_start_bytes_{0};
uint32_t last_request_ms_{0};
@@ -213,19 +161,7 @@ class VncClient : public Component
uint16_t hash_capacity_{0};
uint16_t hash_cols_{0};
uint16_t hash_rows_{0};
#ifdef USE_VNC_INPUT
uint16_t ptr_x_{0};
uint16_t ptr_y_{0};
#endif
#ifdef USE_VNC_TOUCHSCREEN
uint16_t press_x_{0};
uint16_t press_y_{0};
#endif
#ifdef USE_VNC_INPUT
uint8_t ptr_buttons_{0}; // as the server will see them once the waiting releases are sent
uint8_t pending_count_{0};
#endif
uint8_t tile_w_{0};
uint8_t tile_h_{0};
uint8_t subrects_left_{0};
@@ -257,18 +193,9 @@ class VncClient : public Component
bool busy_warned_{false};
bool refresh_retried_{false}; // one retry per refresh the driver abandoned
bool network_was_up_{false};
#ifdef USE_VNC_TOUCHSCREEN
bool has_touchscreen_{false};
bool dragging_{false};
bool touching_{false};
#endif
bool big_endian_; // the pixel order the display takes
uint8_t rx_[RX_BUFFER_SIZE];
#ifdef USE_VNC_INPUT
// Key-up and button-release messages waiting for room, oldest first.
uint8_t pending_[MAX_PENDING_RELEASES][KEY_EVENT_SIZE];
#endif
alignas(4) uint8_t tile_[TILE_BUFFER_SIZE];
};
+2 -18
View File
@@ -245,22 +245,6 @@ WiFiConfigureAction = wifi_ns.class_(
)
# The supplicant parses the bootstrapping key as a DER encoded P-256 private key in hex; these wrap the
# 32 byte key the way the ESP-IDF enrollee example does
DPP_KEY_DER_PREFIX = "30310201010420"
DPP_KEY_DER_SUFFIX = "a00a06082a8648ce3d030107"
def _validate_dpp_private_key(value: object) -> str:
"""The key is 32 bytes written as 64 hexadecimal characters."""
value = cv.string_strict(value)
if len(value) != 64 or any(c not in "0123456789abcdefABCDEF" for c in value):
raise cv.Invalid(
"private_key must be 32 bytes written as 64 hexadecimal characters"
)
return value.lower()
def _validate_dpp_device_info(value: object) -> str:
"""The supplicant puts the text on a space separated command line and reads it up to the next space."""
value = cv.string_strict(value)
@@ -338,7 +322,7 @@ EAP_AUTH_SCHEMA = cv.All(
# Wi-Fi Easy Connect (DPP) enrollee; the ESP-IDF supplicant supports the QR code method
DPP_SCHEMA = cv.Schema(
{
cv.Optional(CONF_PRIVATE_KEY): cv.sensitive(_validate_dpp_private_key),
cv.Optional(CONF_PRIVATE_KEY): cv.sensitive(cv.string_strict),
cv.Optional(CONF_DEVICE_INFO): _validate_dpp_device_info,
cv.Optional(CONF_CHANNELS, default=[6]): cv.ensure_list(
cv.int_range(min=1, max=14)
@@ -920,7 +904,7 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_ESP_WIFI_DPP_SUPPORT", True)
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_FEAT_WIFI_EXT_DPP", True)
if (key := dpp.get(CONF_PRIVATE_KEY)) is not None:
cg.add(var.set_dpp_key(f"{DPP_KEY_DER_PREFIX}{key}{DPP_KEY_DER_SUFFIX}"))
cg.add(var.set_dpp_key(key))
if (info := dpp.get(CONF_DEVICE_INFO)) is not None:
cg.add(var.set_dpp_device_info(info))
cg.add(var.set_dpp_channels(",".join(str(c) for c in dpp[CONF_CHANNELS])))
+5 -9
View File
@@ -2744,13 +2744,8 @@ def url(value):
raise Invalid("Expected a file scheme or a URL scheme with host")
# Characters git never allows in a branch or tag name (git check-ref-format)
GIT_REF_PATTERN = r"[^\s~^:?*\[\\]+"
def git_ref(value: typing.Any) -> str:
value = string_strict(value)
if re.fullmatch(GIT_REF_PATTERN, value) is None:
def git_ref(value):
if re.match(r"[a-zA-Z0-9_./-]+", value) is None:
raise Invalid("Not a valid git ref")
return value
@@ -3005,8 +3000,9 @@ def validate_source_shorthand(value):
except Invalid:
pass
# Regex for GitHub repo name with optional branch/tag
m = re.fullmatch(
rf"github://(?:([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+)(?:@({GIT_REF_PATTERN}))?|pr#([0-9]+))",
# Note: git allows other branch/tag names as well, but never seen them used before
m = re.match(
r"github://(?:([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+)(?:@([a-zA-Z0-9\-_.\./]+))?|pr#([0-9]+))",
value,
)
if m is None:
+1 -2
View File
@@ -910,8 +910,7 @@ async def to_code(config: ConfigType) -> None:
CORE.add_job(_add_automations, config)
if not CORE.using_toolchain_esp_idf:
cg.add_build_flag("-fno-exceptions")
cg.add_build_flag("-fno-exceptions")
# Libraries
for lib in config[CONF_LIBRARIES]:
-2
View File
@@ -536,8 +536,6 @@
#define USE_SOCKET_TCP_CLIENT_LINK
#define USE_SOCKET_TCP_LISTENER
#define USE_LWIP_FAST_SELECT
#define USE_VNC_INPUT
#define USE_VNC_TOUCHSCREEN
#define USE_SPEAKER
#define USE_SPEAKER_MEDIA_PLAYER_ON_OFF
-15
View File
@@ -1,5 +1,4 @@
#include "esphome/core/gpio.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome {
@@ -19,18 +18,4 @@ void log_pin(const char *tag, const char *prefix, GPIOPin *pin) {
}
#endif
namespace internal {
void pulse_reset_ms(GPIOPin *pin, uint32_t high_before_ms, uint32_t low_ms, uint32_t high_after_ms) {
if (pin == nullptr)
return;
pin->setup();
const uint32_t waits[] = {high_before_ms, low_ms, high_after_ms};
// Level for each step is high, low, high; a zero high_before_ms skips the first step
for (size_t i = high_before_ms == 0; i < 3; i++) {
pin->digital_write(i != 1);
delay(waits[i]);
}
}
} // namespace internal
} // namespace esphome
-20
View File
@@ -144,24 +144,4 @@ void log_pin(const char *tag, const __FlashStringHelper *prefix, GPIOPin *pin);
void log_pin(const char *tag, const char *prefix, GPIOPin *pin);
#endif
/// Waveform for pulse_reset(): high, low, then high again. Name the fields at the call site, e.g.
/// pulse_reset(this->reset_pin_, {.high_before_ms = 1, .low_ms = 10}).
struct ResetPulse {
uint32_t high_before_ms{0}; ///< Drive high this long first; skipped when 0
uint32_t low_ms{0}; ///< Hold the pin low (in reset) this long
uint32_t high_after_ms{0}; ///< Wait this long after releasing the pin
};
namespace internal {
/// Out-of-line body of pulse_reset(); call that instead.
void pulse_reset_ms(GPIOPin *pin, uint32_t high_before_ms, uint32_t low_ms, uint32_t high_after_ms);
} // namespace internal
/// Set up an active-low reset pin and drive it through \p pulse. Blocks for the whole sequence;
/// does nothing when pin is null. Inline so the fields reach pulse_reset_ms() in registers
/// (RISC-V would otherwise pass the struct by reference).
ESPHOME_ALWAYS_INLINE inline void pulse_reset(GPIOPin *pin, ResetPulse pulse) {
internal::pulse_reset_ms(pin, pulse.high_before_ms, pulse.low_ms, pulse.high_after_ms);
}
} // namespace esphome
+6 -12
View File
@@ -42,26 +42,20 @@ _MIRROR_ATTEMPTS = 3
_MIRROR_SWEEP_ATTEMPTS = NETWORK_MAX_ATTEMPTS
def get_project_link_flags(flags: Iterable[str] | None = None) -> list[str]:
"""Return the -Wl, linker flags from ``flags`` in their given order
(default: the current build's flags, sorted)."""
def get_project_link_flags() -> list[str]:
"""Return the sorted -Wl, linker flags from the current build."""
from esphome.core import CORE # local import to avoid circular dependency
if flags is None:
flags = sorted(CORE.build_flags)
return [flag for flag in flags if flag.startswith("-Wl,")]
return sorted(flag for flag in CORE.build_flags if flag.startswith("-Wl,"))
def get_project_compile_flags(flags: Iterable[str] | None = None) -> list[str]:
"""Return the -D and -W (non-linker) flags from ``flags`` in their given
order (default: the current build's flags, sorted)."""
def get_project_compile_flags() -> list[str]:
"""Return the sorted -D and -W (non-linker) flags from the current build."""
from esphome.core import CORE # local import to avoid circular dependency
if flags is None:
flags = sorted(CORE.build_flags)
return [
flag
for flag in flags
for flag in sorted(CORE.build_flags)
if flag.startswith("-D")
or (flag.startswith("-W") and not flag.startswith("-Wl,"))
]
+5 -5
View File
@@ -718,9 +718,9 @@ def _clone_or_update_locked(
# compute the same cache key as this one.
original_url = url
if username is not None and password is not None:
user = urllib.parse.quote(username, safe="")
token = urllib.parse.quote(password, safe="")
url = url.replace("://", f"://{user}:{token}@")
url = url.replace(
"://", f"://{urllib.parse.quote(username)}:{urllib.parse.quote(password)}@"
)
hash_dir_name = _compute_destination_path(key, domain).name
repo_dir = _repo_entry_dir(key, domain, subpath)
@@ -1051,8 +1051,8 @@ class GitFile:
"""Parse a git shorthand URL into its components."""
if not isinstance(shorthand, str):
raise ValueError("Git shorthand must be a string")
m = re.fullmatch(
rf"(?P<domain>[a-zA-Z0-9\-]+)://(?P<owner>[a-zA-Z0-9\-]+)/(?P<repo>[a-zA-Z0-9\-\._]+)/(?P<filename>[a-zA-Z0-9\-_.\./]+)(?:@(?P<ref>{cv.GIT_REF_PATTERN}))?(?:\?(?P<query>[a-zA-Z0-9\-_.\./]+))?",
m = re.match(
r"(?P<domain>[a-zA-Z0-9\-]+)://(?P<owner>[a-zA-Z0-9\-]+)/(?P<repo>[a-zA-Z0-9\-\._]+)/(?P<filename>[a-zA-Z0-9\-_.\./]+)(?:@(?P<ref>[a-zA-Z0-9\-_.\./]+))?(?:\?(?P<query>[a-zA-Z0-9\-_.\./]+))?",
shorthand,
)
if m is None:
+1 -1
View File
@@ -2,7 +2,7 @@ dependencies:
bblanchon/arduinojson:
version: "7.4.3"
esphome/dlms_parser:
version: 2.2.2
version: 2.2.0
esphome/esp-audio-libs:
version: 4.1.0
esphome/esp-micro-speech-features:
+2 -7
View File
@@ -284,17 +284,12 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None:
_COMPONENT_CACHE[domain] = manif
return manif
module_name = f"esphome.components.{domain}"
try:
module = importlib.import_module(module_name)
module = importlib.import_module(f"esphome.components.{domain}")
except ImportError as e:
if exception:
raise
if (
isinstance(e, ModuleNotFoundError)
and e.name is not None
and f"{module_name}.".startswith(f"{e.name}.")
):
if "No module named" in str(e):
_LOGGER.info(
"Unable to import component %s: %s", domain, str(e), exc_info=False
)
+385
View File
@@ -0,0 +1,385 @@
"""Rewrite the OTA encryption key in place: the `key:` line the loader read
it from, or the secrets.yaml line a `!secret` on it points to."""
from __future__ import annotations
from dataclasses import dataclass, field
import logging
import os
from pathlib import Path
import re
from esphome.components.noise import static_encryption_key
from esphome.const import CONF_API, CONF_ENCRYPTION, CONF_KEY
from esphome.core import CORE, EsphomeError
from esphome.espota2 import CONF_OLD_KEY, esphome_ota_items
from esphome.types import ConfigType
from esphome.yaml_edit import (
LineEdit,
editable_file,
field_line_re,
own_documents,
read_text,
rewrite,
source_line,
source_of,
)
from esphome.yaml_util import secrets_path_for
_LOGGER = logging.getLogger(__name__)
_DOMAIN = "ota_key_edit"
@dataclass
class KeyEdit(LineEdit):
"""A line edit with the secret it holds and the other configs using it."""
secret: str | None = None
shared_with: list[Path] = field(default_factory=list)
def _api_encryption() -> ConfigType:
return ((CORE.raw_config or {}).get(CONF_API) or {}).get(CONF_ENCRYPTION) or {}
def _key_blocks(raw: ConfigType, key: str) -> list[ConfigType]:
"""The api and esphome ota ``encryption:`` mappings whose key is ``key``."""
blocks = [raw.get(CONF_API) or {}, *esphome_ota_items(raw)]
found = []
for block in blocks:
if (node := static_encryption_key(block)) is None or str(node) != key:
continue
encryption = block[CONF_ENCRYPTION]
if source_of(encryption, CONF_KEY) is not None:
found.append(encryption)
elif hasattr(next(k for k in encryption if k == CONF_KEY), "esp_range"):
# Read from a file but not on a line of its own, as with a merge
# key; only a key validation added, which has no range at all,
# is passed over (a bare ota block takes the api key)
raise EsphomeError(
f"The '{CONF_KEY}' of an '{CONF_ENCRYPTION}:' block is not on a "
"line of its own; edit the key by hand"
)
return found
_INDENT_RE = re.compile(r"\s*")
_KEY_FIELD_RE = re.compile(rf"^\s*{CONF_KEY}\s*:")
_BARE_BLOCK_RE = re.compile(rf"^\s*{CONF_ENCRYPTION}\s*:\s*(#.*)?$")
_YAML_SUFFIXES = (".yaml", ".yml")
_INCLUDE_DIR_RE = re.compile(r"!include_dir_\w+\s+(?P<dir>[^\s#]+)")
# An include or package file whose path holds a substitution
_SUBSTITUTED_INCLUDE_RE = re.compile(
r"(?:!include\w*\s+|\bfile:\s*)[\"']?(?P<target>[^\s#\"']*\$[^\s#\"']*)"
)
def _secret_ref(name: str = r"[^\s\"']+") -> str:
"""`!secret name` as the whole scalar: `!secret ota key` is not `ota`, and
a comment needs whitespace before its `#`, so `a#b` is one name."""
return rf"!secret\s+(?P<q>[\"']?)(?P<name>{name})(?P=q)(?=\s+#|\s*$)"
def _secret_field_re(name: str) -> re.Pattern[str]:
"""``name: !secret <any>``."""
return re.compile(rf"^\s*{name}\s*:\s*{_secret_ref()}")
def _secret_use_re(name: str) -> re.Pattern[str]:
"""A use of the secret ``name`` anywhere on a line."""
return re.compile(_secret_ref(re.escape(name)))
_KEY_SECRET_RE = _secret_field_re(CONF_KEY)
_OLD_KEY_SECRET_RE = _secret_field_re(CONF_OLD_KEY)
def _indent(text: str) -> str:
return _INDENT_RE.match(text).group()
def _without_comment(text: str) -> str:
"""The line up to its comment: a `#` after whitespace that is not inside
a quoted scalar, so `!include "shared # ota.yaml"` keeps its name."""
quote = ""
for i, char in enumerate(text):
if quote:
if char == quote:
quote = ""
elif char in "\"'":
quote = char
elif char == "#" and (i == 0 or text[i - 1].isspace()):
return text[:i]
return text
def _file_use_re(file_name: str) -> re.Pattern[str]:
"""The file name at a path boundary; `wifi_api.yaml` is not `api.yaml`."""
return re.compile(rf"(?<![\w.-]){re.escape(file_name)}(?![\w.])")
def _root_indent(lines: list[str]) -> str:
"""The indent of the first content line; a root mapping may be indented."""
content = (
t for t in lines if t.strip() and not t.strip().startswith(("#", "---", "%"))
)
return next((_indent(t) for t in content), "")
def _config_texts() -> tuple[list[tuple[Path, str]], list[str]]:
"""Every yaml file under the config dir with its text, and what could
not be read (a symlinked directory is not walked); walked once per run."""
if (found := CORE.data.get(_DOMAIN)) is None:
found = CORE.data[_DOMAIN] = _walk_config_texts()
return found
def _walk_config_texts() -> tuple[list[tuple[Path, str]], list[str]]:
data_dir = CORE.data_dir.resolve()
found = []
skipped: list[str] = []
def skip(what: object) -> None:
skipped.append(str(what))
_LOGGER.debug("Skipping %s", what)
for dirpath, dirnames, filenames in os.walk(
CORE.config_dir.resolve(), onerror=skip
):
for name in list(dirnames):
path = Path(dirpath, name)
if path == data_dir:
dirnames.remove(name)
elif path.is_symlink():
dirnames.remove(name)
skip(f"{path} is a link")
for filename in filenames:
path = Path(dirpath, filename)
if path.suffix not in _YAML_SUFFIXES:
continue
try:
text = read_text(path)
except EsphomeError as err:
skip(err)
continue
# Searched, never edited: comments do not count
found.append((path, "\n".join(map(_without_comment, text.splitlines()))))
return sorted(found), skipped
def with_sharers(edits: list[KeyEdit]) -> list[str]:
"""Fill in ``shared_with``: the other configs that use each rewritten
line's secret or include, directly or through further includes. Returns
what the scan could not follow (unreadable files, directory and
substituted includes)."""
main = CORE.config_path.resolve()
own = own_documents()
# This config's own includes stay in: another device may reach a line
# through one of them
texts, skipped = _config_texts()
texts = [(path, text) for path, text in texts if path.resolve() != main]
unchecked = [*skipped]
for path, text in texts:
if match := _INCLUDE_DIR_RE.search(text):
unchecked.append(f"{path} includes the directory {match['dir']}")
if match := _SUBSTITUTED_INCLUDE_RE.search(text):
unchecked.append(
f"{path} includes {match['target']} through a substitution"
)
for edit in edits:
if edit.insert_after and edit.secret:
continue # a new secrets line has no users yet
ref = (
_secret_use_re(edit.secret) if edit.secret else _file_use_re(edit.path.name)
)
users = {path for path, text in texts if ref.search(text)}
frontier = users
while frontier:
frontier = {
path
for name in {p.name for p in frontier}
for path, text in texts
if path not in users and _file_use_re(name).search(text)
}
users |= frontier
edit.shared_with = sorted(p for p in users if p.resolve() not in own)
return unchecked
def _secret_file(doc: Path) -> Path:
return editable_file(secrets_path_for(doc))
def _secret_line(
secrets_path: Path, name: str, value: str | None = None
) -> tuple[int, re.Match[str]] | None:
"""The root level ``name:`` line, None when absent."""
lines = read_text(secrets_path).splitlines()
line_re = field_line_re(name, value, re.escape(_root_indent(lines)))
return next(
((i, m) for i, text in enumerate(lines) if (m := line_re.match(text))), None
)
def _secret_rewrite(
secrets_path: Path, name: str, value: str, expect: str | None = None
) -> KeyEdit | None:
"""Set the root level ``name:`` line to ``value``; None when it is not a
plain scalar holding ``expect``."""
if (hit := _secret_line(secrets_path, name, expect)) is None:
return None
i, m = hit
return KeyEdit(secrets_path, i, m.string, rewrite(m, value), secret=name)
def _secret_insert(secrets_path: Path, after: str, name: str, value: str) -> KeyEdit:
"""Add ``name: value`` under the root level ``after`` line."""
if (hit := _secret_line(secrets_path, after)) is None:
raise EsphomeError(
f"No plain '{after}:' line in {secrets_path}; edit it by hand"
)
i, m = hit
return KeyEdit(
secrets_path,
i,
m.string,
f'{_indent(m.string)}{name}: "{value}"',
insert_after=True,
secret=name,
)
def locate_key_edits(old_key: str, new_key: str) -> list[KeyEdit]:
"""The edits that replace the current key; refuses a substitution, flow
mapping or remote package."""
literal_re = field_line_re(CONF_KEY, old_key)
blocks = _key_blocks(CORE.raw_config or {}, old_key)
if not blocks:
raise EsphomeError("The current key was not found on a line of the yaml")
edits: list[KeyEdit] = []
key_lines: set[tuple[Path, int]] = set()
for block in blocks:
doc, line_no, text = source_line(block, CONF_KEY)
key_lines.add((doc.resolve(), line_no))
if match := _KEY_SECRET_RE.match(text):
name, secrets_path = match["name"], _secret_file(doc)
edit = _secret_rewrite(secrets_path, name, new_key, expect=old_key)
if edit is None:
raise EsphomeError(
f"No '{name}:' line with the current key in {secrets_path}"
)
elif match := literal_re.match(text):
edit = KeyEdit(doc, line_no, text, rewrite(match, new_key))
else:
raise EsphomeError(
f"{doc}:{line_no + 1} does not hold the key as a plain value or "
"a !secret; edit the key by hand"
)
# The api and ota blocks may share one !secret line
if edit not in edits:
edits.append(edit)
for edit in edits:
if edit.secret:
_refuse_other_own_uses(edit.secret, key_lines)
return edits
def _refuse_other_own_uses(name: str, allowed: set[tuple[Path, int]]) -> None:
"""The secret may only feed the ``allowed`` lines; a wifi password on it
would change too."""
ref = _secret_use_re(name)
for doc in own_documents():
for i, text in enumerate(read_text(doc).splitlines()):
if ref.search(_without_comment(text)) and (doc, i) not in allowed:
raise EsphomeError(
f"'{name}' is also used at {doc}:{i + 1}; a rotation would "
"change that value too, edit the key by hand"
)
def _secret_name(block: ConfigType) -> str | None:
"""The secrets file name a ``key: !secret <name>`` line of ``block`` holds."""
if source_of(block, CONF_KEY) is None:
return None
match = _KEY_SECRET_RE.match(source_line(block, CONF_KEY)[2])
return match["name"] if match else None
def old_key_edit(old_key: str) -> list[KeyEdit]:
"""Set ``old_key:`` on the esphome ota block, rewriting or adding it. A
key from secrets.yaml stays there as ``<name>_old``. Of several entries
the one with its own old_key or key line gets it."""
items = [
(item, item[CONF_ENCRYPTION] or {})
for item in esphome_ota_items(CORE.raw_config or {})
if CONF_ENCRYPTION in item
]
if not items:
raise EsphomeError("The esphome OTA platform has no 'encryption:' block")
item, encryption = next(
(
pair
for name in (CONF_OLD_KEY, CONF_KEY)
for pair in items
if source_of(pair[1], name) is not None
),
items[0],
)
if (existing := encryption.get(CONF_OLD_KEY)) is not None:
doc, line_no, text = source_line(encryption, CONF_OLD_KEY)
if match := _OLD_KEY_SECRET_RE.match(text):
name, secrets_path = match["name"], _secret_file(doc)
if (edit := _secret_rewrite(secrets_path, name, old_key)) is None:
raise EsphomeError(
f"No plain '{name}:' line in {secrets_path}; edit it by hand"
)
_refuse_other_own_uses(name, {(doc.resolve(), line_no)})
return [edit]
match = field_line_re(CONF_OLD_KEY, str(existing)).match(text)
if match is None:
raise EsphomeError(
f"{doc}:{line_no + 1} does not hold '{CONF_OLD_KEY}' as a plain "
"value; edit it by hand"
)
return [KeyEdit(doc, line_no, text, rewrite(match, old_key))]
# Indent from the block's own key line, else one level under `encryption:`
if source_of(encryption, CONF_KEY) is not None:
mapping, anchor, anchor_re, step = encryption, CONF_KEY, _KEY_FIELD_RE, ""
else:
mapping, anchor, anchor_re, step = item, CONF_ENCRYPTION, _BARE_BLOCK_RE, " "
doc, line_no, text = source_line(mapping, anchor)
if not anchor_re.match(text):
raise EsphomeError(
f"{doc}:{line_no + 1} does not hold '{anchor}:' as a block line; "
"edit it by hand"
)
# The block's own key line decides; a bare block takes after the api key
keyed = encryption if mapping is encryption else _api_encryption()
if (secret := _secret_name(keyed)) is None:
rendered = f'{CONF_OLD_KEY}: "{old_key}"'
extra = []
else:
rendered = f"{CONF_OLD_KEY}: !secret {secret}_old"
secrets_path = _secret_file(doc) # the file that gets old_key
# An existing `<secret>_old` line is reused only when nothing uses it
if (
kept := _secret_rewrite(secrets_path, f"{secret}_old", old_key)
) is not None:
ref = _secret_use_re(f"{secret}_old")
texts, skipped = _config_texts()
if skipped: # an unread file might use the line
raise EsphomeError(
"Could not read every configuration to check the secret: "
+ skipped[0]
)
if users := [p for p, t in texts if ref.search(t)]:
raise EsphomeError(
f"'{secret}_old:' in {secrets_path} is used by "
+ ", ".join(str(p) for p in users)
+ f"; rename it or add '{CONF_OLD_KEY}' by hand"
)
extra = [kept or _secret_insert(secrets_path, secret, f"{secret}_old", old_key)]
edit = KeyEdit(
doc, line_no, text, f"{_indent(text)}{step}{rendered}", insert_after=True
)
return [edit, *extra]
+4 -9
View File
@@ -26,20 +26,17 @@ from esphome.const import (
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_LN882X,
PLATFORM_NRF52,
PLATFORM_RP2,
PLATFORM_RTL87XX,
Platform,
)
_LOGGER = logging.getLogger(__name__)
# Hooks whose loss only degrades diagnostics or upload size; skipping one
# of these is logged at debug, while skipping a hook that changes what the
# CLI does (upload method, log transport) warns. A new hook is loud by
# default.
COSMETIC_HOOKS: Final = frozenset({"process_stacktrace", "ota_image"})
# Hooks whose loss only degrades diagnostics; skipping one of these is
# logged at debug, while skipping a hook that changes what the CLI does
# (upload method, log transport) warns. A new hook is loud by default.
COSMETIC_HOOKS: Final = frozenset({"process_stacktrace"})
# Platforms whose firmware prints the crash handler report share one gate.
_CRASH_HANDLER_GATE = r"0x[0-9a-fA-F]{3,}\b|CRASH DETECTED ON PREVIOUS BOOT"
@@ -74,8 +71,6 @@ PLATFORM_HOOKS: Final[dict[str, frozenset[str]]] = {
"show_logs": frozenset({PLATFORM_NRF52}),
"upload_program": frozenset({PLATFORM_NRF52}),
"process_stacktrace": frozenset(STACKTRACE_GATES),
# The file an app update sends, when it is not the build output itself
"ota_image": frozenset({PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RTL87XX}),
}
+17 -12
View File
@@ -966,9 +966,7 @@ class ESPHomeLoaderMixin:
if self.name == CORE.config_path:
raise e
try:
main_config_dir = CORE.config_path.parent
main_secret_yml = main_config_dir / SECRET_YAML
secrets = self.yaml_loader(main_secret_yml)
secrets = self.yaml_loader(CORE.config_path.parent / SECRET_YAML)
except EsphomeError as er:
raise EsphomeError(f"{e}\n{er}") from er
@@ -1036,15 +1034,8 @@ class ESPHomeLoaderMixin:
) -> OrderedDict[str, dict[str, Any]]:
files = filter_yaml_files(find_files(self._rel_path(node.value), "*.yaml"))
mapping = OrderedDict()
paths: dict[str, Path] = {}
for fname in files:
filename = fname.stem
if (first := paths.setdefault(filename, fname)) != fname:
raise yaml.MarkedYAMLError(
f"!include_dir_named: {first} and {fname} both use the key "
f"'{filename}'; rename one of them",
node.start_mark,
)
mapping[filename] = self.yaml_loader(fname)
return mapping
@@ -1356,14 +1347,28 @@ def _is_file_valid(name: str) -> bool:
def find_files(directory: Path, pattern: str) -> Iterator[Path]:
"""Recursively find files in a directory matching *pattern*, skipping hidden entries."""
for root, dirs, files in os.walk(directory):
dirs[:] = sorted(d for d in dirs if _is_file_valid(d))
for f in sorted(files):
dirs[:] = [d for d in dirs if _is_file_valid(d)]
for f in files:
filename = Path(f)
if _is_file_valid(f) and filename.match(pattern):
filename = Path(root) / filename
yield filename
def secrets_path_for(document: Path) -> Path:
"""The secrets.yaml a ``!secret`` in ``document`` resolves against: the
one beside the document when it loads, else the main config's, the
way construct_secret falls back."""
beside = document.parent / SECRET_YAML
if document == CORE.config_path:
return beside
try:
load_yaml(beside, clear_secrets=False, track_document_range=False)
except EsphomeError:
return CORE.config_path.parent / SECRET_YAML
return beside
def is_secret(value):
try:
return _SECRET_VALUES[str(value)]
+1 -1
View File
@@ -106,7 +106,7 @@ build_unflags =
; esp32 envs (which would double-include them).
[common:idf-component-libs]
lib_deps =
esphome/dlms_parser@2.2.2 ; dlms_meter
esphome/dlms_parser@2.2.0 ; dlms_meter
bblanchon/ArduinoJson@7.4.3 ; json
lvgl/lvgl@9.6.0 ; lvgl
+4 -4
View File
@@ -1,18 +1,18 @@
# cryptography 49+ ships no Intel macOS wheels (arm64 only); esptool caps <49 there.
# Keep 48.0.1, the last universal2 release, so esphome stays installable on Intel Macs.
cryptography==50.0.2; platform_system != "Darwin" or platform_machine != "x86_64"
cryptography==50.0.2; platform_system == "Darwin" and platform_machine == "x86_64"
cryptography==48.0.1; platform_system == "Darwin" and platform_machine == "x86_64"
voluptuous==0.16.0
PyYAML==6.0.3
paho-mqtt==2.1.0
colorama==0.4.6
tzlocal==5.4.5 # from time
tzlocal==5.4.4 # from time
tzdata>=2026.5 # from time
pyserial==3.5
platformio==6.2.0
esptool==5.6.0
click==8.5.0
aioesphomeapi==46.23.0
aioesphomeapi==46.22.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.151.5
puremagic==2.2.0
@@ -28,7 +28,7 @@ bleak==3.0.2
smpclient==7.3.0
# smp 4.2+ needs cbor2 6+, which ships no Intel macOS wheels (arm64 only).
smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64"
smp==4.2.0; platform_system == "Darwin" and platform_machine == "x86_64"
smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64"
requests==2.34.2
py7zr==1.1.4
platformdirs==4.13.5 # native esp-idf toolchain global cache dir
+2 -2
View File
@@ -1,7 +1,7 @@
pylint==4.1.3
ruff==0.17.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
ruff==0.17.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
prek==0.5.7 # .github/workflows/ci.yml reads this pin
prek==0.5.6 # .github/workflows/ci.yml reads this pin
yamlrocks==0.7.1 # used by script/sync_dependency_versions.py
# Unit tests
@@ -1,81 +0,0 @@
"""Tests for the show_test_card and update_interval check in the display schema."""
import logging
import pytest
from esphome.components.display import FULL_DISPLAY_SCHEMA, _validate_test_card
import esphome.config_validation as cv
from esphome.core import ID
from esphome.types import ConfigType
WARNING = "`show_test_card: True` is used with `update_interval: never`"
def test_test_card_with_update_interval_never_warns(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The test card is only drawn when something else updates the display."""
with caplog.at_level(logging.WARNING, logger="esphome.components.display"):
FULL_DISPLAY_SCHEMA({"show_test_card": True, "update_interval": "never"})
assert WARNING in caplog.text
def test_warning_names_the_display(caplog: pytest.LogCaptureFixture) -> None:
"""With several displays the warning says which one it is about."""
config = {
"id": ID("status_epaper", is_declaration=True),
"show_test_card": True,
"update_interval": cv.update_interval("never"),
}
with caplog.at_level(logging.WARNING, logger="esphome.components.display"):
_validate_test_card(config)
assert f"status_epaper: {WARNING}" in caplog.text
def test_warning_without_display_id_has_no_prefix(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A generated id is unset at validation time, so the warning starts with the message."""
config = {
"id": ID(None, is_declaration=True),
"show_test_card": True,
"update_interval": cv.update_interval("never"),
}
with caplog.at_level(logging.WARNING, logger="esphome.components.display"):
_validate_test_card(config)
assert WARNING in caplog.text
assert f": {WARNING}" not in caplog.text
@pytest.mark.parametrize(
"config",
[
pytest.param(
{"show_test_card": True, "update_interval": "1s"}, id="test_card_1s"
),
pytest.param({"show_test_card": True}, id="test_card_default_interval"),
pytest.param(
{"show_test_card": False, "update_interval": "never"},
id="no_test_card_never",
),
pytest.param({"update_interval": "never"}, id="never_only"),
],
)
def test_other_combinations_do_not_warn(
config: ConfigType, caplog: pytest.LogCaptureFixture
) -> None:
"""Only the test card together with `update_interval: never` warns."""
with caplog.at_level(logging.WARNING, logger="esphome.components.display"):
FULL_DISPLAY_SCHEMA(config)
assert WARNING not in caplog.text
def test_test_card_without_update_interval_accepted(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Platforms such as epaper_spi may leave update_interval unset at this stage."""
config = {"show_test_card": True}
with caplog.at_level(logging.WARNING, logger="esphome.components.display"):
assert _validate_test_card(config) is config
assert WARNING not in caplog.text
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff. Show more