Compare commits

...
Author SHA1 Message Date
J. Nick Koston a2f765a733 Keep a gdb failure from replacing the test's own error
Decode gdb's output with replacement characters and treat an OSError like
a timeout.
2026-10-26 13:58:52 -10:00
J. Nick Koston 3a62bd2a4e Give the debug re-run symbols for custom-flag fixtures and stripped OTA binaries
Fixtures with their own build_flags now get -g too, and the OTA tests pass
their unstripped build so gdb reads its symbols.
2026-10-26 13:33:09 -10:00
J. Nick Koston 7dd3a6f451 Backtrace an early crash, keep the re-run going without core dumps
The died-immediately path now prints the backtrace before raising, the
core setup only warns when it fails, and the re-run's per-test timeout
sits under the step limit.
2026-10-26 13:22:49 -10:00
J. Nick Koston a7b5429032 Revert "Temporary: crash a fixture at boot to check the CI backtrace"
This reverts commit 3f31aeeccf.
2026-10-26 13:20:17 -10:00
J. Nick Koston 3f31aeeccf Temporary: crash a fixture at boot to check the CI backtrace 2026-10-26 13:05:35 -10:00
J. Nick Koston b8c26bea20 [ci] Build integration tests without debug symbols, re-run failures with them
Debug symbols double the object size and nothing reads them on a passing
run. A failed bucket re-runs its failed tests with -g, core dumps and gdb,
so a crash prints a backtrace.
2026-10-26 13:03:43 -10:00
1a50e1394a [vnc] Add input actions and touchscreen forwarding (#20627)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-26 12:48:25 -10:00
luar123andJ. Nick Koston ae1f912a5f [esp32] fix rtcio handling (#21576)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-26 22:42:56 +00:00
916798184d [nextion] Keep a queued command when an unrelated response arrives (#19019)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-26 12:41:48 -10:00
Adam Matthews 1e49616bc5 [api] Fix compile of responding actions with array args (#21672) 2026-10-26 12:32:11 -10:00
esphome[bot] c66f71bb96 Bump aioesphomeapi from 46.22.0 to 46.23.0 (#21700) 2026-10-26 22:31:36 +00:00
8db0774d67 [integration] Test state_class on the integration sensor (#21489)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-26 12:28:18 -10:00
Jonathan Swoboda f735c4634e [esp32] Split and quote build flags in native ESP-IDF builds (#21680) 2026-10-26 17:54:37 -04:00
fe125726ee [template] Stop a running position action on the next cover command (#21599)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-26 11:39:25 -10:00
e05f2fc74e [copy] Forward cover toggle and lock open to the source (#21598)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-26 11:22:58 -10:00
PanoplianandJ. Nick Koston 4e68f1d324 [display] Warn when show_test_card is used with update_interval: never (#20887)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-26 11:12:41 -10:00
Bascht74andJ. Nick Koston 98706c594b [demo] Set the cover to idle when it is stopped (#21600)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-26 11:09:08 -10:00
Jonathan Swoboda 633c2b7f71 [core] Load !include_dir_* files in a fixed order (#21678) 2026-10-26 16:44:24 -04:00
dependabot[bot] 516511b346 Bump actions/setup-python from 7.0.0 to 7.1.0 (#21694)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-26 20:27:44 +00:00
dependabot[bot] f72dc7c329 Bump astral-sh/setup-uv from 10.3.0 to 10.3.2 (#21695)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-26 10:27:07 -10:00
dependabot[bot] f1695ac0b0 Bump CodSpeedHQ/action from 5.5.0 to 5.6.0 (#21692)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-26 10:27:01 -10:00
dependabot[bot] 24c64a8efd Bump prek from 0.5.6 to 0.5.7 (#21689)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-26 10:26:54 -10:00
dependabot[bot]andesphome[bot] d3638336ae Bump ruff from 0.17.1 to 0.17.2 (#21683)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-26 10:26:47 -10:00
dependabot[bot] 53bffb560b Bump tzlocal from 5.4.4 to 5.4.5 (#21686)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-26 10:26:44 -10:00
dependabot[bot] 2af39e6643 Bump smp from 4.1.0 to 4.2.0 (#21685)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-26 10:26:40 -10:00
dependabot[bot] 533a3afebd Bump actions/setup-python from 7.0.0 to 7.1.0 in /.github/actions/restore-python (#21690)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-26 10:26:34 -10:00
dependabot[bot] ddb7a6d9aa Bump astral-sh/setup-uv from 10.3.0 to 10.3.2 in /.github/actions/restore-python (#21688)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-26 10:26:28 -10:00
dependabot[bot] ae99accace Bump cryptography from 48.0.1 to 50.0.2 (#21684)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-26 10:26:15 -10:00
dependabot[bot] 474667d1a8 Bump github/codeql-action/init from 4.39.0 to 4.39.1 (#21693)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-26 10:26:08 -10:00
dependabot[bot] dbc93bb133 Bump docker/login-action from 4.6.0 to 4.7.0 in the docker-actions group (#21687)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-26 10:26:01 -10:00
dependabot[bot] 5486ef400f Bump github/codeql-action/analyze from 4.39.0 to 4.39.1 (#21691)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-26 10:25:54 -10:00
J. Nick Koston bd723f75af [pipsolar] Test the switch commands (#21655) 2026-10-26 16:17:27 -04:00
J. Nick Koston efba19ea3a [ld2420] Switch the module to simple reporting when the select picks Simple (#21651) 2026-10-26 08:29:45 -10:00
73 changed files with 3175 additions and 127 deletions

No files matched your search

+2 -2
View File
@@ -17,7 +17,7 @@ runs:
steps:
- name: Set up Python ${{ inputs.python-version }}
id: python
uses: actions/setup-python@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
+2 -2
View File
@@ -23,13 +23,13 @@ jobs:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@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
+4 -4
View File
@@ -62,7 +62,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@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
View File
@@ -34,7 +34,7 @@ jobs:
run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@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
+2 -2
View File
@@ -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}}"
+6 -6
View File
@@ -86,7 +86,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@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"
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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
+54 -6
View File
@@ -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, []))
+4 -3
View File
@@ -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);
+2 -3
View File
@@ -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
+1
View File
@@ -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_;
};
+1
View File
@@ -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();
+15 -4
View File
@@ -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
+19 -11
View File
@@ -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);
+26 -6
View File
@@ -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) {
+1
View File
@@ -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);
+1 -2
View File
@@ -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))",
)
+4 -3
View File
@@ -339,8 +339,8 @@ def final_validation(config_list):
def lv_conf_path_flag(lv_conf_h_file: Path) -> str:
"""The ``-DLV_CONF_PATH`` build flag, decoded identically by the shell
lexers (PlatformIO, host, ESP8266) and 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():
+27 -9
View File
@@ -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);
+10
View File
@@ -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_;
@@ -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;
+103 -5
View File
@@ -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
),
)
+136
View File
@@ -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
+224 -2
View File
@@ -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
+74 -1
View File
@@ -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];
};
+2 -1
View File
@@ -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]:
+2
View File
@@ -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
+12 -6
View File
@@ -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,"))
]
+9 -2
View File
@@ -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
+4 -4
View File
@@ -1,18 +1,18 @@
# cryptography 49+ ships no Intel macOS wheels (arm64 only); esptool caps <49 there.
# Keep 48.0.1, the last universal2 release, so esphome stays installable on Intel Macs.
cryptography==50.0.2; platform_system != "Darwin" or platform_machine != "x86_64"
cryptography==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
+2 -2
View File
@@ -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]
+72
View File
@@ -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"),
[
+31
View File
@@ -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
+28
View File
@@ -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
+11
View File
@@ -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
+4
View File
@@ -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
+54
View File
@@ -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
+18
View File
@@ -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
+575
View File
@@ -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
+53
View File
@@ -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:
+3
View File
@@ -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}; }
+52 -3
View File
@@ -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
+1
View File
@@ -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)
+30
View File
@@ -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)
+34 -13
View File
@@ -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
+67 -2
View File
@@ -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()
+166 -4
View File
@@ -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,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)
+25
View File
@@ -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:
@@ -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"})):
+58
View File
@@ -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: