mirror of
https://github.com/esphome/esphome.git
synced 2026-10-27 00:12:11 +00:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2f765a733 | ||
|
|
3a62bd2a4e | ||
|
|
7dd3a6f451 | ||
|
|
a7b5429032 | ||
|
|
3f31aeeccf | ||
|
|
b8c26bea20 | ||
|
|
1a50e1394a | ||
|
|
ae1f912a5f | ||
|
|
916798184d | ||
|
|
1e49616bc5 | ||
|
|
c66f71bb96 | ||
|
|
8db0774d67 | ||
|
|
f735c4634e | ||
|
|
fe125726ee | ||
|
|
e05f2fc74e | ||
|
|
4e68f1d324 | ||
|
|
98706c594b | ||
|
|
633c2b7f71 | ||
|
|
516511b346 | ||
|
|
f72dc7c329 | ||
|
|
f1695ac0b0 | ||
|
|
24c64a8efd | ||
|
|
d3638336ae | ||
|
|
53bffb560b | ||
|
|
2af39e6643 | ||
|
|
533a3afebd | ||
|
|
ddb7a6d9aa | ||
|
|
ae99accace | ||
|
|
474667d1a8 | ||
|
|
dbc93bb133 | ||
|
|
5486ef400f | ||
|
|
bd723f75af | ||
|
|
efba19ea3a | ||
|
|
6c68985c14 | ||
|
|
7be8a7c390 | ||
|
|
eb8cf139d7 | ||
|
|
ab6ebac628 |
No files matched your search
@@ -17,7 +17,7 @@ runs:
|
||||
steps:
|
||||
- name: Set up Python ${{ inputs.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.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@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
|
||||
@@ -23,13 +23,13 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.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@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull-request-only workflow: a save could never be shared and
|
||||
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.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@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -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@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.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@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
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@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.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@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
@@ -462,6 +462,7 @@ 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.
|
||||
@@ -477,6 +478,32 @@ 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.
|
||||
@@ -630,7 +657,7 @@ jobs:
|
||||
apt-get install -y libc6-dbg
|
||||
|
||||
- name: Run CodSpeed benchmarks
|
||||
uses: CodSpeedHQ/action@821a40cd6c80e3cfe387caf55668287addd808da # v5.5.0
|
||||
uses: CodSpeedHQ/action@78102a468f96d8e0ccb92d0ec49425753fc42062 # v5.6.0
|
||||
with:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
@@ -1457,7 +1484,7 @@ jobs:
|
||||
ref: main
|
||||
path: device-builder
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Set up uv
|
||||
@@ -1466,7 +1493,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@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
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@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.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@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.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@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
path: lib/home-assistant
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.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@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
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@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.17.1
|
||||
rev: v0.17.2
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
|
||||
@@ -443,8 +443,7 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
- **Hub forwarding entities need no C++ class:** a button, switch, number or select that only calls its hub
|
||||
uses `button.ApplyButton` (`switch.ApplySwitch`, `number.ApplyNumber`, `select.ApplySelect`) in its schema
|
||||
and `apply=` on the `sub_*` call. The body is a call on the hub with `entity` and `state`, `value` or
|
||||
`index`/`option` in scope; on a switch, number or select `publish="before"` or `"after"` publishes the state,
|
||||
and `publish="on_success"` publishes only when the hub call returns true (a write the device can refuse).
|
||||
`index`/`option` in scope; on a switch, number or select `publish="before"` or `"after"` publishes the state.
|
||||
`publish="momentary"` is only for legacy action switches (restart, factory reset): it publishes off and runs
|
||||
the body only when turned on. Never use it for a new entity; an action is a button.
|
||||
```python
|
||||
|
||||
@@ -25,6 +25,7 @@ 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__)
|
||||
|
||||
@@ -154,6 +155,47 @@ 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."""
|
||||
@@ -294,18 +336,24 @@ 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.
|
||||
project_compile_opts = get_project_compile_flags()
|
||||
# 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())))
|
||||
)
|
||||
)
|
||||
extra_compile_options = "\n".join(
|
||||
f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)'
|
||||
for flag in project_compile_opts
|
||||
f"idf_build_set_property(COMPILE_OPTIONS {flag} APPEND)"
|
||||
for flag in _cmake_args(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 get_project_cxx_compile_flags()
|
||||
f"idf_build_set_property(CXX_COMPILE_OPTIONS {flag} APPEND)"
|
||||
for flag in _cmake_args(get_project_cxx_compile_flags())
|
||||
)
|
||||
|
||||
cpp_standard_options = (
|
||||
@@ -461,7 +509,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 = get_project_link_flags()
|
||||
link_opts = _cmake_args(get_project_link_flags(_lexed_build_flags()))
|
||||
link_opts_str = "\n ".join(link_opts) if link_opts else ""
|
||||
src_cmake = "\n".join(CORE.data[KEY_ESP32].get(KEY_SRC_CMAKE, []))
|
||||
|
||||
|
||||
@@ -253,8 +253,9 @@ 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
|
||||
auto args = std::make_tuple(x...);
|
||||
// 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...);
|
||||
uint32_t call_id = std::get<0>(args);
|
||||
|
||||
bool success = this->success_.value(x...);
|
||||
@@ -308,7 +309,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::make_tuple(x...);
|
||||
auto args = std::forward_as_tuple(x...);
|
||||
uint32_t call_id = std::get<0>(args);
|
||||
if (call_id != 0) {
|
||||
this->parent_->unregister_active_action_call(call_id);
|
||||
|
||||
@@ -45,9 +45,8 @@ void CopyCover::control(const cover::CoverCall &call) {
|
||||
auto position = call.get_position();
|
||||
if (position.has_value())
|
||||
call2.set_position(*position);
|
||||
auto tilt2 = call.get_tilt();
|
||||
if (tilt2.has_value())
|
||||
call2.set_tilt(*tilt2);
|
||||
if (call.get_toggle().has_value())
|
||||
call2.set_command_toggle();
|
||||
call2.perform();
|
||||
}
|
||||
|
||||
|
||||
@@ -21,4 +21,6 @@ void CopyLock::control(const lock::LockCall &call) {
|
||||
call2.perform();
|
||||
}
|
||||
|
||||
void CopyLock::open_latch() { this->source_->open(); }
|
||||
|
||||
} // namespace esphome::copy
|
||||
@@ -13,6 +13,7 @@ class CopyLock final : public lock::Lock, public Component {
|
||||
|
||||
protected:
|
||||
void control(const lock::LockCall &call) override;
|
||||
void open_latch() override;
|
||||
|
||||
lock::Lock *source_;
|
||||
};
|
||||
|
||||
@@ -57,6 +57,7 @@ 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();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, Protocol
|
||||
|
||||
from esphome import automation, core, pins
|
||||
@@ -34,6 +35,8 @@ 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)
|
||||
@@ -102,13 +105,21 @@ BASIC_DISPLAY_SCHEMA = cv.Schema(
|
||||
).extend(cv.polling_component_schema("1s"))
|
||||
|
||||
|
||||
def _validate_test_card(config):
|
||||
def _validate_test_card(config: ConfigType) -> ConfigType:
|
||||
if (
|
||||
config.get(CONF_SHOW_TEST_CARD, False)
|
||||
and config.get(CONF_UPDATE_INTERVAL, False) == SCHEDULER_DONT_RUN
|
||||
and (update_interval := config.get(CONF_UPDATE_INTERVAL)) is not None
|
||||
and update_interval.total_milliseconds == SCHEDULER_DONT_RUN
|
||||
):
|
||||
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."
|
||||
# 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,
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ CONF_CUSTOM_PATTERNS = "custom_patterns"
|
||||
CONF_SKIP_CRC = "skip_crc"
|
||||
CONF_DEFAULT_OBIS = "default_obis"
|
||||
|
||||
DLMS_PARSER_VERSION = "2.2.0"
|
||||
DLMS_PARSER_VERSION = "2.2.2"
|
||||
|
||||
dlms_meter_component_ns = cg.esphome_ns.namespace("dlms_meter")
|
||||
DlmsMeterComponent = dlms_meter_component_ns.class_(
|
||||
|
||||
@@ -2389,7 +2389,9 @@ FRAMEWORK_SCHEMA = cv.Schema(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_NAME): cv.string_strict,
|
||||
cv.Optional(CONF_SOURCE): cv.git_ref,
|
||||
cv.Optional(CONF_SOURCE): cv.All(
|
||||
cv.string_strict, cv.Length(min=1)
|
||||
),
|
||||
cv.Optional(CONF_REF): cv.string,
|
||||
cv.Optional(CONF_PATH): cv.string,
|
||||
}
|
||||
|
||||
@@ -17,8 +17,13 @@
|
||||
#include "hal/rtc_io_hal.h"
|
||||
#endif
|
||||
|
||||
#ifndef SOC_GPIO_SUPPORT_RTC_INDEPENDENT
|
||||
#define SOC_GPIO_SUPPORT_RTC_INDEPENDENT 0 // NOLINT
|
||||
// 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
|
||||
#endif
|
||||
|
||||
namespace esphome {
|
||||
@@ -53,10 +58,12 @@ struct ISRPinArg {
|
||||
#ifdef USE_GPIO_HOLD
|
||||
bool hold;
|
||||
#endif
|
||||
#if defined(USE_ESP32_VARIANT_ESP32)
|
||||
#ifdef ISR_PIN_USE_RTC
|
||||
bool use_rtc;
|
||||
#ifdef RTCIO_NEED_MAPPING
|
||||
int rtc_pin;
|
||||
#endif
|
||||
#endif
|
||||
};
|
||||
|
||||
#ifdef USE_GPIO_HOLD
|
||||
@@ -69,17 +76,15 @@ 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(USE_ESP32_VARIANT_ESP32)
|
||||
#if defined(ISR_PIN_USE_RTC) && defined(SOC_RTCIO_HOLD_SUPPORTED)
|
||||
if (arg->use_rtc) {
|
||||
#ifdef RTCIO_NEED_MAPPING
|
||||
rtcio_hal_hold_disable(arg->rtc_pin);
|
||||
rtcio_hal_hold_enable(arg->rtc_pin);
|
||||
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) {
|
||||
#else
|
||||
rtcio_hal_hold_disable(arg->pin);
|
||||
rtcio_hal_hold_enable(arg->pin);
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
@@ -96,10 +101,12 @@ ISRInternalGPIOPin ESP32InternalGPIOPin::to_isr() const {
|
||||
#ifdef USE_GPIO_HOLD
|
||||
arg->hold = this->get_hold();
|
||||
#endif
|
||||
#if defined(USE_ESP32_VARIANT_ESP32)
|
||||
#ifdef ISR_PIN_USE_RTC
|
||||
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);
|
||||
}
|
||||
@@ -251,7 +258,8 @@ 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);
|
||||
#if defined(USE_ESP32_VARIANT_ESP32)
|
||||
#ifdef USE_ESP32_VARIANT_ESP32
|
||||
// Only ESP32 uses the RTC registers for pullup/pulldown
|
||||
if (arg->use_rtc) {
|
||||
if (flags & gpio::FLAG_PULLUP) {
|
||||
rtcio_hal_pullup_enable(arg->rtc_pin);
|
||||
|
||||
@@ -138,6 +138,11 @@ 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;
|
||||
@@ -207,13 +212,10 @@ 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
|
||||
@@ -244,7 +246,9 @@ 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
|
||||
this->set_operating_mode(OP_NORMAL_MODE);
|
||||
// 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);
|
||||
}
|
||||
|
||||
void LD2420Component::factory_reset_action() {
|
||||
@@ -356,6 +360,22 @@ 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) {
|
||||
|
||||
@@ -119,6 +119,7 @@ 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);
|
||||
|
||||
@@ -30,6 +30,5 @@ async def to_code(config: ConfigType) -> None:
|
||||
CONF_OPERATING_MODE,
|
||||
hub.set_operating_mode_select,
|
||||
options=CONF_SELECTS,
|
||||
apply="set_operating_mode(static_cast<ld2420::OpMode>(index + 1))",
|
||||
publish="before",
|
||||
apply="change_operating_mode(static_cast<ld2420::OpMode>(index + 1))",
|
||||
)
|
||||
@@ -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 the quoted CMake arguments
|
||||
(ESP-IDF, Zephyr) the build flags pass through."""
|
||||
lexers (PlatformIO, host, ESP8266, and ESP-IDF before it quotes each
|
||||
token for CMake) and the raw quoted CMake argument (Zephyr)."""
|
||||
# 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,7 +414,8 @@ async def to_code(configs):
|
||||
"LV_COLOR_CHROMA_KEY",
|
||||
await lvalid.lv_color.process(config_0[df.CONF_TRANSPARENCY_KEY]),
|
||||
)
|
||||
cg.add_build_flag("-Isrc")
|
||||
if not CORE.using_toolchain_esp_idf:
|
||||
cg.add_build_flag("-Isrc")
|
||||
|
||||
cg.add_global(lvgl_ns.using)
|
||||
for font in df.get_esphome_fonts_used():
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <span>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
@@ -53,16 +54,25 @@ using DataCallbackManagerBase = CallbackManager<void(const AudioCapture &)>;
|
||||
|
||||
class DataCallbackManager : public DataCallbackManagerBase {
|
||||
public:
|
||||
explicit DataCallbackManager(const audio::AudioStreamInfo *stream_info) : stream_info_(stream_info) {}
|
||||
DataCallbackManager(const audio::AudioStreamInfo *stream_info, const bool *mute_state)
|
||||
: stream_info_(stream_info), mute_state_(mute_state) {}
|
||||
|
||||
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) { this->call(AudioCapture{data, *this->stream_info_, 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});
|
||||
}
|
||||
}
|
||||
|
||||
protected:
|
||||
const audio::AudioStreamInfo *stream_info_;
|
||||
const bool *mute_state_;
|
||||
};
|
||||
|
||||
class Microphone {
|
||||
@@ -72,15 +82,7 @@ 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(
|
||||
[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);
|
||||
}
|
||||
});
|
||||
this->data_callbacks_.add(as_capture_callback(std::forward<F>(data_callback)));
|
||||
}
|
||||
|
||||
bool is_running() const { return this->state_ == STATE_RUNNING; }
|
||||
@@ -92,8 +94,11 @@ class Microphone {
|
||||
audio::AudioStreamInfo get_audio_stream_info() { return this->audio_stream_info_; }
|
||||
|
||||
protected:
|
||||
void publish_audio_(std::span<const uint8_t> data, const audio::AudioStreamInfo &stream_info,
|
||||
int64_t capture_time_us = 0) {
|
||||
/// 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());
|
||||
}
|
||||
this->data_callbacks_.call(AudioCapture{data, stream_info, capture_time_us});
|
||||
}
|
||||
|
||||
@@ -105,7 +110,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_};
|
||||
DataCallbackManager data_callbacks_{&this->audio_stream_info_, &this->mute_state_};
|
||||
};
|
||||
|
||||
} // namespace esphome::microphone
|
||||
@@ -452,6 +452,16 @@ 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)) {
|
||||
@@ -1173,8 +1183,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 pacer is not ready,
|
||||
* the command is saved in the queue entry for retry rather than dropped.
|
||||
* 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.
|
||||
*
|
||||
* @param variable_name Name of the variable or component associated with the command.
|
||||
* @param command The raw command string to send.
|
||||
@@ -1185,6 +1195,15 @@ 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
|
||||
@@ -1346,14 +1365,13 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) {
|
||||
std::string command = "get " + component->get_variable_name_to_send();
|
||||
|
||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||
// 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();
|
||||
// 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);
|
||||
}
|
||||
this->nextion_queue_.push_back(nextion_queue);
|
||||
#else // USE_NEXTION_COMMAND_SPACING
|
||||
if (this->send_command_(command)) {
|
||||
this->nextion_queue_.push_back(nextion_queue);
|
||||
|
||||
@@ -1470,6 +1470,16 @@ 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_;
|
||||
|
||||
@@ -4,25 +4,29 @@ import esphome.config_validation as cv
|
||||
from esphome.const import CONF_POWER_MODE, ENTITY_CATEGORY_CONFIG
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from ..audio_dac import CONF_PCM5122, PCM5122
|
||||
from ..audio_dac import CONF_PCM5122, PCM5122, pcm5122_ns
|
||||
|
||||
PCM5122PowerSwitch = pcm5122_ns.class_("PCM5122PowerSwitch", switch.Switch)
|
||||
|
||||
pcm5122_power_switch_mode = pcm5122_ns.enum("PCM5122PowerSwitchMode")
|
||||
PCM5122_POWER_SWITCH_MODE_ENUM = pcm5122_power_switch_mode.options(
|
||||
"standby", "powerdown", prefix="PCM5122_POWER_SWITCH_MODE_"
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = switch.switch_schema(
|
||||
switch.ApplySwitch,
|
||||
PCM5122PowerSwitch,
|
||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||
).extend(
|
||||
{
|
||||
cv.GenerateID(CONF_PCM5122): cv.use_id(PCM5122),
|
||||
cv.Optional(CONF_POWER_MODE, default="powerdown"): cv.one_of(
|
||||
"standby", "powerdown", lower=True
|
||||
cv.Optional(CONF_POWER_MODE, default="powerdown"): cv.enum(
|
||||
PCM5122_POWER_SWITCH_MODE_ENUM, lower=True
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_PCM5122])
|
||||
setter = "set_standby" if config[CONF_POWER_MODE] == "standby" else "set_powerdown"
|
||||
# The switch shows the new state only once the DAC accepted it
|
||||
await switch.sub_switches(config, parent=hub).create(
|
||||
config, None, apply=f"{setter}(state)", publish="on_success"
|
||||
)
|
||||
var = await switch.new_switch(config)
|
||||
await cg.register_parented(var, config[CONF_PCM5122])
|
||||
cg.add(var.set_power_mode(config[CONF_POWER_MODE]))
|
||||
@@ -0,0 +1,12 @@
|
||||
#include "power_switch.h"
|
||||
|
||||
namespace esphome::pcm5122 {
|
||||
|
||||
void PCM5122PowerSwitch::write_state(bool state) {
|
||||
bool ok = (this->mode_ == PCM5122_POWER_SWITCH_MODE_STANDBY) ? this->parent_->set_standby(state)
|
||||
: this->parent_->set_powerdown(state);
|
||||
if (ok)
|
||||
this->publish_state(state);
|
||||
}
|
||||
|
||||
} // namespace esphome::pcm5122
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/switch/switch.h"
|
||||
|
||||
#include "../pcm5122.h"
|
||||
|
||||
namespace esphome::pcm5122 {
|
||||
|
||||
enum PCM5122PowerSwitchMode : uint8_t {
|
||||
PCM5122_POWER_SWITCH_MODE_STANDBY,
|
||||
PCM5122_POWER_SWITCH_MODE_POWERDOWN,
|
||||
};
|
||||
|
||||
class PCM5122PowerSwitch final : public switch_::Switch, public Parented<PCM5122> {
|
||||
public:
|
||||
void set_power_mode(PCM5122PowerSwitchMode mode) { this->mode_ = mode; }
|
||||
|
||||
protected:
|
||||
void write_state(bool state) override;
|
||||
|
||||
PCM5122PowerSwitchMode mode_{PCM5122_POWER_SWITCH_MODE_POWERDOWN};
|
||||
};
|
||||
|
||||
} // namespace esphome::pcm5122
|
||||
@@ -90,6 +90,7 @@ 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;
|
||||
@@ -137,7 +138,23 @@ void ResamplerMicrophone::process_audio_(const microphone::AudioCapture &capture
|
||||
}
|
||||
|
||||
if (this->resampler_ == nullptr) {
|
||||
this->publish_audio_(capture.data, input_stream_info, capture.capture_time_us);
|
||||
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});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ 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;
|
||||
|
||||
@@ -5,25 +5,37 @@ 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
|
||||
from esphome.types import ConfigType, TemplateArgsType
|
||||
|
||||
from .keysyms import button_mask, keysym
|
||||
|
||||
CODEOWNERS = ["@MindFreeze"]
|
||||
DEPENDENCIES = ["display", "network"]
|
||||
AUTO_LOAD = ["socket"]
|
||||
AUTO_LOAD = ["socket", "unicode"]
|
||||
DOMAIN = "vnc"
|
||||
MULTI_CONF = True
|
||||
|
||||
@@ -34,11 +46,14 @@ 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"
|
||||
@@ -72,6 +87,7 @@ 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({}),
|
||||
}
|
||||
@@ -115,6 +131,14 @@ 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
|
||||
@@ -138,15 +162,89 @@ 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)
|
||||
|
||||
|
||||
VNC_ID_SCHEMA = automation.maybe_simple_id(
|
||||
{cv.GenerateID(): cv.use_id(VncClient)},
|
||||
)
|
||||
_CLIENT_ID = {cv.GenerateID(): cv.use_id(VncClient)}
|
||||
|
||||
VNC_ID_SCHEMA = automation.maybe_simple_id(_CLIENT_ID)
|
||||
|
||||
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
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,136 @@
|
||||
"""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
|
||||
@@ -1,6 +1,7 @@
|
||||
#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"
|
||||
@@ -8,6 +9,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cinttypes>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <iterator>
|
||||
|
||||
@@ -53,6 +55,17 @@ 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) {
|
||||
@@ -77,8 +90,59 @@ 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) *
|
||||
@@ -121,6 +185,9 @@ 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(); }
|
||||
@@ -195,10 +262,15 @@ 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_ >= this->update_delay_ms_ ||
|
||||
now - this->first_change_ms_ >= this->max_update_delay_ms_)) {
|
||||
(now - this->last_change_ms_ >= update_delay || 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();
|
||||
@@ -207,6 +279,9 @@ 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"));
|
||||
@@ -263,6 +338,13 @@ 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();
|
||||
@@ -984,4 +1066,144 @@ 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
|
||||
@@ -4,9 +4,13 @@
|
||||
#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 {
|
||||
|
||||
@@ -16,6 +20,12 @@ 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,
|
||||
@@ -55,7 +65,12 @@ enum class VncFailure : uint8_t {
|
||||
};
|
||||
|
||||
/// RFB (VNC) client that draws the remote framebuffer into a display.
|
||||
class VncClient : public Component {
|
||||
class VncClient : public Component
|
||||
#ifdef USE_VNC_TOUCHSCREEN
|
||||
,
|
||||
public touchscreen::TouchListener
|
||||
#endif
|
||||
{
|
||||
public:
|
||||
explicit VncClient(display::Display *display, bool big_endian = false) : display_(display), big_endian_(big_endian) {}
|
||||
|
||||
@@ -65,6 +80,15 @@ 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;
|
||||
@@ -76,6 +100,13 @@ 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));
|
||||
}
|
||||
@@ -86,6 +117,13 @@ 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);
|
||||
@@ -107,6 +145,17 @@ 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;
|
||||
@@ -135,6 +184,9 @@ 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};
|
||||
@@ -161,7 +213,19 @@ 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};
|
||||
@@ -193,9 +257,18 @@ 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];
|
||||
};
|
||||
|
||||
|
||||
@@ -2744,8 +2744,13 @@ def url(value):
|
||||
raise Invalid("Expected a file scheme or a URL scheme with host")
|
||||
|
||||
|
||||
def git_ref(value):
|
||||
if re.match(r"[a-zA-Z0-9_./-]+", value) is None:
|
||||
# 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:
|
||||
raise Invalid("Not a valid git ref")
|
||||
return value
|
||||
|
||||
@@ -3000,9 +3005,8 @@ def validate_source_shorthand(value):
|
||||
except Invalid:
|
||||
pass
|
||||
# Regex for GitHub repo name with optional branch/tag
|
||||
# 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]+))",
|
||||
m = re.fullmatch(
|
||||
rf"github://(?:([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+)(?:@({GIT_REF_PATTERN}))?|pr#([0-9]+))",
|
||||
value,
|
||||
)
|
||||
if m is None:
|
||||
|
||||
@@ -910,7 +910,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
CORE.add_job(_add_automations, config)
|
||||
|
||||
cg.add_build_flag("-fno-exceptions")
|
||||
if not CORE.using_toolchain_esp_idf:
|
||||
cg.add_build_flag("-fno-exceptions")
|
||||
|
||||
# Libraries
|
||||
for lib in config[CONF_LIBRARIES]:
|
||||
|
||||
@@ -536,6 +536,8 @@
|
||||
#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
|
||||
|
||||
@@ -5,7 +5,7 @@ from importlib import import_module
|
||||
from importlib.util import find_spec
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Literal, get_args
|
||||
from typing import Any, Literal
|
||||
|
||||
from esphome.automation import apply_static_function, parent_ref
|
||||
import esphome.codegen as cg
|
||||
@@ -503,8 +503,7 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) ->
|
||||
config[_KEY_ICON_IDX] = icon_idx
|
||||
|
||||
|
||||
ApplyPublishMode = Literal["before", "after", "momentary", "on_success"]
|
||||
ApplyPublish = ApplyPublishMode | None
|
||||
ApplyPublish = Literal["before", "after", "momentary"] | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -578,8 +577,7 @@ class SubEntities:
|
||||
|
||||
With ``apply`` the entity is the domain's ``Apply*`` class and its control runs
|
||||
``parent->apply``, or ``apply`` as a namespaced free statement when no parent is bound, with
|
||||
``publish`` ("before"/"after") placing the domain's publish; ``"on_success"`` publishes only when
|
||||
``apply`` returns true. ``publish="momentary"`` instead
|
||||
``publish`` ("before"/"after") placing the domain's publish. ``publish="momentary"`` instead
|
||||
acknowledges first (a switch publishes off) and runs ``apply`` only when turned on. It is only for
|
||||
legacy action switches; a new action entity is a button.
|
||||
"""
|
||||
@@ -602,8 +600,10 @@ class SubEntities:
|
||||
)
|
||||
if publish is not None and (apply is None or self.apply_entity.publish is None):
|
||||
raise ValueError(f"{entity_id}: publish= needs apply= on a stateful domain")
|
||||
if publish is not None and publish not in (modes := get_args(ApplyPublishMode)):
|
||||
raise ValueError(f"{entity_id}: publish= must be one of {', '.join(modes)}")
|
||||
if publish not in (None, "before", "after", "momentary"):
|
||||
raise ValueError(
|
||||
f'{entity_id}: publish= must be "before", "after" or "momentary"'
|
||||
)
|
||||
if publish == "momentary" and self.apply_entity.momentary is None:
|
||||
raise ValueError(f'{entity_id}: publish="momentary" is not supported here')
|
||||
|
||||
@@ -626,14 +626,11 @@ class SubEntities:
|
||||
if isinstance(parent, ID):
|
||||
parent = await get_variable(parent)
|
||||
apply_entity = self.apply_entity
|
||||
expr = apply if parent is None else f"{parent_ref(parent)}->{apply}"
|
||||
call = f"{expr};"
|
||||
call = f"{apply};" if parent is None else f"{parent_ref(parent)}->{apply};"
|
||||
if publish == "before":
|
||||
body = [apply_entity.publish, call]
|
||||
elif publish == "after":
|
||||
body = [call, apply_entity.publish]
|
||||
elif publish == "on_success":
|
||||
body = [f"if ({expr}) {{ {apply_entity.publish} }}"]
|
||||
elif publish == "momentary":
|
||||
acknowledge, condition = apply_entity.momentary
|
||||
body = [acknowledge, f"if ({condition}) {{ {call} }}"]
|
||||
|
||||
@@ -42,20 +42,26 @@ _MIRROR_ATTEMPTS = 3
|
||||
_MIRROR_SWEEP_ATTEMPTS = NETWORK_MAX_ATTEMPTS
|
||||
|
||||
|
||||
def get_project_link_flags() -> list[str]:
|
||||
"""Return the sorted -Wl, linker flags from the current build."""
|
||||
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)."""
|
||||
from esphome.core import CORE # local import to avoid circular dependency
|
||||
|
||||
return sorted(flag for flag in CORE.build_flags if flag.startswith("-Wl,"))
|
||||
if flags is None:
|
||||
flags = sorted(CORE.build_flags)
|
||||
return [flag for flag in flags if flag.startswith("-Wl,")]
|
||||
|
||||
|
||||
def get_project_compile_flags() -> list[str]:
|
||||
"""Return the sorted -D and -W (non-linker) flags from the current build."""
|
||||
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)."""
|
||||
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 sorted(CORE.build_flags)
|
||||
for flag in flags
|
||||
if flag.startswith("-D")
|
||||
or (flag.startswith("-W") and not flag.startswith("-Wl,"))
|
||||
]
|
||||
|
||||
+2
-2
@@ -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.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\-_.\./]+))?",
|
||||
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\-_.\./]+))?",
|
||||
shorthand,
|
||||
)
|
||||
if m is None:
|
||||
|
||||
@@ -2,7 +2,7 @@ dependencies:
|
||||
bblanchon/arduinojson:
|
||||
version: "7.4.3"
|
||||
esphome/dlms_parser:
|
||||
version: 2.2.0
|
||||
version: 2.2.2
|
||||
esphome/esp-audio-libs:
|
||||
version: 4.1.0
|
||||
esphome/esp-micro-speech-features:
|
||||
|
||||
+7
-2
@@ -284,12 +284,17 @@ 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(f"esphome.components.{domain}")
|
||||
module = importlib.import_module(module_name)
|
||||
except ImportError as e:
|
||||
if exception:
|
||||
raise
|
||||
if "No module named" in str(e):
|
||||
if (
|
||||
isinstance(e, ModuleNotFoundError)
|
||||
and e.name is not None
|
||||
and f"{module_name}.".startswith(f"{e.name}.")
|
||||
):
|
||||
_LOGGER.info(
|
||||
"Unable to import component %s: %s", domain, str(e), exc_info=False
|
||||
)
|
||||
|
||||
@@ -1036,8 +1036,15 @@ 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
|
||||
|
||||
@@ -1349,8 +1356,8 @@ 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[:] = [d for d in dirs if _is_file_valid(d)]
|
||||
for f in files:
|
||||
dirs[:] = sorted(d for d in dirs if _is_file_valid(d))
|
||||
for f in sorted(files):
|
||||
filename = Path(f)
|
||||
if _is_file_valid(f) and filename.match(pattern):
|
||||
filename = Path(root) / filename
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ build_unflags =
|
||||
; esp32 envs (which would double-include them).
|
||||
[common:idf-component-libs]
|
||||
lib_deps =
|
||||
esphome/dlms_parser@2.2.0 ; dlms_meter
|
||||
esphome/dlms_parser@2.2.2 ; dlms_meter
|
||||
bblanchon/ArduinoJson@7.4.3 ; json
|
||||
lvgl/lvgl@9.6.0 ; lvgl
|
||||
|
||||
|
||||
+4
-4
@@ -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==48.0.1; platform_system == "Darwin" and platform_machine == "x86_64"
|
||||
cryptography==50.0.2; 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.4 # from time
|
||||
tzlocal==5.4.5 # from time
|
||||
tzdata>=2026.5 # from time
|
||||
pyserial==3.5
|
||||
platformio==6.2.0
|
||||
esptool==5.6.0
|
||||
click==8.5.0
|
||||
aioesphomeapi==46.22.0
|
||||
aioesphomeapi==46.23.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.1.0; platform_system == "Darwin" and platform_machine == "x86_64"
|
||||
smp==4.2.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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pylint==4.1.3
|
||||
ruff==0.17.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
|
||||
ruff==0.17.2 # .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.6 # .github/workflows/ci.yml reads this pin
|
||||
prek==0.5.7 # .github/workflows/ci.yml reads this pin
|
||||
yamlrocks==0.7.1 # used by script/sync_dependency_versions.py
|
||||
|
||||
# Unit tests
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""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
|
||||
@@ -1,8 +1,9 @@
|
||||
"""The ``-DLV_CONF_PATH`` build flag carries a path. PlatformIO, the host
|
||||
build and the native ESP8266 build shell-lex build flags, while the ESP-IDF
|
||||
and Zephyr builds write each flag into a quoted CMake argument; both must
|
||||
decode it to one ``-DLV_CONF_PATH="..."`` argument, including when the
|
||||
project path contains a space."""
|
||||
build and the native ESP8266 build shell-lex build flags; the ESP-IDF build
|
||||
shell-lexes them and then quotes each token as a CMake argument; the Zephyr
|
||||
build writes each flag into a quoted CMake argument as is. All must decode it
|
||||
to one ``-DLV_CONF_PATH="..."`` argument, including when the project path
|
||||
contains a space."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,7 +13,9 @@ import shlex
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.build_gen.espidf import _cmake_args
|
||||
from esphome.components.lvgl import lv_conf_path_flag
|
||||
from esphome.platformio.library import lex_build_flags
|
||||
|
||||
|
||||
def _cmake_quoted_argument(flag: str) -> str:
|
||||
@@ -37,3 +40,7 @@ def test_flag_is_one_argument_everywhere(build_path: str) -> None:
|
||||
|
||||
assert shlex.split(flag) == [expected]
|
||||
assert _cmake_quoted_argument(flag) == expected
|
||||
assert [
|
||||
_cmake_quoted_argument(arg.removeprefix('"').removesuffix('"'))
|
||||
for arg in _cmake_args(lex_build_flags([flag], "lvgl"))
|
||||
] == [expected]
|
||||
@@ -56,6 +56,17 @@ EPAPER_SPI = """
|
||||
reset_pin: GPIO25
|
||||
"""
|
||||
|
||||
TOUCHSCREEN = """
|
||||
i2c:
|
||||
sda: GPIO32
|
||||
scl: GPIO33
|
||||
|
||||
touchscreen:
|
||||
- platform: gt911
|
||||
id: touch
|
||||
display: {display}
|
||||
"""
|
||||
|
||||
|
||||
S3_BASE = """
|
||||
esphome:
|
||||
@@ -155,6 +166,10 @@ def config_error(
|
||||
return validate
|
||||
|
||||
|
||||
def _defines() -> set[str]:
|
||||
return {define.name for define in CORE.defines}
|
||||
|
||||
|
||||
def test_it8951_owned_by_vnc(
|
||||
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
|
||||
) -> None:
|
||||
@@ -263,6 +278,30 @@ vnc:
|
||||
assert TEST_CARD not in main_cpp
|
||||
|
||||
|
||||
def test_no_touchscreen_no_forwarding(
|
||||
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
|
||||
) -> None:
|
||||
main_cpp = generate_main(write_yaml(IT8951, VNC))
|
||||
|
||||
assert "USE_VNC_TOUCHSCREEN" not in _defines()
|
||||
assert "register_touchscreen" not in main_cpp
|
||||
|
||||
|
||||
def test_touchscreen_forwarding(
|
||||
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
|
||||
) -> None:
|
||||
main_cpp = generate_main(
|
||||
write_yaml(
|
||||
IT8951,
|
||||
TOUCHSCREEN.format(display="epd"),
|
||||
"\nvnc:\n - host: 192.168.1.20\n touchscreen_id: touch\n",
|
||||
)
|
||||
)
|
||||
|
||||
assert "USE_VNC_TOUCHSCREEN" in _defines()
|
||||
assert re.search(r"->register_touchscreen\(touch\);", main_cpp)
|
||||
|
||||
|
||||
def test_action_codegen(
|
||||
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
|
||||
) -> None:
|
||||
@@ -277,11 +316,30 @@ vnc:
|
||||
interval:
|
||||
- interval: 60s
|
||||
then:
|
||||
- vnc.send_pointer:
|
||||
x: 10
|
||||
y: 20
|
||||
buttons: [left, wheel_down]
|
||||
- vnc.send_key: Return
|
||||
- vnc.send_key:
|
||||
key: "A"
|
||||
action: down
|
||||
- vnc.send_key: 7
|
||||
- vnc.type_text: "é€"
|
||||
- vnc.refresh
|
||||
""",
|
||||
)
|
||||
)
|
||||
|
||||
assert "remote->send_pointer(10, 20, 17);" in main_cpp
|
||||
assert (
|
||||
"remote->send_key(0xFF0D, vnc::VncKeyAction::VNC_KEY_ACTION_PRESS);" in main_cpp
|
||||
)
|
||||
assert "remote->send_key(0x41, vnc::VncKeyAction::VNC_KEY_ACTION_DOWN);" in main_cpp
|
||||
assert (
|
||||
"remote->send_key(0x37, vnc::VncKeyAction::VNC_KEY_ACTION_PRESS);" in main_cpp
|
||||
)
|
||||
assert r'remote->type_text("\303\251\342\202\254", 5);' in main_cpp
|
||||
assert "remote->refresh();" in main_cpp
|
||||
|
||||
|
||||
@@ -405,6 +463,20 @@ display:
|
||||
assert "use epaper_spi" in out
|
||||
|
||||
|
||||
def test_touchscreen_on_other_display_rejected(
|
||||
config_error: Callable[..., str],
|
||||
) -> None:
|
||||
out = config_error(
|
||||
IT8951,
|
||||
EPAPER_SPI,
|
||||
TOUCHSCREEN.format(display="other"),
|
||||
"\nvnc:\n - host: 192.168.1.20\n display_id: epd\n"
|
||||
" touchscreen_id: touch\n",
|
||||
)
|
||||
|
||||
assert "Touchscreen 'touch' belongs to display 'other', not 'epd'" in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("vnc_options", "error"),
|
||||
[
|
||||
|
||||
@@ -400,6 +400,37 @@ api:
|
||||
root["items"][0] = "item1";
|
||||
root["items"][1] = "item2";
|
||||
|
||||
# ==========================================================================
|
||||
# Array args in actions that respond. The default array args are
|
||||
# non-copyable FixedVector views, so api.respond and the action that
|
||||
# unregisters the call must read call_id without copying the other args.
|
||||
# ==========================================================================
|
||||
- action: test_respond_only_array
|
||||
supports_response: only
|
||||
variables:
|
||||
float_arr: float[]
|
||||
string_arr: string[]
|
||||
then:
|
||||
- api.respond:
|
||||
data: !lambda |-
|
||||
root["floats"] = float_arr.size();
|
||||
root["strings"] = string_arr.size();
|
||||
|
||||
- action: test_respond_status_array
|
||||
variables:
|
||||
int_arr: int[]
|
||||
then:
|
||||
- api.respond:
|
||||
success: !lambda 'return !int_arr.empty();'
|
||||
|
||||
- action: test_respond_optional_array
|
||||
variables:
|
||||
bool_arr: bool[]
|
||||
then:
|
||||
- api.respond:
|
||||
data: !lambda |-
|
||||
root["bools"] = bool_arr.size();
|
||||
|
||||
# ==========================================================================
|
||||
# supports_response: none (no api.respond action)
|
||||
# No call_id or return_response - just user variables
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/components/demo/demo_cover.h"
|
||||
#include "esphome/core/application.h"
|
||||
|
||||
namespace esphome::demo::testing {
|
||||
|
||||
using cover::COVER_CLOSED;
|
||||
using cover::COVER_OPERATION_IDLE;
|
||||
using cover::COVER_OPERATION_OPENING;
|
||||
|
||||
// Type 4 is the demo cover with a stop button. It starts closed.
|
||||
TEST(DemoCoverTest, StopEndsMovement) {
|
||||
DemoCover cover;
|
||||
cover.set_type(DemoCoverType::TYPE_4);
|
||||
cover.setup();
|
||||
|
||||
cover.make_call().set_command_open().perform();
|
||||
EXPECT_EQ(cover.current_operation, COVER_OPERATION_OPENING);
|
||||
|
||||
cover.make_call().set_command_stop().perform();
|
||||
EXPECT_EQ(cover.current_operation, COVER_OPERATION_IDLE);
|
||||
EXPECT_EQ(cover.position, COVER_CLOSED);
|
||||
// MOVE_TIMEOUT_ID (0) is gone, so the move cannot finish 2 s later
|
||||
EXPECT_FALSE(App.scheduler.cancel_timeout(&cover, uint32_t{0}));
|
||||
}
|
||||
|
||||
} // namespace esphome::demo::testing
|
||||
@@ -17,3 +17,4 @@ sensor:
|
||||
sensor: integration_my_sensor
|
||||
name: Integration Sensor
|
||||
time_unit: s
|
||||
state_class: total_increasing
|
||||
@@ -6,3 +6,4 @@ sensor:
|
||||
sensor: integration_my_sensor
|
||||
name: Integration Sensor
|
||||
time_unit: s
|
||||
state_class: total_increasing
|
||||
@@ -6,3 +6,4 @@ sensor:
|
||||
sensor: integration_my_sensor
|
||||
name: Integration Sensor
|
||||
time_unit: s
|
||||
state_class: total_increasing
|
||||
@@ -17,7 +17,7 @@ class TestMicrophone : public Microphone {
|
||||
void start() override {}
|
||||
void stop() override {}
|
||||
|
||||
void publish(std::span<const uint8_t> data, const audio::AudioStreamInfo &stream_info, int64_t capture_time_us) {
|
||||
void publish(std::span<uint8_t> data, const audio::AudioStreamInfo &stream_info, int64_t capture_time_us) {
|
||||
this->publish_audio_(data, stream_info, capture_time_us);
|
||||
}
|
||||
bool has_callbacks() const { return this->has_data_callbacks_(); }
|
||||
@@ -56,20 +56,24 @@ TEST(MicrophoneDataCallbacks, CallbackGetsDataFormatAndCaptureTime) {
|
||||
});
|
||||
EXPECT_TRUE(mic.has_callbacks());
|
||||
|
||||
mic.publish(SAMPLES, STEREO_48K, 123456);
|
||||
std::vector<uint8_t> buffer = SAMPLES;
|
||||
mic.publish(buffer, STEREO_48K, 123456);
|
||||
EXPECT_EQ(data, SAMPLES);
|
||||
EXPECT_EQ(stream_info, STEREO_48K);
|
||||
EXPECT_EQ(capture_time_us, 123456);
|
||||
}
|
||||
|
||||
TEST(MicrophoneDataCallbacks, MutedCallbackGetsZeros) {
|
||||
TEST(MicrophoneDataCallbacks, MuteZeroesTheBufferInPlace) {
|
||||
TestMicrophone mic;
|
||||
std::vector<uint8_t> data;
|
||||
mic.add_data_callback([&](const AudioCapture &capture) { data.assign(capture.data.begin(), capture.data.end()); });
|
||||
|
||||
mic.set_mute_state(true);
|
||||
mic.publish(SAMPLES, STEREO_48K, 0);
|
||||
EXPECT_EQ(data, std::vector<uint8_t>(SAMPLES.size(), 0));
|
||||
std::vector<uint8_t> buffer = SAMPLES;
|
||||
mic.publish(buffer, STEREO_48K, 0);
|
||||
const std::vector<uint8_t> zeros(SAMPLES.size(), 0);
|
||||
EXPECT_EQ(data, zeros);
|
||||
EXPECT_EQ(buffer, zeros);
|
||||
}
|
||||
|
||||
TEST(MicrophoneDataCallbacks, DataTriggerPassesACopy) {
|
||||
@@ -115,4 +119,17 @@ TEST(MicrophoneDataCallbacks, DeprecatedCallUsesMicrophoneFormat) {
|
||||
EXPECT_EQ(capture_time_us, 0);
|
||||
}
|
||||
|
||||
// Remove in 2027.5.0 with the deprecated DataCallbackManager::call(data)
|
||||
TEST(MicrophoneDataCallbacks, DeprecatedCallDeliversZerosWhileMuted) {
|
||||
TestMicrophone mic;
|
||||
std::vector<uint8_t> data;
|
||||
mic.add_data_callback([&](const AudioCapture &capture) { data.assign(capture.data.begin(), capture.data.end()); });
|
||||
|
||||
mic.set_mute_state(true);
|
||||
const std::vector<uint8_t> buffer = SAMPLES;
|
||||
mic.publish_deprecated(buffer);
|
||||
EXPECT_EQ(data, std::vector<uint8_t>(SAMPLES.size(), 0));
|
||||
EXPECT_EQ(buffer, SAMPLES);
|
||||
}
|
||||
|
||||
} // namespace esphome::microphone::testing
|
||||
@@ -1,3 +1,5 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.types import ConfigType
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
@@ -10,3 +12,12 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
if dep not in deps:
|
||||
deps.append(dep)
|
||||
manifest.dependencies = deps
|
||||
|
||||
# The command queue ordering code under test is gated on
|
||||
# USE_NEXTION_COMMAND_SPACING, which the component only defines when
|
||||
# command_spacing is configured. Define it here so the C++ unit test build
|
||||
# compiles those paths.
|
||||
async def to_code_testing(config: ConfigType) -> None:
|
||||
cg.add_define("USE_NEXTION_COMMAND_SPACING")
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
@@ -8,33 +8,48 @@
|
||||
|
||||
#include "esphome/components/nextion/nextion.h"
|
||||
#include "esphome/components/uart/uart_component.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::nextion::testing {
|
||||
|
||||
// Sets the loop clock the component reads through App.get_loop_component_start_time(), so spacing can be
|
||||
// stepped forward deterministically. The guard only publishes the time; it does not restore it on destruction.
|
||||
static void set_loop_time(uint32_t now) { LoopBlockingGuard clock(nullptr, LOG_STR("test"), now); }
|
||||
|
||||
// Explicit instantiation skips access checks, which lets the test reach the queue and the response
|
||||
// handlers without making them public on the component.
|
||||
template<std::list<NextionQueue *> Nextion::*Q, void (Nextion::*N)(int), void (Nextion::*S)(const std::string &),
|
||||
void (Nextion::*R)(NextionQueue *), bool NextionBase::*U>
|
||||
void (Nextion::*R)(NextionQueue *), bool NextionBase::*U, bool (Nextion::*RQ)(bool), void (Nextion::*PP)()>
|
||||
struct NextionAccess {
|
||||
friend std::list<NextionQueue *> &queue(Nextion &nextion) { return nextion.*Q; }
|
||||
friend void numeric_return(Nextion &nextion, int value) { (nextion.*N)(value); }
|
||||
friend void string_return(Nextion &nextion, const std::string &value) { (nextion.*S)(value); }
|
||||
friend void release_queue_entry(Nextion &nextion, NextionQueue *entry) { (nextion.*R)(entry); }
|
||||
friend void mark_setup(Nextion &nextion) { nextion.*U = true; }
|
||||
friend bool remove_from_q(Nextion &nextion, bool report_empty) { return (nextion.*RQ)(report_empty); }
|
||||
friend void process_pending(Nextion &nextion) { (nextion.*PP)(); }
|
||||
};
|
||||
std::list<NextionQueue *> &queue(Nextion &nextion);
|
||||
void numeric_return(Nextion &nextion, int value);
|
||||
void string_return(Nextion &nextion, const std::string &value);
|
||||
void release_queue_entry(Nextion &nextion, NextionQueue *entry);
|
||||
void mark_setup(Nextion &nextion);
|
||||
bool remove_from_q(Nextion &nextion, bool report_empty);
|
||||
void process_pending(Nextion &nextion);
|
||||
template struct NextionAccess<&Nextion::nextion_queue_, &Nextion::set_numeric_return_, &Nextion::set_string_return_,
|
||||
&Nextion::release_queue_entry_, &Nextion::is_setup_>;
|
||||
&Nextion::release_queue_entry_, &Nextion::is_setup_, &Nextion::remove_from_q_,
|
||||
&Nextion::process_pending_in_queue_>;
|
||||
|
||||
/// Accepts the commands the component writes; the display never answers
|
||||
class FakeUART : public uart::UARTComponent {
|
||||
public:
|
||||
void write_array(const uint8_t *data, size_t len) override {}
|
||||
/// Every byte written, so tests can check what reached the wire and in what order
|
||||
std::string sent;
|
||||
|
||||
void write_array(const uint8_t *data, size_t len) override {
|
||||
this->sent.append(reinterpret_cast<const char *>(data), len);
|
||||
}
|
||||
bool peek_byte(uint8_t *data) override { return false; }
|
||||
bool read_array(uint8_t *data, size_t len) override { return false; }
|
||||
size_t available() override { return 0; }
|
||||
@@ -110,6 +125,19 @@ class DeliverQueueResponse : public ::testing::Test {
|
||||
return names;
|
||||
}
|
||||
|
||||
/// Commands that reached the wire, in order. send_command_() ends each with the 0xFF 0xFF 0xFF terminator.
|
||||
std::vector<std::string> sent_commands_() {
|
||||
std::vector<std::string> commands;
|
||||
const std::string terminator(3, static_cast<char>(0xFF));
|
||||
size_t start = 0;
|
||||
size_t end = 0;
|
||||
while ((end = this->uart_.sent.find(terminator, start)) != std::string::npos) {
|
||||
commands.push_back(this->uart_.sent.substr(start, end - start));
|
||||
start = end + terminator.size();
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
FakeUART uart_;
|
||||
Nextion nextion_;
|
||||
std::vector<std::unique_ptr<MockComponent>> components_;
|
||||
@@ -240,4 +268,55 @@ TEST_F(DeliverQueueResponse, QueryNamedRawGetIsNotRawGet) {
|
||||
EXPECT_TRUE(this->queued_().empty());
|
||||
}
|
||||
|
||||
// With command spacing, an unrelated ACK or error must not consume a front entry whose command is still
|
||||
// unsent: the response cannot belong to it, so remove_from_q_() leaves it queued instead of losing it.
|
||||
TEST_F(DeliverQueueResponse, AckKeepsUnsentFrontEntry) {
|
||||
this->nextion_.set_command_spacing(50);
|
||||
auto *unsent = this->add_query_("temp", NextionQueueType::SENSOR, false);
|
||||
|
||||
EXPECT_FALSE(remove_from_q(this->nextion_, false));
|
||||
|
||||
EXPECT_FALSE(unsent->received);
|
||||
EXPECT_EQ(this->queued_(), std::vector<std::string>{"temp"});
|
||||
}
|
||||
|
||||
// Once a command is waiting for spacing, a later write must not go out ahead of it, even after the spacing
|
||||
// interval has elapsed. process_pending_in_queue_() then drains the queue in the original transmit order.
|
||||
TEST_F(DeliverQueueResponse, WriteDoesNotOvertakePendingCommand) {
|
||||
this->nextion_.set_command_spacing(50);
|
||||
|
||||
set_loop_time(1000);
|
||||
this->nextion_.send_command("cmd1"); // nothing waiting, goes out now
|
||||
this->nextion_.send_command("cmd2"); // spacing not elapsed, kept pending
|
||||
|
||||
set_loop_time(1100); // spacing elapsed
|
||||
this->nextion_.send_command("cmd3"); // must queue behind cmd2, not overtake it
|
||||
EXPECT_EQ(this->sent_commands_(), std::vector<std::string>{"cmd1"});
|
||||
|
||||
process_pending(this->nextion_); // sends cmd2
|
||||
set_loop_time(1200);
|
||||
process_pending(this->nextion_); // sends cmd3
|
||||
EXPECT_EQ(this->sent_commands_(), (std::vector<std::string>{"cmd1", "cmd2", "cmd3"}));
|
||||
}
|
||||
|
||||
// A GET follows the same rule: while an earlier command is still waiting, the query is kept pending and only
|
||||
// transmitted once the queue drains in order, never ahead of the command that came before it.
|
||||
TEST_F(DeliverQueueResponse, GetDoesNotOvertakePendingCommand) {
|
||||
this->nextion_.set_command_spacing(50);
|
||||
auto *sensor = this->components_.emplace_back(std::make_unique<MockComponent>("bri", NextionQueueType::SENSOR)).get();
|
||||
|
||||
set_loop_time(1000);
|
||||
this->nextion_.send_command("cmd1"); // goes out now
|
||||
this->nextion_.send_command("cmd2"); // kept pending by spacing
|
||||
|
||||
set_loop_time(1100); // spacing elapsed
|
||||
this->nextion_.add_to_get_queue(sensor); // must queue behind cmd2
|
||||
EXPECT_EQ(this->sent_commands_(), std::vector<std::string>{"cmd1"});
|
||||
|
||||
process_pending(this->nextion_); // sends cmd2
|
||||
set_loop_time(1200);
|
||||
process_pending(this->nextion_); // sends "get bri"
|
||||
EXPECT_EQ(this->sent_commands_(), (std::vector<std::string>{"cmd1", "cmd2", "get bri"}));
|
||||
}
|
||||
|
||||
} // namespace esphome::nextion::testing
|
||||
@@ -1,6 +0,0 @@
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# The DAC is an audio_dac platform on I2C; the tests drive its power switch.
|
||||
manifest.dependencies = manifest.dependencies + ["audio_dac", "i2c", "switch"]
|
||||
@@ -33,7 +33,3 @@ switch:
|
||||
pcm5122: pcm5122_dac
|
||||
name: PCM5122 Power Down
|
||||
power_mode: powerdown
|
||||
- platform: pcm5122
|
||||
pcm5122: pcm5122_dac
|
||||
name: PCM5122 Standby
|
||||
power_mode: standby
|
||||
@@ -1,102 +0,0 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <array>
|
||||
|
||||
#include "esphome/components/pcm5122/pcm5122.h"
|
||||
#include "esphome/components/switch/switch.h"
|
||||
|
||||
namespace esphome::pcm5122::testing {
|
||||
|
||||
static constexpr uint8_t ADDRESS = 0x4D;
|
||||
|
||||
// The bodies codegen emits for power_mode: standby and power_mode: powerdown.
|
||||
static PCM5122 *dac = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static void standby(switch_::Switch *entity, bool state) {
|
||||
if (dac->set_standby(state))
|
||||
entity->publish_state(state);
|
||||
}
|
||||
static void powerdown(switch_::Switch *entity, bool state) {
|
||||
if (dac->set_powerdown(state))
|
||||
entity->publish_state(state);
|
||||
}
|
||||
|
||||
// Page 0 registers written as register/value pairs; failing writes are not acknowledged.
|
||||
class RegisterBus final : public i2c::I2CBus {
|
||||
public:
|
||||
i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t * /*out*/,
|
||||
size_t /*out_size*/) override {
|
||||
EXPECT_EQ(address, ADDRESS);
|
||||
if (this->fail)
|
||||
return i2c::ERROR_NOT_ACKNOWLEDGED;
|
||||
if (size == 2)
|
||||
this->registers[data[0]] = data[1];
|
||||
return i2c::ERROR_OK;
|
||||
}
|
||||
|
||||
std::array<uint8_t, 256> registers{};
|
||||
bool fail{false};
|
||||
};
|
||||
|
||||
class PCM5122PowerSwitchTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
this->dac_.set_i2c_bus(&this->bus_);
|
||||
this->dac_.set_i2c_address(ADDRESS);
|
||||
dac = &this->dac_;
|
||||
this->standby_.add_on_state_callback([this](bool) { ++this->publishes_; });
|
||||
this->powerdown_.add_on_state_callback([this](bool) { ++this->publishes_; });
|
||||
}
|
||||
|
||||
uint8_t power_control_() const { return this->bus_.registers[PCM5122_REG_POWER_CONTROL]; }
|
||||
|
||||
RegisterBus bus_;
|
||||
PCM5122 dac_;
|
||||
switch_::ApplySwitch<standby> standby_;
|
||||
switch_::ApplySwitch<powerdown> powerdown_;
|
||||
unsigned publishes_{0};
|
||||
};
|
||||
|
||||
TEST_F(PCM5122PowerSwitchTest, StandbySwitchRequestsStandbyAndPublishes) {
|
||||
this->standby_.turn_on();
|
||||
EXPECT_EQ(this->power_control_(), PCM5122_POWER_CONTROL_RQST);
|
||||
EXPECT_TRUE(this->standby_.state);
|
||||
EXPECT_EQ(this->publishes_, 1u);
|
||||
|
||||
this->standby_.turn_off();
|
||||
EXPECT_EQ(this->power_control_(), 0);
|
||||
EXPECT_FALSE(this->standby_.state);
|
||||
EXPECT_EQ(this->publishes_, 2u);
|
||||
}
|
||||
|
||||
TEST_F(PCM5122PowerSwitchTest, PowerdownSwitchRequestsPowerdownAndPublishes) {
|
||||
this->powerdown_.turn_on();
|
||||
EXPECT_EQ(this->power_control_(), PCM5122_POWER_CONTROL_RQPD);
|
||||
EXPECT_TRUE(this->powerdown_.state);
|
||||
EXPECT_EQ(this->publishes_, 1u);
|
||||
|
||||
this->powerdown_.turn_off();
|
||||
EXPECT_EQ(this->power_control_(), 0);
|
||||
EXPECT_FALSE(this->powerdown_.state);
|
||||
EXPECT_EQ(this->publishes_, 2u);
|
||||
}
|
||||
|
||||
TEST_F(PCM5122PowerSwitchTest, FailedWriteLeavesTheSwitchUnpublished) {
|
||||
this->bus_.fail = true;
|
||||
this->standby_.turn_on();
|
||||
this->powerdown_.turn_on();
|
||||
EXPECT_EQ(this->publishes_, 0u);
|
||||
EXPECT_FALSE(this->standby_.has_state());
|
||||
EXPECT_FALSE(this->powerdown_.has_state());
|
||||
}
|
||||
|
||||
TEST_F(PCM5122PowerSwitchTest, FailedWriteDoesNotLeakIntoTheNextRequest) {
|
||||
this->bus_.fail = true;
|
||||
this->standby_.turn_on();
|
||||
this->bus_.fail = false;
|
||||
this->powerdown_.turn_on();
|
||||
EXPECT_EQ(this->power_control_(), PCM5122_POWER_CONTROL_RQPD);
|
||||
EXPECT_FALSE(this->standby_.has_state());
|
||||
EXPECT_TRUE(this->powerdown_.state);
|
||||
}
|
||||
|
||||
} // namespace esphome::pcm5122::testing
|
||||
@@ -1,6 +0,0 @@
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# Builds the power switch platform against the DAC.
|
||||
manifest.dependencies = manifest.dependencies + ["pcm5122"]
|
||||
@@ -1,5 +1,6 @@
|
||||
# This file's presence makes pytest treat this directory as a package named
|
||||
# "vnc"; required for cpp unit testing.
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import socket as socket_component
|
||||
from esphome.types import ConfigType
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
@@ -8,8 +9,11 @@ from tests.testing_helpers import ComponentManifestOverride
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# MULTI_CONF components get no config in a unit test build, so to_code would never run.
|
||||
manifest.multi_conf = False
|
||||
manifest.dependencies = [*manifest.dependencies, "touchscreen"]
|
||||
|
||||
async def to_code_testing(config: ConfigType) -> None:
|
||||
socket_component.require_tcp_client_link()
|
||||
cg.add_define("USE_VNC_INPUT")
|
||||
cg.add_define("USE_VNC_TOUCHSCREEN")
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
@@ -16,6 +16,60 @@ interval:
|
||||
- interval: 60s
|
||||
then:
|
||||
- vnc.refresh: vnc_client
|
||||
- vnc.send_pointer:
|
||||
id: vnc_client
|
||||
x: 10
|
||||
y: 20
|
||||
- vnc.send_pointer:
|
||||
id: vnc_client
|
||||
x: 10
|
||||
y: 20
|
||||
buttons: 1
|
||||
- vnc.send_pointer:
|
||||
id: vnc_client
|
||||
x: 10
|
||||
y: 20
|
||||
buttons: [left, wheel_down]
|
||||
- vnc.send_pointer:
|
||||
id: vnc_client
|
||||
x: 10
|
||||
y: 20
|
||||
buttons: right
|
||||
- vnc.send_pointer:
|
||||
id: vnc_client
|
||||
x: !lambda return 30;
|
||||
y: !lambda return 40;
|
||||
buttons: !lambda return 4;
|
||||
- vnc.send_key:
|
||||
id: vnc_client
|
||||
key: Return
|
||||
- vnc.send_key:
|
||||
id: vnc_client
|
||||
key: "a"
|
||||
- vnc.send_key:
|
||||
id: vnc_client
|
||||
key: 0xFF1B
|
||||
- vnc.send_key:
|
||||
id: vnc_client
|
||||
key: !lambda return 0xFF09;
|
||||
- vnc.send_key:
|
||||
id: vnc_client
|
||||
key: Shift_L
|
||||
action: down
|
||||
- vnc.send_key:
|
||||
id: vnc_client
|
||||
key: Shift_L
|
||||
action: up
|
||||
- vnc.send_key:
|
||||
id: vnc_client
|
||||
key: "é"
|
||||
action: press
|
||||
- vnc.type_text:
|
||||
id: vnc_client
|
||||
text: "Hello\n"
|
||||
- vnc.type_text:
|
||||
id: vnc_client
|
||||
text: !lambda return std::string("world");
|
||||
- if:
|
||||
condition:
|
||||
vnc.is_connected: vnc_client
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
packages:
|
||||
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
|
||||
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
|
||||
vnc: !include common.yaml
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
display:
|
||||
- platform: test_display
|
||||
id: vnc_touch_display
|
||||
dimensions: 320x240
|
||||
|
||||
touchscreen:
|
||||
- platform: ft5x06
|
||||
i2c_id: i2c_bus
|
||||
id: vnc_touchscreen
|
||||
display: vnc_touch_display
|
||||
|
||||
vnc:
|
||||
- id: vnc_touch_client
|
||||
display_id: vnc_touch_display
|
||||
touchscreen_id: vnc_touchscreen
|
||||
host: 192.0.2.11
|
||||
@@ -0,0 +1,575 @@
|
||||
#include "vnc_test_helpers.h"
|
||||
|
||||
#include "esphome/components/touchscreen/touchscreen.h"
|
||||
|
||||
#ifdef USE_HOST
|
||||
|
||||
namespace esphome::vnc::testing {
|
||||
|
||||
static constexpr uint32_t KEYSYM_RETURN = 0xFF0D;
|
||||
static constexpr uint32_t KEYSYM_SHIFT_L = 0xFFE1;
|
||||
static constexpr uint32_t KEYSYM_CONTROL_L = 0xFFE3;
|
||||
|
||||
static touchscreen::TouchPoint point(uint8_t id, uint16_t x, uint16_t y, uint8_t state) {
|
||||
touchscreen::TouchPoint tp{};
|
||||
tp.id = id;
|
||||
tp.x = x;
|
||||
tp.y = y;
|
||||
tp.state = static_cast<int8_t>(state);
|
||||
return tp;
|
||||
}
|
||||
|
||||
/// One finger, reported to the listener the way Touchscreen does: update() then touch() on first contact
|
||||
/// (no update() when it lands on (0, 0), the new point's previous position), update() while it moves, and on
|
||||
/// lift update() with the point RELEASING, then release().
|
||||
class Finger {
|
||||
public:
|
||||
explicit Finger(VncClient &client) : client_(client) {}
|
||||
void at(uint16_t x, uint16_t y) {
|
||||
if (!this->down_) {
|
||||
this->tp_ = point(0, 0, 0, touchscreen::STATE_PRESSED);
|
||||
} else {
|
||||
this->tp_.state = touchscreen::STATE_UPDATED;
|
||||
}
|
||||
this->tp_.x_prev = this->tp_.x;
|
||||
this->tp_.y_prev = this->tp_.y;
|
||||
this->tp_.x = x;
|
||||
this->tp_.y = y;
|
||||
if (x != this->tp_.x_prev || y != this->tp_.y_prev) {
|
||||
this->client_.update({this->tp_});
|
||||
}
|
||||
if (!this->down_) {
|
||||
this->client_.touch(this->tp_);
|
||||
}
|
||||
this->down_ = true;
|
||||
}
|
||||
void lift() {
|
||||
ASSERT_TRUE(this->down_);
|
||||
this->tp_.state |= touchscreen::STATE_RELEASING;
|
||||
this->client_.update({this->tp_});
|
||||
this->client_.release();
|
||||
this->down_ = false;
|
||||
}
|
||||
|
||||
protected:
|
||||
VncClient &client_;
|
||||
touchscreen::TouchPoint tp_{};
|
||||
bool down_{false};
|
||||
};
|
||||
|
||||
class VncInput : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override { this->begin(64, 48, 64, 48); }
|
||||
void begin(int disp_w, int disp_h, uint16_t fb_w, uint16_t fb_h) {
|
||||
this->f_.reset();
|
||||
this->s_.reset();
|
||||
this->d_ = std::make_unique<FakeDisplay>(disp_w, disp_h);
|
||||
this->s_ = std::make_unique<Session>(this->d_.get());
|
||||
this->s_->start(fb_w, fb_h);
|
||||
this->f_ = std::make_unique<Finger>(this->s_->client);
|
||||
}
|
||||
Session &s() { return *this->s_; }
|
||||
TestVncClient &c() { return this->s_->client; }
|
||||
Finger &f() { return *this->f_; }
|
||||
std::unique_ptr<FakeDisplay> d_;
|
||||
std::unique_ptr<Session> s_;
|
||||
std::unique_ptr<Finger> f_;
|
||||
};
|
||||
|
||||
TEST_F(VncInput, PointerEventBytes) {
|
||||
c().send_pointer(10, 20, 1);
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(1, 10, 20)));
|
||||
c().send_pointer(63, 47, 8 | 16);
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(24, 63, 47)));
|
||||
c().send_pointer(1000, 1000, 0);
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(0, 63, 47)));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, KeyEventBytes) {
|
||||
c().send_key(KEYSYM_RETURN, VncKeyAction::VNC_KEY_ACTION_DOWN);
|
||||
EXPECT_EQ(hex(s().take()), hex({4, 1, 0, 0, 0, 0, 0xFF, 0x0D}));
|
||||
c().send_key(KEYSYM_RETURN, VncKeyAction::VNC_KEY_ACTION_UP);
|
||||
EXPECT_EQ(hex(s().take()), hex({4, 0, 0, 0, 0, 0, 0xFF, 0x0D}));
|
||||
c().send_key(0x01000000 | 0x20AC, VncKeyAction::VNC_KEY_ACTION_PRESS);
|
||||
EXPECT_EQ(hex(s().take()), hex(press(0x010020AC)));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TypeText) {
|
||||
c().type_text("aB\n", 3);
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({press('a'), press('B'), press(KEYSYM_RETURN)})));
|
||||
c().type_text(std::string("\t\b"));
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({press(0xFF09), press(0xFF08)})));
|
||||
// Other control characters have no key.
|
||||
c().type_text(std::string("x\x01y\x7F"));
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({press('x'), press('y')})));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TypeTextUtf8) {
|
||||
c().type_text(std::string("\xC3\xA9\xE2\x82\xAC"));
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({press(0xE9), press(0x010020AC)})));
|
||||
c().type_text(std::string("\xF0\x9F\x98\x80"));
|
||||
EXPECT_EQ(hex(s().take()), hex(press(0x0101F600)));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TypeTextSkipsBrokenUtf8) {
|
||||
// A lead byte without its continuation, a stray continuation, and a cut-off sequence.
|
||||
c().type_text(std::string("\xC3(\x80"
|
||||
"a\xE2\x82"));
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({press('('), press('a')})));
|
||||
}
|
||||
|
||||
// A cut-off sequence ends at the first byte that is not a continuation byte, so "\xE2a" still types the 'a'.
|
||||
TEST_F(VncInput, TruncatedUtf8KeepsTheNextCharacter) {
|
||||
c().type_text(std::string("\xE2"
|
||||
"a"));
|
||||
EXPECT_EQ(hex(s().take()), hex(press('a')));
|
||||
c().type_text(std::string("x\xF0"
|
||||
"yz"));
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({press('x'), press('y'), press('z')})));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, InvalidUtf8ValuesAreSkipped) {
|
||||
// Overlong 'A', a surrogate, and a value above U+10FFFF; U+10FFFF itself is kept.
|
||||
c().type_text(std::string("\xC1\x81"
|
||||
"a\xED\xA0\x80"
|
||||
"b\xF4\x90\x80\x80"
|
||||
"c\xF4\x8F\xBF\xBF"));
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({press('a'), press('b'), press('c'), press(0x0110FFFF)})));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, LengthCutsAMultiByteCharacter) {
|
||||
c().type_text("a\xC3\xA9", 2);
|
||||
EXPECT_EQ(hex(s().take()), hex(press('a')));
|
||||
c().type_text("\xC3\xA9", 1);
|
||||
EXPECT_EQ(s().take().size(), 0u);
|
||||
}
|
||||
|
||||
TEST_F(VncInput, EmbeddedNulIsSkipped) {
|
||||
c().type_text("a\0b", 3);
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({press('a'), press('b')})));
|
||||
}
|
||||
|
||||
// --- A full send buffer never gets a partial message.
|
||||
|
||||
class VncInputFull : public VncInput {
|
||||
protected:
|
||||
/// Fills the socket, then the client's buffer up to `room` free bytes.
|
||||
void fill(size_t room) {
|
||||
uint8_t junk[1024];
|
||||
std::memset(junk, 0xEE, sizeof(junk));
|
||||
for (;;) {
|
||||
ssize_t n = ::write(s().client_fd, junk, sizeof(junk));
|
||||
if (n <= 0) {
|
||||
break;
|
||||
}
|
||||
this->expected_.insert(this->expected_.end(), junk, junk + n);
|
||||
}
|
||||
while (::write(s().client_fd, junk, 1) == 1) {
|
||||
this->expected_.push_back(0xEE);
|
||||
}
|
||||
ASSERT_GE(c().link().tx_free(), room);
|
||||
Bytes filler(c().link().tx_free() - room, 0xDD);
|
||||
ASSERT_EQ(c().link().queue(filler.data(), filler.size()), filler.size());
|
||||
this->expected_.insert(this->expected_.end(), filler.begin(), filler.end());
|
||||
ASSERT_EQ(c().link().tx_free(), room);
|
||||
}
|
||||
/// Everything that reaches the server once it reads again.
|
||||
Bytes drain() {
|
||||
Bytes all;
|
||||
for (int idle = 0; idle < 3;) {
|
||||
Bytes more = s().take();
|
||||
all.insert(all.end(), more.begin(), more.end());
|
||||
idle = more.empty() ? idle + 1 : 0;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
Bytes expected_;
|
||||
};
|
||||
|
||||
TEST_F(VncInputFull, PointerDroppedWhole) {
|
||||
this->fill(5);
|
||||
c().send_pointer(1, 2, 1);
|
||||
c().send_key('a', VncKeyAction::VNC_KEY_ACTION_DOWN);
|
||||
EXPECT_EQ(hex(this->drain()), hex(this->expected_));
|
||||
}
|
||||
|
||||
TEST_F(VncInputFull, PressNeedsRoomForBothHalves) {
|
||||
this->fill(15);
|
||||
c().send_key('a', VncKeyAction::VNC_KEY_ACTION_PRESS);
|
||||
c().send_key('b', VncKeyAction::VNC_KEY_ACTION_DOWN);
|
||||
EXPECT_EQ(hex(this->drain()), hex(cat({this->expected_, key(true, 'b')})));
|
||||
}
|
||||
|
||||
TEST_F(VncInputFull, DroppedKeyUpIsSentOnceThereIsRoom) {
|
||||
this->fill(5);
|
||||
c().send_key(KEYSYM_SHIFT_L, VncKeyAction::VNC_KEY_ACTION_UP);
|
||||
EXPECT_EQ(hex(this->drain()), hex(this->expected_));
|
||||
s().loop();
|
||||
EXPECT_EQ(hex(s().take()), hex(key(false, KEYSYM_SHIFT_L)));
|
||||
}
|
||||
|
||||
TEST_F(VncInputFull, LaterInputWaitsForTheDroppedKeyUp) {
|
||||
this->fill(5);
|
||||
c().send_key(KEYSYM_SHIFT_L, VncKeyAction::VNC_KEY_ACTION_UP);
|
||||
EXPECT_EQ(hex(this->drain()), hex(this->expected_));
|
||||
c().send_key('a', VncKeyAction::VNC_KEY_ACTION_PRESS);
|
||||
c().send_pointer(3, 4, 0);
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({key(false, KEYSYM_SHIFT_L), press('a'), pointer(0, 3, 4)})));
|
||||
}
|
||||
|
||||
TEST_F(VncInputFull, ClickReleaseWaitsForRoom) {
|
||||
this->fill(6);
|
||||
c().send_pointer(5, 5, 1);
|
||||
c().send_pointer(5, 5, 0);
|
||||
EXPECT_EQ(hex(this->drain()), hex(cat({this->expected_, pointer(1, 5, 5)})));
|
||||
s().loop();
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(0, 5, 5)));
|
||||
}
|
||||
|
||||
TEST_F(VncInputFull, ReleaseOverflowEndsTheSession) {
|
||||
this->fill(5);
|
||||
for (uint32_t k = 0; k < 4; k++) {
|
||||
c().send_key('a' + k, VncKeyAction::VNC_KEY_ACTION_UP);
|
||||
}
|
||||
EXPECT_TRUE(c().is_connected());
|
||||
c().send_key('e', VncKeyAction::VNC_KEY_ACTION_UP);
|
||||
EXPECT_FALSE(c().is_connected());
|
||||
}
|
||||
|
||||
TEST_F(VncInputFull, BothHeldModifiersAreReleased) {
|
||||
this->fill(5);
|
||||
c().send_key(KEYSYM_SHIFT_L, VncKeyAction::VNC_KEY_ACTION_UP);
|
||||
c().send_key(KEYSYM_CONTROL_L, VncKeyAction::VNC_KEY_ACTION_UP);
|
||||
EXPECT_EQ(hex(this->drain()), hex(this->expected_));
|
||||
s().loop();
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({key(false, KEYSYM_SHIFT_L), key(false, KEYSYM_CONTROL_L)})));
|
||||
}
|
||||
|
||||
TEST_F(VncInputFull, TextStopsAtAWholeCharacter) {
|
||||
this->fill(40);
|
||||
c().type_text(std::string("abcd"));
|
||||
EXPECT_EQ(hex(this->drain()), hex(cat({this->expected_, press('a'), press('b')})));
|
||||
}
|
||||
|
||||
TEST_F(VncInputFull, DroppedReleaseIsSentOnceThereIsRoom) {
|
||||
f().at(10, 10);
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(1, 10, 10)));
|
||||
this->fill(3);
|
||||
f().lift();
|
||||
EXPECT_EQ(hex(this->drain()), hex(this->expected_));
|
||||
s().loop();
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(0, 10, 10)));
|
||||
}
|
||||
|
||||
TEST_F(VncInputFull, TouchPressRetriedWhenDropped) {
|
||||
this->fill(3);
|
||||
f().at(10, 10);
|
||||
Bytes all = this->drain();
|
||||
EXPECT_EQ(hex(all), hex(this->expected_));
|
||||
f().at(11, 11);
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(1, 11, 11)));
|
||||
}
|
||||
|
||||
TEST(VncInputState, DroppedWhenNotRunning) {
|
||||
FakeDisplay d;
|
||||
Session s(&d);
|
||||
Finger f(s.client);
|
||||
s.attach();
|
||||
s.deliver(Rfb().version());
|
||||
s.take();
|
||||
s.client.send_pointer(1, 1, 1);
|
||||
s.client.send_key('a', VncKeyAction::VNC_KEY_ACTION_PRESS);
|
||||
s.client.type_text(std::string("abc"));
|
||||
f.at(5, 5);
|
||||
f.lift();
|
||||
s.loop();
|
||||
EXPECT_EQ(s.take().size(), 0u);
|
||||
}
|
||||
|
||||
TEST(VncInputState, DroppedAfterTheSessionEnds) {
|
||||
FakeDisplay d;
|
||||
Session s(&d);
|
||||
Finger f(s.client);
|
||||
s.start(64, 48);
|
||||
f.at(5, 5);
|
||||
s.take();
|
||||
s.close_peer();
|
||||
s.loop();
|
||||
f.lift();
|
||||
s.client.send_pointer(1, 1, 1);
|
||||
|
||||
// The next session starts with no button held: the first touch is a press again.
|
||||
s.start(64, 48);
|
||||
f.at(7, 8);
|
||||
EXPECT_EQ(hex(s.take()), hex(pointer(1, 7, 8)));
|
||||
}
|
||||
|
||||
// --- Touch
|
||||
|
||||
TEST_F(VncInput, TouchPressIsSentAtOnce) {
|
||||
f().at(10, 12);
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(1, 10, 12)));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TouchMovesInsideTheSlopAreSuppressed) {
|
||||
f().at(20, 20);
|
||||
s().take();
|
||||
f().at(25, 24);
|
||||
f().at(28, 12);
|
||||
f().at(12, 28);
|
||||
EXPECT_EQ(s().take().size(), 0u);
|
||||
f().at(29, 20);
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(1, 29, 20)));
|
||||
// Once dragging, every change goes out, even back inside the slop.
|
||||
f().at(21, 20);
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(1, 21, 20)));
|
||||
f().at(21, 20);
|
||||
EXPECT_EQ(s().take().size(), 0u);
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TapReleasesWhereItPressed) {
|
||||
f().at(10, 10);
|
||||
f().at(13, 12);
|
||||
f().lift();
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, 10, 10), pointer(0, 10, 10)})));
|
||||
c().release();
|
||||
EXPECT_EQ(s().take().size(), 0u);
|
||||
}
|
||||
|
||||
TEST_F(VncInput, DragReleasesAtTheLastPosition) {
|
||||
f().at(10, 10);
|
||||
f().at(30, 30);
|
||||
f().at(40, 35);
|
||||
f().lift();
|
||||
EXPECT_EQ(hex(s().take()),
|
||||
hex(cat({pointer(1, 10, 10), pointer(1, 30, 30), pointer(1, 40, 35), pointer(0, 40, 35)})));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, EachTapStartsANewSlop) {
|
||||
f().at(10, 10);
|
||||
f().at(40, 10);
|
||||
f().lift();
|
||||
s().take();
|
||||
f().at(50, 10);
|
||||
f().at(52, 10);
|
||||
f().lift();
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, 50, 10), pointer(0, 50, 10)})));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TouchIgnoresButtonsLeftByAnAutomation) {
|
||||
c().send_pointer(5, 5, 8 | 4);
|
||||
s().take();
|
||||
f().at(10, 10);
|
||||
f().lift();
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, 10, 10), pointer(0, 10, 10)})));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TouchPressesEvenWhileAnAutomationHoldsLeft) {
|
||||
c().send_pointer(5, 5, 1);
|
||||
s().take();
|
||||
f().at(3, 3);
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(1, 3, 3)));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, PressOutsideTheVisibleAreaIsIgnored) {
|
||||
this->begin(64, 48, 100, 80);
|
||||
f().at(64, 10);
|
||||
f().at(10, 48);
|
||||
f().lift();
|
||||
EXPECT_EQ(s().take().size(), 0u);
|
||||
f().at(63, 47);
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(1, 63, 47)));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, PressOutsideASmallerServerScreenIsIgnored) {
|
||||
this->begin(64, 48, 32, 20);
|
||||
f().at(40, 10);
|
||||
EXPECT_EQ(s().take().size(), 0u);
|
||||
f().at(31, 19);
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(1, 31, 19)));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, DragIsClampedToTheVisibleArea) {
|
||||
this->begin(64, 48, 100, 80);
|
||||
f().at(50, 30);
|
||||
f().at(200, 300);
|
||||
f().lift();
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, 50, 30), pointer(1, 63, 47), pointer(0, 63, 47)})));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, ClampedMoveInsideTheSlopStaysATap) {
|
||||
this->begin(64, 48, 100, 80);
|
||||
f().at(60, 40);
|
||||
f().at(200, 300);
|
||||
f().lift();
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, 60, 40), pointer(0, 60, 40)})));
|
||||
}
|
||||
|
||||
// The client never scales: a touch at display x,y is a pointer event at framebuffer x,y.
|
||||
TEST_F(VncInput, TouchCoordinatesAreNotScaled) {
|
||||
this->begin(960, 540, 960, 540);
|
||||
const uint16_t points[][2] = {{0, 0}, {192, 108}, {480, 270}, {767, 431}, {900, 500}, {959, 539}, {959, 0}, {0, 539}};
|
||||
for (const auto &p : points) {
|
||||
f().at(p[0], p[1]);
|
||||
f().lift();
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, p[0], p[1]), pointer(0, p[0], p[1])}))) << p[0] << "," << p[1];
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TouchCoordinatesAreNotScaledOnALargerServer) {
|
||||
this->begin(960, 540, 1280, 720);
|
||||
f().at(900, 500);
|
||||
f().at(950, 530);
|
||||
f().lift();
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, 900, 500), pointer(1, 950, 530), pointer(0, 950, 530)})));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, FirstTouchAtTheOriginComesOnlyThroughTouch) {
|
||||
c().touch(point(0, 0, 0, touchscreen::STATE_PRESSED));
|
||||
c().release();
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, 0, 0), pointer(0, 0, 0)})));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TouchSkipsALiftedPoint) {
|
||||
c().touch(point(0, 5, 5, touchscreen::STATE_PRESSED | touchscreen::STATE_RELEASING));
|
||||
c().touch(point(0, 5, 5, touchscreen::STATE_RELEASED));
|
||||
EXPECT_EQ(s().take().size(), 0u);
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TouchListenerSkipsStalePoints) {
|
||||
// Finger 0 was lifted earlier and lingers as RELEASED.
|
||||
c().update({point(0, 1, 1, touchscreen::STATE_RELEASED), point(1, 20, 21, touchscreen::STATE_PRESSED)});
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(1, 20, 21)));
|
||||
// Finger 0 is being lifted now (still flagged PRESSED as well).
|
||||
c().update({point(0, 5, 5, touchscreen::STATE_PRESSED | touchscreen::STATE_RELEASING),
|
||||
point(1, 40, 21, touchscreen::STATE_UPDATED)});
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(1, 40, 21)));
|
||||
// A point from an uncalibrated touchscreen.
|
||||
c().update({point(0, 3, 3, touchscreen::STATE_CALIBRATE)});
|
||||
EXPECT_EQ(s().take().size(), 0u);
|
||||
c().update({});
|
||||
EXPECT_EQ(s().take().size(), 0u);
|
||||
c().release();
|
||||
EXPECT_EQ(hex(s().take()), hex(pointer(0, 40, 21)));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TouchListenerUsesTheFirstActivePoint) {
|
||||
c().update({point(0, 10, 10, touchscreen::STATE_PRESSED), point(1, 50, 40, touchscreen::STATE_PRESSED)});
|
||||
c().update({point(0, 10, 11, touchscreen::STATE_UPDATED), point(1, 60, 40, touchscreen::STATE_UPDATED)});
|
||||
c().release();
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, 10, 10), pointer(0, 10, 10)})));
|
||||
}
|
||||
|
||||
TEST_F(VncInput, TouchListenerPassesCoordinatesThrough) {
|
||||
this->begin(960, 540, 960, 540);
|
||||
c().update({point(0, 767, 431, touchscreen::STATE_PRESSED)});
|
||||
c().update({point(0, 192, 108, touchscreen::STATE_UPDATED)});
|
||||
c().release();
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, 767, 431), pointer(1, 192, 108), pointer(0, 192, 108)})));
|
||||
}
|
||||
|
||||
// --- Through the real touchscreen state machine: raw controller points to pointer events.
|
||||
|
||||
class FakeTouchscreen : public touchscreen::Touchscreen {
|
||||
public:
|
||||
struct Raw {
|
||||
uint8_t id;
|
||||
int16_t x;
|
||||
int16_t y;
|
||||
};
|
||||
void attach(display::Display *display) {
|
||||
this->set_display(display);
|
||||
// What Touchscreen::call_setup() does.
|
||||
this->display_width_ = display->get_width();
|
||||
this->display_height_ = display->get_height();
|
||||
}
|
||||
/// One controller read with these fingers down, then the deferred listener calls.
|
||||
void report(std::vector<Raw> points) {
|
||||
this->points_ = std::move(points);
|
||||
this->store_.touched = true;
|
||||
this->loop();
|
||||
App.scheduler.call(millis());
|
||||
}
|
||||
|
||||
protected:
|
||||
void update_touches() override {
|
||||
this->skip_update_ = false;
|
||||
for (const auto &p : this->points_) {
|
||||
this->add_raw_touch_position_(p.id, p.x, p.y);
|
||||
}
|
||||
}
|
||||
std::vector<Raw> points_;
|
||||
};
|
||||
|
||||
/// The display position the touchscreen component computes for a raw value.
|
||||
static uint16_t mapped(int raw, int raw_max, int size) {
|
||||
const int normalized = raw <= 0 ? 0 : (raw >= raw_max ? 0xfff : 0xfff * raw / raw_max);
|
||||
return static_cast<uint16_t>(normalized * size / 0x1000);
|
||||
}
|
||||
|
||||
class VncTouchscreen : public VncInput {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
// The test main does not construct App; the touchscreen defers its listener calls to the scheduler.
|
||||
static const bool APP_CONSTRUCTED = (new (&App) Application(), true);
|
||||
(void) APP_CONSTRUCTED;
|
||||
this->begin(960, 540, 960, 540);
|
||||
this->ts_.set_calibration(0, 960, 0, 540);
|
||||
this->ts_.attach(this->d_.get());
|
||||
this->c().register_touchscreen(&this->ts_);
|
||||
}
|
||||
FakeTouchscreen ts_;
|
||||
};
|
||||
|
||||
TEST_F(VncTouchscreen, TapsLandWhereTheFingerIs) {
|
||||
const int16_t taps[][2] = {{10, 10}, {100, 50}, {480, 270}, {700, 400}, {900, 500}, {955, 535}};
|
||||
for (const auto &t : taps) {
|
||||
this->ts_.report({{0, t[0], t[1]}});
|
||||
this->ts_.report({});
|
||||
const uint16_t x = mapped(t[0], 960, 960);
|
||||
const uint16_t y = mapped(t[1], 540, 540);
|
||||
EXPECT_LE(std::abs(x - t[0]), 1);
|
||||
EXPECT_LE(std::abs(y - t[1]), 1);
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, x, y), pointer(0, x, y)}))) << t[0] << "," << t[1];
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(VncTouchscreen, TapAtTheOrigin) {
|
||||
this->ts_.report({{0, 0, 0}});
|
||||
this->ts_.report({});
|
||||
EXPECT_EQ(hex(s().take()), hex(cat({pointer(1, 0, 0), pointer(0, 0, 0)})));
|
||||
}
|
||||
|
||||
TEST_F(VncTouchscreen, DragFollowsTheFinger) {
|
||||
this->ts_.report({{0, 100, 100}});
|
||||
this->ts_.report({{0, 104, 103}});
|
||||
this->ts_.report({{0, 300, 200}});
|
||||
this->ts_.report({{0, 600, 400}});
|
||||
this->ts_.report({});
|
||||
const uint16_t x0 = mapped(100, 960, 960), y0 = mapped(100, 540, 540);
|
||||
const uint16_t x1 = mapped(300, 960, 960), y1 = mapped(200, 540, 540);
|
||||
const uint16_t x2 = mapped(600, 960, 960), y2 = mapped(400, 540, 540);
|
||||
EXPECT_EQ(hex(s().take()),
|
||||
hex(cat({pointer(1, x0, y0), pointer(1, x1, y1), pointer(1, x2, y2), pointer(0, x2, y2)})));
|
||||
}
|
||||
|
||||
TEST_F(VncTouchscreen, LiftedFirstFingerDoesNotPullThePointerBack) {
|
||||
this->ts_.report({{0, 100, 100}});
|
||||
this->ts_.report({{0, 100, 100}, {1, 500, 300}});
|
||||
// Finger 0 lifts; it lingers in the list as RELEASING, then RELEASED.
|
||||
this->ts_.report({{1, 510, 300}});
|
||||
this->ts_.report({{1, 520, 300}});
|
||||
this->ts_.report({});
|
||||
Bytes tx = s().take();
|
||||
ASSERT_GE(tx.size(), 12u);
|
||||
const Bytes release(tx.end() - 6, tx.end());
|
||||
EXPECT_EQ(hex(release), hex(pointer(0, mapped(520, 960, 960), mapped(300, 540, 540))));
|
||||
for (size_t i = 0; i + 6 <= tx.size(); i += 6) {
|
||||
const uint16_t x = (tx[i + 2] << 8) | tx[i + 3];
|
||||
EXPECT_TRUE(x == mapped(100, 960, 960) || x >= mapped(500, 960, 960)) << "event at x=" << x;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::vnc::testing
|
||||
|
||||
#endif // USE_HOST
|
||||
@@ -125,6 +125,59 @@ TEST(VncPacingDelays, ZeroDelayRefreshesOnTheNextPass) {
|
||||
EXPECT_EQ(d.updates(), 1);
|
||||
}
|
||||
|
||||
TEST_F(VncPacing, InputPaintsWithoutWaitingForTheScreenToSettle) {
|
||||
set_now(1100);
|
||||
s().client.send_pointer(5, 5, 1);
|
||||
s().deliver(frame());
|
||||
s().loop();
|
||||
EXPECT_EQ(d_.updates(), 1);
|
||||
// Every frame during the boost is painted on the next pass.
|
||||
set_now(1150);
|
||||
s().deliver(frame(10, 0x4321));
|
||||
s().loop();
|
||||
EXPECT_EQ(d_.updates(), 2);
|
||||
}
|
||||
|
||||
TEST_F(VncPacing, KeysAndTextAlsoBoost) {
|
||||
set_now(1100);
|
||||
s().client.send_key(0xFF0D, VncKeyAction::VNC_KEY_ACTION_PRESS);
|
||||
s().deliver(frame());
|
||||
s().loop();
|
||||
EXPECT_EQ(d_.updates(), 1);
|
||||
set_now(4000);
|
||||
s().client.type_text("a", 1);
|
||||
s().deliver(frame(10, 0x4321));
|
||||
s().loop();
|
||||
EXPECT_EQ(d_.updates(), 2);
|
||||
}
|
||||
|
||||
TEST_F(VncPacing, BoostEndsTwoSecondsAfterTheLastInput) {
|
||||
set_now(1100);
|
||||
s().client.send_pointer(5, 5, 0);
|
||||
set_now(3100);
|
||||
s().deliver(frame());
|
||||
s().loop();
|
||||
EXPECT_EQ(d_.updates(), 0);
|
||||
s().at(3299);
|
||||
EXPECT_EQ(d_.updates(), 0);
|
||||
s().at(3300);
|
||||
EXPECT_EQ(d_.updates(), 1);
|
||||
}
|
||||
|
||||
TEST_F(VncPacing, DroppedInputDoesNotBoost) {
|
||||
s().close_peer();
|
||||
s().loop(2);
|
||||
ASSERT_FALSE(s().client.is_connected());
|
||||
set_now(1100);
|
||||
s().client.send_pointer(5, 5, 1);
|
||||
s().start(64, 48);
|
||||
s().deliver(frame());
|
||||
s().loop();
|
||||
EXPECT_EQ(d_.updates(), 0);
|
||||
s().at(1300);
|
||||
EXPECT_EQ(d_.updates(), 1);
|
||||
}
|
||||
|
||||
TEST_F(VncPacing, NextRequestRightAfterEachFrameWithA50msFloor) {
|
||||
set_now(1010);
|
||||
s().deliver(frame());
|
||||
|
||||
@@ -8,6 +8,15 @@ interval:
|
||||
- interval: 60s
|
||||
then:
|
||||
- vnc.refresh
|
||||
- vnc.send_pointer:
|
||||
x: 10
|
||||
y: 20
|
||||
- vnc.send_key: Return
|
||||
- vnc.send_key: 0xFF1B
|
||||
- vnc.send_key:
|
||||
key: Shift_L
|
||||
action: down
|
||||
- vnc.type_text: "Hello\n"
|
||||
- if:
|
||||
condition: vnc.is_connected
|
||||
then:
|
||||
|
||||
@@ -365,6 +365,9 @@ inline Bytes handshake_38(uint16_t w, uint16_t h) {
|
||||
inline Bytes fbur(bool incremental, uint16_t w, uint16_t h) {
|
||||
return Rfb().u8(3).u8(incremental ? 1 : 0).u16(0).u16(0).u16(w).u16(h).bytes;
|
||||
}
|
||||
inline Bytes pointer(uint8_t mask, uint16_t x, uint16_t y) { return Rfb().u8(5).u8(mask).u16(x).u16(y).bytes; }
|
||||
inline Bytes key(bool down, uint32_t keysym) { return Rfb().u8(4).u8(down ? 1 : 0).u16(0).u32(keysym).bytes; }
|
||||
inline Bytes press(uint32_t keysym) { return cat({key(true, keysym), key(false, keysym)}); }
|
||||
inline Bytes set_pixel_format_565() { return {0, 0, 0, 0, 16, 16, 0, 1, 0, 31, 0, 63, 0, 31, 11, 5, 0, 0, 0, 0}; }
|
||||
inline Bytes set_pixel_format_332() { return {0, 0, 0, 0, 8, 8, 0, 1, 0, 7, 0, 7, 0, 3, 5, 2, 0, 0, 0, 0}; }
|
||||
inline Bytes set_encodings() { return {2, 0, 0, 3, 0, 0, 0, 5, 0, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0x11}; }
|
||||
|
||||
@@ -16,6 +16,7 @@ import re
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
@@ -28,6 +29,7 @@ import pytest_asyncio
|
||||
import esphome.config
|
||||
from esphome.core import CORE
|
||||
from esphome.helpers import (
|
||||
get_bool_env,
|
||||
get_usable_cpu_count,
|
||||
read_file,
|
||||
rmtree,
|
||||
@@ -39,6 +41,7 @@ from esphome.host.toolchain import get_elf_path
|
||||
from .const import (
|
||||
API_CONNECTION_TIMEOUT,
|
||||
DEFAULT_API_PORT,
|
||||
GDB_TIMEOUT,
|
||||
LOCALHOST,
|
||||
PORT_POLL_INTERVAL,
|
||||
PORT_WAIT_TIMEOUT,
|
||||
@@ -80,6 +83,13 @@ REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
# CI caches parts of this path; keep in sync with ci.yml integration-tests.
|
||||
INTEGRATION_TESTS_ROOT = Path.home() / ".esphome-integration-tests"
|
||||
|
||||
# Opt-in -g and gdb core backtraces; CI enables both only to re-run failed tests
|
||||
DEBUG_SYMBOLS = get_bool_env("ESPHOME_INTEGRATION_DEBUG_SYMBOLS")
|
||||
CORE_DIR = os.environ.get("ESPHOME_INTEGRATION_CORE_DIR")
|
||||
_CRASH_SIGNALS = frozenset(
|
||||
{signal.SIGABRT, signal.SIGBUS, signal.SIGFPE, signal.SIGILL, signal.SIGSEGV}
|
||||
)
|
||||
|
||||
|
||||
def _get_build_env() -> dict[str, str]:
|
||||
"""Environment for an ``esphome compile`` subprocess."""
|
||||
@@ -227,9 +237,12 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s
|
||||
" build_flags:\n"
|
||||
' - "-DDEBUG" # Enable assert() statements\n'
|
||||
' - "-DESPHOME_DEBUG" # Enable ESPHOME_DEBUG_ASSERT checks\n'
|
||||
' - "-DESPHOME_DEBUG_API" # Enable API protocol asserts\n'
|
||||
' - "-g" # Add debug symbols',
|
||||
' - "-DESPHOME_DEBUG_API" # Enable API protocol asserts'
|
||||
+ ('\n - "-g" # Add debug symbols' if DEBUG_SYMBOLS else ""),
|
||||
)
|
||||
elif DEBUG_SYMBOLS and " build_flags:\n" in content:
|
||||
# A fixture with its own flags still gets symbols for a debug re-run
|
||||
content = content.replace(" build_flags:\n", ' build_flags:\n - "-g"\n', 1)
|
||||
|
||||
# Replace external component path placeholder if present
|
||||
if "EXTERNAL_COMPONENT_PATH" in content:
|
||||
@@ -772,16 +785,46 @@ async def _read_stream_lines(
|
||||
line_callback(decoded_line.rstrip())
|
||||
|
||||
|
||||
def _print_crash_backtrace(binary_path: Path, returncode: int, pid: int) -> None:
|
||||
"""Print a gdb backtrace from the core of a binary that died from a crash signal."""
|
||||
if CORE_DIR is None or -returncode not in _CRASH_SIGNALS:
|
||||
return
|
||||
core = Path(CORE_DIR) / f"core.{pid}"
|
||||
if not core.is_file() or (gdb := shutil.which("gdb")) is None:
|
||||
print(f"No core or gdb for the crash of {binary_path} (looked for {core})")
|
||||
return
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[gdb, "-batch", "-iex", "set debuginfod enabled off"]
|
||||
+ ["-ex", "thread apply all bt", str(binary_path), str(core)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
errors="replace",
|
||||
timeout=GDB_TIMEOUT,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as err:
|
||||
# Best effort: never replace the test's own failure
|
||||
print(f"gdb could not read {core}: {err}")
|
||||
return
|
||||
print(f"--- Backtrace of {binary_path} ({core}) ---")
|
||||
print(result.stdout + result.stderr)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def run_binary(
|
||||
binary_path: Path,
|
||||
line_callback: Callable[[str], None] | None = None,
|
||||
symbols_path: Path | None = None,
|
||||
) -> AsyncGenerator[tuple[asyncio.subprocess.Process, list[str]]]:
|
||||
"""Run a binary under a PTY, capture log output, and clean up on exit.
|
||||
|
||||
Yields the running ``Process`` and a live list of captured log lines.
|
||||
No port wait -- callers that need that should use
|
||||
``run_binary_and_wait_for_port``."""
|
||||
``run_binary_and_wait_for_port``.
|
||||
``symbols_path`` is the unstripped build when ``binary_path`` is a stripped
|
||||
copy; a crash backtrace reads its symbols from it.
|
||||
"""
|
||||
# Create a pseudo-terminal to make the binary think it's running interactively
|
||||
# This is needed because the ESPHome host logger checks isatty()
|
||||
controller_fd, device_fd = pty.openpty()
|
||||
@@ -810,6 +853,9 @@ async def run_binary(
|
||||
)
|
||||
|
||||
if process.returncode is not None:
|
||||
_print_crash_backtrace(
|
||||
symbols_path or binary_path, process.returncode, process.pid
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"Process died immediately with return code {process.returncode}. "
|
||||
"Ensure the binary is valid and can run successfully."
|
||||
@@ -851,6 +897,9 @@ async def run_binary(
|
||||
# Last resort: SIGKILL
|
||||
process.kill()
|
||||
await process.wait()
|
||||
_print_crash_backtrace(
|
||||
symbols_path or binary_path, process.returncode, process.pid
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
||||
@@ -19,3 +19,4 @@ KEY_ACTIVATION_DELAY = 0.5 # seconds
|
||||
# Process shutdown timeouts
|
||||
SIGINT_TIMEOUT = 5.0 # seconds
|
||||
SIGTERM_TIMEOUT = 2.0 # seconds
|
||||
GDB_TIMEOUT = 60.0 # seconds, reading a crashed binary's core
|
||||
@@ -0,0 +1,26 @@
|
||||
esphome:
|
||||
name: copy-cover-toggle-test
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
cover:
|
||||
- platform: template
|
||||
name: "Source Cover"
|
||||
id: source_cover
|
||||
toggle_action:
|
||||
- cover.template.publish:
|
||||
id: source_cover
|
||||
position: 0.5
|
||||
- platform: copy
|
||||
name: "Copy Cover"
|
||||
id: copy_cover
|
||||
source_id: source_cover
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Toggle Copy"
|
||||
id: toggle_copy
|
||||
on_press:
|
||||
- cover.toggle: copy_cover
|
||||
@@ -0,0 +1,27 @@
|
||||
esphome:
|
||||
name: copy-lock-open-test
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
lock:
|
||||
- platform: template
|
||||
name: "Source Lock"
|
||||
id: source_lock
|
||||
lock_action:
|
||||
- lock.template.publish:
|
||||
id: source_lock
|
||||
state: LOCKED
|
||||
unlock_action:
|
||||
- lock.template.publish:
|
||||
id: source_lock
|
||||
state: UNLOCKED
|
||||
open_action:
|
||||
- lock.template.publish:
|
||||
id: source_lock
|
||||
state: OPEN
|
||||
- platform: copy
|
||||
name: "Copy Lock"
|
||||
id: copy_lock
|
||||
source_id: source_lock
|
||||
@@ -0,0 +1,27 @@
|
||||
esphome:
|
||||
name: cover-position-cancel-test
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
cover:
|
||||
- platform: template
|
||||
name: "Test Cover"
|
||||
id: test_cover
|
||||
position_action:
|
||||
- delay: 1s
|
||||
- cover.template.publish:
|
||||
id: test_cover
|
||||
position: !lambda "return pos;"
|
||||
open_action:
|
||||
- cover.template.publish:
|
||||
id: test_cover
|
||||
state: OPEN
|
||||
close_action:
|
||||
- cover.template.publish:
|
||||
id: test_cover
|
||||
state: CLOSED
|
||||
# Only there so that the cover supports toggle
|
||||
toggle_action:
|
||||
- logger.log: "Toggle"
|
||||
@@ -0,0 +1,96 @@
|
||||
esphome:
|
||||
name: uart-mock-ld2420-select-mode
|
||||
|
||||
host:
|
||||
api:
|
||||
batch_delay: 0ms # Disable batching to receive all state updates
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy ld2420's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
id: mock_uart
|
||||
baud_rate: 115200
|
||||
auto_start: false
|
||||
responses:
|
||||
# Firmware v2.0.0 (1.5.4 or newer), so setup picks energy reporting
|
||||
- expect_tx:
|
||||
[0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x0C, 0x00,
|
||||
0x00, 0x01,
|
||||
0x00, 0x00,
|
||||
0x06, 0x00,
|
||||
0x76, 0x32, 0x2E, 0x30, 0x2E, 0x30,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# Generic ACK for every other command
|
||||
- expect_tx: [0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x04, 0x00,
|
||||
0xFF, 0x01,
|
||||
0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
injections:
|
||||
# Sent after the select switched the module to Simple: "ON Range 0035\r\n" is only
|
||||
# parsed while the component expects simple frames.
|
||||
- delay: 100ms
|
||||
inject_rx:
|
||||
[
|
||||
0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20,
|
||||
0x30, 0x30, 0x33, 0x35,
|
||||
0x0D, 0x0A,
|
||||
]
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
on_press:
|
||||
- lambda: 'id(mock_uart).start_scenario();'
|
||||
# A changed timeout gets apply_config_action() past its no-change check
|
||||
- platform: template
|
||||
name: "Apply Changed Config"
|
||||
on_press:
|
||||
- lambda: |-
|
||||
id(ld2420_dev).new_config.timeout++;
|
||||
id(ld2420_dev).apply_config_action();
|
||||
- platform: template
|
||||
name: "Inject Simple Frame"
|
||||
on_press:
|
||||
- lambda: |-
|
||||
// "ON Range 0040\r\n"
|
||||
id(mock_uart).inject_to_rx_buffer(std::vector<uint8_t>{
|
||||
0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20, 0x30, 0x30, 0x34, 0x30, 0x0D, 0x0A});
|
||||
|
||||
ld2420:
|
||||
id: ld2420_dev
|
||||
uart_id: mock_uart
|
||||
|
||||
sensor:
|
||||
- platform: ld2420
|
||||
ld2420_id: ld2420_dev
|
||||
moving_distance:
|
||||
name: "Moving Distance"
|
||||
|
||||
select:
|
||||
- platform: ld2420
|
||||
ld2420_id: ld2420_dev
|
||||
operating_mode:
|
||||
name: "Operating Mode"
|
||||
@@ -0,0 +1,73 @@
|
||||
esphome:
|
||||
name: uart-mock-ld2420-mode-nack
|
||||
|
||||
host:
|
||||
api:
|
||||
batch_delay: 0ms # Disable batching to receive all state updates
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy ld2420's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
id: mock_uart
|
||||
baud_rate: 115200
|
||||
responses:
|
||||
# Firmware v2.0.0 (1.5.4 or newer), so setup picks energy reporting
|
||||
- expect_tx:
|
||||
[0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x0C, 0x00,
|
||||
0x00, 0x01,
|
||||
0x00, 0x00,
|
||||
0x06, 0x00,
|
||||
0x76, 0x32, 0x2E, 0x30, 0x2E, 0x30,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# The simple system mode write is refused (error word 1)
|
||||
- expect_tx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x12, 0x00,
|
||||
0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x04, 0x00,
|
||||
0x12, 0x01,
|
||||
0x01, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# Generic ACK for every other command
|
||||
- expect_tx: [0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x04, 0x00,
|
||||
0xFF, 0x01,
|
||||
0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
ld2420:
|
||||
id: ld2420_dev
|
||||
uart_id: mock_uart
|
||||
|
||||
select:
|
||||
- platform: ld2420
|
||||
ld2420_id: ld2420_dev
|
||||
operating_mode:
|
||||
name: "Operating Mode"
|
||||
@@ -0,0 +1,41 @@
|
||||
esphome:
|
||||
name: uart-mock-pipsolar-switches
|
||||
|
||||
host:
|
||||
api:
|
||||
batch_delay: 0ms
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy pipsolar's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 2400
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
id: mock_uart
|
||||
baud_rate: 2400
|
||||
auto_start: false
|
||||
|
||||
# No polling, so only the switch writes reach the command queue and no
|
||||
# polled status publishes the switch states
|
||||
pipsolar:
|
||||
id: inverter0
|
||||
uart_id: mock_uart
|
||||
update_interval: never
|
||||
|
||||
switch:
|
||||
- platform: pipsolar
|
||||
pipsolar_id: inverter0
|
||||
output_source_priority_utility:
|
||||
name: Priority Utility
|
||||
input_voltage_range:
|
||||
name: Input Voltage Range
|
||||
pv_power_balance:
|
||||
name: PV Power Balance
|
||||
@@ -12,6 +12,31 @@ api:
|
||||
- snapshot.take:
|
||||
id: disp
|
||||
filename: !lambda return filename;
|
||||
- action: tap
|
||||
variables:
|
||||
x: int
|
||||
y: int
|
||||
then:
|
||||
- vnc.send_pointer:
|
||||
x: !lambda return x;
|
||||
y: !lambda return y;
|
||||
buttons: [left]
|
||||
- vnc.send_pointer:
|
||||
x: !lambda return x;
|
||||
y: !lambda return y;
|
||||
buttons: 0
|
||||
- action: key
|
||||
variables:
|
||||
keysym: int
|
||||
then:
|
||||
- vnc.send_key:
|
||||
key: !lambda return keysym;
|
||||
- action: type
|
||||
variables:
|
||||
chars: string
|
||||
then:
|
||||
- vnc.type_text:
|
||||
text: !lambda return chars;
|
||||
- action: vnc_refresh
|
||||
then:
|
||||
- vnc.refresh: remote
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""A toggle sent to a copy cover runs the toggle action of its source cover."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aioesphomeapi import CoverInfo, CoverState
|
||||
import pytest
|
||||
|
||||
from .state_utils import press_and_wait, require_entity, subscribe_after_initial_states
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_cover_toggle(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
entities, _ = await client.list_entities_services()
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
copy_cover = require_entity(entities, "copy_cover", CoverInfo)
|
||||
|
||||
# Both covers start fully open (1.0); the source's toggle_action publishes 0.5
|
||||
state = await press_and_wait(
|
||||
client,
|
||||
entities,
|
||||
sub.waiter,
|
||||
"toggle_copy",
|
||||
CoverState,
|
||||
key=copy_cover.key,
|
||||
)
|
||||
assert state.position == pytest.approx(0.5, abs=0.01)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""An open command sent to a copy lock runs the open action of its source lock."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aioesphomeapi import LockCommand, LockEntityState, LockInfo, LockState
|
||||
import pytest
|
||||
|
||||
from .state_utils import require_entity, subscribe_after_initial_states
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_lock_open(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
entities, _ = await client.list_entities_services()
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
copy_lock = require_entity(entities, "copy_lock", LockInfo)
|
||||
assert copy_lock.supports_open
|
||||
|
||||
# The source's unlock_action would publish UNLOCKED; only its open_action publishes OPEN
|
||||
next_state = sub.waiter.next_state(
|
||||
key=copy_lock.key, state_type=LockEntityState
|
||||
)
|
||||
client.lock_command(key=copy_lock.key, command=LockCommand.OPEN)
|
||||
state = await next_state
|
||||
assert state.state == LockState.OPEN
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Integration test for how commands cancel a running position action on a template cover."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from aioesphomeapi import CoverInfo, CoverState, EntityState
|
||||
import pytest
|
||||
|
||||
from .state_utils import require_entity, subscribe_after_initial_states
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
def _seen(positions: list[float], value: float) -> bool:
|
||||
return any(p == pytest.approx(value, abs=0.01) for p in positions)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cover_position_action_cancel(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test that stop, toggle, open, close and a new position cancel a running position action."""
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
positions: list[float] = []
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, CoverState):
|
||||
positions.append(state.position)
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
|
||||
cover = require_entity(entities, "test_cover", CoverInfo)
|
||||
|
||||
# position_action publishes its target 1s after the command. The second
|
||||
# command comes 0.2s after a move to 0.5, so 0.5 shows up within the
|
||||
# 1.5s wait only if the position action for 0.5 was not cancelled.
|
||||
async def move_to_half_then(**command: Any) -> None:
|
||||
positions.clear()
|
||||
client.cover_command(key=cover.key, position=0.5)
|
||||
await asyncio.sleep(0.2)
|
||||
client.cover_command(key=cover.key, **command)
|
||||
await asyncio.sleep(1.5)
|
||||
assert not _seen(positions, 0.5)
|
||||
|
||||
await move_to_half_then(stop=True)
|
||||
|
||||
await move_to_half_then(toggle=True)
|
||||
|
||||
# open_action publishes 1.0
|
||||
await move_to_half_then(position=1.0)
|
||||
assert positions[-1] == pytest.approx(1.0, abs=0.01)
|
||||
|
||||
# close_action publishes 0.0
|
||||
await move_to_half_then(position=0.0)
|
||||
assert positions[-1] == pytest.approx(0.0, abs=0.01)
|
||||
|
||||
# the position action for 0.3 runs to its end
|
||||
await move_to_half_then(position=0.3)
|
||||
assert positions[-1] == pytest.approx(0.3, abs=0.01)
|
||||
@@ -141,9 +141,10 @@ async def _build(
|
||||
write_yaml_config: ConfigWriter,
|
||||
compile_esphome: CompileFunction,
|
||||
reserved_tcp_port: tuple[int, socket.socket],
|
||||
) -> tuple[int, int, Path]:
|
||||
) -> tuple[int, int, Path, Path]:
|
||||
"""Reserve an OTA port, compile the fixture with it, and release both
|
||||
ports right before the binary is started."""
|
||||
ports right before the binary is started. Returns the stripped binary to
|
||||
run and upload, then the unstripped build for crash backtraces."""
|
||||
api_port, api_socket = reserved_tcp_port
|
||||
with _reserve_port() as (ota_port, ota_socket):
|
||||
config_path = await write_yaml_config(
|
||||
@@ -152,11 +153,11 @@ async def _build(
|
||||
binary_path = await compile_esphome(config_path)
|
||||
api_socket.close()
|
||||
ota_socket.close()
|
||||
return api_port, ota_port, await _strip_debug(binary_path)
|
||||
return api_port, ota_port, await _strip_debug(binary_path), binary_path
|
||||
|
||||
|
||||
async def _strip_debug(binary_path: Path) -> Path:
|
||||
"""The fixtures build with -g; every upload sends the image, so drop the
|
||||
"""A debug re-run builds with -g; every upload sends the image, so drop the
|
||||
debug info (GNU and Apple strip both take -S)."""
|
||||
if (strip := shutil.which("strip")) is None:
|
||||
_LOGGER.warning(
|
||||
@@ -207,6 +208,7 @@ class _Device:
|
||||
api_port: int
|
||||
ota_port: int
|
||||
binary_path: Path
|
||||
symbols_path: Path
|
||||
proc: asyncio.subprocess.Process | None = None
|
||||
waiter: LineWaiter = field(default_factory=LineWaiter)
|
||||
|
||||
@@ -302,7 +304,9 @@ async def test_host_ota_self_update(
|
||||
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
|
||||
)
|
||||
)
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (
|
||||
async with run_binary(
|
||||
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (
|
||||
proc,
|
||||
_lines,
|
||||
):
|
||||
@@ -341,7 +345,9 @@ async def test_host_ota_deflate(
|
||||
"""A stream that inflates past the size the client announced."""
|
||||
return _REAL_COMPRESS(data + bytes(8192), *args, **kwargs)
|
||||
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (
|
||||
async with run_binary(
|
||||
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (
|
||||
proc,
|
||||
_lines,
|
||||
):
|
||||
@@ -422,7 +428,9 @@ async def test_host_ota_encrypted(
|
||||
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
|
||||
)
|
||||
)
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines):
|
||||
async with run_binary(
|
||||
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (proc, lines):
|
||||
dev.proc = proc
|
||||
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
@@ -458,7 +466,9 @@ async def test_host_ota_encrypted_safe_mode(
|
||||
)
|
||||
# The api port never opens in safe mode, so wait for the log line instead
|
||||
force_safe_mode(DEVICE_NAME)
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines):
|
||||
async with run_binary(
|
||||
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (proc, lines):
|
||||
dev.proc = proc
|
||||
await _wait_for_line(lines, "SAFE MODE IS ACTIVE", PORT_WAIT_TIMEOUT)
|
||||
await _wait_for_port(LOCALHOST, dev.ota_port, PORT_WAIT_TIMEOUT)
|
||||
@@ -485,7 +495,9 @@ async def test_host_ota_api_key_offer_with_password(
|
||||
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
|
||||
)
|
||||
)
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines):
|
||||
async with run_binary(
|
||||
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (proc, lines):
|
||||
dev.proc = proc
|
||||
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
|
||||
await _wait_for_line(lines, "Encryption: offered")
|
||||
@@ -535,7 +547,9 @@ async def test_host_ota_provisioned_api_key(
|
||||
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
|
||||
)
|
||||
)
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines):
|
||||
async with run_binary(
|
||||
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (proc, lines):
|
||||
dev.proc = proc
|
||||
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
|
||||
await _wait_for_line(lines, "once the api key is provisioned")
|
||||
@@ -570,7 +584,9 @@ async def test_host_ota_provisioned_api_key_safe_mode(
|
||||
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
|
||||
)
|
||||
)
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, _lines):
|
||||
async with run_binary(
|
||||
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (proc, _lines):
|
||||
dev.proc = proc
|
||||
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
|
||||
await _provision_key(dev, api_client_connected)
|
||||
@@ -579,7 +595,9 @@ async def test_host_ota_provisioned_api_key_safe_mode(
|
||||
# exits, so safe mode takes a second start
|
||||
force_safe_mode(DEVICE_NAME)
|
||||
key = PROVISIONING_PSK.decode()
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines):
|
||||
async with run_binary(
|
||||
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (proc, lines):
|
||||
dev.proc = proc
|
||||
await _wait_for_line(lines, "SAFE MODE IS ACTIVE", PORT_WAIT_TIMEOUT)
|
||||
await _wait_for_port(LOCALHOST, dev.ota_port, PORT_WAIT_TIMEOUT)
|
||||
@@ -611,7 +629,10 @@ async def test_host_ota_rejects_garbage(
|
||||
bogus_path = integration_test_dir / "bogus.bin"
|
||||
bogus_path.write_bytes(b"NOT-AN-EXECUTABLE-AT-ALL" * 8)
|
||||
|
||||
async with run_binary(dev.binary_path) as (proc, _lines):
|
||||
async with run_binary(dev.binary_path, symbols_path=dev.symbols_path) as (
|
||||
proc,
|
||||
_lines,
|
||||
):
|
||||
dev.proc = proc
|
||||
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
|
||||
pid_before = proc.pid
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Integration test for the LD2420 operating mode select with a mock UART.
|
||||
|
||||
The module runs firmware v2.0.0, so it starts in energy reporting. Choosing Simple
|
||||
must write the simple system mode to the module and parse simple frames afterwards;
|
||||
choosing Normal again must write the energy system mode back.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from aioesphomeapi import ButtonInfo, EntityState, SelectInfo, SelectState
|
||||
import pytest
|
||||
|
||||
from .log_utils import LineWaiter
|
||||
from .state_utils import (
|
||||
SensorStateCollector,
|
||||
require_entity,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
# CMD_WRITE_SYS_PARAM (0x0012), parameter 0x0000 (system mode), then the mode and 0x0000
|
||||
WRITE_SIMPLE = "FD:FC:FB:FA:08:00:12:00:00:00:64:00:00:00:04:03:02:01"
|
||||
WRITE_ENERGY = "FD:FC:FB:FA:08:00:12:00:00:00:04:00:00:00:04:03:02:01"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_ld2420_select_mode(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""The select switches the module between energy and simple reporting."""
|
||||
waiter = LineWaiter()
|
||||
collector = SensorStateCollector(sensor_names=["moving_distance"])
|
||||
simple_frame_parsed = collector.add_waiter(
|
||||
lambda: pytest.approx(35.0) in collector.sensor_states["moving_distance"]
|
||||
)
|
||||
second_frame_parsed = collector.add_waiter(
|
||||
lambda: pytest.approx(40.0) in collector.sensor_states["moving_distance"]
|
||||
)
|
||||
modes: list[str] = []
|
||||
back_to_normal = asyncio.Event()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
collector.on_state(state)
|
||||
if isinstance(state, SelectState):
|
||||
modes.append(state.state)
|
||||
if modes[-2:] == ["Calibrate", "Normal"]:
|
||||
back_to_normal.set()
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=waiter.callback),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
collector.build_key_mapping(entities)
|
||||
await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
mode = require_entity(entities, "operating_mode", SelectInfo)
|
||||
apply_changed = require_entity(entities, "apply_changed_config", ButtonInfo)
|
||||
# Setup wrote energy mode for this firmware
|
||||
await waiter.wait_for("TX", WRITE_ENERGY)
|
||||
writes_before = waiter.count(WRITE_ENERGY)
|
||||
|
||||
client.select_command(mode.key, "Simple")
|
||||
await waiter.wait_for("TX", WRITE_SIMPLE)
|
||||
|
||||
client.button_command(
|
||||
require_entity(entities, "start_scenario", ButtonInfo).key
|
||||
)
|
||||
await wait_or_fail(simple_frame_parsed, 3.0, "Simple frame was not parsed")
|
||||
|
||||
# Applying a changed config in Simple keeps Simple
|
||||
client.button_command(apply_changed.key)
|
||||
await waiter.wait_for("Reconfiguring")
|
||||
inject = require_entity(entities, "inject_simple_frame", ButtonInfo)
|
||||
|
||||
async def inject_until_parsed() -> None:
|
||||
# Simple reports are rate limited, so keep sending until one is published
|
||||
while not second_frame_parsed.done():
|
||||
client.button_command(inject.key)
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
await wait_or_fail(inject_until_parsed(), 5.0, "Simple stopped after apply")
|
||||
assert modes[-1] == "Simple"
|
||||
|
||||
client.select_command(mode.key, "Normal")
|
||||
await waiter.wait_count(
|
||||
WRITE_ENERGY, writes_before + 1, 3.0, "Normal did not write energy mode"
|
||||
)
|
||||
assert waiter.count(WRITE_ENERGY) == writes_before + 1
|
||||
|
||||
# Applying a changed config ends calibration
|
||||
client.select_command(mode.key, "Calibrate")
|
||||
client.button_command(apply_changed.key)
|
||||
await wait_or_fail(back_to_normal, 5.0, "Apply did not end calibration")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_ld2420_select_mode_nack(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""A refused Simple write puts the select back on the mode the module reports."""
|
||||
waiter = LineWaiter()
|
||||
states: list[str] = []
|
||||
back_to_normal = asyncio.Event()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, SelectState):
|
||||
states.append(state.state)
|
||||
if states[-2:] == ["Simple", "Normal"]:
|
||||
back_to_normal.set()
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=waiter.callback),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
mode = require_entity(entities, "operating_mode", SelectInfo)
|
||||
client.select_command(mode.key, "Simple")
|
||||
# Three tries of about a second each before the write is given up
|
||||
await wait_or_fail(back_to_normal, 6.0, "Select did not go back to Normal")
|
||||
assert waiter.count(WRITE_SIMPLE) == 3
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Integration test for the pipsolar switches with mock UART.
|
||||
|
||||
Each write queues the switch's on or off command on the hub, a direction with
|
||||
no command queues nothing, nothing is written at boot, and the write itself
|
||||
does not publish the switch state (the hub's polled status does).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from aioesphomeapi import EntityState, SwitchInfo
|
||||
import pytest
|
||||
|
||||
from .log_utils import LineWaiter
|
||||
from .state_utils import require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
QUEUED = re.compile(r"Command queued successfully: (\w+) at position")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_pipsolar_switches(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Switch writes queue their commands and publish nothing themselves."""
|
||||
external_components_path = str(
|
||||
Path(__file__).parent / "fixtures" / "external_components"
|
||||
)
|
||||
yaml_config = yaml_config.replace(
|
||||
"EXTERNAL_COMPONENT_PATH", external_components_path
|
||||
)
|
||||
waiter = LineWaiter()
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=waiter.callback),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
utility = require_entity(entities, "priority_utility", SwitchInfo)
|
||||
voltage_range = require_entity(entities, "input_voltage_range", SwitchInfo)
|
||||
balance = require_entity(entities, "pv_power_balance", SwitchInfo)
|
||||
|
||||
states: list[EntityState] = []
|
||||
initial = asyncio.get_running_loop().create_future()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
states.append(state)
|
||||
if len(states) == 3 and not initial.done():
|
||||
initial.set_result(None)
|
||||
|
||||
client.subscribe_states(on_state)
|
||||
await asyncio.wait_for(initial, timeout=5.0)
|
||||
assert all(state.missing_state for state in states)
|
||||
# Nothing is restored or written at boot
|
||||
assert waiter.count(QUEUED) == 0
|
||||
|
||||
async def queued_after(key: int, on: bool) -> str:
|
||||
future = waiter.expect(QUEUED)
|
||||
client.switch_command(key, on)
|
||||
match = await waiter.wait(future)
|
||||
return match.group(1)
|
||||
|
||||
sent = waiter.expect("Sending command from queue: POP00")
|
||||
assert await queued_after(utility.key, True) == "POP00"
|
||||
await waiter.wait(sent)
|
||||
|
||||
# The utility switch has no off command, so the next queued command
|
||||
# is the one written after it
|
||||
future = waiter.expect(QUEUED)
|
||||
client.switch_command(utility.key, False)
|
||||
client.switch_command(balance.key, True)
|
||||
assert (await waiter.wait(future)).group(1) == "PSPB1"
|
||||
assert await queued_after(balance.key, False) == "PSPB0"
|
||||
assert await queued_after(voltage_range.key, True) == "PGR01"
|
||||
assert await queued_after(voltage_range.key, False) == "PGR00"
|
||||
|
||||
# A fresh connection's snapshot shows the writes left every switch unpublished
|
||||
async with api_client_connected() as fresh:
|
||||
snapshot: list[EntityState] = []
|
||||
done = asyncio.get_running_loop().create_future()
|
||||
|
||||
def on_snapshot(state: EntityState) -> None:
|
||||
snapshot.append(state)
|
||||
if len(snapshot) == 3 and not done.done():
|
||||
done.set_result(None)
|
||||
|
||||
fresh.subscribe_states(on_snapshot)
|
||||
await asyncio.wait_for(done, timeout=5.0)
|
||||
assert all(state.missing_state for state in snapshot), snapshot
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Integration test for the vnc component against a fake RFB server.
|
||||
|
||||
One compiled device runs every step in order against one server: the first frame, request
|
||||
pacing, a reconnect after the server closes the link, a stream cut into tiny pieces, an
|
||||
pacing, input, a reconnect after the server closes the link, a stream cut into tiny pieces, an
|
||||
unknown message type that makes the client drop the link, and tiles that are skipped when sent
|
||||
again unchanged.
|
||||
|
||||
@@ -209,6 +209,20 @@ def full_request(incremental: bool) -> Message:
|
||||
}
|
||||
|
||||
|
||||
def tap_events(x: int, y: int) -> list[Message]:
|
||||
return [
|
||||
{"type": "pointer", "mask": 1, "x": x, "y": y},
|
||||
{"type": "pointer", "mask": 0, "x": x, "y": y},
|
||||
]
|
||||
|
||||
|
||||
def key_press(keysym: int) -> list[Message]:
|
||||
return [
|
||||
{"type": "key", "down": True, "keysym": keysym},
|
||||
{"type": "key", "down": False, "keysym": keysym},
|
||||
]
|
||||
|
||||
|
||||
def combine_updates(*updates: bytes) -> bytes:
|
||||
"""Merge FramebufferUpdates into one, so rects built with different options share it."""
|
||||
count = sum(struct.unpack_from(">H", update, 2)[0] for update in updates)
|
||||
@@ -291,6 +305,10 @@ def fill_noise(
|
||||
server.set_pixel(col, row, (rng.randrange(256), rng.randrange(256), 0))
|
||||
|
||||
|
||||
def inputs_since(server: FakeRfbServer, mark: int) -> list[Message]:
|
||||
return [m for m in server.messages[mark:] if m["type"] in ("pointer", "key")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vnc_client(
|
||||
yaml_config: str,
|
||||
@@ -299,7 +317,7 @@ async def test_vnc_client(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The client renders, paces and recovers from drops and bad streams."""
|
||||
"""The client renders, paces, sends input and recovers from drops and bad streams."""
|
||||
snapshot_dir = tmp_path / "snapshots"
|
||||
monkeypatch.setenv("ESPHOME_SNAPSHOT_DIR", str(snapshot_dir))
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -423,6 +441,53 @@ async def test_vnc_client(
|
||||
await exchange(server, whole, log=log)
|
||||
assert_shows(await device.snapshot("refresh"), server)
|
||||
|
||||
# Input: the API actions send exact pointer and key events, with no scaling even
|
||||
# though the server screen is larger than the display.
|
||||
mark = len(server.messages)
|
||||
for x, y in (
|
||||
(10, 20),
|
||||
(0, 0),
|
||||
(DISPLAY_WIDTH - 1, DISPLAY_HEIGHT - 1),
|
||||
(70, 55),
|
||||
(500, 500),
|
||||
):
|
||||
await device.run("tap", x=x, y=y)
|
||||
await device.run("key", keysym=0xFF0D)
|
||||
await device.run("type", chars="aB\n\t\b\x01 é€")
|
||||
expected_input = [
|
||||
*tap_events(10, 20),
|
||||
*tap_events(0, 0),
|
||||
*tap_events(DISPLAY_WIDTH - 1, DISPLAY_HEIGHT - 1),
|
||||
*tap_events(70, 55),
|
||||
*tap_events(SERVER_WIDTH - 1, SERVER_HEIGHT - 1),
|
||||
*key_press(0xFF0D),
|
||||
*key_press(ord("a")),
|
||||
*key_press(ord("B")),
|
||||
*key_press(0xFF0D),
|
||||
*key_press(0xFF09),
|
||||
*key_press(0xFF08),
|
||||
*key_press(ord(" ")),
|
||||
*key_press(0xE9),
|
||||
*key_press(0x010020AC),
|
||||
]
|
||||
await server.wait_until(
|
||||
lambda: len(inputs_since(server, mark)) >= len(expected_input),
|
||||
what="input events",
|
||||
)
|
||||
await asyncio.sleep(0.2)
|
||||
assert inputs_since(server, mark) == expected_input
|
||||
|
||||
# Text longer than the send buffer is flushed on the way, not cut.
|
||||
mark = len(server.messages)
|
||||
long_text = "".join(chr(ord("a") + i % 26) for i in range(300))
|
||||
await device.run("type", chars=long_text)
|
||||
expected_input = [m for c in long_text for m in key_press(ord(c))]
|
||||
await server.wait_until(
|
||||
lambda: len(inputs_since(server, mark)) >= len(expected_input),
|
||||
what="long text",
|
||||
)
|
||||
assert inputs_since(server, mark) == expected_input
|
||||
|
||||
# Reconnect: the server closes the link; the client comes back after the backoff
|
||||
# and sets the session up again.
|
||||
server.discard_pending()
|
||||
|
||||
@@ -476,7 +476,7 @@ def test_get_component_cmakelists_single_link_flag() -> None:
|
||||
|
||||
content = get_component_cmakelists()
|
||||
assert (
|
||||
"target_link_options(${COMPONENT_LIB} PUBLIC\n -Wl,--gc-sections\n)"
|
||||
'target_link_options(${COMPONENT_LIB} PUBLIC\n "-Wl,--gc-sections"\n)'
|
||||
in content
|
||||
)
|
||||
|
||||
@@ -519,9 +519,9 @@ def test_get_component_cmakelists_multiple_link_flags_sorted() -> None:
|
||||
content = get_component_cmakelists()
|
||||
expected = (
|
||||
"target_link_options(${COMPONENT_LIB} PUBLIC\n"
|
||||
" -Wl,--gc-sections\n"
|
||||
" -Wl,-Map=out.map\n"
|
||||
" -Wl,-z,noexecstack\n"
|
||||
' "-Wl,--gc-sections"\n'
|
||||
' "-Wl,-Map=out.map"\n'
|
||||
' "-Wl,-z,noexecstack"\n'
|
||||
")"
|
||||
)
|
||||
assert expected in content
|
||||
@@ -640,6 +640,168 @@ def test_get_project_cmakelists_cxx_build_flags(tmp_path: Path) -> None:
|
||||
assert include_pos < flag_pos < project_pos
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("flag", "lines"),
|
||||
[
|
||||
# One entry with several flags is split, as PlatformIO does
|
||||
("-DA=1 -DB=2", ['"-DA=1"', '"-DB=2"']),
|
||||
# Shell quotes are removed and escaped quotes are kept as a C string
|
||||
('-DMY_STR="hello"', ['"-DMY_STR=hello"']),
|
||||
('-DMY_STR=\\"hello\\"', ['"-DMY_STR=\\"hello\\""']),
|
||||
# CMake must not expand ${...} or read \ as an escape
|
||||
("-DY=${HOME}", ['"-DY=\\${HOME}"']),
|
||||
("-DP='a\\b'", ['"-DP=a\\\\b"']),
|
||||
],
|
||||
)
|
||||
def test_get_project_cmakelists_quotes_compile_flags(
|
||||
flag: str, lines: list[str]
|
||||
) -> None:
|
||||
with (
|
||||
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch.object(CORE, "build_flags", {flag}),
|
||||
):
|
||||
from esphome.build_gen.espidf import get_project_cmakelists
|
||||
|
||||
content = get_project_cmakelists(minimal=True)
|
||||
|
||||
emitted = [
|
||||
line.removeprefix("idf_build_set_property(COMPILE_OPTIONS ").removesuffix(
|
||||
" APPEND)"
|
||||
)
|
||||
for line in content.splitlines()
|
||||
if line.startswith("idf_build_set_property(COMPILE_OPTIONS ")
|
||||
]
|
||||
assert emitted == lines
|
||||
|
||||
|
||||
def test_get_component_cmakelists_quotes_link_flags() -> None:
|
||||
CORE.build_flags = {"-Wl,--defsym=a=1 -Wl,--gc-sections"}
|
||||
from esphome.build_gen.espidf import get_component_cmakelists
|
||||
|
||||
content = get_component_cmakelists()
|
||||
assert (
|
||||
"target_link_options(${COMPONENT_LIB} PUBLIC\n"
|
||||
' "-Wl,--defsym=a=1"\n'
|
||||
' "-Wl,--gc-sections"\n'
|
||||
")"
|
||||
) in content
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"entry", ["-DTEST=1 -Wl,--wrap=malloc", "-Wl,--wrap=malloc -DTEST=1"]
|
||||
)
|
||||
def test_mixed_entry_routes_each_flag(entry: str) -> None:
|
||||
"""An entry holding a define and a linker flag sends each flag to its own
|
||||
place, whichever comes first."""
|
||||
with (
|
||||
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch.object(CORE, "build_flags", {entry}),
|
||||
):
|
||||
from esphome.build_gen.espidf import (
|
||||
get_component_cmakelists,
|
||||
get_project_cmakelists,
|
||||
)
|
||||
|
||||
project = get_project_cmakelists(minimal=True)
|
||||
component = get_component_cmakelists()
|
||||
|
||||
compile_lines = [
|
||||
line
|
||||
for line in project.splitlines()
|
||||
if line.startswith("idf_build_set_property(COMPILE_OPTIONS ")
|
||||
]
|
||||
assert compile_lines == [
|
||||
'idf_build_set_property(COMPILE_OPTIONS "-DTEST=1" APPEND)'
|
||||
]
|
||||
assert (
|
||||
'target_link_options(${COMPONENT_LIB} PUBLIC\n "-Wl,--wrap=malloc"\n)'
|
||||
) in component
|
||||
|
||||
|
||||
def test_split_linker_flags_keep_their_order() -> None:
|
||||
"""Linker flags from one entry stay in the order written; a group must
|
||||
open before it closes."""
|
||||
CORE.build_flags = {"-Wl,--start-group -Wl,-lfoo -Wl,--end-group"}
|
||||
from esphome.build_gen.espidf import get_component_cmakelists
|
||||
|
||||
content = get_component_cmakelists()
|
||||
assert (
|
||||
"target_link_options(${COMPONENT_LIB} PUBLIC\n"
|
||||
' "-Wl,--start-group"\n'
|
||||
' "-Wl,-lfoo"\n'
|
||||
' "-Wl,--end-group"\n'
|
||||
")"
|
||||
) in content
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"entry", ["-DLEVEL=2 -DLEVEL=1", "-DLEVEL=1 -DLEVEL=2 -DLEVEL=1"]
|
||||
)
|
||||
def test_split_defines_keep_their_order(entry: str) -> None:
|
||||
"""A repeated define keeps the order written, so the last one wins as on
|
||||
the command line. CMake drops repeated compile options after the first,
|
||||
so only the last copy of an identical define is emitted."""
|
||||
with (
|
||||
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch.object(CORE, "build_flags", {entry}),
|
||||
):
|
||||
from esphome.build_gen.espidf import get_project_cmakelists
|
||||
|
||||
content = get_project_cmakelists(minimal=True)
|
||||
|
||||
assert [
|
||||
line
|
||||
for line in content.splitlines()
|
||||
if line.startswith("idf_build_set_property(COMPILE_OPTIONS ")
|
||||
] == [
|
||||
'idf_build_set_property(COMPILE_OPTIONS "-DLEVEL=2" APPEND)',
|
||||
'idf_build_set_property(COMPILE_OPTIONS "-DLEVEL=1" APPEND)',
|
||||
]
|
||||
|
||||
|
||||
def test_dropped_flag_warns_once_per_entry(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A flag that native ESP-IDF does not pass on is named in one warning
|
||||
per entry, however often the project files are written."""
|
||||
with (
|
||||
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch.object(
|
||||
CORE,
|
||||
"build_flags",
|
||||
{"-DFOO=1 -fno-rtti", "-DBAR=2 -fno-rtti", "-O2"},
|
||||
),
|
||||
caplog.at_level(logging.WARNING, logger="esphome.build_gen.espidf"),
|
||||
):
|
||||
from esphome.build_gen.espidf import (
|
||||
get_component_cmakelists,
|
||||
get_project_cmakelists,
|
||||
)
|
||||
|
||||
CORE.data.setdefault(KEY_ESP32, {}).pop("warned_build_flags", None)
|
||||
for _ in range(2):
|
||||
get_project_cmakelists(minimal=True)
|
||||
get_component_cmakelists()
|
||||
|
||||
warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING]
|
||||
assert warnings == [
|
||||
(
|
||||
f"Build flag '-fno-rtti' in '{entry}' is ignored: native "
|
||||
"ESP-IDF builds only pass -D, -W and -Wl, flags"
|
||||
)
|
||||
for entry in ("-DBAR=2 -fno-rtti", "-DFOO=1 -fno-rtti")
|
||||
] + [
|
||||
(
|
||||
"Build flag '-O2' is ignored: native ESP-IDF builds only pass -D, -W "
|
||||
"and -Wl, flags"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_get_component_cmakelists_no_compile_features() -> None:
|
||||
"""The C++ standard is pinned project-wide via CXX_COMPILE_OPTIONS in the
|
||||
top-level CMakeLists; the src component must not set its own."""
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Tests for the esp32 framework ``components:`` schema."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components import esp32
|
||||
import esphome.config_validation as cv
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source",
|
||||
[
|
||||
"https://github.com/owner/component.git",
|
||||
"git@github.com:owner/component.git",
|
||||
],
|
||||
)
|
||||
def test_component_source_accepts_repository_url(source: str) -> None:
|
||||
config = esp32.FRAMEWORK_SCHEMA(
|
||||
{"components": [{"name": "owner/component", "source": source}]}
|
||||
)
|
||||
assert config["components"][0]["source"] == source
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", [1.0, ""])
|
||||
def test_component_source_rejects_invalid(source: object) -> None:
|
||||
with pytest.raises(cv.Invalid):
|
||||
esp32.FRAMEWORK_SCHEMA(
|
||||
{"components": [{"name": "owner/component", "source": source}]}
|
||||
)
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Tests for the vnc keysym and pointer button validators."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.vnc.keysyms import (
|
||||
ALIASES,
|
||||
BUTTONS,
|
||||
KEYSYMS,
|
||||
button_mask,
|
||||
keysym,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import HexInt
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "code"),
|
||||
[
|
||||
("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),
|
||||
("F1", 0xFFBE),
|
||||
("F2", 0xFFBF),
|
||||
("F12", 0xFFC9),
|
||||
("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),
|
||||
],
|
||||
)
|
||||
def test_keysym_names(name: str, code: int) -> None:
|
||||
"""Every documented name maps to its X11 keysym, in any case."""
|
||||
assert KEYSYMS[name] == code
|
||||
assert keysym(name) == code
|
||||
assert keysym(name.lower()) == code
|
||||
assert keysym(name.upper()) == code
|
||||
|
||||
|
||||
def test_function_keys_are_contiguous() -> None:
|
||||
"""F1 to F12 run without gaps."""
|
||||
assert [KEYSYMS[f"F{n}"] for n in range(1, 13)] == list(range(0xFFBE, 0xFFCA))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("alias", "code"),
|
||||
[
|
||||
("enter", 0xFF0D),
|
||||
("Enter", 0xFF0D),
|
||||
("esc", 0xFF1B),
|
||||
("ESC", 0xFF1B),
|
||||
("del", 0xFFFF),
|
||||
("ins", 0xFF63),
|
||||
("pgup", 0xFF55),
|
||||
("PgDn", 0xFF56),
|
||||
("ctrl", 0xFFE3),
|
||||
("shift", 0xFFE1),
|
||||
("alt", 0xFFE9),
|
||||
("super", 0xFFEB),
|
||||
("backspace", 0xFF08),
|
||||
("tab", 0xFF09),
|
||||
("return", 0xFF0D),
|
||||
("RETURN", 0xFF0D),
|
||||
("page_up", 0xFF55),
|
||||
("Prior", 0xFF55),
|
||||
("Next", 0xFF56),
|
||||
],
|
||||
)
|
||||
def test_keysym_aliases(alias: str, code: int) -> None:
|
||||
"""Short aliases and lowercase names resolve to the X11 keysym."""
|
||||
assert keysym(alias) == code
|
||||
|
||||
|
||||
def test_lowercase_names_are_unique() -> None:
|
||||
"""No two names or aliases collide once lowercased."""
|
||||
names = [name.lower() for name in KEYSYMS] + list(ALIASES)
|
||||
assert len(names) == len(set(names))
|
||||
assert all(alias == alias.lower() for alias in ALIASES)
|
||||
assert all(name in KEYSYMS for name in ALIASES.values())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("digit", "code"), [(n, 0x30 + n) for n in range(10)])
|
||||
def test_keysym_unquoted_digit(digit: int, code: int) -> None:
|
||||
"""An unquoted digit 0 to 9 is that digit, not a raw keysym."""
|
||||
assert keysym(digit) == code
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", [10, 0x41, 0xFF0D, 0x01000041, 0x1FFFFFFF])
|
||||
def test_keysym_int_passes_through(code: int) -> None:
|
||||
"""Other integers in range are raw keysyms."""
|
||||
assert keysym(code) == code
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", [-1, 0x20000000])
|
||||
def test_keysym_int_out_of_range(code: int) -> None:
|
||||
"""Integers outside the keysym range are rejected."""
|
||||
with pytest.raises(cv.Invalid, match="value must be at"):
|
||||
keysym(code)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [True, False])
|
||||
def test_keysym_bool_rejected(value: bool) -> None:
|
||||
"""YAML booleans such as y and n ask the user to quote the key."""
|
||||
with pytest.raises(cv.Invalid, match="quotes"):
|
||||
keysym(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("char", "code"),
|
||||
[
|
||||
(" ", 0x20),
|
||||
("a", 0x61),
|
||||
("A", 0x41),
|
||||
("0", 0x30),
|
||||
("9", 0x39),
|
||||
("~", 0x7E),
|
||||
(" ", 0xA0),
|
||||
("é", 0xE9),
|
||||
("ÿ", 0xFF),
|
||||
("€", 0x010020AC),
|
||||
("Ā", 0x01000100),
|
||||
("😀", 0x0101F600),
|
||||
],
|
||||
)
|
||||
def test_keysym_single_character(char: str, code: int) -> None:
|
||||
"""Latin-1 characters are their code point, others use the Unicode keysym range."""
|
||||
assert keysym(char) == code
|
||||
|
||||
|
||||
def test_keysym_single_character_is_case_sensitive() -> None:
|
||||
"""A single letter is never folded by the case-insensitive name lookup."""
|
||||
assert keysym("a") != keysym("A")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("char", ["\x00", "\n", "\t", "\x1f", "\x7f", "\x80", "\x9f"])
|
||||
def test_keysym_control_character_rejected(char: str) -> None:
|
||||
"""Control characters are not keys."""
|
||||
with pytest.raises(cv.Invalid, match="Control character"):
|
||||
keysym(char)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "code"),
|
||||
[
|
||||
("0xFF0D", 0xFF0D),
|
||||
("0xff0d", 0xFF0D),
|
||||
("0XFF0D", 0xFF0D),
|
||||
("0x0", 0),
|
||||
("0x1FFFFFFF", 0x1FFFFFFF),
|
||||
],
|
||||
)
|
||||
def test_keysym_hex_string(value: str, code: int) -> None:
|
||||
"""Hexadecimal strings are raw keysyms."""
|
||||
assert keysym(value) == code
|
||||
|
||||
|
||||
def test_keysym_hex_string_out_of_range() -> None:
|
||||
"""A hexadecimal keysym above the range is rejected."""
|
||||
with pytest.raises(cv.Invalid, match="value must be at"):
|
||||
keysym("0x20000000")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0x", "0xZZ", "0x12G"])
|
||||
def test_keysym_hex_string_invalid(value: str) -> None:
|
||||
"""A malformed hexadecimal string is rejected."""
|
||||
with pytest.raises(cv.Invalid, match="cannot parse"):
|
||||
keysym(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["", "Foo", "Return2", "65", "F13", "left_shift"])
|
||||
def test_keysym_unknown_name(value: str) -> None:
|
||||
"""Unknown names list a few valid ones."""
|
||||
with pytest.raises(cv.Invalid, match="Unknown key") as exc_info:
|
||||
keysym(value)
|
||||
assert "Return" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [None, 1.5, ["a"], {"key": "a"}])
|
||||
def test_keysym_wrong_type(value: object) -> None:
|
||||
"""Values that are not strings or integers are rejected."""
|
||||
with pytest.raises(cv.Invalid, match="Expected a key name"):
|
||||
keysym(value)
|
||||
|
||||
|
||||
def test_keysym_returns_hex_int() -> None:
|
||||
"""Keysyms render as hexadecimal in the generated code."""
|
||||
assert isinstance(keysym("Return"), HexInt)
|
||||
assert isinstance(keysym("a"), HexInt)
|
||||
assert isinstance(keysym(5), HexInt)
|
||||
assert isinstance(keysym(0xFF0D), HexInt)
|
||||
assert isinstance(keysym("0xFF0D"), HexInt)
|
||||
|
||||
|
||||
def test_buttons_table() -> None:
|
||||
"""Button names map to the RFB pointer mask bits."""
|
||||
assert BUTTONS == {
|
||||
"left": 1,
|
||||
"middle": 2,
|
||||
"right": 4,
|
||||
"wheel_up": 8,
|
||||
"wheel_down": 16,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "mask"),
|
||||
[
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(255, 255),
|
||||
("left", 1),
|
||||
("LEFT", 1),
|
||||
("middle", 2),
|
||||
("right", 4),
|
||||
("wheel_up", 8),
|
||||
("wheel_down", 16),
|
||||
(["left"], 1),
|
||||
(["left", "right"], 5),
|
||||
(["left", "middle", "right", "wheel_up", "wheel_down"], 31),
|
||||
(["left", "left"], 1),
|
||||
([], 0),
|
||||
(None, 0),
|
||||
],
|
||||
)
|
||||
def test_button_mask(value: object, mask: int) -> None:
|
||||
"""Masks are numbers or the OR of the named buttons."""
|
||||
assert button_mask(value) == mask
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [-1, 256])
|
||||
def test_button_mask_out_of_range(value: int) -> None:
|
||||
"""Numeric masks must fit in one byte."""
|
||||
with pytest.raises(cv.Invalid, match="value must be at"):
|
||||
button_mask(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["back", ["left", "back"], [1]])
|
||||
def test_button_mask_unknown_name(value: object) -> None:
|
||||
"""Unknown button names are rejected."""
|
||||
with pytest.raises(cv.Invalid, match="Unknown value"):
|
||||
button_mask(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [True, False])
|
||||
def test_button_mask_bool_rejected(value: bool) -> None:
|
||||
"""Booleans are not silently read as button 1 or no buttons."""
|
||||
with pytest.raises(cv.Invalid, match="boolean"):
|
||||
button_mask(value)
|
||||
@@ -204,6 +204,31 @@ async def test_app_is_default_initialized(
|
||||
assert "new (&App) Application()" not in raw_expressions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("toolchain", "expected"),
|
||||
[(Toolchain.PLATFORMIO, True), (Toolchain.ESP_IDF, False)],
|
||||
)
|
||||
async def test_no_exceptions_flag_skipped_on_native_idf(
|
||||
yaml_file: Callable[[str], Path],
|
||||
toolchain: Toolchain,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
"""Native ESP-IDF drops -fno-exceptions from build_flags and sets it
|
||||
itself, so core does not add it there."""
|
||||
result = load_config_from_fixture(yaml_file, "valid_area_device.yaml", FIXTURES_DIR)
|
||||
assert result is not None
|
||||
CORE.toolchain = toolchain
|
||||
|
||||
with patch("esphome.core.config.cg") as mock_cg:
|
||||
mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock()
|
||||
mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock()
|
||||
await config.to_code(result[CONF_ESPHOME])
|
||||
|
||||
flags = [c.args[0] for c in mock_cg.add_build_flag.call_args_list]
|
||||
assert ("-fno-exceptions" in flags) is expected
|
||||
|
||||
|
||||
def test_config_load_without_area_clears_stale_core_area(
|
||||
yaml_file: Callable[[str], Path],
|
||||
) -> None:
|
||||
|
||||
@@ -1711,14 +1711,6 @@ _SELECT_APPLY = entity_helpers.ApplyEntity(
|
||||
"after",
|
||||
"::hub->set_mode(index, options_);\nentity->publish_state(index);",
|
||||
),
|
||||
(
|
||||
"set_mode(option)",
|
||||
"on_success",
|
||||
(
|
||||
"const char *option = entity->option_at(index);\n"
|
||||
"if (::hub->set_mode(option)) { entity->publish_state(index); }"
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_sub_entities_apply_locals(
|
||||
@@ -1891,35 +1883,6 @@ async def test_sub_entities_apply_momentary(
|
||||
assert apply_functions[0][2] == body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("parent", "apply", "body"),
|
||||
[
|
||||
(
|
||||
_HUB,
|
||||
"write_level(state)",
|
||||
"if (::hub->write_level(state)) { entity->publish_state(state); }",
|
||||
),
|
||||
(
|
||||
None,
|
||||
"chip::write_level(state)",
|
||||
"if (chip::write_level(state)) { entity->publish_state(state); }",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_sub_entities_apply_on_success(
|
||||
setup_test_environment: list[str],
|
||||
apply_functions: list[tuple[str, str, str]],
|
||||
parent: MockObj | None,
|
||||
apply: str,
|
||||
body: str,
|
||||
) -> None:
|
||||
"""publish="on_success" publishes only when the hub call returns true."""
|
||||
sub = _apply_sub([], {"k": _apply_conf("k_sw")}, parent=parent)
|
||||
await sub("k", None, apply=apply, publish="on_success")
|
||||
assert apply_functions[0][2] == body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sub_entities_apply_without_parent_needs_namespaced_call(
|
||||
setup_test_environment: list[str],
|
||||
|
||||
@@ -3121,9 +3121,21 @@ def test_git_ref_valid() -> None:
|
||||
assert cv.git_ref("v1.2.3") == "v1.2.3"
|
||||
|
||||
|
||||
def test_git_ref_invalid() -> None:
|
||||
@pytest.mark.parametrize("ref", ["main", "release/v6.0", "feat+x", "a1b2c3d4"])
|
||||
def test_git_ref_valid_chars(ref: str) -> None:
|
||||
assert cv.git_ref(ref) == ref
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ref", ["main hi there", "a:b", "x~1", "", "main\n"])
|
||||
def test_git_ref_invalid(ref: str) -> None:
|
||||
with pytest.raises(Invalid, match="Not a valid git ref"):
|
||||
cv.git_ref("!!!")
|
||||
cv.git_ref(ref)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ref", [1.0, 2025, None])
|
||||
def test_git_ref_not_string(ref: object) -> None:
|
||||
with pytest.raises(Invalid, match="Must be string"):
|
||||
cv.git_ref(ref)
|
||||
|
||||
|
||||
def test_source_refresh_always() -> None:
|
||||
@@ -3291,6 +3303,34 @@ def test_validate_source_shorthand_github_pr(setup_core: Path) -> None:
|
||||
assert result[CONF_REF] == "pull/1234/head"
|
||||
|
||||
|
||||
def test_validate_source_shorthand_github_ref_with_slash(setup_core: Path) -> None:
|
||||
result = cv.validate_source_shorthand("github://user/repo@release/v1")
|
||||
assert result[CONF_REF] == "release/v1"
|
||||
|
||||
|
||||
def test_validate_source_shorthand_github_ref_keeps_all_chars(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
result = cv.validate_source_shorthand("github://user/repo@feat+x")
|
||||
assert result[CONF_REF] == "feat+x"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"github://esphome/esphome/esphome/components@dev",
|
||||
"github://user/repo/",
|
||||
"github://user/repo@main hi",
|
||||
"github://pr#1234abc",
|
||||
],
|
||||
)
|
||||
def test_validate_source_shorthand_rejects_trailing_text(
|
||||
setup_core: Path, value: str
|
||||
) -> None:
|
||||
with pytest.raises(Invalid, match="not a file system path"):
|
||||
cv.validate_source_shorthand(value)
|
||||
|
||||
|
||||
def test_validate_source_shorthand_invalid(setup_core: Path) -> None:
|
||||
with pytest.raises(Invalid, match="not a file system path"):
|
||||
cv.validate_source_shorthand("notvalid")
|
||||
|
||||
@@ -2239,6 +2239,17 @@ def _make_core(flags: set[str]):
|
||||
return core
|
||||
|
||||
|
||||
def test_project_flags_keep_given_order() -> None:
|
||||
"""A flag list passed in keeps its order; only CORE.build_flags is sorted."""
|
||||
flags = ["-Wl,--start-group", "-DB=1", "-Wl,-lfoo", "-DA=1", "-Wl,--end-group"]
|
||||
assert get_project_link_flags(flags) == [
|
||||
"-Wl,--start-group",
|
||||
"-Wl,-lfoo",
|
||||
"-Wl,--end-group",
|
||||
]
|
||||
assert get_project_compile_flags(flags) == ["-DB=1", "-DA=1"]
|
||||
|
||||
|
||||
class TestGetProjectCompileFlags:
|
||||
def test_returns_define_flags(self) -> None:
|
||||
with patch("esphome.core.CORE", _make_core({"-DFOO", "-DBAR=1"})):
|
||||
|
||||
@@ -3332,6 +3332,26 @@ def test_git_file_from_shorthand_without_ref_or_query() -> None:
|
||||
assert gf == git.GitFile("codeberg.org", "owner", "repo", "file.yaml")
|
||||
|
||||
|
||||
def test_git_file_from_shorthand_keeps_whole_ref() -> None:
|
||||
gf = git.GitFile.from_shorthand("github://owner/repo/file.yaml@feat+x?full_config")
|
||||
|
||||
assert gf.ref == "feat+x"
|
||||
assert gf.query == "full_config"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"shorthand",
|
||||
[
|
||||
"github://owner/repo/file.yaml@main hi",
|
||||
"github://owner/repo/my file.yaml",
|
||||
"github://owner/repo/file.yaml?full config",
|
||||
],
|
||||
)
|
||||
def test_git_file_from_shorthand_rejects_trailing_text(shorthand: str) -> None:
|
||||
with pytest.raises(ValueError, match="not in expected"):
|
||||
git.GitFile.from_shorthand(shorthand)
|
||||
|
||||
|
||||
def test_git_file_from_shorthand_requires_string() -> None:
|
||||
with pytest.raises(ValueError, match="must be a string"):
|
||||
git.GitFile.from_shorthand(123)
|
||||
|
||||
@@ -26,6 +26,7 @@ from esphome.loader import (
|
||||
clear_component_meta_finders,
|
||||
get_alias_metadata,
|
||||
get_component,
|
||||
get_platform,
|
||||
install_meta_finder,
|
||||
)
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
@@ -809,6 +810,44 @@ def test_lookup_module_import_error_missing_module_returns_none(
|
||||
assert "definitely_missing_component_xyz" not in _COMPONENT_CACHE
|
||||
|
||||
|
||||
def test_lookup_module_missing_platform_returns_none(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A platform file that does not exist is "not found", not an error."""
|
||||
with caplog.at_level(logging.INFO, logger="esphome.loader"):
|
||||
assert get_platform("definitely_missing_platform_xyz", "template") is None
|
||||
assert "Unable to import component template.definitely_missing_platform_xyz" in (
|
||||
caplog.text
|
||||
)
|
||||
assert not [r for r in caplog.records if r.levelno >= logging.ERROR]
|
||||
|
||||
|
||||
def test_lookup_module_missing_dependency_is_logged_as_error(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A component that exists but imports a missing package reports that
|
||||
package as an error, instead of looking like a missing component."""
|
||||
comp = tmp_path / "depfailcomp"
|
||||
comp.mkdir()
|
||||
(comp / "__init__.py").write_text("import definitely_missing_package_xyz\n")
|
||||
saved = list(sys.meta_path)
|
||||
try:
|
||||
install_meta_finder(tmp_path)
|
||||
with caplog.at_level(logging.INFO, logger="esphome.loader"):
|
||||
assert get_component("depfailcomp") is None
|
||||
finally:
|
||||
sys.meta_path[:] = saved
|
||||
for name in [
|
||||
m for m in sys.modules if m.startswith("esphome.components.depfailcomp")
|
||||
]:
|
||||
del sys.modules[name]
|
||||
errors = [r for r in caplog.records if r.levelno >= logging.ERROR]
|
||||
assert len(errors) == 1
|
||||
assert errors[0].getMessage() == "Unable to import component depfailcomp:"
|
||||
assert errors[0].exc_info is not None
|
||||
assert errors[0].exc_info[1].name == "definitely_missing_package_xyz"
|
||||
|
||||
|
||||
def test_lookup_module_missing_module_raises_when_requested() -> None:
|
||||
"""With ``exception=True`` the ImportError propagates."""
|
||||
with pytest.raises(ImportError):
|
||||
|
||||
@@ -413,6 +413,64 @@ all_sensors: !include_dir_named named_dir
|
||||
assert "sensor3" in actual["all_sensors"]
|
||||
|
||||
|
||||
def test_find_files_sorted(tmp_path: Path) -> None:
|
||||
"""Files come back in name order, each directory's files before its
|
||||
subdirectories, whatever order the filesystem lists them in."""
|
||||
tree = {
|
||||
tmp_path: (["z_dir", "a_dir"], ["b.yaml", "a.yaml"]),
|
||||
tmp_path / "a_dir": ([], ["c.yaml"]),
|
||||
tmp_path / "z_dir": ([], ["d.yaml"]),
|
||||
}
|
||||
|
||||
def fake_walk(top: Path) -> Generator[tuple[str, list[str], list[str]]]:
|
||||
dirs, files = (list(x) for x in tree[Path(top)])
|
||||
yield str(top), dirs, files
|
||||
for d in dirs:
|
||||
yield from fake_walk(Path(top) / d)
|
||||
|
||||
with patch("esphome.yaml_util.os.walk", fake_walk):
|
||||
found = list(yaml_util.find_files(tmp_path, "*.yaml"))
|
||||
|
||||
assert found == [
|
||||
tmp_path / "a.yaml",
|
||||
tmp_path / "b.yaml",
|
||||
tmp_path / "a_dir" / "c.yaml",
|
||||
tmp_path / "z_dir" / "d.yaml",
|
||||
]
|
||||
|
||||
|
||||
def test_include_dir_list_is_sorted(
|
||||
tmp_path: Path, yaml_util_module: ModuleType
|
||||
) -> None:
|
||||
test_dir = tmp_path / "test_dir"
|
||||
(test_dir / "sub").mkdir(parents=True)
|
||||
for name in ("c", "a", "b"):
|
||||
(test_dir / f"{name}.yaml").write_text(f"key: {name}")
|
||||
(test_dir / "sub" / "d.yaml").write_text("key: d")
|
||||
test_yaml = tmp_path / "test.yaml"
|
||||
test_yaml.write_text("test: !include_dir_list test_dir\n")
|
||||
|
||||
actual = yaml_util_module.load_yaml(test_yaml)
|
||||
|
||||
assert [entry["key"] for entry in actual["test"]] == ["a", "b", "c", "d"]
|
||||
|
||||
|
||||
def test_include_dir_named_duplicate_name_is_an_error(
|
||||
tmp_path: Path, yaml_util_module: ModuleType
|
||||
) -> None:
|
||||
"""Two files with the same name in different subdirectories would map to
|
||||
the same key, so one of them would be silently dropped."""
|
||||
test_dir = tmp_path / "test_dir"
|
||||
(test_dir / "sub").mkdir(parents=True)
|
||||
(test_dir / "a.yaml").write_text("key: top")
|
||||
(test_dir / "sub" / "a.yaml").write_text("key: sub")
|
||||
test_yaml = tmp_path / "test.yaml"
|
||||
test_yaml.write_text("test: !include_dir_named test_dir\n")
|
||||
|
||||
with pytest.raises(EsphomeError, match="both use the key 'a'"):
|
||||
yaml_util_module.load_yaml(test_yaml)
|
||||
|
||||
|
||||
def test_secret_values_tracking(
|
||||
fixture_path: Path, yaml_util_module: ModuleType
|
||||
) -> None:
|
||||
|
||||
Reference in new issue
Block a user